mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(ui): cinematic movie and series details pages and dashboard hero (#1792)
This commit is contained in:
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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "معلومات",
|
||||
|
||||
@@ -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": "معلومات",
|
||||
|
||||
@@ -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": "Інфармацыя",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Πληροφορίες",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "情報",
|
||||
|
||||
@@ -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": "정보",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Инфо",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "信息",
|
||||
|
||||
@@ -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": "資訊",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
+28
-62
@@ -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>
|
||||
+46
-4
@@ -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();
|
||||
|
||||
+41
-13
@@ -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;
|
||||
}
|
||||
|
||||
+22
-83
@@ -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
|
||||
|
||||
-10
@@ -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;
|
||||
}
|
||||
|
||||
+6
@@ -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);
|
||||
|
||||
+24
-2
@@ -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();
|
||||
|
||||
+27
@@ -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' },
|
||||
|
||||
+13
@@ -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(
|
||||
':'
|
||||
|
||||
+23
-17
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -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') {
|
||||
|
||||
+39
@@ -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(
|
||||
|
||||
+61
-63
@@ -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();
|
||||
}
|
||||
|
||||
+16
-1
@@ -1,5 +1,20 @@
|
||||
@let isFavorite = isFavorite$ | async;
|
||||
@if (isFavorite === false) {
|
||||
@if (appearance() === 'icon') {
|
||||
<app-detail-icon-button
|
||||
[label]="
|
||||
(isFavorite
|
||||
? 'PORTALS.REMOVE_FROM_FAVORITES'
|
||||
: 'PORTALS.ADD_TO_FAVORITES'
|
||||
) | translate
|
||||
"
|
||||
[icon]="isFavorite ? 'favorite' : 'favorite_border'"
|
||||
tone="favorite"
|
||||
[active]="isFavorite === true"
|
||||
[pressedState]="isFavorite === true"
|
||||
testId="series-favorite-toggle"
|
||||
(pressed)="isFavorite ? removeFromFavorites() : addToFavorites()"
|
||||
/>
|
||||
} @else if (isFavorite === false) {
|
||||
<button class="favorite-btn" (click)="addToFavorites()">
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
|
||||
|
||||
+4
-1
@@ -9,6 +9,7 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { BehaviorSubject, map, of, switchMap } from 'rxjs';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { DetailIconButtonComponent } from '@iptvnator/ui/components';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
matchesFavoriteById,
|
||||
@@ -27,7 +28,7 @@ type FavoriteButtonItem = StalkerSelectedVodItem & {
|
||||
templateUrl: './stalker-favorites-button.component.html',
|
||||
styleUrls: ['./stalker-favorites-button.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
imports: [AsyncPipe, MatIconModule, TranslateModule],
|
||||
imports: [AsyncPipe, DetailIconButtonComponent, MatIconModule, TranslateModule],
|
||||
})
|
||||
export class FavoritesButtonComponent {
|
||||
private playlistService = inject(PlaylistsService);
|
||||
@@ -35,6 +36,8 @@ export class FavoritesButtonComponent {
|
||||
|
||||
readonly itemId = input.required<string>();
|
||||
readonly item = input.required<FavoriteButtonItem>();
|
||||
/** `icon`: the 44px heart of the cinematic action row. */
|
||||
readonly appearance = input<'text' | 'icon'>('text');
|
||||
|
||||
private readonly favoritesChanged$ = new BehaviorSubject<void>(undefined);
|
||||
|
||||
|
||||
+4
@@ -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') {
|
||||
|
||||
+4
@@ -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>();
|
||||
}
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
+130
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -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);
|
||||
});
|
||||
});
|
||||
+82
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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();
|
||||
});
|
||||
});
|
||||
+111
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
-248
@@ -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>
|
||||
}
|
||||
+72
-10
@@ -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();
|
||||
|
||||
+277
-19
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+126
-25
@@ -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> {
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { computed, inject, Injectable, Signal, signal } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata';
|
||||
import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter';
|
||||
import type { XtreamSerieDetailsView } from './serial-details-playback.service';
|
||||
|
||||
/** The season container's download adapter for the series on screen. */
|
||||
@Injectable()
|
||||
export class SerialDetailsDownloadAdapterService {
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
private readonly selectedItem = signal<Signal<XtreamSerieDetailsView | null> | null>(
|
||||
null
|
||||
);
|
||||
|
||||
bind(selectedItem: Signal<XtreamSerieDetailsView | null>): void {
|
||||
this.selectedItem.set(selectedItem);
|
||||
}
|
||||
|
||||
readonly adapter = computed<SeasonEpisodeDownloadAdapter | null>(() => {
|
||||
const playlist = this.xtreamStore.currentPlaylist();
|
||||
const item = this.selectedItem()?.() ?? null;
|
||||
if (!playlist || !item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createXtreamSeriesDownloadAdapter({
|
||||
playlistId: playlist.id,
|
||||
seriesId: Number(item.series_id),
|
||||
title: item.info.name,
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
userAgent: playlist.userAgent,
|
||||
referrer: playlist.referrer,
|
||||
origin: playlist.origin,
|
||||
metadataContext: createXtreamSeriesDownloadMetadataContext(
|
||||
item.info,
|
||||
this.translateService.currentLang ||
|
||||
this.translateService.defaultLang ||
|
||||
'en'
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
+393
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+80
@@ -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);
|
||||
});
|
||||
});
|
||||
+107
-100
@@ -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
Reference in new issue
Block a user