Merge branch 'master' into perf/j3-epg-ribbon-window

This commit is contained in:
4gray authored and GitHub committed 2026-10-05 23:11:34 +02:00
commit 7d9f1df106
171 files changed
+10386 -4444

No files matched your search

@@ -0,0 +1,10 @@
---
type: fix
area: dashboard
---
The dashboard hero stays readable in the light theme: titles without artwork
get a light tinted backdrop instead of a dark slab, text keeps a solid backing
over busy artwork (also in narrow windows), and the star rating is darker.
Screen readers now find one stable "Dashboard" page heading and hear slide
changes they make.
@@ -0,0 +1,8 @@
---
type: fix
area: playback
---
On Linux, the app no longer switches a saved Embedded MPV player back to the
default player when your login shell is slow to start. It now keeps your
choice until it knows for certain whether mpv is installed.
@@ -0,0 +1,6 @@
---
type: feature
area: workspace
---
Back is now always in the same place: Settings, search, Discover and actor pages use the Back button at the start of the top bar instead of their own. Any page you reached from another one also shows it, and it takes you to the previous page.
@@ -0,0 +1,6 @@
---
type: fix
area: workspace
---
On macOS the side rail now starts level with the page content and the dashboard banner, so its first button no longer crowds the window's close, minimize and zoom buttons.
@@ -29,6 +29,11 @@ import {
routePlayableStreams,
startAndConfirmPlayback,
} from './playable-stream-fixture';
import {
addCurrentDetailToFavorites,
goBackFromDetail,
toggleFavoriteForChannel,
} from './dashboard-e2e-flows';
test.describe('Dashboard Activation', () => {
test('opens live favorites in the collection route and movies/series in global collection detail views from the dashboard', async ({
@@ -305,38 +310,6 @@ function dashboardRailCardByTitle(
.first();
}
async function goBackFromDetail(page: Page): Promise<void> {
// Return to the list: the header's Back is route-level in browse and
// watch alike (closing the player is the bar's own Close button).
const backButton = page.getByTestId('workspace-header-back');
await expect(backButton).toBeVisible({ timeout: 20000 });
try {
await backButton.click({ timeout: 5000 });
} catch {
await backButton.evaluate((button: HTMLButtonElement) =>
button.click()
);
}
}
// By accessible name, not class: the Xtream movie detail's favorite control is
// an icon-only button that carries its label in aria-label, while series and
// Stalker details still use the labeled variant. This matches both.
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page
.getByRole('button', { name: /add to favorites/i })
.first();
await expect(addButton).toBeVisible({ timeout: 20000 });
await addButton.click();
await expect(
page.getByRole('button', { name: /remove from favorites/i }).first()
).toBeVisible({
timeout: 20000,
});
}
async function expectInlineCollectionDetail(
page: Page,
params: {
@@ -383,20 +356,3 @@ async function playFirstSeriesEpisode(page: Page): Promise<void> {
await expect(episodeCard).toBeVisible({ timeout: 20000 });
await episodeCard.click();
}
async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = page
.locator('[data-test-id="channel-item"]')
.filter({ hasText: title })
.first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
/star/
);
}
@@ -0,0 +1,54 @@
import type { Page } from '@playwright/test';
import { expect } from './electron-test-fixtures';
// Steps that put content on the dashboard: favourites from the Live TV list
// and from a detail page, and the way back from that detail page.
export async function goBackFromDetail(page: Page): Promise<void> {
// Return to the list: the header's Back is route-level in browse and
// watch alike (closing the player is the bar's own Close button).
const backButton = page.getByTestId('workspace-header-back');
await expect(backButton).toBeVisible({ timeout: 20000 });
try {
await backButton.click({ timeout: 5000 });
} catch {
await backButton.evaluate((button: HTMLButtonElement) =>
button.click()
);
}
}
// By accessible name, not class: the Xtream movie detail's favorite control is
// an icon-only button that carries its label in aria-label, while series and
// Stalker details still use the labeled variant. This matches both.
export async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page
.getByRole('button', { name: /add to favorites/i })
.first();
await expect(addButton).toBeVisible({ timeout: 20000 });
await addButton.click();
await expect(
page.getByRole('button', { name: /remove from favorites/i }).first()
).toBeVisible({
timeout: 20000,
});
}
export async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = page
.locator('[data-test-id="channel-item"]')
.filter({ hasText: title })
.first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
/star/
);
}
@@ -0,0 +1,355 @@
import type { Locator, Page } from '@playwright/test';
import { writeFileSync } from 'node:fs';
import sharp = require('sharp');
import {
addXtreamPortal,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
goToDashboard,
launchElectronApp,
openWorkspaceSection,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchXtreamLiveFixture,
fetchXtreamSeriesFixture,
fetchXtreamVodFixture,
getXtreamTitle,
} from './portal-mock-fixtures';
import { applyTheme, measureBackdropTextContrast } from './theme-contrast';
import {
addCurrentDetailToFavorites,
goBackFromDetail,
toggleFavoriteForChannel,
} from './dashboard-e2e-flows';
// ---------------------------------------------------------------------------
// The dashboard hero's text must stay legible over any artwork, in both
// themes and in the narrow layout where the slide spans the whole width.
// Every mock image is replaced by a black-and-white checkerboard, the worst
// case for either theme's scrim; series images fail, so the favourited
// series falls back to the generated gradient. Each slide kind (16:9
// backdrop, blurred poster, no artwork, live channel) is measured from the
// screen at a wide and a narrow content width.
// ---------------------------------------------------------------------------
type SlideKind = 'backdrop' | 'poster' | 'fallback' | 'live';
const widths = { wide: 1280, narrow: 760 } as const;
const minimumContrast = 4.5;
const xtreamCredentials = {
username: defaultXtreamUsername,
password: defaultXtreamPassword,
};
/** A full TMDB slide has a rating and a two-line overview; the mock has no
* TMDB, so the measurement adds both, styled by the hero's own rules. */
const sampleOverview =
'A long synopsis that wraps onto a second line, so the body text of a ' +
'fully enriched slide is measured where it really sits over the artwork.';
async function busyArtwork(): Promise<Buffer> {
const width = 1280;
const height = 720;
const square = 40;
const pixels = Buffer.alloc(width * height * 3);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const white =
(Math.floor(x / square) + Math.floor(y / square)) % 2 === 0;
pixels.fill(
white ? 255 : 0,
(y * width + x) * 3,
(y * width + x) * 3 + 3
);
}
}
return sharp(pixels, { raw: { width, height, channels: 3 } })
.png()
.toBuffer();
}
/** Serves the checkerboard for every mock image except series artwork,
* which fails: an image the page has already shown is reused from memory,
* so the failure has to be in place before the series is first opened. */
async function routeArtwork(page: Page): Promise<void> {
const image = await busyArtwork();
await page.route(
(url) => url.hostname === 'picsum.photos',
(route) =>
/\/seed\/series-/.test(route.request().url())
? route.fulfill({ status: 404, body: '' })
: route.fulfill({
status: 200,
contentType: 'image/png',
body: image,
})
);
}
async function slideKinds(page: Page): Promise<SlideKind[]> {
return page.locator('.hero__backdrop').evaluateAll((backdrops) =>
backdrops.map((backdrop): SlideKind => {
if (backdrop.classList.contains('hero__backdrop--live')) {
return 'live';
}
if (!backdrop.querySelector('.hero__backdrop-image')) {
return 'fallback';
}
return backdrop.classList.contains('hero__backdrop--poster')
? 'poster'
: 'backdrop';
})
);
}
async function showSlide(page: Page, index: number): Promise<Locator> {
const dot = page.getByTestId('dashboard-hero-dot').nth(index);
await dot.click();
await expect(dot).toHaveAttribute('aria-current', 'true');
// The backdrop crossfade and the slide's entrance have finished (not the
// image's slow zoom, which never changes what is under the text).
await expect
.poll(() =>
page.evaluate(() =>
[
document.querySelector('.hero__backdrop--active'),
document.querySelector('.hero__content'),
].every(
(element) =>
element &&
element
.getAnimations()
.every(
(animation) => animation.playState !== 'running'
)
)
)
)
.toBe(true);
return page.getByTestId('dashboard-hero-slide');
}
/** Sum of non-input layout shifts while the hero runs through every slide
* on its own, at a shortened interval. */
async function rotationLayoutShift(page: Page): Promise<number> {
const hero = page.getByTestId('dashboard-hero');
const dots = page.getByTestId('dashboard-hero-dot');
const count = await dots.count();
await page.mouse.move(1, 1);
await hero.evaluate((element) => {
(element as HTMLElement).style.setProperty(
'--hero-rotation-ms',
'600ms'
);
const shifts: number[] = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as (PerformanceEntry & {
value: number;
hadRecentInput: boolean;
})[]) {
if (!entry.hadRecentInput) {
shifts.push(entry.value);
}
}
}).observe({ type: 'layout-shift' });
(window as unknown as { __heroShifts: number[] }).__heroShifts = shifts;
});
// Back to the first slide after one full cycle, then a quiet moment.
const first = await dots.evaluateAll((all) =>
all.findIndex((dot) => dot.getAttribute('aria-current') === 'true')
);
for (let step = 1; step <= count; step++) {
await expect(dots.nth((first + step) % count)).toHaveAttribute(
'aria-current',
'true',
{ timeout: 5_000 }
);
}
await page.waitForTimeout(500);
return page.evaluate(() =>
(window as unknown as { __heroShifts: number[] }).__heroShifts.reduce(
(sum, value) => sum + value,
0
)
);
}
/** Adds what a TMDB-enriched slide shows: a rating chip and an overview. */
async function enrichSlide(slide: Locator): Promise<void> {
await slide.evaluate((content, overview) => {
const chip = content.querySelector('.hero__pill');
if (chip) {
const rating = chip.cloneNode() as HTMLElement;
rating.classList.add('meta-chip--rating');
rating.textContent = '★ 7.4';
chip.before(rating);
}
// The overview takes the title's view encapsulation attribute, so the
// hero's `.hero__description` rule styles it.
const title = content.querySelector('.hero__title');
const scope = Array.from(title?.attributes ?? []).find((attribute) =>
attribute.name.startsWith('_ngcontent')
);
const actions = content.querySelector('.hero__actions');
if (scope && actions && !content.querySelector('.hero__description')) {
const description = document.createElement('p');
description.setAttribute(scope.name, '');
description.className = 'hero__description';
description.textContent = overview;
actions.before(description);
}
}, sampleOverview);
await expect(slide.locator('.hero__description')).toHaveCount(1);
}
/** Every piece of slide text a viewer reads, one element per colour. */
function slideTexts(slide: Locator): Record<string, Locator> {
return {
eyebrow: slide.locator('.hero__eyebrow > span:not(.hero__eyebrow-sep)'),
title: slide.locator('.hero__title'),
pill: slide.locator('.hero__pill'),
programme: slide.locator('.hero__programme'),
description: slide.locator('.hero__description'),
button: slide.locator('.hero__button > span'),
};
}
test.describe('Dashboard hero legibility', () => {
test('keeps slide text at 4.5:1 over any artwork in both themes and widths', async ({
dataDir,
request,
}, testInfo) => {
test.setTimeout(240_000);
await resetMockServers(request, ['xtream']);
const live = await fetchXtreamLiveFixture(request, xtreamCredentials);
const vod = await fetchXtreamVodFixture(request, xtreamCredentials);
const series = await fetchXtreamSeriesFixture(
request,
xtreamCredentials
);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
const results: string[] = [];
try {
await routeArtwork(page);
await page.setViewportSize({ width: widths.wide, height: 800 });
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
// Live slide: a favourite channel with a programme on air.
await openWorkspaceSection(page, 'Live TV');
await clickCategoryByNameExact(page, live.categoryName);
await toggleFavoriteForChannel(page, getXtreamTitle(live.items[0]));
// Backdrop slide: favouriting from the detail page stores the
// movie's 16:9 backdrop.
await page
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(page, vod.categoryName);
await clickFirstGridListCard(page);
await addCurrentDetailToFavorites(page);
await goBackFromDetail(page);
// No-artwork slide: series images fail, poster and backdrop.
await page
.getByRole('link', { name: 'Series', exact: true })
.click();
await clickCategoryByNameExact(page, series.categoryName);
await clickFirstGridListCard(page);
await addCurrentDetailToFavorites(page);
await goToDashboard(page);
await expect(page.getByTestId('dashboard-hero')).toBeVisible({
timeout: 20_000,
});
// Poster-only slides come from the Xtream "recently added" list.
await expect
.poll(async () => [...new Set(await slideKinds(page))].sort(), {
timeout: 20_000,
})
.toEqual(['backdrop', 'fallback', 'live', 'poster']);
// One stable page heading; the rotating slide title is an h2.
await expect(page.locator('h1')).toHaveCount(1);
await expect(page.getByTestId('dashboard-page-heading')).toHaveText(
'Dashboard'
);
await expect(
page
.getByTestId('dashboard-hero-slide')
.locator('h2.hero__title')
).toHaveCount(1);
// An unattended rotation, counted like the launch journey's
// settled layout-shift counter (non-input shifts only). Slides of
// different heights still resize the hero by a few pixels and
// move the rails below (0.005 here, 0.013 before this change);
// a scrim or heading that reflowed the slide would add lines.
const shift = await rotationLayoutShift(page);
results.push(`rotation layout shift ${shift.toFixed(3)}`);
expect(shift).toBeLessThan(0.02);
await page.getByTestId('dashboard-hero-pause').click();
const kinds = await slideKinds(page);
for (const theme of ['light', 'dark'] as const) {
await applyTheme(page, theme);
for (const [layout, width] of Object.entries(widths)) {
await page.setViewportSize({ width, height: 800 });
// The narrow layout is the dashboard container's
// ≤720px query, not the window width.
const narrow = await page
.locator('.hero__content')
.evaluate(
(content) =>
getComputedStyle(content).maxWidth === 'none'
);
expect(narrow).toBe(layout === 'narrow');
for (const [index, kind] of kinds.entries()) {
const slide = await showSlide(page, index);
await enrichSlide(slide);
await page.mouse.move(1, 1);
const name = `${theme}-${layout}-${kind}`;
const shot = testInfo.outputPath(`hero-${name}.png`);
await page
.getByTestId('dashboard-hero')
.screenshot({ path: shot });
await testInfo.attach(name, {
path: shot,
contentType: 'image/png',
});
for (const [part, texts] of Object.entries(
slideTexts(slide)
)) {
for (const text of await texts.all()) {
const ratio = await measureBackdropTextContrast(
page,
text
);
results.push(
`${name} ${part} ${ratio.toFixed(2)}`
);
expect
.soft(ratio, `${name} ${part}`)
.toBeGreaterThanOrEqual(minimumContrast);
}
}
}
}
}
} finally {
const report = testInfo.outputPath('contrast.txt');
writeFileSync(report, results.join('\n'));
await testInfo.attach('contrast', {
path: report,
contentType: 'text/plain',
});
await closeElectronApp(app);
}
});
});
@@ -24,6 +24,11 @@ import {
// 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.
//
// Pages without a Back of their own (the list a detail returns to) get the
// header's history fallback while an in-app previous page exists; on a
// phone it yields to the drawer toggle, and with nowhere to go the slot is
// empty rather than a disabled arrow.
// ---------------------------------------------------------------------------
const widths = [1280, 780, 375];
@@ -45,6 +50,16 @@ function headerBack(page: Page): Locator {
return page.getByTestId('workspace-header-back');
}
/**
* The generic history Back. A detail's own Back advertises Escape in browse;
* the fallback runs no page handler, so it advertises none.
*/
async function expectHistoryBack(page: Page): Promise<void> {
await expect(headerBack(page)).toBeVisible();
await expect(headerBack(page)).toHaveAccessibleName('Back');
await expect(headerBack(page)).not.toHaveAttribute('aria-keyshortcuts');
}
/** Line boxes of the heading's text; 1 means it did not wrap. */
function headingLineCount(page: Page): Promise<number> {
return page
@@ -228,12 +243,15 @@ async function startFirstEpisode(page: Page): Promise<void> {
).toBeVisible({ timeout: 20_000 });
}
/** The header Back leaves the detail and is gone from the list it opens. */
/**
* The header Back leaves the detail; the list it opens keeps only the
* history fallback (it was itself reached by navigation).
*/
async function expectHeaderBackReturnsToList(page: Page): Promise<void> {
await headerBack(page).click();
await expect(page).not.toHaveURL(detailUrlPattern);
await expect(page.locator('app-portal-detail-shell')).toHaveCount(0);
await expect(headerBack(page)).toHaveCount(0);
await expectHistoryBack(page);
}
test.describe('Portal detail header Back', () => {
@@ -248,7 +266,7 @@ test.describe('Portal detail header Back', () => {
const page = app.mainWindow;
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await expect(headerBack(page)).toHaveCount(0);
await expectHistoryBack(page);
const detailUrl = await openFirstSeries(page);
await expectBackInHeader(page, 'browse');
@@ -287,4 +305,45 @@ test.describe('Portal detail header Back', () => {
await closeElectronApp(app);
}
});
test('@xtream @electron falls back to history where no page offers Back', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
await page.waitForURL(/\/workspace\//);
const startUrl = page.url();
// The first page of the session has nowhere to go back to: the
// slot is empty, not a disabled arrow.
await expect(headerBack(page)).toHaveCount(0);
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
const listUrl = page.url();
await expectHistoryBack(page);
// On a phone the list's drawer toggle keeps the slot: it is the
// only way into the categories.
await page.setViewportSize({ width: 375, height: 800 });
await expect(page.getByTestId('context-drawer-toggle')).toBeVisible();
await expect(headerBack(page)).toBeHidden();
await page.setViewportSize({ width: widths[0], height: 800 });
await headerBack(page).click();
await expect(page).toHaveURL(startUrl);
await expect(headerBack(page)).toHaveCount(0);
// Forward history is not offered; browser Forward still works and
// brings the fallback back.
await page.goForward();
await expect(page).toHaveURL(listUrl);
await expectHistoryBack(page);
} finally {
await closeElectronApp(app);
}
});
});
@@ -0,0 +1,119 @@
/* eslint-disable playwright/expect-expect -- These are Node assertion-based repository contract tests. */
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
// docs/architecture/zoneless-migration.md lists every component that still
// opts out of OnPush. This keeps the checklist and the code in step: a new
// Eager component fails here, and so does a converted one left unticked.
const workspaceRoot = fileURLToPath(new URL('../../../../', import.meta.url));
const checklistPath = 'docs/architecture/zoneless-migration.md';
const sourceRoots = ['apps', 'libs'];
const skippedDirectories = new Set([
'node_modules',
'dist',
'coverage',
'test-stubs',
]);
// Test-only files follow the repository's `.spec` / `.test` naming, with an
// optional suffix of one or more segments (`.spec-stubs.ts`,
// `.test-helpers.ts`, `.test-data-stubs.ts`).
const testOnlyFile = /(\.(spec|test)(-\w+)*|^test-setup)\.ts$/;
function listProductionSources(directory: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
if (skippedDirectories.has(entry.name)) continue;
if (entry.name.endsWith('-e2e')) continue;
files.push(...listProductionSources(path));
} else if (
entry.name.endsWith('.ts') &&
!entry.name.endsWith('.d.ts') &&
!testOnlyFile.test(entry.name)
) {
files.push(relative(workspaceRoot, path).split(sep).join('/'));
}
}
return files;
}
function readSources(): Map<string, string> {
const sources = new Map<string, string>();
for (const root of sourceRoots) {
for (const file of listProductionSources(join(workspaceRoot, root))) {
sources.set(file, readFileSync(join(workspaceRoot, file), 'utf8'));
}
}
return sources;
}
function readEagerChecklist(): { open: string[]; done: string[] } {
const markdown = readFileSync(join(workspaceRoot, checklistPath), 'utf8');
const section = markdown.split(/^## Eager components$/m)[1];
assert.ok(
section,
`${checklistPath} must have an "Eager components" section`
);
const body = section.split(/^## /m)[0];
const open: string[] = [];
const done: string[] = [];
for (const match of body.matchAll(/^- \[( |x)\] `([^`]+\.ts)`/gm)) {
(match[1] === 'x' ? done : open).push(match[2]);
}
return { open: open.sort(), done: done.sort() };
}
const sources = readSources();
test('the zoneless checklist lists exactly the components that are still Eager', () => {
const eager = [...sources]
.filter(([, text]) => text.includes('ChangeDetectionStrategy.Eager'))
.map(([file]) => file)
.sort();
const { open } = readEagerChecklist();
assert.deepEqual(
eager,
open,
`Production files with ChangeDetectionStrategy.Eager must match the unticked entries in ${checklistPath}. ` +
'Do not add Eager components; tick an entry when its component is converted.'
);
});
test('the guard skips test-only file names and keeps production ones', () => {
for (const name of [
'player.component.spec.ts',
'serial-details.test-stubs.ts',
'dashboard.spec-stubs.ts',
'rail.test-data-stubs.ts',
'test-setup.ts',
]) {
assert.ok(testOnlyFile.test(name), `${name} is test-only`);
}
for (const name of [
'player.component.ts',
'spec-utils.ts',
'contest.ts',
'latest-setup.ts',
'testing.service.ts',
]) {
assert.ok(!testOnlyFile.test(name), `${name} ships`);
}
});
test('ticked checklist entries name files that exist', () => {
for (const file of readEagerChecklist().done) {
assert.ok(sources.has(file), `${file} is ticked but does not exist`);
}
});
test('no production component uses the deprecated Default strategy alias', () => {
for (const [file, text] of sources) {
assert.doesNotMatch(text, /ChangeDetectionStrategy\.Default\b/, file);
}
});
@@ -638,6 +638,18 @@ test.describe('Electron Workspace Search', () => {
await expect(
xtreamSearchResultCards(app.mainWindow).first()
).toBeVisible({ timeout: 20000 });
// The search page's Back is the header's leading button, not an
// arrow beside its title, and it returns to the dashboard.
const headerBack = app.mainWindow.getByTestId(
'workspace-header-back'
);
await expect(headerBack).toBeVisible();
await expect(
app.mainWindow.getByRole('button', { name: 'Back', exact: true })
).toHaveCount(1);
await headerBack.click();
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
} finally {
await closeElectronApp(app);
}
@@ -198,3 +198,98 @@ export async function expectSkeletonContrast(
).toBeGreaterThanOrEqual(1.3);
}
}
/** Contrast of an element's own text against whatever is painted behind it
* (images, gradient scrims, translucent chips), read back from the screen.
* The text is made transparent for the capture, so its `text-shadow` stays
* in the backdrop it is meant to support; its CSS colour (with alpha and
* ancestor opacity) is then composited over every pixel under its line
* boxes. Returns the worst ratio, so one dark patch of artwork under one
* letter fails it. */
export async function measureBackdropTextContrast(
page: Page,
text: Locator
): Promise<number> {
await expect(text).toBeVisible();
const probe = await text.evaluate((element) => {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = getComputedStyle(element).color;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
let alpha = a / 255;
for (
let node: Element | null = element;
node;
node = node.parentElement
) {
alpha *= Number(getComputedStyle(node).opacity);
}
const range = document.createRange();
range.selectNodeContents(element);
const box = range.getBoundingClientRect();
const style = (element as HTMLElement).style;
const previous = style.getPropertyValue('color');
style.setProperty('color', 'transparent', 'important');
(element as HTMLElement).dataset['contrastPrevious'] = previous;
return {
color: [r, g, b, alpha],
// Whole pixels inside the line boxes, clear of glyph edges that
// spill past them.
clip: {
x: Math.ceil(box.left),
y: Math.ceil(box.top),
width: Math.max(1, Math.floor(box.width) - 1),
height: Math.max(1, Math.floor(box.height) - 1),
},
};
});
try {
const { data, info } = await sharp(
await page.screenshot({ clip: probe.clip })
)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const [fr, fg, fb, fa] = probe.color;
const luminance = (r: number, g: number, b: number) =>
[r, g, b]
.map((value) => {
const s = value / 255;
return s <= 0.04045
? s / 12.92
: ((s + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, value, index) =>
sum + value * [0.2126, 0.7152, 0.0722][index],
0
);
let worst = Infinity;
for (let i = 0; i < data.length; i += info.channels) {
const [br, bg, bb] = [data[i], data[i + 1], data[i + 2]];
const front = luminance(
fr * fa + br * (1 - fa),
fg * fa + bg * (1 - fa),
fb * fa + bb * (1 - fa)
);
const back = luminance(br, bg, bb);
worst = Math.min(
worst,
(Math.max(front, back) + 0.05) / (Math.min(front, back) + 0.05)
);
}
return worst;
} finally {
await text.evaluate((element) => {
const style = (element as HTMLElement).style;
const previous = (element as HTMLElement).dataset[
'contrastPrevious'
];
style.removeProperty('color');
if (previous) style.setProperty('color', previous);
delete (element as HTMLElement).dataset['contrastPrevious'];
});
}
}
@@ -187,3 +187,69 @@ test.describe('Custom window controls', () => {
}
});
});
test.describe('macOS traffic lights', () => {
test.skip(
process.platform !== 'darwin',
'Only macOS draws the native traffic lights over the rail'
);
test('@electron the first rail link starts with the content area, clear of the lights', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
await expect(page.locator('.app-rail')).toHaveClass(/is-macos/);
const firstLink = page.locator('.app-rail a').first();
await expect(firstLink).toBeVisible();
const [linkBox, contentBox] = await Promise.all([
firstLink.boundingBox(),
page.locator('.workspace-content').boundingBox(),
]);
// Aligned with the content area, where the dashboard hero starts.
expect(
Math.abs((linkBox?.y ?? 0) - (contentBox?.y ?? -100))
).toBeLessThanOrEqual(1);
const lights = await app.electronApp.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition()
);
expect(lights, 'native window button position').toBeTruthy();
const lightsY = lights?.y ?? Number.NaN;
// The buttons are about 14pt tall; keep a visible gap below them,
// measured in window pixels (CSS pixels times the zoom factor).
const linkTopInWindowPixels = async (): Promise<number> => {
const [box, zoom] = await Promise.all([
firstLink.boundingBox(),
page.evaluate(() => window.outerWidth / window.innerWidth),
]);
return (box?.y ?? Number.NaN) * zoom;
};
expect(await linkTopInWindowPixels()).toBeGreaterThanOrEqual(
lightsY + 14 + 16
);
// App zoom scales CSS pixels but not the native buttons: at the
// smallest zoom the inset must still clear them.
for (let step = 0; step < 8; step++) {
await page.evaluate(() =>
window.electron.adjustZoomLevel('out')
);
}
await expect
.poll(() =>
page.evaluate(() => window.outerWidth / window.innerWidth)
)
.toBeLessThan(0.6);
await expect
.poll(linkTopInWindowPixels)
.toBeGreaterThanOrEqual(lightsY + 14 + 8);
await page.evaluate(() => window.electron.adjustZoomLevel('reset'));
} finally {
await closeElectronApp(app);
}
});
});
@@ -0,0 +1,198 @@
/**
* The Linux native-view support check end to end in the main process: the
* real IPC handlers, native service and login shell PATH lookup. Only the
* process boundary is faked: the shell (`readPath`), `mpv --version`
* (`spawnSync`) and Electron. `process.platform` is forced here, so the
* Linux branch runs on every host.
*/
import {
EMBEDDED_MPV_PREPARE,
EMBEDDED_MPV_SUPPORT,
type EmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
const mockSpawnSync = jest.fn();
const mockIpcHandle = jest.fn();
jest.mock('child_process', () => ({ spawnSync: mockSpawnSync }));
jest.mock('electron', () => ({
app: {
isPackaged: true,
getAppPath: () => '/mock/app.asar',
commandLine: { getSwitchValue: () => '' },
},
ipcMain: { handle: mockIpcHandle },
powerSaveBlocker: {
start: jest.fn(),
stop: jest.fn(),
isStarted: jest.fn(),
},
screen: { getDisplayMatching: jest.fn() },
}));
jest.mock('../app', () => ({
__esModule: true,
default: { mainWindow: null },
}));
jest.mock('../services/embedded-mpv-session-options', () => ({
readEmbeddedMpvSessionOptions: () => ({
extraOptions: [],
autoReconnect: true,
}),
}));
jest.mock('../services/embedded-mpv-frame-copy-platform.util', () => ({
...jest.requireActual('../services/embedded-mpv-frame-copy-platform.util'),
getFrameCopyRuntimeAvailability: () => ({
usable: false,
reason: 'helper-probe-failed',
}),
isFrameCopyRuntimeUsable: () => false,
}));
const INHERITED_PATH = '/usr/bin:/bin';
const LOGIN_SHELL_ONLY_DIR = '/home/user/.local/bin';
const LOGIN_SHELL_PATH = `${LOGIN_SHELL_ONLY_DIR}:${INHERITED_PATH}`;
/** Budget of the lookup; the shell in these tests never answers within it. */
const LOOKUP_BUDGET_MS = 5;
type SupportHandler = (event: unknown) => Promise<EmbeddedMpvSupport>;
async function flushLookup(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
}
describe('Embedded MPV support and a slow login shell (Linux native-view)', () => {
const originalPlatform = process.platform;
const originalEnv = {
PATH: process.env.PATH,
DISPLAY: process.env.DISPLAY,
WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY,
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY:
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY,
};
let answerShell: (path: string) => void;
function handlerFor(channel: string): SupportHandler {
const registration = mockIpcHandle.mock.calls.find(
([registered]) => registered === channel
);
if (!registration) {
throw new Error(`Missing ipcMain handler for ${channel}`);
}
return registration[1] as SupportHandler;
}
beforeEach(async () => {
jest.resetModules();
mockIpcHandle.mockReset();
mockSpawnSync.mockReset();
// mpv is installed where only the login shell PATH reaches it.
mockSpawnSync.mockImplementation(() => ({
status: (process.env.PATH ?? '')
.split(':')
.includes(LOGIN_SHELL_ONLY_DIR)
? 0
: 1,
}));
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.PATH = INHERITED_PATH;
process.env.DISPLAY = ':0';
delete process.env.WAYLAND_DISPLAY;
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
const { scheduleDeferredFixPath } =
await import('../startup/login-shell-path');
const { embeddedMpvNativeService } =
await import('../services/embedded-mpv-native.service');
await import('./embedded-mpv.events');
// The addon is normally a vendored .node file; with it in place,
// mpv on PATH is the only thing support depends on.
(
embeddedMpvNativeService as unknown as {
addon: { isSupported(): boolean };
}
).addon = { isSupported: () => true };
scheduleDeferredFixPath(
() =>
new Promise((resolve) => {
answerShell = resolve;
}),
LOOKUP_BUDGET_MS
);
// The lookup starts on the next tick; only then can it be answered.
await flushLookup();
});
afterEach(async () => {
// Let the lookup finish, so no test leaves a pending shell behind.
answerShell(INHERITED_PATH);
await flushLookup();
});
afterAll(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it.each([EMBEDDED_MPV_SUPPORT, EMBEDDED_MPV_PREPARE])(
'%s reports a missing mpv as inconclusive until the shell answers',
async (channel) => {
const check = handlerFor(channel);
// The lookup runs out of budget: this probe sees the inherited
// PATH, where mpv is missing.
await expect(check({})).resolves.toMatchObject({
supported: false,
inconclusive: true,
});
// Asked again meanwhile: the cached answer is still not final.
await expect(check({})).resolves.toMatchObject({
supported: false,
inconclusive: true,
});
expect(mockSpawnSync).toHaveBeenCalledTimes(1);
answerShell(LOGIN_SHELL_PATH);
await flushLookup();
const settled = await check({});
expect(settled.supported).toBe(true);
expect(settled.inconclusive).toBeUndefined();
expect(mockSpawnSync).toHaveBeenCalledTimes(2);
}
);
it('reports a missing mpv as final once the shell answered without it', async () => {
const support = handlerFor(EMBEDDED_MPV_SUPPORT);
await expect(support({})).resolves.toMatchObject({
supported: false,
inconclusive: true,
});
answerShell(INHERITED_PATH);
await flushLookup();
const settled = await support({});
expect(settled.supported).toBe(false);
expect(settled.reason).toContain('mpv executable');
expect(settled.inconclusive).toBeUndefined();
});
it('reports a missing mpv as final when the shell answered in time', async () => {
answerShell(INHERITED_PATH);
await flushLookup();
const answer = await handlerFor(EMBEDDED_MPV_SUPPORT)({});
expect(answer.supported).toBe(false);
expect(answer.inconclusive).toBeUndefined();
});
});
@@ -10,6 +10,7 @@ const mockEmbeddedMpvService = {
getSupport: jest.fn(),
willProbeLinuxMpvExecutable: jest.fn(() => false),
forgetLinuxMpvExecutableProbe: jest.fn(),
markLinuxMpvExecutableProbeProvisional: jest.fn(),
setPaused: jest.fn(),
};
const mockSessionOptions = {
@@ -26,9 +27,7 @@ jest.mock('../services/embedded-mpv-session-options', () => ({
}));
const mockWaitForLoginShellPath = jest.fn(() => Promise.resolve(true));
let settleLookup: () => void = () => undefined;
const mockLookupSettled = new Promise<void>((resolve) => {
settleLookup = resolve;
});
let mockLookupSettled = Promise.resolve();
jest.mock('../startup/login-shell-path', () => ({
waitForLoginShellPath: () => mockWaitForLoginShellPath(),
whenLoginShellPathSettled: () => mockLookupSettled,
@@ -69,6 +68,16 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
});
describe('support checks and the login shell PATH', () => {
beforeEach(() => {
// A lookup of its own per test: the pending re-probe of one test
// must not answer for the next.
mockLookupSettled = new Promise<void>((resolve) => {
settleLookup = resolve;
});
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe.mockClear();
mockEmbeddedMpvService.markLinuxMpvExecutableProbeProvisional.mockClear();
});
afterEach(() => {
mockWaitForLoginShellPath.mockClear();
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReset();
@@ -110,6 +119,10 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
expect(
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
).not.toHaveBeenCalled();
// The probe saw the login shell PATH: its answer is final.
expect(
mockEmbeddedMpvService.markLinuxMpvExecutableProbeProvisional
).not.toHaveBeenCalled();
}
);
@@ -125,6 +138,17 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
await expect(
getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({})
).resolves.toEqual({ supported: false });
// The service is told before it probes, so the answer of this
// very check is already marked as not final.
const { markLinuxMpvExecutableProbeProvisional, getSupport } =
mockEmbeddedMpvService;
expect(
markLinuxMpvExecutableProbeProvisional
).toHaveBeenCalledTimes(1);
expect(
markLinuxMpvExecutableProbeProvisional.mock
.invocationCallOrder[0]
).toBeLessThan(getSupport.mock.invocationCallOrder[0]);
expect(
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
).not.toHaveBeenCalled();
@@ -136,6 +160,33 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
).toHaveBeenCalledTimes(1);
});
it('still re-probes when the check on the inherited PATH throws', async () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation();
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue(
true
);
mockWaitForLoginShellPath.mockResolvedValueOnce(false);
mockEmbeddedMpvService.getSupport.mockImplementation(() => {
throw new Error('probe failed');
});
try {
await expect(
getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({})
).rejects.toThrow('probe failed');
// Otherwise the provisional state would outlive the lookup.
settleLookup();
await new Promise<void>((resolve) => setImmediate(resolve));
expect(
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
).toHaveBeenCalledTimes(1);
} finally {
consoleErrorSpy.mockRestore();
}
});
it('does not wait when no probe runs, nor for session calls', async () => {
mockEmbeddedMpvService.getSupport.mockReturnValue({
supported: true,
@@ -80,13 +80,14 @@ async function afterLoginShellPathIfProbing<T>(check: () => T): Promise<T> {
) {
return check();
}
// The lookup ran out of budget, so this probe sees the inherited PATH.
// Once the shell does answer, the result is probed again.
const result = check();
// The lookup ran out of budget, so this probe sees the inherited PATH: a
// missing mpv is answered as inconclusive, never as a verdict the
// renderer may persist. Once the shell does answer, it is probed again.
getService().markLinuxMpvExecutableProbeProvisional();
void whenLoginShellPathSettled().then(() =>
getService().forgetLinuxMpvExecutableProbe()
);
return result;
return check();
}
handleEmbeddedMpv(EMBEDDED_MPV_SUPPORT, () =>
@@ -414,6 +414,56 @@ describe('EmbeddedMpvNativeService power blocker', () => {
expect(service.willProbeLinuxMpvExecutable()).toBe(true);
});
it('reports a missing mpv as inconclusive only while its probe is provisional', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
delete process.env.WAYLAND_DISPLAY;
mockSpawnSync.mockReturnValue({ status: 1 });
mockRuntimeUsable();
// The login shell has not answered: mpv is looked up on the
// inherited PATH.
service.markLinuxMpvExecutableProbeProvisional();
expect(service.getSupport()).toEqual(
expect.objectContaining({
supported: false,
inconclusive: true,
})
);
expect(service.prepareAddon()).toEqual(
expect.objectContaining({
supported: false,
inconclusive: true,
})
);
// It answered: the next probe is a verdict again.
service.forgetLinuxMpvExecutableProbe();
const settled = service.getSupport();
expect(settled.supported).toBe(false);
expect(settled.reason).toContain('mpv executable');
expect(settled.inconclusive).toBeUndefined();
});
it('keeps every other answer final while the mpv probe is provisional', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
delete process.env.WAYLAND_DISPLAY;
mockSpawnSync.mockReturnValue({ status: 0 });
mockRuntimeUsable();
service.markLinuxMpvExecutableProbeProvisional();
const found = service.getSupport();
expect(found.supported).toBe(true);
expect(found.inconclusive).toBeUndefined();
// mpv is there, the addon is not: the PATH cannot change that.
addon.isSupported.mockReturnValue(false);
const unsupported = service.getSupport();
expect(unsupported.supported).toBe(false);
expect(unsupported.inconclusive).toBeUndefined();
});
it('predicts no probe for the frame-copy engine or native Wayland', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
@@ -174,6 +174,12 @@ export class EmbeddedMpvNativeService {
private powerBlockerId: number | null = null;
private readonly loadAddonModule = createRequire(__filename);
private cachedLinuxMpvExecutableReason: string | null | undefined;
/**
* True while `mpv --version` runs, or was cached, on the inherited PATH
* because the login shell had not answered: a missing mpv is then no
* verdict yet.
*/
private linuxMpvExecutableProbeIsProvisional = false;
private frameCopyAdapter: EmbeddedMpvFrameCopyAdapter | null = null;
private sessionOptionsDirectory: string | null = null;
/**
@@ -401,6 +407,16 @@ export class EmbeddedMpvNativeService {
*/
forgetLinuxMpvExecutableProbe(): void {
this.cachedLinuxMpvExecutableReason = undefined;
this.linuxMpvExecutableProbeIsProvisional = false;
}
/**
* Declares that the probe sees the inherited PATH, the login shell one
* not having arrived. Until `forgetLinuxMpvExecutableProbe()`, a missing
* mpv is reported as `inconclusive`, so no caller settles on it.
*/
markLinuxMpvExecutableProbeProvisional(): void {
this.linuxMpvExecutableProbeIsProvisional = true;
}
getSupport(): EmbeddedMpvSupport {
@@ -458,6 +474,9 @@ export class EmbeddedMpvNativeService {
supported: false,
platform: process.platform,
reason: missingLinuxMpvExecutableReason,
...(this.linuxMpvExecutableProbeIsProvisional
? { inconclusive: true }
: {}),
...this.getFrameCopySupportDetails(),
};
}
+8 -4
View File
@@ -406,17 +406,21 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
)
)
.toBe(0.25);
// M3U has no browse Back target, so the header shows no arrow in either
// state; the now-playing bar's own Close button returns to browse.
// M3U registers no Back target in either state: the header's only arrow
// is the history fallback to the dashboard the import started from,
// which claims no Escape. The now-playing bar's own Close button
// returns to browse.
const shell = detail(page).locator('app-portal-detail-shell');
const headerBack = page.locator('[data-test-id="workspace-header-back"]');
await expect(headerBack).toHaveCount(0);
await expect(headerBack).toHaveCount(1);
await expect(headerBack).not.toHaveAttribute('aria-keyshortcuts');
await shell
.locator('app-portal-inline-player')
.getByRole('button', { name: 'Close player', exact: true })
.click();
await expect(inlineVideo(page)).toHaveCount(0);
await expect(headerBack).toHaveCount(0);
await expect(headerBack).toHaveCount(1);
await expect(headerBack).not.toHaveAttribute('aria-keyshortcuts');
// The hero keeps its own inset (32px, or 20px in a pane narrower than
// 760px) in both states.
expect(
+30 -12
View File
@@ -21,8 +21,9 @@ import {
* by default so the content keeps the full viewport width, opened from
* the header toggle (winning over the persisted desktop inline width),
* and closed again by picking a category or tapping the backdrop.
* 4. The settings section list scrolls instead of painting over the
* Back footer — now inside the open drawer.
* 4. Settings keeps its drawer toggle beside the header Back (the drawer
* holds the sections), and the section list scrolls inside the drawer.
* On a portal list, the header's history Back yields to the toggle.
* 5. On a 640x360 landscape phone the live route keeps the channel
* sidebar at least 72px tall and the player container inside the
* viewport.
@@ -107,17 +108,25 @@ test.describe('portrait phone 375x812', () => {
await expectRailLinksInsideTopBar(page);
});
test('@mobile settings drawer opens from the header toggle and keeps the section list clear of the Back footer', async ({
test('@mobile settings keeps the drawer toggle beside the header Back and the section list inside the drawer', async ({
page,
}) => {
await page.goto('/workspace/settings');
// Back is the header's leading button. The drawer holds the section
// list, so its toggle stays beside Back instead of giving way.
const back = page.locator('[data-test-id="workspace-header-back"]');
const toggle = page.locator('[data-test-id="context-drawer-toggle"]');
await expect(back).toBeVisible();
await expect(toggle).toBeVisible();
expect((await boxOf(back)).x).toBeLessThan((await boxOf(toggle)).x);
// The phone context panel is an off-canvas drawer: hidden until the
// header toggle opens it, so the settings content owns the pane.
const panel = page.locator('.context-panel--settings');
await expect(panel).toBeHidden();
await page.locator('[data-test-id="context-drawer-toggle"]').click();
await toggle.click();
await expect(panel).toBeVisible();
// Narrower than the viewport so the backdrop stays tappable, and
@@ -126,15 +135,13 @@ test.describe('portrait phone 375x812', () => {
expect(panelBox.width).toBeGreaterThanOrEqual(300);
expect(panelBox.width).toBeLessThanOrEqual(PHONE.width - 20);
const footer = panel.locator('.settings-panel-footer');
await expect(footer.locator('.settings-back-button')).toBeVisible();
// Before #1326 the section list kept its full content height and
// painted over the footer whenever the panel was shorter than its
// sections; now the list scrolls and ends above the footer.
// No footer Back any more; the list scrolls and ends inside the
// panel instead of painting past it (#1326).
await expect(panel.locator('button:has-text("Back")')).toHaveCount(0);
const listBox = await boxOf(panel.locator('.settings-sections-list'));
const footerBox = await boxOf(footer);
expect(listBox.y + listBox.height).toBeLessThanOrEqual(footerBox.y + 1);
expect(listBox.y + listBox.height).toBeLessThanOrEqual(
panelBox.y + panelBox.height + 1
);
// Tapping the backdrop (right of the drawer) closes it.
await page
@@ -163,7 +170,18 @@ test.describe('xtream portal routes on a phone', () => {
test('@mobile @xtream vod route keeps the context panel in a drawer behind the header toggle', async ({
page,
}) => {
// The import navigated here from the dashboard, so the header offers
// the history Back on wide screens...
const back = page.locator('[data-test-id="workspace-header-back"]');
await expect(back).toBeVisible();
await page.setViewportSize(PHONE);
// ...and on a phone it yields to the drawer toggle, the list's only
// way into its categories.
await expect(back).toBeHidden();
await expect(
page.locator('[data-test-id="context-drawer-toggle"]')
).toBeVisible();
// Hidden by default — the content owns the full pane. This is the
// successor to the #1326 stacked layout, which left the content
+12 -2
View File
@@ -7,7 +7,12 @@ async function openSettings(page: Page) {
// The bare settings URL redirects to the default section page.
await page.waitForURL(/\/workspace\/settings\/general$/);
await expect(page.locator('.settings-container')).toBeVisible();
await expect(page.locator('.settings-back-button')).toBeVisible();
await expect(settingsBack(page)).toBeVisible();
}
/** Settings' Back is the workspace header's leading button. */
function settingsBack(page: Page) {
return page.locator('[data-test-id="workspace-header-back"]');
}
/** Settings render one section page at a time — open it via the rail. */
@@ -36,7 +41,12 @@ test.describe('Settings', () => {
test('@settings @web Check settings page', async ({ page }) => {
await openSettings(page);
await page.locator('.settings-back-button').click();
// The context panel no longer carries a Back of its own.
await expect(
page.locator('app-workspace-settings-context-panel button')
).toHaveCount(0);
await settingsBack(page).click();
await page.waitForURL(/\/workspace\/dashboard$/);
});
test('@settings @web Change video player', async ({ page }) => {
@@ -0,0 +1,104 @@
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import { expect } from './fixtures';
import {
BACKEND_PROXY,
EMBEDDED_SERIES_MAC,
MOCK_SERVER,
} from './stalker-portal.fixture';
/** A VOD row of the embedded-series scenario carrying a `series[]` array. */
interface EmbeddedSeriesItem {
name: string;
series: unknown[];
}
/**
* Finds a VOD item carrying an embedded series[] array in the mock catalog
* and reports how many episodes it currently has.
*/
export async function findEmbeddedSeriesItem(
request: APIRequestContext
): Promise<{ embeddedItem: EmbeddedSeriesItem; episodeCount: number }> {
const listResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml`
);
const listBody = await listResponse.json();
const embeddedItem = listBody.payload.js.data.find(
(item: { series?: unknown[] }) =>
Array.isArray(item.series) && item.series.length > 0
);
expect(embeddedItem).toBeDefined();
const episodeCount: number = embeddedItem.series.length;
return { embeddedItem, episodeCount };
}
/**
* Opens the embedded-series item from its category (the first row is "All")
* and waits until the series detail lists its last episode. Returns the
* item's card in the category grid.
*/
export async function openEmbeddedSeriesItem(
page: Page,
itemName: string,
episodeCount: number
): Promise<Locator> {
const categories = page.locator('.category-item');
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const card = page.getByText(itemName).first();
await expect(card).toBeVisible({ timeout: 10_000 });
await card.click();
await expect(
page.getByRole('heading', {
name: `${episodeCount}. Episode ${episodeCount}`,
exact: true,
})
).toBeVisible({ timeout: 10_000 });
return card;
}
/**
* From now on the portal has "released" one more episode: extend series[] in
* every search response (the background snapshot refresh re-fetches the item
* via a title search).
*/
export async function releaseExtraEpisodeInSearchResponses(
page: Page
): Promise<void> {
await page.route('**/localhost:3000/stalker**', async (route) => {
const originalUrl = new URL(route.request().url());
if (!originalUrl.searchParams.get('search')) {
await route.fallback();
return;
}
const mockUrl = new URL(BACKEND_PROXY);
const targetId = originalUrl.searchParams.get('targetId');
const providerUrl = targetId
? Buffer.from(targetId, 'base64url').toString()
: originalUrl.searchParams.get('url');
if (providerUrl) {
mockUrl.searchParams.set('url', providerUrl);
}
originalUrl.searchParams.forEach((value, key) => {
if (key === 'targetId') {
return;
}
mockUrl.searchParams.set(key, value);
});
const response = await route.fetch({ url: mockUrl.toString() });
const body = await response.json();
const rows: { series?: string[] }[] =
body?.payload?.js?.data ?? body?.js?.data ?? [];
for (const row of rows) {
if (Array.isArray(row.series) && row.series.length > 0) {
row.series = [...row.series, String(row.series.length + 1)];
}
}
await route.fulfill({ response, body: JSON.stringify(body) });
});
}
+238
View File
@@ -0,0 +1,238 @@
import type { Page } from '@playwright/test';
import { setInputValue } from './e2e-helpers';
import { expect } from './fixtures';
import {
getRegisteredProviderUrl,
interceptProviderTargetRegistration,
} from './provider-target-route';
/**
* Mock-portal endpoints, scenario MACs and page helpers of `stalker.e2e.ts`.
*
* The scenario MACs declared here belong to that spec alone: it lists them in
* its `OWNED_MACS` and resets them before every test, so a sibling spec that
* reused one would have its mock state cleared mid-run. See the isolation
* notes at the top of `stalker.e2e.ts`.
*/
const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210';
export const MOCK_SERVER = `http://localhost:${MOCK_PORT}`;
const PORTAL_URL = `${MOCK_SERVER}/portal.php`;
/**
* Canonical Ministra path. `PORTAL_URL` above is classified by the app as a
* "simple" portal (no handshake, no token, no watchdog); this shape is the
* authenticated branch, which the mock guards like the real middleware.
*/
export const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`;
export const BACKEND_PROXY = `${MOCK_SERVER}/stalker`;
/** Default scenario MAC — balanced catalog, 8 categories, 40 items */
export const DEFAULT_MAC = '00:1A:79:00:00:01';
/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */
export const MINIMAL_MAC = '00:1A:79:00:00:03';
/** Embedded-series MAC — 50% of VOD items carry an embedded series[] array */
export const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05';
/** Legacy pagination MAC — portal without get_all_channels support */
export const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
/**
* Static-cmd MAC — ITV rows carrying a directly playable `cmd` with
* `use_http_tmp_link` and `use_load_balancing` both `'0'`, i.e. a portal that
* expects no `create_link` call at all.
*/
export const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
/**
* The full-portal authentication tests assert state transitions within one
* portal session, so a reset from a concurrent browser project or repeat
* worker would invalidate the assertion itself. Giving every concurrent
* worker slot its own MAC range preserves browser parallelism and also keeps
* `--repeat-each` runs isolated.
*/
export interface StatefulAuthMacs {
authenticatedFlow: string;
loginRequired: string;
tokenReuse: string;
deviceConflict: string;
reauthentication: string;
}
export function getStatefulAuthMacs({
parallelIndex,
}: {
parallelIndex: number;
}): StatefulAuthMacs {
if (
!Number.isSafeInteger(parallelIndex) ||
parallelIndex < 0 ||
parallelIndex > 255
) {
throw new Error(
`Unsupported Playwright parallel index: ${parallelIndex}`
);
}
const workerOctet = parallelIndex
.toString(16)
.padStart(2, '0')
.toUpperCase();
const workerPrefix = `00:1A:79:AE:${workerOctet}`;
return {
authenticatedFlow: `${workerPrefix}:01`,
loginRequired: `${workerPrefix}:02`,
tokenReuse: `${workerPrefix}:03`,
deviceConflict: `${workerPrefix}:04`,
reauthentication: `${workerPrefix}:05`,
};
}
/**
* Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for
* it, so no token is ever adopted and content requests fail permanently.
*/
export const AUTH_REJECTED_MAC = 'AA:BB:CC:DD:EE:01';
/**
* Intercept calls to the Angular dev backend (/stalker proxy) and redirect
* them to the mock server. This avoids needing a real backend or changing
* any app environment configuration.
*/
export async function interceptStalkerRequests(page: Page): Promise<void> {
const providerTargets = await interceptProviderTargetRegistration(page);
await page.route('**/localhost:3000/stalker**', async (route) => {
const originalUrl = new URL(route.request().url());
const mockUrl = new URL(BACKEND_PROXY);
const providerUrl = getRegisteredProviderUrl(
originalUrl,
providerTargets
);
if (providerUrl) {
mockUrl.searchParams.set('url', providerUrl);
}
originalUrl.searchParams.forEach((value, key) => {
if (key === 'targetId') {
return;
}
mockUrl.searchParams.set(key, value);
});
await route.continue({ url: mockUrl.toString() });
});
}
/**
* Add a Stalker portal via the UI:
* 1. Click the "add playlist" button to open the unified dialog
* 2. Select "Stalker" toggle
* 3. Fill in the form and submit
*/
export async function addStalkerPortal(
page: Page,
options: { name?: string; mac?: string } = {}
): Promise<void> {
const { name = 'Mock Stalker Portal', mac = DEFAULT_MAC } = options;
await page.getByRole('button', { name: 'Add playlist' }).click();
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: /Stalker portal/i }).click();
await setInputValue(dialog.locator('input#title'), name);
await setInputValue(dialog.locator('input#portalUrl'), PORTAL_URL);
await setInputValue(dialog.locator('input#macAddress'), mac);
const addButton = dialog.getByRole('button', {
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(dialog).toBeHidden();
await page.waitForURL(/stalker.*vod/);
}
/**
* Add a Stalker portal through the canonical Ministra URL, which the app
* imports as a FULL portal: handshake, Bearer token and watchdog.
*/
export async function addFullStalkerPortal(
page: Page,
options: {
name?: string;
mac: string;
expectContent?: boolean;
username?: string;
password?: string;
}
): Promise<void> {
const {
name = 'Full Stalker Portal',
mac,
expectContent = true,
username,
password,
} = options;
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
await setInputValue(dialog.locator('input#title'), name);
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
await setInputValue(dialog.locator('input#macAddress'), mac);
if (username !== undefined) {
await setInputValue(dialog.locator('input#username'), username);
}
if (password !== undefined) {
await setInputValue(dialog.locator('input#password'), password);
}
const addButton = dialog.getByRole('button', {
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(dialog).toBeHidden();
if (expectContent) {
await page.waitForURL(/stalker.*vod/, { timeout: 30_000 });
}
}
export const CONTENT_ACTIONS = [
'get_categories',
'get_genres',
'get_ordered_list',
'get_all_channels',
];
/** Every portal request in order, with the token it carried. */
export function recordPortalRequests(
page: Page
): Array<{ action: string; token: string | null }> {
const requests: Array<{ action: string; token: string | null }> = [];
page.on('request', (request) => {
const url = new URL(request.url());
if (!url.pathname.endsWith('/stalker')) {
return;
}
const action = url.searchParams.get('action');
if (!action) {
return;
}
requests.push({ action, token: url.searchParams.get('token') });
});
return requests;
}
+38 -328
View File
@@ -1,4 +1,4 @@
import { type APIRequestContext, type Page } from '@playwright/test';
import { type APIRequestContext } from '@playwright/test';
import {
closeSeriesMenu,
expectSeriesSurfacesInBothThemes,
@@ -10,14 +10,33 @@ import {
verifyStalkerPlaybackCategoryReturn,
verifyUncachedStalkerSearch,
} from './stalker-category-search.fixture';
import {
findEmbeddedSeriesItem,
openEmbeddedSeriesItem,
releaseExtraEpisodeInSearchResponses,
} from './stalker-embedded-series.fixture';
import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture';
import { verifyStalkerOpenInPlaylist } from './stalker-open-in-playlist.fixture';
import { playFirstItvChannel } from './stalker-itv-playback.fixture';
import { expect, test } from './fixtures';
import {
getRegisteredProviderUrl,
interceptProviderTargetRegistration,
} from './provider-target-route';
AUTH_REJECTED_MAC,
BACKEND_PROXY,
CONTENT_ACTIONS,
DEFAULT_MAC,
EMBEDDED_SERIES_MAC,
FULL_PORTAL_URL,
LEGACY_PAGINATION_MAC,
MINIMAL_MAC,
MOCK_SERVER,
STATIC_CMD_MAC,
type StatefulAuthMacs,
addFullStalkerPortal,
addStalkerPortal,
getStatefulAuthMacs,
interceptStalkerRequests,
recordPortalRequests,
} from './stalker-portal.fixture';
import { expect, test } from './fixtures';
/**
* Stalker Portal E2E Tests
@@ -58,121 +77,10 @@ import {
test.describe.configure({ mode: 'serial' });
const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210';
const MOCK_SERVER = `http://localhost:${MOCK_PORT}`;
const PORTAL_URL = `${MOCK_SERVER}/portal.php`;
/**
* Canonical Ministra path. `PORTAL_URL` above is classified by the app as a
* "simple" portal (no handshake, no token, no watchdog); this shape is the
* authenticated branch, which the mock guards like the real middleware.
*/
const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`;
const BACKEND_PROXY = `${MOCK_SERVER}/stalker`;
/** Default scenario MAC — balanced catalog, 8 categories, 40 items */
const DEFAULT_MAC = '00:1A:79:00:00:01';
/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */
const MINIMAL_MAC = '00:1A:79:00:00:03';
/** Embedded-series MAC — 50% of VOD items carry an embedded series[] array */
const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05';
/** Legacy pagination MAC — portal without get_all_channels support */
const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
/**
* Static-cmd MAC — ITV rows carrying a directly playable `cmd` with
* `use_http_tmp_link` and `use_load_balancing` both `'0'`, i.e. a portal that
* expects no `create_link` call at all.
*/
const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
/**
* These tests assert state transitions within one portal session, so a reset
* from a concurrent browser project or repeat worker would invalidate the
* assertion itself. Giving every concurrent worker slot its own MAC range
* preserves browser parallelism and also keeps `--repeat-each` runs isolated.
*/
interface StatefulAuthMacs {
authenticatedFlow: string;
loginRequired: string;
tokenReuse: string;
deviceConflict: string;
reauthentication: string;
}
function getStatefulAuthMacs({
parallelIndex,
}: {
parallelIndex: number;
}): StatefulAuthMacs {
if (
!Number.isSafeInteger(parallelIndex) ||
parallelIndex < 0 ||
parallelIndex > 255
) {
throw new Error(
`Unsupported Playwright parallel index: ${parallelIndex}`
);
}
const workerOctet = parallelIndex
.toString(16)
.padStart(2, '0')
.toUpperCase();
const workerPrefix = `00:1A:79:AE:${workerOctet}`;
return {
authenticatedFlow: `${workerPrefix}:01`,
loginRequired: `${workerPrefix}:02`,
tokenReuse: `${workerPrefix}:03`,
deviceConflict: `${workerPrefix}:04`,
reauthentication: `${workerPrefix}:05`,
};
}
/**
* Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for
* it, so no token is ever adopted and content requests fail permanently.
*/
const AUTH_REJECTED_MAC = 'AA:BB:CC:DD:EE:01';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Intercept calls to the Angular dev backend (/stalker proxy) and redirect
* them to the mock server. This avoids needing a real backend or changing
* any app environment configuration.
*/
async function interceptStalkerRequests(page: Page): Promise<void> {
const providerTargets = await interceptProviderTargetRegistration(page);
await page.route('**/localhost:3000/stalker**', async (route) => {
const originalUrl = new URL(route.request().url());
const mockUrl = new URL(BACKEND_PROXY);
const providerUrl = getRegisteredProviderUrl(
originalUrl,
providerTargets
);
if (providerUrl) {
mockUrl.searchParams.set('url', providerUrl);
}
originalUrl.searchParams.forEach((value, key) => {
if (key === 'targetId') {
return;
}
mockUrl.searchParams.set(key, value);
});
await route.continue({ url: mockUrl.toString() });
});
}
/** Every MAC this file owns; all are cleared in one batched reset request. */
const OWNED_MACS = [
DEFAULT_MAC,
@@ -223,116 +131,6 @@ async function resetMockServer(
throw lastError;
}
/**
* Add a Stalker portal via the UI:
* 1. Click the "add playlist" button to open the unified dialog
* 2. Select "Stalker" toggle
* 3. Fill in the form and submit
*/
async function addStalkerPortal(
page: Page,
options: { name?: string; mac?: string } = {}
): Promise<void> {
const { name = 'Mock Stalker Portal', mac = DEFAULT_MAC } = options;
await page.getByRole('button', { name: 'Add playlist' }).click();
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: /Stalker portal/i }).click();
await setInputValue(dialog.locator('input#title'), name);
await setInputValue(dialog.locator('input#portalUrl'), PORTAL_URL);
await setInputValue(dialog.locator('input#macAddress'), mac);
const addButton = dialog.getByRole('button', {
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(dialog).toBeHidden();
await page.waitForURL(/stalker.*vod/);
}
/**
* Add a Stalker portal through the canonical Ministra URL, which the app
* imports as a FULL portal: handshake, Bearer token and watchdog.
*/
async function addFullStalkerPortal(
page: Page,
options: {
name?: string;
mac: string;
expectContent?: boolean;
username?: string;
password?: string;
}
): Promise<void> {
const {
name = 'Full Stalker Portal',
mac,
expectContent = true,
username,
password,
} = options;
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
await setInputValue(dialog.locator('input#title'), name);
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
await setInputValue(dialog.locator('input#macAddress'), mac);
if (username !== undefined) {
await setInputValue(dialog.locator('input#username'), username);
}
if (password !== undefined) {
await setInputValue(dialog.locator('input#password'), password);
}
const addButton = dialog.getByRole('button', {
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(dialog).toBeHidden();
if (expectContent) {
await page.waitForURL(/stalker.*vod/, { timeout: 30_000 });
}
}
const CONTENT_ACTIONS = [
'get_categories',
'get_genres',
'get_ordered_list',
'get_all_channels',
];
/** Every portal request in order, with the token it carried. */
function recordPortalRequests(
page: Page
): Array<{ action: string; token: string | null }> {
const requests: Array<{ action: string; token: string | null }> = [];
page.on('request', (request) => {
const url = new URL(request.url());
if (!url.pathname.endsWith('/stalker')) {
return;
}
const action = url.searchParams.get('action');
if (!action) {
return;
}
requests.push({ action, token: url.searchParams.get('token') });
});
return requests;
}
// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------
@@ -967,16 +765,8 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e
request,
}) => {
// Find an embedded-series VOD item in the mock catalog first
const listResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml`
);
const listBody = await listResponse.json();
const embeddedItem = listBody.payload.js.data.find(
(item: { series?: unknown[] }) =>
Array.isArray(item.series) && item.series.length > 0
);
expect(embeddedItem).toBeDefined();
const episodeCount: number = embeddedItem.series.length;
const { embeddedItem, episodeCount } =
await findEmbeddedSeriesItem(request);
await addStalkerPortal(page, {
name: 'Embedded Series Portal',
@@ -985,19 +775,7 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e
// Open the embedded-series item from its category and favorite it —
// this persists a snapshot with the current episode list
const categories = page.locator('.category-item');
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const card = page.getByText(embeddedItem.name).first();
await expect(card).toBeVisible({ timeout: 10_000 });
await card.click();
await expect(
page.getByRole('heading', {
name: `${episodeCount}. Episode ${episodeCount}`,
exact: true,
})
).toBeVisible({ timeout: 10_000 });
await openEmbeddedSeriesItem(page, embeddedItem.name, episodeCount);
await page.getByRole('button', { name: 'Add to favorites' }).click();
// Wait for the async favorite persistence before navigating away
await expect(
@@ -1007,39 +785,7 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e
// From now on the portal has "released" one more episode: extend
// series[] in every search response (the background snapshot refresh
// re-fetches the item via a title search)
await page.route('**/localhost:3000/stalker**', async (route) => {
const originalUrl = new URL(route.request().url());
if (!originalUrl.searchParams.get('search')) {
await route.fallback();
return;
}
const mockUrl = new URL(BACKEND_PROXY);
const targetId = originalUrl.searchParams.get('targetId');
const providerUrl = targetId
? Buffer.from(targetId, 'base64url').toString()
: originalUrl.searchParams.get('url');
if (providerUrl) {
mockUrl.searchParams.set('url', providerUrl);
}
originalUrl.searchParams.forEach((value, key) => {
if (key === 'targetId') {
return;
}
mockUrl.searchParams.set(key, value);
});
const response = await route.fetch({ url: mockUrl.toString() });
const body = await response.json();
const rows: { series?: string[] }[] =
body?.payload?.js?.data ?? body?.js?.data ?? [];
for (const row of rows) {
if (Array.isArray(row.series) && row.series.length > 0) {
row.series = [...row.series, String(row.series.length + 1)];
}
}
await route.fulfill({ response, body: JSON.stringify(body) });
});
await releaseExtraEpisodeInSearchResponses(page);
// Open the item from the Favorites view: the stored snapshot renders
// first, then the background refresh patches in the new episode
@@ -1083,35 +829,15 @@ test('@stalker season watched toggle — embedded series marks and clears every
// Reuse the modeled embedded-series flow: find a VOD item carrying an
// embedded series[] array, open it from its category, and land on the
// series detail with its episode list.
const listResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml`
);
const listBody = await listResponse.json();
const embeddedItem = listBody.payload.js.data.find(
(item: { series?: unknown[] }) =>
Array.isArray(item.series) && item.series.length > 0
);
expect(embeddedItem).toBeDefined();
const episodeCount: number = embeddedItem.series.length;
const { embeddedItem, episodeCount } =
await findEmbeddedSeriesItem(request);
await addStalkerPortal(page, {
name: 'Embedded Series Watch Portal',
mac: EMBEDDED_SERIES_MAC,
});
const categories = page.locator('.category-item');
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const card = page.getByText(embeddedItem.name).first();
await expect(card).toBeVisible({ timeout: 10_000 });
await card.click();
await expect(
page.getByRole('heading', {
name: `${episodeCount}. Episode ${episodeCount}`,
exact: true,
})
).toBeVisible({ timeout: 10_000 });
await openEmbeddedSeriesItem(page, embeddedItem.name, episodeCount);
await expectSeriesSurfacesInBothThemes(page, testInfo);
@@ -1153,35 +879,19 @@ test('@stalker series watched toggle — embedded series marks and clears from t
}) => {
// Same modeled embedded-series flow as the season test above, driven
// through the series-level ⋮ menu instead of the season button.
const listResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml`
);
const listBody = await listResponse.json();
const embeddedItem = listBody.payload.js.data.find(
(item: { series?: unknown[] }) =>
Array.isArray(item.series) && item.series.length > 0
);
expect(embeddedItem).toBeDefined();
const episodeCount: number = embeddedItem.series.length;
const { embeddedItem, episodeCount } =
await findEmbeddedSeriesItem(request);
await addStalkerPortal(page, {
name: 'Embedded Series Watch Menu Portal',
mac: EMBEDDED_SERIES_MAC,
});
const categories = page.locator('.category-item');
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const card = page.getByText(embeddedItem.name).first();
await expect(card).toBeVisible({ timeout: 10_000 });
await card.click();
await expect(
page.getByRole('heading', {
name: `${episodeCount}. Episode ${episodeCount}`,
exact: true,
})
).toBeVisible({ timeout: 10_000 });
const card = await openEmbeddedSeriesItem(
page,
embeddedItem.name,
episodeCount
);
// 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
@@ -0,0 +1,86 @@
import type { Page } from '@playwright/test';
import { expect, test } from './fixtures';
import {
addXtreamPortal,
interceptXtreamRequests,
MOCK_SERVER,
} from './xtream-series-playback.fixture';
/**
* Pages reached from a detail or the header search draw no Back arrow of
* their own: they register the workspace header's leading Back, which keeps
* their previous return behaviour (history Back).
* Contract: docs/architecture/workspace-shell.md, "Header Back".
*/
const headerBack = (page: Page) =>
page.locator(
'app-workspace-shell-header [data-test-id="workspace-header-back"]'
);
/** The one Back button on the page, wherever it lives. */
const anyBack = (page: Page) =>
page.getByRole('button', { name: 'Back', exact: true });
async function expectOnlyHeaderBack(page: Page): Promise<void> {
await expect(headerBack(page)).toBeVisible();
await expect(anyBack(page)).toHaveCount(1);
// None of these pages handles Escape.
await expect(headerBack(page)).not.toHaveAttribute('aria-keyshortcuts');
}
test.beforeEach(async ({ page, request }) => {
await request.post(`${MOCK_SERVER}/reset`);
await page.goto('/');
await interceptXtreamRequests(page);
await addXtreamPortal(page);
});
test('@web @xtream the in-portal search page returns through the header Back', async ({
page,
}) => {
await page
.locator('app-workspace-shell-rail a[href$="/workspace/dashboard"]')
.first()
.click();
await page.waitForURL(/\/workspace\/dashboard$/);
// The rail link's tooltip would otherwise sit over the header's leading
// button for as long as the pointer rests on the link.
await page.mouse.move(640, 400);
// Enter on the dashboard opens the active portal's search page.
const search = page.locator(
'app-workspace-shell-header .search-field input[type="search"]'
);
await search.fill('Movie');
await search.press('Enter');
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/search\?q=Movie$/);
await expect(page.locator('app-search-layout')).toBeVisible();
await expectOnlyHeaderBack(page);
await headerBack(page).click();
await page.waitForURL(/\/workspace\/dashboard$/);
});
for (const { name, path, selector } of [
{
name: 'Discover',
path: 'discover?type=movie&genre=18&genreLabel=Drama',
selector: 'app-discover-view',
},
{ name: 'actor', path: 'actor/287', selector: 'app-actor-view' },
]) {
test(`@web @xtream the ${name} page returns through the header Back`, async ({
page,
}) => {
const listUrl = page.url();
const portalUrl = listUrl.replace(/\/vod.*$/, '');
await page.goto(`${portalUrl}/${path}`);
await expect(page.locator(selector)).toBeAttached();
await expectOnlyHeaderBack(page);
await headerBack(page).click();
await page.waitForURL(listUrl);
});
}
@@ -0,0 +1,89 @@
import { TestBed } from '@angular/core/testing';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
EMBEDDED_MPV_SUPPORT_RECHECK_MS,
EmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
import { SettingsEmbeddedMpvFacade } from './settings-embedded-mpv.facade';
const SUPPORTED: EmbeddedMpvSupport = { supported: true, platform: 'linux' };
/** A slow login shell: mpv was looked up before its PATH arrived. */
const INCONCLUSIVE: EmbeddedMpvSupport = {
supported: false,
platform: 'linux',
reason: 'mpv executable missing',
inconclusive: true,
};
describe('SettingsEmbeddedMpvFacade', () => {
const originalElectron = window.electron;
let getEmbeddedMpvSupport: jest.Mock;
let facade: SettingsEmbeddedMpvFacade;
beforeEach(() => {
jest.useFakeTimers();
getEmbeddedMpvSupport = jest.fn();
window.electron = {
platform: 'linux',
getEmbeddedMpvSupport,
} as unknown as typeof window.electron;
TestBed.configureTestingModule({
providers: [
SettingsEmbeddedMpvFacade,
{
provide: RuntimeCapabilitiesService,
useValue: { isElectron: true },
},
],
});
facade = TestBed.inject(SettingsEmbeddedMpvFacade);
});
afterEach(() => {
TestBed.resetTestingModule();
window.electron = originalElectron;
jest.useRealTimers();
});
it('offers Embedded MPV once an inconclusive answer turns into supported', async () => {
getEmbeddedMpvSupport
.mockResolvedValueOnce(INCONCLUSIVE)
.mockResolvedValue(SUPPORTED);
await facade.load();
expect(facade.supported()).toBe(false);
// The page stays open; nobody calls load() again.
await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS);
expect(facade.supported()).toBe(true);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
});
it('stops asking once the settings page is closed', async () => {
getEmbeddedMpvSupport.mockResolvedValue(INCONCLUSIVE);
await facade.load();
TestBed.resetTestingModule();
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(1);
});
it('reports a failed probe as unsupported', async () => {
getEmbeddedMpvSupport.mockRejectedValue(new Error('addon load failed'));
await facade.load();
expect(facade.support()).toEqual({
supported: false,
platform: 'linux',
reason: 'addon load failed',
});
});
});
@@ -1,6 +1,15 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import {
computed,
DestroyRef,
inject,
Injectable,
signal,
} from '@angular/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
import {
EmbeddedMpvSupport,
watchEmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
/**
* Probes the desktop backend for embedded MPV support so the playback
@@ -9,6 +18,7 @@ import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
@Injectable()
export class SettingsEmbeddedMpvFacade {
private readonly runtime = inject(RuntimeCapabilitiesService);
private stopSupportWatch: (() => void) | undefined;
readonly support = signal<EmbeddedMpvSupport | null>(null);
@@ -24,6 +34,14 @@ export class SettingsEmbeddedMpvFacade {
() => this.support()?.engine === 'frame-copy'
);
constructor() {
inject(DestroyRef).onDestroy(() => this.stopSupportWatch?.());
}
/**
* Resolves with the first answer. An inconclusive one keeps being asked
* for while the page is open, so the option appears without reopening it.
*/
async load(): Promise<void> {
if (!this.runtime.isElectron) {
this.support.set({
@@ -43,15 +61,27 @@ export class SettingsEmbeddedMpvFacade {
return;
}
try {
this.support.set(await window.electron.getEmbeddedMpvSupport());
} catch (error) {
this.stopSupportWatch?.();
await new Promise<void>((answered) => {
this.stopSupportWatch = watchEmbeddedMpvSupport(
() => window.electron.getEmbeddedMpvSupport(),
(support) => {
this.support.set(support);
answered();
},
(error) => {
this.support.set({
supported: false,
platform: window.electron.platform,
reason: error instanceof Error ? error.message : String(error),
reason:
error instanceof Error
? error.message
: String(error),
});
answered();
}
);
});
}
/**
@@ -66,7 +66,9 @@ export class SettingsSearchFacade {
});
constructor() {
void this.settingsSearch.ensureEmbeddedMpvSupportLoaded();
inject(DestroyRef).onDestroy(
this.settingsSearch.followEmbeddedMpvSupport()
);
effect(() => {
if (!this.isSearching()) {
@@ -95,6 +95,21 @@ describe('SettingsComponent search', () => {
expect(query('app-settings-general-section')).not.toBeNull();
});
it('follows Embedded MPV support only while the page is open', () => {
const stopFollowing = jest.fn();
const follow = jest
.spyOn(settingsSearch, 'followEmbeddedMpvSupport')
.mockReturnValue(stopFollowing);
const page = TestBed.createComponent(SettingsComponent);
expect(follow).toHaveBeenCalledTimes(1);
expect(stopFollowing).not.toHaveBeenCalled();
// Closing the page ends it: nothing shows these rows any more.
page.destroy();
expect(stopFollowing).toHaveBeenCalledTimes(1);
});
it('shows an empty state when nothing matches', () => {
setSettingsSearchQuery('zzzz-no-such-setting');
fixture.detectChanges();
+3 -2
View File
@@ -157,8 +157,9 @@ html {
--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;
// Star rating chip: a deep amber, since the chip's 12px text needs
// 4.5:1 over hero artwork under the light scrim (#a16207 measured 3.4:1).
--app-rating-color: #7a4a00;
.dark-theme {
@include mat.all-component-colors($dark-theme);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 KiB

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 KiB

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 440 KiB

After

Width:  |  Height:  |  Size: 407 KiB

@@ -52,7 +52,7 @@ to the self-hosted browser version.
steps={[
'Open the Add playlist dialog from the dashboard, the header playlist switcher, or the command palette entry "Add M3U playlist".',
'Choose "M3U URL" and paste the link your provider gave you, or choose "M3U file" and pick the file, or choose "Raw m3u text" and paste the playlist itself.',
'Give the playlist a title. For a URL the title is optional; IPTVnator falls back to the file name.',
'For a URL, optionally fill in a playlist title; without one, IPTVnator falls back to the file name. A file keeps its file name and pasted text is titled "Imported as text"; you can rename either later.',
'Click "Add playlist". The channels are parsed, grouped by their group-title attribute, and the playlist opens.',
]}
/>
@@ -1,13 +1,13 @@
---
title: Back Up Your Playlists and Move to a New Computer
description: Export and restore an IPTVnator playlist backup, understand which favorites and playback state it carries, and check what needs to be moved separately when changing computers.
pubDate: 2026-09-27
pubDate: 2026-10-04
author: 4gray
heroImage: /iptvnator/blog/feature-guides/screenshots/guide-backup-dark.png
tags:
- guide
- troubleshooting
draft: true
draft: false
faq:
- q: Is a playlist backup a complete copy of the app?
a: No. It contains playlist definitions, supported playlist-specific user state and global EPG source addresses. It does not include the full settings profile, cached catalog and EPG data, or downloaded media.
@@ -38,7 +38,7 @@ by source type, so check the table below before relying on it as your only copy
1. On the computer with the library you want to keep, open **Settings → Backup**.
2. Under **Import/export playlist backups**, choose **Export**.
3. Save the JSON file. The suggested name includes the export date, for example
`iptvnator-playlist-backup-2026-09-27.json`.
`iptvnator-playlist-backup-2026-10-04.json`.
4. Keep a copy outside the app's own data folder. If you are moving computers, transfer
it through a location you control.
@@ -46,9 +46,9 @@ Export the receiving computer's library too if it already has sources and favori
care about. Restoring a snapshot can replace saved state for a matching source; preserving
both backups gives you a record of each library before the import.
![Settings Backup with Import data and Export data actions](/iptvnator/blog/feature-guides/screenshots/guide-backup-dark.png)
![Settings Backup with Import and Export actions](/iptvnator/blog/feature-guides/screenshots/guide-backup-dark.png)
*Settings → Backup contains both actions: Export data creates the backup; Import data restores it.*
*Settings → Backup contains both actions: Export creates the backup; Import restores it.*
## What travels in the file
@@ -108,6 +108,7 @@ importing snapshots can make it harder to tell which state you are examining.
## Related
- [Choose between Stable and Nightly updates](/iptvnator/blog/stable-nightly-updates-guide/)
- [Add an M3U playlist and EPG](/iptvnator/blog/m3u-playlist-epg-setup-guide/)
- [Connect an Xtream account](/iptvnator/blog/xtream-codes-setup-guide/)
- [Connect a Stalker portal](/iptvnator/blog/stalker-portal-setup-guide/)
@@ -1,12 +1,12 @@
---
title: Stable or Nightly? How IPTVnator Updates Work
description: Choose an update channel in the desktop app, understand what happens when you switch back to Stable, and find the build details that make a nightly bug report useful.
pubDate: 2026-09-27
pubDate: 2026-10-04
author: 4gray
heroImage: /iptvnator/blog/feature-guides/screenshots/guide-update-channel-dark.png
tags:
- guide
draft: true
draft: false
faq:
- q: Which update channel should I use?
a: Stable is the default and suits everyday viewing. Nightly is for trying changes before the next stable release and helping test them. Nightly builds can introduce regressions.
@@ -103,6 +103,7 @@ the update or only after refreshing a playlist.
## Related
- [Back up your playlists before updating](/iptvnator/blog/playlist-backup-restore-guide/)
- [Stable releases](https://github.com/4gray/iptvnator/releases)
- [Nightly builds](https://github.com/4gray/iptvnator-nightly/releases)
- [Desktop installation options](/iptvnator/download/)
@@ -14,11 +14,11 @@ faq:
- q: Which portal URL should I enter?
a: Whatever your provider gave you, as long as it starts with http or https. A bare host, an address ending in /c, portal.php, or server/load.php all work. IPTVnator probes the usual endpoints behind that address and keeps the one that answers, so you rarely need to guess the exact path.
- q: The provider's MAC address is written with dashes or in lowercase. Does it matter?
a: No. IPTVnator normalizes hyphens, dots, spaces and lowercase letters into the canonical 00:1A:79:XX:XX:XX form when you leave the field, and again when you click Add. The bytes stay the same, so the portal sees the address it registered.
a: No. IPTVnator normalizes hyphens, dots, spaces and lowercase letters into the canonical 00:1A:79:XX:XX:XX form when you leave the field, and again when you click Add playlist. The bytes stay the same, so the portal sees the address it registered.
- q: Should I turn on "Generate device IDs from the MAC address"?
a: Only for a MAC the portal has never seen, or one you already use in StbEmu with generated IDs. A portal pins the first device ID it receives to the MAC and refuses any other one afterwards, so enabling it for an existing box with a different ID locks the account out. If your provider did not mention device IDs, leave the option off.
- q: Why does IPTVnator ask for a username and password on some portals?
a: A few Ministra portals require a login on top of the MAC address. IPTVnator reports "This portal requires a login and password" when it sees that; fill in the two optional fields and click Add again. Portals that never ask for it ignore the fields.
a: A few Ministra portals require a login on top of the MAC address. IPTVnator reports "This portal requires a login and password" under the portal URL when it sees that; fill in the two optional fields and click Add playlist again. Portals that never ask for it ignore the fields.
- q: Does IPTVnator show a program guide and catch-up for portal channels?
a: Yes. It loads the portal's seven-day schedule once per session and shows the current program in the channel list and a timeline under the player. Channels the portal marks as archived can be played from the timeline. On the desktop app, XMLTV sources configured in the settings act as a fallback.
- q: Where can I see when my portal subscription expires?
@@ -65,8 +65,8 @@ it, because some reseller panels are configured differently.
steps={[
'Open the Add playlist dialog from the dashboard, the header playlist switcher, or the command palette entry "Add Stalker portal".',
'Choose the "Stalker portal" method.',
'Fill in a title of your choice, the portal URL and the MAC address. Leave the optional fields empty unless your provider gave you values for them.',
'Click "Add". IPTVnator probes the portal, authenticates with your MAC address, and opens the new source. "Validating portal..." can take a few seconds on slow servers.',
'Fill in a playlist title of your choice, the portal URL in the "Server URL" field and the MAC address. Leave the optional fields empty unless your provider gave you values for them.',
'Click "Add playlist". IPTVnator probes the portal, authenticates with your MAC address, and opens the new source. "Validating portal…" can take a few seconds on slow servers.',
]}
/>
@@ -75,10 +75,10 @@ it, because some reseller panels are configured differently.
<Alert type="success" title="Paste the provider message instead">
The Auto-detect method in the same dialog reads a pasted email or chat message. A MAC
address is its strongest signal, so a message with a portal link and a MAC address
pre-fills the Stalker form for you. You still click Add yourself.
pre-fills the Stalker form for you. You still click Add playlist yourself.
</Alert>
## What happens when you click Add
## What happens when you click Add playlist
Providers write portal addresses in a dozen shapes, and the same host can run a strict
Ministra middleware or a tolerant reseller panel. Guessing from the URL used to break real
@@ -94,10 +94,11 @@ setups, so IPTVnator no longer guesses:
that address, and if a provider migrates the portal, IPTVnator re-probes once on the
first failure and repairs the stored connection.
On a full portal the profile also carries your subscription facts, and the dialog confirms
the import with the expiry date. A portal that cannot be reached during the import is still
added when the address looks like a panel, so a temporarily offline provider does not block
you; the next successful request completes the setup.
On a full portal the profile also carries your subscription facts, and IPTVnator confirms
the import with "Portal validated" and the expiry date. A portal that cannot be reached
during the import is still added when the address looks like a panel, with a notice that it
was added without validation, so a temporarily offline provider does not block you; the next
successful request completes the setup.
## After the import
@@ -146,7 +147,8 @@ behind your back, and that matters because of how portals treat device IDs:
later one. A later empty value locks the MAC out for good.
- **Generate device IDs from the MAC address** produces the same pair StbEmu generates, so a
MAC you already use there keeps working. It is offered at import only, writes the values
into the visible fields, and re-derives them if you correct the MAC before clicking Add.
into the visible fields, and re-derives them if you correct the MAC before clicking Add
playlist.
- Once a device ID is stored, the playlist details dialog warns before you change or clear
it, because the portal will not accept the new value.
@@ -155,10 +157,13 @@ exactly those values.
## Troubleshooting
When the portal refuses the import, the dialog stays open and shows the reason under the
portal URL, followed by the portal's own words when it sent any. Editing a field clears it.
- **The portal requires a login and password**: fill in the optional username and password
fields and click Add again. Some Ministra portals demand a login on top of the MAC address.
fields and click Add playlist again. Some Ministra portals demand a login on top of the MAC address.
- **The portal rejected the login and password**: the credentials are wrong or the
subscription lapsed; the portal's own message is shown below the headline.
subscription lapsed; the portal's own message follows the headline.
- **The portal has a different device ID registered for this MAC address**: another box or
emulator authenticated first. Restore the device ID you used there, or ask the provider to
reset the device for this MAC.
@@ -66,18 +66,21 @@ credentials. IPTVnator can read them out of the link for you, as shown below.
'Open the Add playlist dialog. You will find the button on the dashboard, in the header playlist switcher, and in the command palette under "Add Xtream Codes playlist".',
'Choose the "Xtream credentials" method.',
'Fill in a playlist title of your choice, the server URL, the username and the password.',
'Click "Test Connection". IPTVnator asks the portal for the account status and shows the result under the form.',
'Click "Add". IPTVnator downloads the categories for live TV, movies and series and opens the new source.',
'Click "Test HTTPS and HTTP". IPTVnator asks the portal for the account status and shows the result under the server URL. If an `https://` address refuses the connection or does not speak HTTPS, the test retries the same address over plain HTTP once, which is why the dialog warns that your username and password may travel unencrypted.',
'Click "Add playlist". IPTVnator downloads the categories for live TV, movies and series and opens the new source.',
]}
/>
![Add playlist dialog with the Xtream credentials method selected and a successful connection test](/iptvnator/blog/guides/screenshots/guide-xtream-add-playlist-dark.png)
The connection test reports one of four states. **Connection successful! Portal is active** is
the one you want. **Portal subscription has expired** and **Portal is inactive** mean the
portal answered but refuses the account; check the subscription with your provider. **Could
not connect to the portal** means no answer at all: a typo in the server URL, a wrong port, a
missing `http://` or `https://`, or a host that is down.
**Connected. The portal is active.** is the result you want; **Connected using HTTP** means the
same over an unencrypted connection, after the HTTPS attempt failed and the server URL was
switched to `http://`. **The subscription has expired** and **The account is inactive or the
credentials are incorrect** mean the portal answered but refuses the account: check the
credentials, then the subscription with your provider. **Could not reach the server** means no
answer at all: a typo in the server URL, a wrong port, or a host that is down. The remaining
messages name what went wrong instead: a failed secure connection, an HTTP error status, or a
server that answered without valid account information.
<Alert type="success" title="Paste the whole link">
If you paste a complete `get.php` or `player_api.php` link into the Server URL field while the
+60 -5
View File
@@ -383,15 +383,17 @@ meanwhile. The host reports busy-state back through the
A series-level counterpart lives in a `⋮` menu at the end of the same
header row (`SeasonWatchPresenter` in `libs/ui/components` owns the state
math for both scopes; the container component sits at the max-lines cap).
math for both scopes, which keeps the container component under the
max-lines cap).
`buildSeriesWatchToggleRequest` flattens every LOADED season with the same
mark/unmark semantics, and the direction is always the one the label
advertised (`markWatched: !seriesFullyWatched()`), never re-inferred from
data at persist time. Hosts route the request through the same machinery
as the season toggle — Xtream via the scope-parameterized
`SerialDetailsSeasonWatchService.handle(..., scope)`, Stalker via the
extracted `runWatchToggleBatch` core — sharing the busy flag, the
ownership guards, and the catalog-badge refresh. Stalker lazy-VOD is the
`SerialDetailsSeasonWatchService.handle(..., scope)`, Stalker via
`StalkerSeriesWatchToggleService` and its `runStalkerWatchToggleBatch`
core — sharing the busy flag, the ownership guards, and the catalog-badge
refresh. Stalker lazy-VOD is the
special case: unopened seasons have empty episode lists, so the container
reports them through the `hasUnloadedSeasons` input (blocks the
"fully watched" verdict and switches the label to its countless variant),
@@ -412,7 +414,8 @@ series-toggle hydration join one in-flight request instead of
duplicating it (a second request's failure could abort a toggle whose
original request succeeded).
The host synchronously re-runs the position reconcile
(`applyReconciledSeriesPositions` — the effect-fed maps only update on
(`StalkerSeriesPositionsService.applyReconciledSeriesPositions` — the
effect-fed maps only update on
the next change-detection tick, and enqueuing against stale maps would
miss the hydrated episodes' legacy rows), rebuilds the request from the
now-complete seasons keeping the captured direction, and reports an
@@ -468,6 +471,58 @@ position telemetry overwrites this launch marker when available. This keeps the
last-watched season and episode correct even when an external player's progress
interface is unavailable; exact external timestamps remain best-effort.
## Forced External Launches From Detail Pages
The detail "…" menu's "Open in external player" sends the title to MPV/VLC
through `PortalPlayer.openExternalPlayback(playback, player)` whatever the
configured player is. The launch IPC cannot be cancelled, and until it
resolves the session is at most `launching` and may not have a closer yet.
Every detail host therefore keeps these rules:
- **One external player per owner.** Before launching, the host closes the
external session the page owns: the session of the same title on the
Stalker pages and the Xtream series page; the session it launched, else the
one matching its movie, on the Xtream movie page. Sessions the page does not
own are left alone. With instance reuse off, a second detached player would
otherwise start beside the first. Stalker hosts use
`replaceOwnedExternalSession` from
`@iptvnator/portal/shared/util`; the Xtream pages use
`closeRunningExternalSession` with the same outcome rules.
- **Unconfirmed teardown cancels the launch.** A live session without a
closer, or a close that rejects, leaves the running player in place and
nothing new launches.
- **Ownership is rechecked after every await.** Stream resolution, the close
and the launch IPC can each outlive the page or be superseded by a newer
start. A stale step stops without reporting, and a launch that resolves
stale closes the session it just opened.
- **No second player while a launch settles.** A repeat of the same launch is
ignored, or its control stays disabled. Movie pages refuse or disable every
other start of that title until the launch settles. Series pages hold the
latest episode choice and, once the launch settled, replace the player it
opened, only while that series is still on screen.
- **Pending starts are owner-scoped.** A start still resolving holds the
actions of its own title only: another title shown by the reused page is
not blocked by it. Movie hosts track starts with
`createPendingPlaybackStart` (`@iptvnator/portal/shared/util`): only the
latest start may clear the flag, and `isPendingFor(owner)` answers for one
owner. The Stalker movie hosts, whose starts wait on a portal round trip,
also `retire(owner)` when the selection leaves it, so a start that never
settles does not keep the flag set on a return to the same title. A movie's
"Reset progress" is scoped the same way.
- **Two gates are page-wide.** The Xtream movie page refuses Play, Start
over, source switches and the menu launch while an external launch it made
has not settled. A series page runs one watched or reset batch at a time,
whichever series is shown; the Stalker page also holds episode starts until
that batch settles.
Owner keys and queueing are provider contracts:
| Host | Contract |
| --- | --- |
| Xtream series | [Forced external launches from detail pages](./xtream-portal-compatibility.md#forced-external-launches-from-detail-pages) |
| Xtream movie | [Menu launch and reset follow the primary button](./vod-multi-source.md#menu-launch-and-reset-follow-the-primary-button) |
| Stalker series and movies | [Forced External Launches](./stalker-portal.md#forced-external-launches) |
## Series Quick Start CTA
Xtream and Stalker series detail views share the quick-start decision helper in
+34 -1
View File
@@ -185,6 +185,39 @@ support API from global workspace startup paths; use an explicit user action
or idle preparation path when a renderer surface only needs to reveal optional
Embedded MPV UI.
An unsupported answer can be `inconclusive`. The Linux native-view `mpv`
executable check runs `mpv --version` by bare name, so the support and prepare
handlers wait for the login shell PATH lookup (`startup/login-shell-path.ts`)
first. When that lookup runs out of its budget, the check runs on the
inherited PATH: `EmbeddedMpvNativeService` then reports a missing `mpv` as
`supported: false` with `inconclusive: true`, keeps doing so while the cached
result stands, and probes again once the shell answers. Every other answer,
including a missing `mpv` after the shell answered, is final. An inconclusive
answer is not a verdict on the machine: never persist a decision made from it.
Whatever holds on to one answer follows it through `watchEmbeddedMpvSupport()`
(`@iptvnator/shared/interfaces`), which asks again after
`EMBEDDED_MPV_SUPPORT_RECHECK_MS`, backing off to
`EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS`, until the answer is final:
- The settings store keeps a saved Embedded MPV selection while the answer is
inconclusive, falls back to the default player only on a final unsupported
answer, and never overwrites a player the user picked meanwhile.
- The player (`EmbeddedMpvSessionController`) and the settings page stay
mounted on one answer: a player mounted in that window starts playback by
itself once `mpv` is found, and the option appears without reopening the
page.
- The settings page also follows the answer for its search
(`SettingsSearchService.followEmbeddedMpvSupport()`) and ends that when it
closes: the Embedded MPV rows become searchable while the page stays open,
and nothing keeps asking once no surface shows them.
The command palette asks on demand, on every open, for its player commands and
its settings rows (`ensureEmbeddedMpvSupportLoaded()`). Only a final answer is
kept for the session; after an inconclusive one, or a failed request, the next
open asks again. An open palette is a snapshot of that moment: it does not wait
for a final answer, because a login shell that never answers would then keep
it from opening.
When `embedded-mpv` is the saved player, the settings store schedules an idle `prepareEmbeddedMpv()` call. This intentionally moves the first native addon load away from the click-to-play path. It can still block the Electron main process briefly because Node native addon loading is synchronous, but doing it during idle is less visible than doing it when the user clicks a video. Actual MPV session creation still happens on playback because it needs the current Electron window handle and viewport bounds.
For the native-view engine, the MPV video surface is a platform view/window,
@@ -967,7 +1000,7 @@ Defensive practice for this component:
Concrete bugs from the audit, recorded so they don't get reintroduced:
- **Infinite session-create loop.** `EmbeddedMpvSessionController.startSession` once wrote `this.support.set(prepared)` after the `prepareEmbeddedMpv` round-trip. The component's session-creation effect tracks `this.support()`, so the write fired the effect → cleanup disposed the session → new session was created → prepare ran again → support was set again. Symptom: endless "Loading stream…" spinner. Fix: do not write `support` inside `startSession`; the constructor's `loadSupport()` already populates it including capabilities.
- **Infinite session-create loop.** `EmbeddedMpvSessionController.startSession` once wrote `this.support.set(prepared)` after the `prepareEmbeddedMpv` round-trip. The component's session-creation effect tracks `this.support()`, so the write fired the effect → cleanup disposed the session → new session was created → prepare ran again → support was set again. Symptom: endless "Loading stream…" spinner. Fix: do not write `support` inside `startSession`; the constructor's `watchSupport()` already populates it including capabilities.
- **Stream restart on volume change.** The session-creation effect once read `this.volume()` directly to pass to `startSession`'s `initialVolume`. Each volume tick re-ran the effect, disposing and recreating the session — for VOD/series this restarted playback from the beginning. Fix: read it via `untracked(() => this.volume())`. Subsequent volume changes flow through `controller.applyVolume()`, never through the effect graph.
- **Spurious `timeUpdate` re-emits and `volume.set` calls.** The session-fan-out effect calls `scheduleControlsHide()`, which reads `isPlaying`, `menus.anyOpen`, `statusLabel`, and `controlsVisible`. Those reads became tracked deps, so opening any popover, pausing, or hovering re-ran the body. No loop in isolation, but a parent that wires `timeUpdate` back into `playback.startTime` would have hit the volume-restart bug class. Fix: wrap the side-effect block in `untracked()` so the effect listens only to session changes.
- **2 Hz no-op stalled-tracker re-runs.** Position polling updates `session` around 2 Hz. Tracking the full session would re-run stalled logic for snapshots with unchanged status, so the controller tracks only `sessionStatus` and invokes `EmbeddedMpvStalledTracker.track` inside `untracked()`, avoiding full-session reruns.
+20 -3
View File
@@ -189,6 +189,18 @@ 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.
## Back Navigation
Page-level Back lives only in the workspace header's leading slot (see
[Header Back](./workspace-shell.md#header-back)). A routed page, or the shell
it renders in, registers it with `registerWorkspaceBack()` instead of drawing
an arrow, so Back keeps one position and one look on every page and never
floats over a scroll owner. Without a registration the header falls back to
browser history while an in-app previous page exists, and shows nothing
otherwise. An arrow that returns within a menu, dialog or player panel is not
page navigation and stays in that surface; an error state may repeat the
header's Back as a labelled recovery button beside its other actions.
## Electron Drag Regions
Every interactive descendant of a drag region—including buttons, links,
@@ -902,7 +914,10 @@ Prefer removing a control over shrinking everything around it:
- Counts and subtitles that a neighbouring control already states.
Never drop the only way back to a hidden surface. A collapse toggle that is
reachable by touch needs its restore affordance to be reachable too.
reachable by touch needs its restore affordance to be reachable too. For the
same reason the header's history Back yields to the context drawer toggle,
and Settings keeps the toggle beside its Back; only a detail page's Back,
whose list shows the toggle again, takes the toggle's slot.
## Typography
@@ -920,8 +935,10 @@ heavier faces would exceed the initial-bytes ratchet).
- JetBrains Mono text stays at 500 or lighter, also where it is a fallback
behind `ui-monospace` (only macOS resolves that). The check enforces this in
any rule that sets the family, directly or through a variable, or inherits
it from an enclosing rule. It cannot see what a mono modifier class inherits
from its base rule; set `font-weight: 500` there.
it from an enclosing rule. A mixin's family and weights count where it is
included, from its own stylesheet module or another one. It cannot see what
a mono modifier class inherits from its base rule; set `font-weight: 500`
there.
- Import whole `@fontsource/<family>/<weight>.css` files. The single-script
files such as `cyrillic-600.css` have no `unicode-range`, so a Cyrillic-only
face wins the weight match for Latin text in `Roboto, …` stacks and sends it
+2 -1
View File
@@ -208,7 +208,8 @@ window, and `evidence.idle.domMutations` the mutation records in the whole
document. The [idle work audit](idle-work-audit-2026-09.md) found Eager
components re-rendering on every such tick in a dev build; this counter
measures the ticks in the optimized build, so plan item C6 can show what
zoneless change detection removes.
zoneless change detection removes; its checklist is the
[zoneless migration](zoneless-migration.md).
The window opens when the settle window closes, so startup data still
landing is not idle work, and it is timed by a renderer `setTimeout`. The
+27 -20
View File
@@ -23,27 +23,29 @@ do not reach global player shortcuts. Descendant controls retain their native
keys and Tab order. Entering watch still scrolls to the top; Back and saved
catalog scroll positions retain the existing navigation contract below.
The shell owns the page's Back action, but the workspace header renders it.
While `backAvailable()` is true, the shell registers a target with
`WorkspaceBackNavigationService` (`@iptvnator/portal/shared/data-access`). The
target, the `WorkspaceBackTarget` contract in `@iptvnator/portal/shared/util`,
carries a label (the host's `backLabel`, else the translated "Back"), whether
Escape currently runs it, and `run()`, which emits `backClicked`. The service
keeps a stack in which the newest registration wins, and each release removes
only its own target. A loading shell replaced by the loaded one therefore
cannot clear its successor, whichever is destroyed first. The header shows the target as an `arrow_back`
icon button in its leading slot (`data-test-id="workspace-header-back"`), to
the right of the macOS traffic lights. That is where desktop apps and Material's
top app bar keep navigation. The header never scrolls, so the control stays
visible over long episode lists, and nothing floats over the scroll owner:
detail columns keep symmetric insets and their full width. The button has
Electron `no-drag` hit testing. In browse its tooltip and `aria-keyshortcuts`
The shell owns the page's Back action, but the workspace header renders it
(the general contract, other pages and the history fallback are in
[Header Back](./workspace-shell.md#header-back)). While `backAvailable()` is
true, the shell registers a target through `registerWorkspaceBack()`
(`@iptvnator/portal/shared/data-access`). It carries the host's `backLabel`
(else the translated "Back"), whether Escape currently runs it, and `run()`,
which emits `backClicked`. The newest registration wins and each release
removes only its own target, so a loading shell replaced by the loaded one
cannot clear its successor, whichever is destroyed first. The header shows the
target as an `arrow_back` icon button in its leading slot
(`data-test-id="workspace-header-back"`), to the right of the macOS traffic
lights. That is where desktop apps and Material's top app bar keep navigation.
The header never scrolls, so the control stays visible over long episode
lists, and nothing floats over the scroll owner: detail columns keep symmetric
insets and their full width. In browse its tooltip and `aria-keyshortcuts`
advertise Escape, and an Escape pressed on the focused button runs Back
itself, because the shell's browse Escape requires focus inside the page. At
≤640 px Back takes the context drawer toggle's slot (one navigation icon); the
list it returns to shows the toggle again. This replaced #1763's 72 px lane
reserved beside a sticky in-page arrow, along with its phone bar. Electron E2E
`detail-header-back.e2e.ts` covers 1280, 780 and 375 px in browse and watch.
list it returns to shows the toggle again, and there the history fallback
yields to it. This replaced #1763's 72 px lane reserved beside a sticky
in-page arrow, along with its phone bar. Electron E2E
`detail-header-back.e2e.ts` covers 1280, 780 and 375 px in browse and watch,
and the history fallback on the list Back returns to.
The header Back is route-level in both states: it emits `backClicked`
whether or not inline playback is active, so the arrow keeps one meaning and
@@ -67,8 +69,13 @@ keep working on the page.
Hosts without browse navigation set `backAvailable=false`: M3U uses its channel
sidebar, and collection bootstrap placeholders have no return handler. They
register no header Back in either state and have no browse Escape action; their
watch exits are the bar's Close player button and Escape. Loading/error shells
with a return handler keep Back available.
watch exits are the bar's Close player button and Escape. The header may still
show the history fallback there (a generic Back to the previous page, without
Escape) when the page was reached by in-app navigation. Loading/error shells
with a return handler keep Back available. The downloads offline and recording
error states additionally keep a labelled "Back to Downloads" button beside
Retry or Remove: it is the error state's recovery action and runs the same
handler as the header Back.
## Summary
+5 -1
View File
@@ -168,7 +168,11 @@ into `apps/website/public/blog/guides/screenshots/` instead of a release folder
guard a release shot does; the add-playlist dialog shots fill the form with the
mock's fictional `marketing` credentials and use a labeled hand-out for the
Auto-detect method rather than a `get.php?username=…` link, because G4 rejects
any URL carrying query credentials. Shots that walk into a Stalker portal
any URL carrying query credentials. The Xtream shot clicks **Test HTTPS and
HTTP** against the plain-`http://` mock, so no HTTPS probe is made, and fails
the run unless the status line reports an active portal; the mock does not
check passwords, so that verdict proves the scenario answered, not that the
password is right. Shots that walk into a Stalker portal
(`open-stalker-live`) make the run start the stalker-mock-server on port 3210
and seed its `marketing-demo` portal as a third source, which is why they are
never part of a release run. That scenario's MAC, `00:1A:79:00:00:07`, is the
+2 -1
View File
@@ -408,7 +408,8 @@ file running in parallel workers, so isolation is per-MAC rather than global:
else is talking to the server — a spec that used it would wipe a sibling
spec's session mid-test.
- `apps/web-e2e/src/stalker.e2e.ts` declares its shared scenario MACs in
`OWNED_MACS` and clears them in one batched request. The sibling specs that
`OWNED_MACS` (the constants live in `stalker-portal.fixture.ts`) and clears
them in one batched request. The sibling specs that
reach this server (`self-hosted.e2e.ts`, the `sources-pwa` helpers) own a
disjoint `00:1A:79:5F:*` range, so neither file can clear the other's state.
- Within each browser project, tests deliberately share content-scenario MACs
+65 -1
View File
@@ -484,7 +484,8 @@ the format at all, so a large share of working installations use a non-Infomir
MAC. Refusing one would stop those users adding or editing a portal that works
for them. The mock encodes the same split (`enforceMacFormat` is set only on
the strict endpoint; `/portal.php` ignores it), and `AUTH_REJECTED_MAC` in
`stalker.e2e.ts` depends on it — a non-Infomir MAC that must reach the strict
`stalker-portal.fixture.ts` (used by `stalker.e2e.ts`) depends on it — a
non-Infomir MAC that must reach the strict
endpoint and be refused _there_, not in the form.
In the edit dialog **both** passes — blur and submit — normalize only a MAC the
@@ -1558,6 +1559,69 @@ Core decision logic and normalization are centralized in:
- `libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts`
- `libs/portal/stalker/data-access/src/lib/models/*.ts`
## Forced External Launches
"Open in external player" needs a `create_link` round trip before it reaches
MPV/VLC. The shared rules are in
[Forced External Launches From Detail Pages](./embedded-inline-playback.md#forced-external-launches-from-detail-pages);
the Stalker keys and queues are:
Series (`StalkerSeriesViewComponent`, `stalker-series-launch-queue.ts`):
- Pending starts and the launch queue's held choices are keyed by
`playlist:series` (`currentSeriesKey`). The view is reused across series
and provider ids collide across playlists, so one series settling never
drops what another holds.
- A start is pending for its series from the click until it settles. A forced
launch stays pending through the close of the previous player, the launch
and the release of a held choice. The pending flag disables the hero button
and the menu's external-player and watched rows.
- Before launching, an episode of the same series still running externally is
closed (`replaceOwnedExternalSession`). The request is rechecked after that
close and after the launch IPC; a superseded launch closes the session it
opened.
- An episode chosen while a forced launch of its series is mid-flight is held
(`StalkerSeriesLaunchQueue.hold`); the latest choice per series wins. On
release it is dropped when the series is no longer shown. Otherwise
`replacePlayer` closes what the launch opened before the choice starts, and
an unconfirmed close drops the choice.
- An episode chosen while a watched or reset batch runs is held in one slot
tagged with its series; the last choice wins. When the batch settles it
goes through the usual gates only if that series is still shown: episode
identities overlap across series.
Movies (`createStalkerVodDetailActions`, used by the catalog detail, the
collection detail and search):
- A repeat for the same `playlist:movie` while its launch is in flight is
ignored, also after leaving the movie and returning to it. Launches of
other movies are not held back.
- The launch joins the host's starts (`beginPendingStart`): it supersedes an
earlier start, is dropped once a later one begins, and keeps Play, Start
over, the watched toggle and the menu rows disabled until it settles.
- The resolved stream is discarded when the movie is no longer selected or a
newer start took over. Movie and series ids collide, so the catalog and
collection details include the content type in the selection check; in
search, a switch to a series changes the playback owner instead, which
supersedes the launch. Otherwise the movie's own external
session is replaced, the host's `beforeExternalLaunch` hook runs (the
catalog and collection details close their inline player there), and the
launch is sent. A launch that resolves after either condition changed
closes the session it opened; one that fails by then is not reported.
- "Reset progress" counts as a pending start of the movie until the write
lands, so a start made meanwhile cannot resume from the row being cleared.
- The pending start is owner-scoped (`createPendingPlaybackStart`). Each host
retires it when the selection leaves the owner; that clears the pending
flag, not the repeat guard of a launch still in flight.
Regression coverage: `stalker-series-launch-queue.spec.ts`,
`stalker-series-view.component.spec.ts`,
`stalker-series-view.season-watch.spec.ts`,
`stalker-vod-detail-actions.spec.ts`,
`stalker-vod-playback-controller.spec.ts` and, in
`libs/portal/shared/util/src/lib/`, `pending-playback-start.spec.ts` and
`replace-owned-external-session.spec.ts`.
## Favorites and Recently Viewed
Current implementation is shared via Stalker-specific helpers:
+1 -1
View File
@@ -257,7 +257,7 @@ Zero i18n keys. Zero UI change.
- populated in all three of `mergeVodInfoWithTmdb` (`:168`), `mergeSerieInfoWithTmdb` (`:215`), `mergeStalkerInfoWithTmdb` (`:257`)
- through `NormalizedVodMeta` (`libs/shared/interfaces/src/lib/vod-details-item.interface.ts`) + **both** normalizers in `vod-details-adapters.ts` — the single convergence point where Xtream and Stalker meet
**Component.** New standalone `app-tmdb-extras-shelf` in `libs/ui/shared-portals`. It must **not** go inline: `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` is **388 lines and is NOT in `tools/eslint/max-lines-baseline.mjs`** — roughly 12 lines of headroom against the hard 400 lint cap.
**Component.** New standalone `app-tmdb-extras-shelf` in `libs/ui/shared-portals`. It must **not** go inline: `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` is **NOT in `tools/eslint/max-lines-baseline.mjs`** and stays under the hard 400 lint cap only because its state lives in sibling helpers (about 340 counted lines today).
**Render** into the `detail-extras` projection slot at **four** sites (`vod-details.component.html:225`, `serial-details.component.html:201`, `vod-details-route.component.html:247`, `stalker-series-view.component.html:202`) — note Xtream `serial-details` has no trailer block today, so it either gains one or the shelf lands inconsistently. Reuse the existing nocookie iframe + `| safe` pipe so the Electron Referer shim keeps working; clicking swaps the embed `src` rather than opening a new player. Cards use `https://img.youtube.com/vi/{key}/hqdefault.jpg` (CSP verified: `img-src` covers it, `frame-src https://www.youtube-nocookie.com` covers the embed). `@if (extras().length > 1)` … `@else` the existing single-trailer markup **verbatim**.
+26
View File
@@ -757,6 +757,32 @@ lookup comes back empty, because "never watched" is an answer: the button must
read Play, not `Resume 42:18` on a stream that starts at zero. A pin on the
route's own row changes nothing; the loaded position already IS that copy's.
## Menu launch and reset follow the primary button
The "…" menu acts on the copy the primary button acts on. The shared launch
rules are in
[Forced External Launches From Detail Pages](./embedded-inline-playback.md#forced-external-launches-from-detail-pages).
- "Open in external player" and "Start over" are host-owned
(`VodDetailsMenuBindings.openExternal` / `restart`); the menu service never
builds a playback itself. The route forces MPV/VLC for the pinned copy from
that copy's own resume point (`playPinnedSource` with `player` and
`replacePlaying`), also while that copy already plays: it is relaunched,
never swapped for the route copy. Only an `unavailable` pin falls through
to the route copy's Resume or Play.
- "Reset progress" clears the row of `primaryTarget`: the pinned copy's own
row, otherwise the route copy's.
- Resets in flight are a list of targets
(`VodDetailsPlaybackService.pendingResets`, `vod-details-reset-target.ts`),
not one flag: the reused page can show another movie and come back, and
resets of one copy can overlap, so each reset removes only its own entry.
- A start is refused (`startResolvedPlayback`) while a launch this page made
has not settled or the list holds the copy the page currently acts on
(`resetTarget`). `startBlocked` disables Play, Start over and the menu
launch on those conditions and while a matched session is still
`launching`; the menu rows are also held while a start is pending. A reset
still writing for another copy does not block it.
## Provider codec metadata
`info.video` / `info.audio` come back in two shapes: the declared string array
+27 -1
View File
@@ -137,7 +137,33 @@ 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`).
(`light-primary-button` from `libs/ui/styles`). With no artwork at all the
stage is a gradient in the title's hue (`--hero-hue`), light in the light
theme and near-black in the dark one; a dark gradient under the light
theme's page-coloured scrim read as a grey slab behind dark text.
Legibility: slide text stays at 4.5:1 or more over any artwork. The side
scrim holds 88% of the page colour up to the slide's right edge
(`--hero-text-edge`: the inset plus `min(560px, 55%)`, the slide's own
`max-width`) before it opens onto the art. In the narrow layout (`dashboard`
container ≤ 720px) the slide spans the width, so a full-bleed scrim sits
behind the text block (90%, fading in just above the eyebrow), the copy gets
a scrim-coloured text shadow, and the slide enters without a fade so that
scrim never flashes the art on a rotation. Body text is 85% of the heading
colour; the rating chip uses `--app-rating-color`, set per theme in
`m3-theme.scss`. Buttons end long labels in an ellipsis.
`dashboard-hero-legibility.e2e.ts` replaces every image with a black-and-white
checkerboard and measures each piece of slide text from the screen in both
themes, at a wide and a narrow width, for a backdrop, a blurred-poster, a
no-artwork and a live slide.
Semantics: the page has one stable, visually hidden `h1` ("Dashboard",
`dashboard-page-heading`); each slide title is an `h2`, like the rail titles.
Slide changes are announced by one polite live region
(`dashboard-hero-announcement`, position and title) that lives outside the
re-created slide and is silent while the slides rotate on their own. A
slide's progress bar is named after its title (a live slide: the programme)
and a title's reads "N% watched". The dots are 24px targets (WCAG 2.5.8).
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
+77 -5
View File
@@ -93,10 +93,9 @@ The shell is intentionally split into four persistent regions:
4. No brand mark: it only repeated the first workspace link (Dashboard,
or Sources when the dashboard is off).
2. Top header:
1. Leading Back slot, shown while the current page registers a target
with `WorkspaceBackNavigationService` (detail pages today). At
≤640 px it takes the context drawer toggle's place. See
[Portal Detail Navigation](./portal-detail-navigation.md).
1. Leading Back slot: the current page's registered Back, else browser
history while an in-app previous page exists, else nothing. See
[Header Back](#header-back).
2. Playlist switcher.
3. Route-aware search input and command palette trigger.
4. Add source action.
@@ -130,6 +129,71 @@ When adding shell behavior, prefer placing it in the service that owns the
nearest existing state. Keep `WorkspaceShellFacade` as a stable re-export layer
for the template unless the template contract itself intentionally changes.
## Header Back
The header's leading slot is the workspace's one page-level Back. Pages do not
render an arrow of their own: they register a `WorkspaceBackTarget`
(`@iptvnator/portal/shared/util`) with `WorkspaceBackNavigationService`
(`@iptvnator/portal/shared/data-access`), normally through
`registerWorkspaceBack()`, which registers for the calling component's
lifetime while its optional `available` predicate holds. The newest
registration wins, and each release removes only its own target. The button
(`data-test-id="workspace-header-back"`) sits beside the macOS traffic lights,
never scrolls, and has Electron `no-drag` hit testing. A target supplies its
label (else the translated "Back"), whether Escape on the page runs it, and
`run()`.
| Page | Registered by | Back runs | ≤640 px |
| --- | --- | --- | --- |
| Portal, collection, offline and recording details | `PortalDetailShellComponent` while `backAvailable()` | the host's `backClicked` | replaces the drawer toggle |
| Xtream and Stalker Discover and actor pages | `DiscoverViewComponent`, `ActorViewComponent` | the route's `Location.back()` | (no drawer) |
| In-portal search, Xtream and Stalker | `SearchLayoutComponent` while `backAvailable()` and no inline detail replaces the results | `Location.back()` | (no drawer) |
| Settings | `WorkspaceSettingsContextPanelComponent`, which exists exactly while the settings route shows | `Location.back()` | beside the drawer toggle |
Detail-page semantics (Escape, browse and watch) are in
[Portal Detail Navigation](./portal-detail-navigation.md#detail-scroll-and-focus).
**History fallback.** Without a registration, the header shows Back while the
previous history entry is an in-app one, and runs `Location.back()`. The
service reads that from the Navigation API: the previous entry must be
same-document (`NavigationHistoryEntry.sameDocument`), so the router pushed it
after this document loaded. Entries from before a reload or from another page
of the origin never count, and the fallback can neither leave nor reload the
app. `currententrychange` keeps it current through pushes, replacements,
traversals and guard-cancelled Back navigations that the router rewrites.
Without the Navigation API (older Safari and Firefox, jsdom) there is no
fallback; registered pages are unaffected. The fallback reads "Back" and
advertises no Escape, because no page handles one for it. Pages that set
`backAvailable=false`, such as M3U details, therefore show it too when they
were reached by navigation.
When there is nowhere to go, the slot is empty rather than a disabled arrow.
Sessions often start on a page that never navigates (an M3U playlist or live
TV), where a disabled arrow would stay for the whole session. The cost is one
shift of the switcher and search when Back first appears or leaves, which
happens only at the start of the history and together with a route change.
There is no Forward button: Stalker inline details are store state, not
history entries, so Forward would skip them.
**Phone width.** `phoneDrawerToggle` sets how Back shares the leading slot with
the context drawer toggle. `replace` (the default) takes the toggle's slot: a
detail page's drawer belongs to the list that Back returns to. `beside` keeps
both: the settings drawer holds the page's own sections. `yield` hides Back
while the toggle shows: the history fallback must not cost a category list its
only way into the drawer, and two navigation icons do not fit beside the
switcher. System and browser Back still work there.
**Left in place.** These controls stay inside their surface on purpose:
1. Downloads offline and recording detail error states keep their labelled
"Back to Downloads" button beside Retry or Remove. It is the error state's
recovery action, not page chrome; the header shows the same Back.
2. Back controls internal to a surface, which leave a panel rather than the
page: the Embedded MPV dock panel and the alternative-sources panel inside
the VOD "…" menu.
3. The M3U player sidebar's Home button, which renders only outside the
workspace shell.
## Context Panel Rules
The shell decides which secondary panel to show from the current route:
@@ -326,7 +390,15 @@ The Electron window hides the native title bar on all desktop platforms
(`titleBarStyle: 'hidden'` in `apps/electron-backend/src/app/app.ts`):
1. macOS keeps the native traffic lights (`titleBarOverlay: true`,
`trafficLightPosition`); the renderer draws no window buttons.
`trafficLightPosition`); the renderer draws no window buttons. The lights
sit in the 56 px header band above the rail, so the macOS rail
(`.app-rail.is-macos`) starts its first link at 56 px: level with the
content area and the dashboard hero, with its hover surface clear of the
lights. App zoom scales CSS pixels but not the lights, so the rail
publishes the page zoom factor (`outerWidth / innerWidth`, refreshed on
`resize`) as `--rail-zoom-factor` and keeps at least 48 window pixels when
zoomed out. `window-controls.e2e.ts` checks the alignment and the gap at
default and minimum zoom on macOS.
2. Windows and Linux use renderer-drawn window controls
(`app-window-controls`, `libs/ui/components/src/lib/window-controls/`).
`frame` is intentionally left untouched so native resize borders and
@@ -405,3 +405,40 @@ deduplicated list, `hasMoreContent` derives from accumulated length vs
and the facade maps page 0 to the skeleton and later pages to the tail
spinner. These catalog/search surfaces use incremental loading instead of
page buttons.
## Forced external launches from detail pages
The "…" menu's MPV/VLC launch follows the shared rules in
[Forced External Launches From Detail Pages](./embedded-inline-playback.md#forced-external-launches-from-detail-pages).
The movie page's pin and reset rules are in
[VOD Multi-Source](./vod-multi-source.md#menu-launch-and-reset-follow-the-primary-button).
The series page keeps its launch state at module level in
`serial-details-external-launch.ts`, so it outlives a recreated page:
- The owner is `playlist:series` (`launchOwner()`). It changes when the page
shows another series and is null once the page is gone.
- Forced launches of one owner run on one chain. A later launch waits for the
earlier one to settle, closes the owner's running episode session and then
launches. Each step rechecks the owner, and a launch that resolves after the
page left the owner closes the session it opened.
- The duplicate guard is keyed by page token plus episode. The token
(`pageToken()`) is owner, page instance and visit, so a launch left behind
by an earlier visit of the same series does not swallow a launch from the
reopened page; that launch queues on the owner's chain.
- While a forced launch of the owner is pending (`forcedLaunchPending`), a
start that does not force a player is queued instead of started. One choice
is kept per owner, the latest wins, and it carries the host and `start` of
the page that made it. Once the chain settles, the player the launch opened
is closed first while the owner stays pending. The choice is dropped when
that page no longer shows the owner or the close was not confirmed.
- The pending flag also disables the menu's external-player row and the
season and series watched actions, and counts as active playback for "Reset
progress".
- The launch-position marker and a launch-failure message apply only while
the page token is unchanged.
Regression coverage: `serial-details-external-launch.spec.ts` (chain,
duplicate guard, queued choice), `serial-details-playback.service.spec.ts`
(page token) and, for the external-player and reset rows,
`libs/ui/components/src/lib/detail-ui/series-hero.state.spec.ts`.
+257
View File
@@ -0,0 +1,257 @@
# Zoneless change-detection migration
Working checklist for plan item C6 of the performance journeys plan: move the
renderer (`apps/web`) from zone.js to `provideZonelessChangeDetection()`.
The win is measured with the change-detection tick counters described in
[performance journeys](performance-journeys.md#change-detection-ticks); the
[idle work audit](idle-work-audit-2026-09.md) found the Eager roots that
re-render on every tick. Update this file in the same PR that converts an item.
The inventory was taken on `e8b181fce` (2026-10-04, Angular 22.1.6). Run
`pnpm nx run electron-backend-e2e:test-performance-harness` after editing the
Eager list: `zoneless-migration.spec.ts` fails when the list and the code
disagree, so a new Eager component cannot land unnoticed and a converted one
must be ticked here.
## Starting point
- **OnPush is already the default.** Since Angular 22 an unset
`changeDetection` means OnPush, and the old `Default` strategy is spelled
`ChangeDetectionStrategy.Eager`. Only components that set `Eager` are
checked on every tick. `ChangeDetectionStrategy.Default` is not used.
- **Renderer bootstrap.** `apps/web/src/app/app.config.ts` provides
`provideZoneChangeDetection({ eventCoalescing: true })` and
`apps/web/project.json` builds with `"polyfills": ["zone.js"]`.
`apps/remote-control-web` does the same; it is a separate app and outside
this migration unless a step says otherwise.
- **Unit tests already run zoneless.** Every `src/test-setup.ts` (apps/web,
apps/remote-control-web and 24 libs) calls `setupZonelessTestEnv` and loads
`zone.js`/`zone.js/testing` only for `fakeAsync` and `waitForAsync`. A
component that passes its specs is therefore not proof of zone-free
production behavior when the spec calls `fixture.detectChanges()` itself.
- **IPC callbacks never ran in the Angular zone.** `window.electron.on*`
listeners arrive through `contextBridge` and are not zone-patched, so every
one that works today already writes signals or calls `NgZone.run`.
- **Counters before the migration** (macOS, from
[performance journeys](performance-journeys.md#change-detection-ticks)):
`renderer.cdTicksToFirstCard` 20–21 (the one-tick zone.js race),
`renderer.cdTicksIdle30s` 3, `renderer.cdTicksToFirstPage` 22;
`renderer.cdTicksToPlaying` has no recorded run yet.
## PR sequence
1. [ ] Keep `@ngrx/store-devtools` out of production bundles (#1810, open). Not a
zone change; it lowered `renderer.initialBytes` before the migration
starts moving it.
2. [x] This inventory and its guard spec.
3. [ ] Per-project PRs, in this order, each converting the project's Eager
components and fixing its zone-dependent sites while zone.js stays on:
`libs/ui/*`, `libs/workspace/*`, `libs/playlist/*`, `libs/portal/*`,
playback (`libs/ui/playback`, `libs/playlist/m3u/feature-player`),
`apps/web`. Each reports the tick counters before and after and runs the
affected unit and E2E tests.
4. [ ] `provideZonelessChangeDetection()` behind a build-time
`fileReplacements` flag, off by default; all four journeys and the
Electron E2E suite run with it on.
5. [ ] Flag on by default, `zone.js` out of `polyfills`, new tick baselines
(`renderer.cdTicksIdle30s` and any counter that becomes deterministic once
the zone.js race is gone).
## Eager components
66 production files, 67 components (`epg-progress-panel.component.ts` holds
two). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
(or setting OnPush) once its template state is signals, signal inputs or
explicitly marked. The guard spec compares the unticked entries with the
files that still contain `ChangeDetectionStrategy.Eager`.
### apps/web (15)
- [ ] `apps/web/src/app/app.component.ts` (idle audit root)
- [ ] `apps/web/src/app/app-update-notification-panel.component.ts` (idle audit root)
- [ ] `apps/web/src/app/settings/app-update-release-notes-dialog.component.ts`
- [ ] `apps/web/src/app/settings/settings.component.ts`
- [ ] `apps/web/src/app/settings/settings-about-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-backup-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-dashboard-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts`
- [ ] `apps/web/src/app/settings/settings-epg-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-general-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-playback-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-remote-control-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-reset-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-tmdb-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts`
### libs/ui (20 files, 21 components)
- [x] `libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts`
- [x] `libs/ui/components/src/lib/content-hero/content-hero.component.ts`
- [x] `libs/ui/components/src/lib/expandable-text/expandable-text.component.ts`
- [x] `libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts`
- [x] `libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts`
- [x] `libs/ui/components/src/lib/progress-capsule/progress-capsule.component.ts`
- [x] `libs/ui/components/src/lib/season-container/episode-info-dialog.component.ts`
- [x] `libs/ui/components/src/lib/watched-badge/watched-badge.component.ts`
- [x] `libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts`
- [x] `libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts` (idle audit root; also `EpgTrustConfirmDialogComponent`)
- [x] `libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts`
- [ ] `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` (`apps/remote-control-web` only)
- [ ] `libs/ui/playback/src/lib/art-player/art-player.component.ts`
- [ ] `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`
- [ ] `libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts`
- [ ] `libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts`
- [ ] `libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts`
- [ ] `libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts`
- [ ] `libs/ui/playback/src/lib/vod-details/vod-details.component.ts`
- [ ] `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts`
`libs/ui/playback` (8) goes with the playback PR, not the `libs/ui` one.
### apps/remote-control-web (1)
- [ ] `apps/remote-control-web/src/app/app.ts` (separate app; converts with `remote-control.component.ts`)
### libs/workspace (7)
- [ ] `libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts`
- [ ] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts`
- [ ] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts`
- [ ] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts`
- [ ] `libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts`
- [ ] `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` (idle audit root)
- [ ] `libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts`
### libs/playlist (14)
- [ ] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/text-import/text-import.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts`
- [ ] `libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts`
- [ ] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts`
`libs/playlist/m3u/feature-player` (2) goes with the playback PR.
### libs/portal (9)
- [ ] `libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts`
- [ ] `libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts`
- [ ] `libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts`
- [ ] `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts`
- [ ] `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts`
- [ ] `libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts`
- [ ] `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts`
- [ ] `libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts`
- [ ] `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts`
Test-only files that set Eager are not listed; they do not ship. The guard
skips every `*.spec.ts` / `*.test.ts` file with or without a suffix of one
or more segments (`*.spec-stubs.ts`, `*.test-helpers.ts`,
`*.test-data-stubs.ts`, …),
`test-setup.ts` and `test-stubs/` directories.
## Zone-dependent sites
Plain (non-signal) fields read by a template and written from a callback
that is not an Angular template event. Under zone.js the next tick happens to
refresh an Eager view; under zoneless nothing schedules one. Each fix makes
the field a signal (or a `computed`), or writes it through one.
| Done | Site | What depends on the zone | Owning PR |
| --- | --- | --- | --- |
| [ ] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback |
| [ ] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback |
| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist |
| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist |
| [ ] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; template getters read `control.value`, which is not signal-backed | playlist |
| [ ] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal |
| [ ] | `libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts` (favorites load) | same pattern; the neighbouring `favoriteMarks.changes$` handler does call `markForCheck` | portal |
| [ ] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal |
| [ ] | `apps/web/src/app/settings/settings-backup.facade.ts` (backup import) | `change` listener on a detached file input → `hydrateFromStore()`; section templates read `form().value.theme`/`coverSize` | apps/web |
| [ ] | `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` | plain `isLoading`/`error`/`status` written after `await` and from a 2 s `setInterval` | only if `apps/remote-control-web` goes zoneless |
## Explicit zone and change-detector calls
They keep working under zoneless (`NgZone` becomes `NoopNgZone`, so `run`
and `runOutsideAngular` just call through). Remove them in the flip PR, not
before: with zone.js on they still matter.
- [ ] `apps/web/src/app/settings/settings-unload-guard.service.ts`: two
`zone.run` calls around the window-close dialog (IPC
`onWindowCloseRequested` and `beforeunload`).
- [ ] `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts`:
`runOutsideAngular(() => setInterval(...))` for the position poll;
`embedded-mpv-session-controller.position.spec.ts` asserts the call and
changes with it.
- [ ] `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts`:
18 `ngZone.run(() => signal.set(...))` calls, all redundant around signal
writes.
- [ ] `libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts`:
one `ngZone.run` around a signal update.
- `ChangeDetectorRef` in `stalker-live-stream-layout.component.ts`
(4 × `markForCheck`, 1 × `detectChanges` before measuring a row) and
`portal-channels-list.component.ts` (3 × `markForCheck`, 2 ×
`detectChanges`): correct under zoneless; replace the Maps with signals in
the portal PR if it stays small.
## Checked and signal-safe
No change needed; recorded so the flag PR knows where to look if a journey
regresses. Embedded MPV and external players are the riskiest paths because
their events arrive over IPC.
- **IPC listeners** (17 registrations): app update status, external player
sessions, window close and window state, player errors, playlist open
requests, embedded MPV sessions, playback history gate, downloads,
recordings, playlist refresh, DB operation and save progress, EPG progress,
playback position updates, channel change and remote-control commands. All
write signals, signal stores or NgRx, or have no UI state.
- **Player libraries** (video.js, mpegts.js, hls.js, artplayer, shaka, native
`<video>`): callbacks bump signals in the control adapters or emit outputs
whose parent handlers write signals.
- **Observers** (13 Intersection/Resize/Mutation observers) and **document
and window listeners** (~40): signals or DOM only. `@HostListener`
bindings are Angular listeners and mark their view.
- **Timers** (~130 `setTimeout`/`setInterval`/rAF/`queueMicrotask`): all
write signals, touch the DOM or focus, or have no UI state, apart from the
two in the table above.
- **Dialogs and snackbars** (20 `afterClosed`/`onAction` sites): signals,
stores, outputs or navigation, apart from the one in the table above.
- No production code uses `NgZone.onStable`, `onMicrotaskEmpty`, `isStable`,
`ApplicationRef.tick()`, `Zone.current` or `ngDoCheck`.
## Build, tests and runtime details
- [ ] `provideServiceWorker(..., { registrationStrategy:
'registerWhenStable:30000' })`: under zoneless "stable" means no pending
tasks. The 30 s bound still registers the worker; check the PWA build in
the flag PR.
- [ ] `change-detection-tick-counter.ts` wraps `ApplicationRef._tick`, which
the zoneless scheduler also calls, so the counters stay comparable.
- [ ] Specs that need zone.js: `fakeAsync` in
`playlist-switcher.component.spec.ts` and `stalker-live-navigation.spec.ts`,
`waitForAsync` in 13 files. They keep `zone.js/testing` until rewritten;
removing zone.js from the build polyfills does not affect them.
- [ ] Unreferenced leftovers to delete in the flip PR:
`apps/web/src/polyfills.ts`, `apps/web/src/polyfills-test.ts`,
`apps/web/src/setup-jest.ts` (no project, tsconfig or Jest config uses
them).
## Measuring a PR
Build `electron-performance` and run the journeys as described in
[performance journeys](performance-journeys.md), then paste
`renderer.cdTicksToFirstCard`, `renderer.cdTicksIdle30s`,
`renderer.cdTicksToFirstPage` and `renderer.cdTicksToPlaying` before and
after. While zone.js is on, removing Eager does not change the number of
ticks, only the work per tick; expect the counters to stay put until the flag
PR and the template work (DOM mutations, profile time) to drop.
+1
View File
@@ -15,6 +15,7 @@ are not prerequisites for reading repository contracts.
| Angular conventions; docs and skills maintenance; local review before a pull request | [Agent workflow](../development/agent-workflow.md) | Use the area's skill below |
| Unit, E2E, lint and coverage; `tools/coverage`, `tools/typecheck` | [Validation map](../architecture/validation-map.md) | Use the area's validation section |
| Performance journeys, counters, benchmark probes and the CI ratchet; `apps/electron-backend-e2e/src/journeys`, `apps/electron-backend-e2e/src/performance`, `tools/performance` | [Performance journeys](../architecture/performance-journeys.md) | Read the contract directly |
| Zoneless change detection, `ChangeDetectionStrategy.Eager` components, `NgZone` usage | [Zoneless migration](../architecture/zoneless-migration.md) | Read the checklist directly |
| Electron entry/events/preload and CDP; `apps/electron-backend` | [Debugging and trace flags](../development/electron-debugging.md), [Electron security](../architecture/electron-security.md) | Use the available global electron skill for automation |
| Releases, notes, screenshots, native assets, Linux manager metadata; `tools/release` | [Release pipeline](../architecture/release-pipeline.md), [note format](../../.changes/README.md) | [Release notes](../../.codex/skills/release-notes/SKILL.md), [release cut](../../.codex/skills/release-cut/SKILL.md) |
@@ -7,4 +7,5 @@ export * from './lib/source-health.service';
export * from './lib/source-cleanup.service';
export * from './lib/register-workspace-back';
export * from './lib/workspace-back-navigation.service';
@@ -0,0 +1,54 @@
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { registerWorkspaceBack } from './register-workspace-back';
import { WorkspaceBackNavigationService } from './workspace-back-navigation.service';
@Component({ template: '' })
class PageComponent {
readonly available = signal(true);
readonly run = jest.fn();
constructor() {
registerWorkspaceBack({
available: this.available,
phoneDrawerToggle: 'beside',
run: () => this.run(),
});
}
}
describe('registerWorkspaceBack', () => {
function setup() {
TestBed.resetTestingModule();
const fixture = TestBed.createComponent(PageComponent);
fixture.detectChanges();
const backNavigation = TestBed.inject(WorkspaceBackNavigationService);
return { fixture, backNavigation, page: fixture.componentInstance };
}
it('offers a generic Back without Escape that runs the page handler', () => {
const { backNavigation, page } = setup();
const target = backNavigation.target();
expect(target?.label()).toBeNull();
expect(target?.escapeShortcut()).toBe(false);
expect(target?.phoneDrawerToggle).toBe('beside');
expect(backNavigation.goBack()).toBe(true);
expect(page.run).toHaveBeenCalledTimes(1);
});
it('registers only while available and releases with the page', () => {
const { fixture, backNavigation, page } = setup();
page.available.set(false);
TestBed.tick();
expect(backNavigation.target()).toBeNull();
page.available.set(true);
TestBed.tick();
expect(backNavigation.target()).not.toBeNull();
fixture.destroy();
expect(backNavigation.target()).toBeNull();
});
});
@@ -0,0 +1,39 @@
import { effect, inject, Signal, signal } from '@angular/core';
import {
WorkspaceBackPhoneSlot,
WorkspaceBackTarget,
} from '@iptvnator/portal/shared/util';
import { WorkspaceBackNavigationService } from './workspace-back-navigation.service';
export interface WorkspaceBackRegistration {
/** Registers only while this returns true; always when omitted. */
readonly available?: () => boolean;
/** Accessible name and tooltip; the generic "Back" when omitted. */
readonly label?: Signal<string | null>;
/** True while Escape on the page runs the same action; false if omitted. */
readonly escapeShortcut?: Signal<boolean>;
readonly phoneDrawerToggle?: WorkspaceBackPhoneSlot;
run(): void;
}
/**
* Offers a page's Back in the workspace header instead of an arrow of its
* own, for as long as the calling component lives and `available` holds.
* Must be called in an injection context (a field initializer or the
* constructor).
*/
export function registerWorkspaceBack(
registration: WorkspaceBackRegistration
): void {
const backNavigation = inject(WorkspaceBackNavigationService);
const target: WorkspaceBackTarget = {
label: registration.label ?? signal(null),
escapeShortcut: registration.escapeShortcut ?? signal(false),
phoneDrawerToggle: registration.phoneDrawerToggle,
run: () => registration.run(),
};
effect((onCleanup) => {
if (registration.available && !registration.available()) return;
onCleanup(backNavigation.register(target));
});
}
@@ -1,11 +1,62 @@
import { Location } from '@angular/common';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
import { WorkspaceBackNavigationService } from './workspace-back-navigation.service';
import {
WORKSPACE_HISTORY_NAVIGATION,
WorkspaceBackNavigationService,
WorkspaceHistoryNavigation,
} from './workspace-back-navigation.service';
/** Session history as the Navigation API reports it. */
class FakeHistory extends EventTarget {
private list: { index: number; sameDocument: boolean }[] = [];
private current = -1;
constructor(sameDocument: boolean[] = [true]) {
super();
sameDocument.forEach((same) => this.push(same, false));
}
get currentEntry() {
return this.list[this.current] ?? null;
}
entries() {
return this.list;
}
/** A router push; earlier documents' entries are not same-document. */
push(sameDocument = true, notify = true): void {
this.list = this.list.slice(0, this.current + 1);
this.list.push({ index: this.list.length, sameDocument });
this.current = this.list.length - 1;
if (notify) this.dispatchEvent(new Event('currententrychange'));
}
traverseTo(index: number): void {
this.current = index;
this.dispatchEvent(new Event('currententrychange'));
}
}
describe('WorkspaceBackNavigationService', () => {
function createService(): WorkspaceBackNavigationService {
const back = jest.fn();
function createService(
history: FakeHistory | null = null
): WorkspaceBackNavigationService {
back.mockReset();
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{ provide: Location, useValue: { back } },
{
provide: WORKSPACE_HISTORY_NAVIGATION,
useValue: history as unknown as WorkspaceHistoryNavigation,
},
],
});
return TestBed.inject(WorkspaceBackNavigationService);
}
@@ -74,4 +125,81 @@ describe('WorkspaceBackNavigationService', () => {
expect(service.target()).toBe(second);
});
describe('history fallback', () => {
it('shows nothing on the first page of the session', () => {
const service = createService(new FakeHistory());
expect(service.target()).toBeNull();
expect(service.goBack()).toBe(false);
});
it('goes back in history once the router pushed a page', () => {
const history = new FakeHistory();
const service = createService(history);
history.push();
const target = service.target();
expect(target?.label()).toBeNull();
// No page handles Escape, and a list keeps its phone drawer.
expect(target?.escapeShortcut()).toBe(false);
expect(target?.phoneDrawerToggle).toBe('yield');
expect(service.goBack()).toBe(true);
expect(back).toHaveBeenCalledTimes(1);
});
it('disappears when Back returns to the first page and returns on Forward', () => {
const history = new FakeHistory();
const service = createService(history);
history.push();
history.traverseTo(0);
expect(service.target()).toBeNull();
history.traverseTo(1);
expect(service.target()).not.toBeNull();
});
it('never leads out of the app or across a reload', () => {
// An entry from another page of the origin, or from this app's
// document before a reload, belongs to a different document.
const service = createService(new FakeHistory([false, true]));
expect(service.target()).toBeNull();
});
it('yields to a page that registers Back and returns after it goes', () => {
const history = new FakeHistory();
const service = createService(history);
history.push();
const fallback = service.target();
const page = createTarget();
const release = service.register(page);
expect(service.target()).toBe(page);
release();
expect(service.target()).toBe(fallback);
});
it('is absent without the Navigation API', () => {
const service = createService(null);
expect(service.target()).toBeNull();
});
it('stops listening when the injector is destroyed', () => {
const history = new FakeHistory();
const remove = jest.spyOn(history, 'removeEventListener');
createService(history);
TestBed.resetTestingModule();
expect(remove).toHaveBeenCalledWith(
'currententrychange',
expect.any(Function)
);
});
});
});
@@ -1,20 +1,91 @@
import { computed, Injectable, signal } from '@angular/core';
import { DOCUMENT, Location } from '@angular/common';
import {
computed,
DestroyRef,
inject,
Injectable,
InjectionToken,
signal,
} from '@angular/core';
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
/** The parts of the browser's Navigation API the history fallback reads. */
export type WorkspaceHistoryNavigation = Pick<
Navigation,
'currentEntry' | 'entries' | 'addEventListener' | 'removeEventListener'
>;
/**
* The browser's Navigation API; null where it is missing (older Safari and
* Firefox, jsdom), which leaves the header without the history fallback.
*/
export const WORKSPACE_HISTORY_NAVIGATION =
new InjectionToken<WorkspaceHistoryNavigation | null>(
'WORKSPACE_HISTORY_NAVIGATION',
{
providedIn: 'root',
factory: () => inject(DOCUMENT).defaultView?.navigation ?? null,
}
);
/**
* True when the previous history entry belongs to this document, i.e. the
* router pushed it in this app session. Entries from before a reload or from
* another page of the origin are excluded, so the fallback never leaves the
* app or reloads it.
*/
function hasInAppPreviousEntry(history: WorkspaceHistoryNavigation): boolean {
const index = history.currentEntry?.index ?? -1;
return index > 0 && history.entries()[index - 1]?.sameDocument === true;
}
/**
* Owns the header's Back slot. Pages register while they offer Back; the most
* recent registration wins, so a page opened above another one takes the slot
* and hands it back when it goes away.
* and hands it back when it goes away. Without a registration the slot falls
* back to browser history while an in-app previous entry exists, and is empty
* otherwise (never a disabled arrow).
*/
@Injectable({ providedIn: 'root' })
export class WorkspaceBackNavigationService {
private readonly location = inject(Location);
private readonly targets = signal<readonly WorkspaceBackTarget[]>([]);
private readonly canGoBackInApp = signal(false);
/**
* Generic Back to the previous page. It advertises no Escape (no page
* handles one) and yields to the phone drawer toggle, so the categories
* of a list reached by navigation stay reachable.
*/
private readonly historyTarget: WorkspaceBackTarget = {
label: signal(null),
escapeShortcut: signal(false),
phoneDrawerToggle: 'yield',
run: () => this.location.back(),
};
readonly target = computed(() => {
const targets = this.targets();
return targets[targets.length - 1] ?? null;
return (
targets[targets.length - 1] ??
(this.canGoBackInApp() ? this.historyTarget : null)
);
});
constructor() {
const history = inject(WORKSPACE_HISTORY_NAVIGATION);
if (!history) return;
// Fires for router pushes and replacements and for traversals,
// including a guard-cancelled Back that the router rewrites.
const sync = () =>
this.canGoBackInApp.set(hasInAppPreviousEntry(history));
sync();
history.addEventListener('currententrychange', sync);
inject(DestroyRef).onDestroy(() =>
history.removeEventListener('currententrychange', sync)
);
}
/**
* Returns the release function. It removes only this target: when one
* page replaces another (a loading shell by the loaded one), creation and
@@ -31,7 +102,7 @@ export class WorkspaceBackNavigationService {
);
}
/** Runs the current target; false when no page offers Back. */
/** Runs the current target; false when the header shows no Back. */
goBack(): boolean {
const target = this.target();
if (!target) return false;
@@ -7,19 +7,7 @@
<div class="header sticky-header">
<div class="header-top">
<div class="header-left">
<div class="header-title-group">
@if (showBackButton()) {
<button
mat-icon-button
class="header-back"
[attr.aria-label]="'BACK' | translate"
(click)="onBackClick()"
>
<mat-icon>arrow_back</mat-icon>
</button>
}
<h2>{{ title() | translate }}</h2>
</div>
<!-- Filters slot -->
<div class="type-filters">
@@ -52,18 +52,6 @@
flex: 1;
flex-wrap: wrap;
// Keep the back arrow and title tightly grouped, independent of the
// wide gap between the title group and the filters (RTL-safe)
.header-title-group {
display: flex;
align-items: center;
gap: 4px;
}
.header-back {
flex-shrink: 0;
}
h2 {
margin: 0;
white-space: nowrap;
@@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslatePipe } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import { SearchLayoutComponent } from './search-layout.component';
describe('SearchLayoutComponent', () => {
@@ -58,24 +59,35 @@ describe('SearchLayoutComponent', () => {
});
}
it('shows the back button only when showBackButton is set and emits backClick', () => {
fixture.detectChanges();
expect(
fixture.debugElement.query(By.css('.header-back'))
).toBeNull();
fixture.componentRef.setInput('showBackButton', true);
fixture.detectChanges();
const backButton = fixture.debugElement.query(By.css('.header-back'));
expect(backButton).not.toBeNull();
it('offers the header Back only while backAvailable is set and the results show', () => {
const backNavigation = TestBed.inject(WorkspaceBackNavigationService);
const emitted: unknown[] = [];
fixture.componentInstance.backClick.subscribe((value) =>
emitted.push(value)
);
(backButton.nativeElement as HTMLButtonElement).click();
fixture.detectChanges();
expect(backNavigation.target()).toBeNull();
fixture.componentRef.setInput('backAvailable', true);
fixture.detectChanges();
// The page carries no arrow of its own.
expect(
fixture.debugElement.query(By.css('button[aria-label="BACK"]'))
).toBeNull();
expect(backNavigation.goBack()).toBe(true);
expect(emitted).toHaveLength(1);
// An inline detail replaces the results and registers its own Back.
fixture.componentRef.setInput('showDetails', true);
fixture.detectChanges();
expect(backNavigation.target()).toBeNull();
fixture.componentRef.setInput('showDetails', false);
fixture.detectChanges();
expect(backNavigation.target()).not.toBeNull();
fixture.destroy();
expect(backNavigation.target()).toBeNull();
});
it('emits nearEnd when the results container is scrolled near the bottom', () => {
@@ -10,6 +10,7 @@ import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access';
import { InfiniteScrollDirective } from '../../directives/infinite-scroll.directive';
import { SearchFormComponent } from '../search-form/search-form.component';
@@ -48,8 +49,12 @@ export class SearchLayoutComponent {
/** Whether to show the close button (for dialog mode) */
readonly showCloseButton = input<boolean>(false);
/** Whether to show the back button (nested search reached via navigation) */
readonly showBackButton = input<boolean>(false);
/**
* Whether the page offers Back (a nested search reached via navigation).
* It is the workspace header's Back, registered while the results show;
* an inline detail above them brings its own.
*/
readonly backAvailable = input<boolean>(false);
/** Whether to show results count */
readonly showResultsCount = input<boolean>(true);
@@ -106,7 +111,7 @@ export class SearchLayoutComponent {
/** Emitted when close button is clicked */
readonly closeClick = output<void>();
/** Emitted when the back button is clicked */
/** Emitted by the header's Back while `backAvailable` is set */
readonly backClick = output<void>();
/**
@@ -116,6 +121,13 @@ export class SearchLayoutComponent {
*/
readonly nearEnd = output<void>();
constructor() {
registerWorkspaceBack({
available: () => this.backAvailable() && !this.showDetails(),
run: () => this.backClick.emit(),
});
}
/** Focus the search input */
focusSearchInput(): void {
this.searchFormComponent()?.focusSearchInput();
@@ -142,10 +154,6 @@ export class SearchLayoutComponent {
this.closeClick.emit();
}
onBackClick(): void {
this.backClick.emit();
}
/** Check if we should show the "no results" state */
get showNoResults(): boolean {
return (
@@ -1,10 +1,25 @@
import { Signal } from '@angular/core';
/**
* How Back shares the phone header (≤640 px) with the context drawer toggle
* on routes that have one.
*
* - `replace` (default): Back takes the toggle's slot. The drawer belongs to
* the list Back returns to (detail pages), which shows the toggle again.
* - `beside`: both stay. The drawer holds the page's own navigation
* (settings sections), so hiding its toggle would strand it.
* - `yield`: Back hides while the toggle shows. Used by the history
* fallback, so a category list never loses its drawer to it.
*/
export type WorkspaceBackPhoneSlot = 'replace' | 'beside' | 'yield';
/** A page's Back action, rendered in the workspace header's leading slot. */
export interface WorkspaceBackTarget {
/** Accessible name and tooltip; null falls back to the generic "Back". */
readonly label: Signal<string | null>;
/** True while Escape on the page runs the same action. */
readonly escapeShortcut: Signal<boolean>;
/** Phone-width placement beside the drawer toggle; `replace` if unset. */
readonly phoneDrawerToggle?: WorkspaceBackPhoneSlot;
run(): void;
}
@@ -38,7 +38,6 @@ import {
PlaylistsService,
} from '@iptvnator/services';
import {
PlaybackPositionData,
ResolvedPortalPlayback,
StalkerVodDetails,
VodDetailsItem,
@@ -47,6 +46,8 @@ import {
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { startStalkerCatalogVodPlayback } from './stalker-catalog-vod-playback';
import { StalkerCatalogVodPosition } from './stalker-catalog-vod-position';
import { startStalkerVodDownload } from './stalker-vod-download';
import { createStalkerVodWatchedToggle } from '../stalker-vod-watched-toggle';
import {
@@ -107,13 +108,16 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
readonly playbackOwnerKey = computed(() =>
JSON.stringify([this.playbackSessionKey(), this.contentType()])
);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);
private unsubscribePositionUpdates: (() => void) | null = null;
private positionLoadGeneration = 0;
private readonly vodPosition = new StalkerCatalogVodPosition({
playbackPositions: this.playbackPositions,
playbackPositionBridge: this.playbackPositionBridge,
playlistId: () => this.catalog.playlist()?.id,
selectedItem: this.selectedItem,
contentType: this.contentType,
isSeriesDetail: () => this.isSeriesDetail(),
});
/** The stored row is in hand (not the placeholder shown while reading). */
readonly positionLoaded = signal(false);
readonly positionLoaded = this.vodPosition.loaded;
/**
* The start still waiting on the portal between the click and playback,
* keyed by its owner: a stale resolution for the previous movie must not
@@ -146,13 +150,13 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
});
readonly selectedVodPlaybackDuration = computed<number | null>(
() => this.selectedVodPosition()?.durationSeconds ?? null
() => this.vodPosition.position()?.durationSeconds ?? null
);
readonly sourceLabel = computed(
() => this.catalog.playlist()?.title ?? null
);
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
() => this.vodPosition.position()?.positionSeconds ?? null
);
/** Manual watched toggle; the child gates it on live playback itself. */
@@ -165,7 +169,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
: null;
},
playbackPositions: this.playbackPositions,
position: this.selectedVodPosition,
position: this.vodPosition.position,
playingNow: computed(
() => this.inlinePlayback() !== null || this.playbackStartPending()
),
@@ -173,8 +177,8 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
applyPosition: (position) => {
// A read still in flight started from the pre-write row; letting
// it land would revert the toggle it never saw.
this.positionLoadGeneration++;
this.selectedVodPosition.set(position);
this.vodPosition.discardPendingLoad();
this.vodPosition.position.set(position);
},
snackBar: this.snackBar,
translateService: this.translateService,
@@ -196,22 +200,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
);
constructor() {
effect(() => {
const item = this.selectedItem();
const playlistId = this.catalog.playlist()?.id;
if (
!item ||
!playlistId ||
this.contentType() !== 'vod' ||
this.isSeriesDetail()
) {
this.selectedVodPosition.set(null);
return;
}
void this.loadSelectedVodPosition(playlistId, Number(item.id));
});
this.vodPosition.connect();
effect(() => {
const ownerKey = this.playbackOwnerKey();
@@ -222,22 +211,6 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
this.currentPlaybackOwnerKey = ownerKey;
this.closeInlinePlayer();
});
this.unsubscribePositionUpdates =
this.playbackPositionBridge.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
const currentItem = this.selectedItem();
if (
data.contentType !== 'vod' ||
data.playlistId !== this.catalog.playlist()?.id ||
data.contentXtreamId !== Number(currentItem?.id)
) {
return;
}
this.selectedVodPosition.set(data);
}
) ?? null;
}
onVodPlay(item: VodDetailsItem, positionSeconds?: number): void {
@@ -281,8 +254,8 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
this.contentType() === 'vod' && !this.isSeriesDetail()
? Number(this.selectedItem()?.id) || null
: null,
selectedVodPosition: this.selectedVodPosition,
discardPendingPositionLoad: () => ++this.positionLoadGeneration,
selectedVodPosition: this.vodPosition.position,
discardPendingPositionLoad: () => this.vodPosition.discardPendingLoad(),
beforeExternalLaunch: () => this.closeInlinePlayer(),
beginPendingStart: () => beginTrackedExternalLaunch(this),
afterProgressReset: (playlistId) =>
@@ -338,7 +311,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
playlistId,
position
),
onSaved: (position) => this.selectedVodPosition.set(position),
onSaved: (position) => this.vodPosition.position.set(position),
});
handleInlineTimeUpdate(event: {
@@ -356,36 +329,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
ngOnDestroy(): void {
this.closeInlinePlayer();
this.unsubscribePositionUpdates?.();
}
private async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
const generation = ++this.positionLoadGeneration;
this.positionLoaded.set(false);
if (Number.isNaN(vodId)) {
this.selectedVodPosition.set(null);
return;
}
const position = await this.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
// Only the newest read for the item still on screen may land: an
// older one would revert a watched toggle or a later selection.
if (
generation !== this.positionLoadGeneration ||
this.catalog.playlist()?.id !== playlistId ||
Number(this.selectedItem()?.id) !== vodId
) {
return;
}
this.selectedVodPosition.set(position ?? null);
this.positionLoaded.set(true);
this.vodPosition.disconnect();
}
private async startStalkerVodPlayback(
@@ -394,54 +338,19 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
thumbnail?: string,
startTime?: number
): Promise<void> {
const requestId = ++this.playbackRequestId;
const sessionKey = this.playbackSessionKey();
const ownerKey = this.playbackOwnerKey();
const usesEmbeddedPlayer = this.portalPlayer.isEmbeddedPlayer();
if (usesEmbeddedPlayer && !sessionKey) return;
const startId = this.pendingStart.begin(ownerKey);
try {
const playback = await this.catalog.resolveVodPlayback(
await startStalkerCatalogVodPlayback(this, {
resolvePlayback: () =>
this.catalog.resolveVodPlayback(
cmd,
title,
thumbnail,
startTime
);
if (
requestId !== this.playbackRequestId ||
this.playbackOwnerKey() !== ownerKey
) {
return;
}
this.positionWriter.reset();
if (usesEmbeddedPlayer) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
if (
requestId !== this.playbackRequestId ||
this.playbackOwnerKey() !== ownerKey
) {
return;
}
this.logger.error('Failed to start inline VOD playback', error);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, undefined, {
duration: 3000,
),
portalPlayer: this.portalPlayer,
resetPositionWriter: () => this.positionWriter.reset(),
logger: this.logger,
translate: this.translateService,
snackBar: this.snackBar,
});
} finally {
this.pendingStart.settle(startId);
}
}
}
@@ -0,0 +1,84 @@
import type { WritableSignal } from '@angular/core';
import type { MatSnackBar } from '@angular/material/snack-bar';
import type { TranslateService } from '@ngx-translate/core';
import type { Logger, PortalPlayer } from '@iptvnator/portal/shared/util';
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
/** The detail page as the owner of its inline player: it tracks its own request ids. */
interface StalkerCatalogVodPlaybackHost {
playbackRequestId: number;
readonly pendingStart: {
begin(owner: string): number;
settle(startId: number): void;
};
playbackOwnerKey(): string;
playbackSessionKey(): string;
readonly inlinePlayback: WritableSignal<ResolvedPortalPlayback | null>;
closeInlinePlayer(): void;
}
interface StalkerCatalogVodPlaybackDeps {
readonly resolvePlayback: () => Promise<ResolvedPortalPlayback>;
readonly portalPlayer: Pick<
PortalPlayer,
'isEmbeddedPlayer' | 'openResolvedPlayback'
>;
/** Clears the inline position writer for the playback about to mount. */
readonly resetPositionWriter: () => void;
readonly logger: Pick<Logger, 'error'>;
readonly translate: Pick<TranslateService, 'instant'>;
readonly snackBar: Pick<MatSnackBar, 'open'>;
}
/**
* Play/Resume of the movie on the routed catalog detail: resolves the
* stream and hands it to the inline or the configured player, unless the
* page moved on or a newer start took over while the portal answered.
*/
export async function startStalkerCatalogVodPlayback(
host: StalkerCatalogVodPlaybackHost,
deps: StalkerCatalogVodPlaybackDeps
): Promise<void> {
const requestId = ++host.playbackRequestId;
const sessionKey = host.playbackSessionKey();
const ownerKey = host.playbackOwnerKey();
const usesEmbeddedPlayer = deps.portalPlayer.isEmbeddedPlayer();
if (usesEmbeddedPlayer && !sessionKey) return;
const startId = host.pendingStart.begin(ownerKey);
try {
const playback = await deps.resolvePlayback();
if (
requestId !== host.playbackRequestId ||
host.playbackOwnerKey() !== ownerKey
) {
return;
}
deps.resetPositionWriter();
if (usesEmbeddedPlayer) {
host.inlinePlayback.set(playback);
return;
}
host.closeInlinePlayer();
void deps.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
if (
requestId !== host.playbackRequestId ||
host.playbackOwnerKey() !== ownerKey
) {
return;
}
deps.logger.error('Failed to start inline VOD playback', error);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? deps.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
: deps.translate.instant('PORTALS.PLAYBACK_ERROR');
deps.snackBar.open(errorMessage, undefined, {
duration: 3000,
});
} finally {
host.pendingStart.settle(startId);
}
}
@@ -0,0 +1,110 @@
import { type Signal, effect, signal } from '@angular/core';
import type { PortalPlaybackPositions } from '@iptvnator/portal/shared/util';
import type { StalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access';
import type { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
interface StalkerCatalogVodPositionConfig {
readonly playbackPositions: Pick<
PortalPlaybackPositions,
'getPlaybackPosition'
>;
readonly playbackPositionBridge: Pick<
PlaybackPositionRuntimeBridgeService,
'onPlaybackPositionUpdate'
>;
readonly playlistId: () => string | undefined;
readonly selectedItem: Signal<StalkerSelectedVodItem | null>;
readonly contentType: () => string;
readonly isSeriesDetail: () => boolean;
}
/**
* The stored playback position of the movie the routed catalog detail
* shows: read whenever the selection changes and kept current from the
* playback runtime while that movie stays on screen.
*/
export class StalkerCatalogVodPosition {
readonly position = signal<PlaybackPositionData | null>(null);
/** The stored row is in hand (not the placeholder shown while reading). */
readonly loaded = signal(false);
private unsubscribePositionUpdates: (() => void) | null = null;
private loadGeneration = 0;
constructor(private readonly config: StalkerCatalogVodPositionConfig) {}
/**
* Starts following the selection and the playback runtime. Registers an
* effect, so the host calls it from its constructor.
*/
connect(): void {
effect(() => {
const item = this.config.selectedItem();
const playlistId = this.config.playlistId();
if (
!item ||
!playlistId ||
this.config.contentType() !== 'vod' ||
this.config.isSeriesDetail()
) {
this.position.set(null);
return;
}
void this.load(playlistId, Number(item.id));
});
this.unsubscribePositionUpdates =
this.config.playbackPositionBridge.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
const currentItem = this.config.selectedItem();
if (
data.contentType !== 'vod' ||
data.playlistId !== this.config.playlistId() ||
data.contentXtreamId !== Number(currentItem?.id)
) {
return;
}
this.position.set(data);
}
) ?? null;
}
disconnect(): void {
this.unsubscribePositionUpdates?.();
}
/** Retires a stored-position read still in flight (a row was written since). */
discardPendingLoad(): void {
this.loadGeneration++;
}
private async load(playlistId: string, vodId: number): Promise<void> {
const generation = ++this.loadGeneration;
this.loaded.set(false);
if (Number.isNaN(vodId)) {
this.position.set(null);
return;
}
const position =
await this.config.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
// Only the newest read for the item still on screen may land: an
// older one would revert a watched toggle or a later selection.
if (
generation !== this.loadGeneration ||
this.config.playlistId() !== playlistId ||
Number(this.config.selectedItem()?.id) !== vodId
) {
return;
}
this.position.set(position ?? null);
this.loaded.set(true);
}
}
@@ -0,0 +1,391 @@
import {
type Signal,
computed,
linkedSignal,
resource,
signal,
} from '@angular/core';
import type { Logger } from '@iptvnator/portal/shared/util';
import {
executeStalkerRequest,
type StalkerPortalRepairService,
type StalkerSessionService,
type StalkerVodSource,
withoutWithheldStalkerItems,
} from '@iptvnator/portal/stalker/data-access';
import {
type DataService,
type ParentalLockService,
resetHostConnectivityGuard,
} from '@iptvnator/services';
import {
type PlaylistMeta,
StalkerPortalActions,
} from '@iptvnator/shared/interfaces';
import { isStalkerSearchRequestCurrent } from './stalker-search-request.util';
import {
buildStalkerSearchRequestParams,
dedupeSearchResults,
recordNewWithheldRows,
resolveSearchWithheldCategoryIds,
type StalkerSearchContentType,
type StalkerSearchResponse,
withAbsoluteScreenshotUri,
} from './stalker-search-results.util';
interface StalkerSearchPagingControllerConfig {
searchTerm: Signal<string>;
selectedFilterType: Signal<StalkerSearchContentType>;
/** The portal being searched; null while none is resolved. */
currentPlaylist: Signal<PlaylistMeta | null>;
dataService: DataService;
parentalLock: ParentalLockService;
stalkerSession: StalkerSessionService;
portalRepair: StalkerPortalRepairService;
logger: Logger;
/** Closes the host's open detail when its genre became withheld. */
closeWithheldDetail: (withheldCategoryIds: ReadonlySet<string>) => void;
}
/**
* Owns the result paging of the Stalker search page: the portal page being
* requested, the pages accumulated into one list and the parental-lock
* bookkeeping that decides whether paging continues.
*/
export class StalkerSearchPagingController {
/** Lock version the accumulated results were built under. */
private searchResultsLockVersion: number | null = null;
/**
* Withheld row ids seen for the current search identity. A page adding
* only new withheld ids is still progress and is skipped automatically;
* a page adding nothing new is the end of the results.
*/
private searchWithheldKey = '';
private readonly searchWithheldIds = new Set<string>();
constructor(private readonly config: StalkerSearchPagingControllerConfig) {}
/**
* Portal page for the current term+filter+portal; resets when any of
* them changes. The playlist belongs to the identity: Angular reuses the
* search route across `/stalker/A/search` -> `/stalker/B/search`, and a
* surviving page number would append portal B's later page onto portal
* A's accumulated results while skipping B's first page.
*/
readonly searchPage = linkedSignal({
source: () => ({
term: this.config.searchTerm(),
type: this.config.selectedFilterType(),
playlistId: this.config.currentPlaylist()?._id ?? null,
}),
computation: () => 1,
});
/** Pages accumulated into one continuous, deduplicated result list. */
private readonly accumulatedSearchResults = signal<StalkerVodSource[]>([]);
readonly searchResults = this.accumulatedSearchResults.asReadonly();
readonly searchHasMore = signal(false);
/**
* A failed append page. The next near-end RETRIES that page instead of
* advancing — incrementing past it would silently omit its results.
*/
readonly searchAppendError = signal(false);
readonly searchResultsResource = resource({
params: () => ({
contentType: this.config.selectedFilterType(),
search: this.config.searchTerm(),
page: this.searchPage(),
playlistId: this.config.currentPlaylist()?._id ?? null,
action: StalkerPortalActions.GetOrderedList,
// Lock/unlock re-fires the search: withheld rows are dropped at
// page time, so the results must be rebuilt when they change.
parentalLockVersion: this.config.parentalLock.version(),
}),
loader: async ({ params }) => {
if (params.search.length < 3) {
this.resetSearchAccumulator();
return [];
}
const playlist = this.config.currentPlaylist();
if (!playlist) {
// A reused route can land on a deleted/unresolved portal —
// the previous portal's cards must not keep rendering.
this.resetSearchAccumulator();
return [];
}
const { portalUrl, macAddress } = playlist;
if (!portalUrl || !macAddress) {
this.resetSearchAccumulator();
return [];
}
const contentType = params.contentType;
const withheldCategoryIds = resolveSearchWithheldCategoryIds(
this.config.parentalLock,
playlist._id,
contentType
);
const lockVersionChanged =
this.searchResultsLockVersion !== null &&
this.searchResultsLockVersion !== params.parentalLockVersion;
this.searchResultsLockVersion = params.parentalLockVersion;
if (lockVersionChanged) {
this.applyRelockToResults(withheldCategoryIds, contentType);
}
if (lockVersionChanged && params.page > 1) {
// A lock flip past page 1: rebuild from page 1 rather than
// appending to pages accumulated under the old lock state.
this.searchPage.set(1);
return this.accumulatedSearchResults();
}
const withheldKey = JSON.stringify([
params.playlistId,
contentType,
params.search,
params.parentalLockVersion,
]);
if (params.page === 1 || this.searchWithheldKey !== withheldKey) {
this.searchWithheldKey = withheldKey;
this.searchWithheldIds.clear();
}
const requestParams = buildStalkerSearchRequestParams(
contentType,
params.search,
params.page
);
// A stale response (term/filter/page/portal — or the parental
// lock — moved on while this page was in flight) must not clobber
// the accumulated list: the request is not aborted, and a
// pre-relock response was filtered with the pre-relock set.
const isCurrent = (): boolean =>
isStalkerSearchRequestCurrent(params, {
search: this.config.searchTerm(),
contentType: this.config.selectedFilterType(),
page: this.searchPage(),
playlistId: this.config.currentPlaylist()?._id ?? null,
parentalLockVersion: this.config.parentalLock.version(),
});
try {
// executeStalkerRequest owns the portal-mode decision (shared
// predicate with URL fallback for legacy rows) and the lazy
// portal repair, so search cannot drift from the catalog
// paths.
const response =
await executeStalkerRequest<StalkerSearchResponse>(
{
dataService: this.config.dataService,
stalkerSession: this.config.stalkerSession,
portalRepair: this.config.portalRepair,
},
playlist,
requestParams
);
const rawItems = (response.js?.data || []).map(
(item: StalkerVodSource) =>
withAbsoluteScreenshotUri(item, portalUrl)
);
const items = withoutWithheldStalkerItems(
rawItems,
contentType,
withheldCategoryIds
);
// Before the withheld-id bookkeeping: a stale page must not
// pre-record ids into a set a newer relock request cleared,
// or that request's page counts no new withheld rows and
// stops paging short of later visible matches.
if (!isCurrent()) {
return items;
}
const newWithheldCount = recordNewWithheldRows(
rawItems,
items,
this.searchWithheldIds
);
const merged = this.applySearchPageSuccess(
params.page,
items,
response.js?.total_items,
// A page made only of withheld rows still is a page the
// portal served; judge progress on what it sent.
rawItems.length > 0 &&
(items.length > 0 || newWithheldCount > 0)
);
this.advancePastWithheldPage(
params.page,
items.length,
newWithheldCount,
isCurrent
);
return merged;
} catch (error) {
this.config.logger.warn('Stalker search page failed', {
page: params.page,
error,
});
if (!isCurrent()) {
return this.accumulatedSearchResults();
}
return this.applySearchPageFailure(params.page);
}
},
});
/**
* Empties the accumulator and every paging flag — used whenever there is
* no searchable portal (short term, missing playlist, malformed row).
*/
resetSearchAccumulator(): void {
this.accumulatedSearchResults.set([]);
this.searchHasMore.set(false);
this.searchAppendError.set(false);
}
/** Merges a successful portal page into the accumulated result list. */
applySearchPageSuccess(
page: number,
items: StalkerVodSource[],
totalItems: number | undefined,
pageHadRows: boolean = items.length > 0
): StalkerVodSource[] {
const previous = page === 1 ? [] : this.accumulatedSearchResults();
const merged =
page === 1 ? items : dedupeSearchResults([...previous, ...items]);
// Paging continues only while pages make progress — with OR without
// a reported total. Dedup after mid-list portal mutations can leave
// the unique list permanently shorter than total_items, and a
// repeated page dedupes to no growth; either way a no-progress
// append is the practical end of the results. A page whose rows were
// all withheld by the parental lock counts as progress too.
const withheldRows = pageHadRows && items.length === 0;
const madeProgress =
page === 1 || merged.length > previous.length || withheldRows;
this.searchHasMore.set(
madeProgress &&
(typeof totalItems === 'number' && totalItems >= 0
? merged.length < totalItems
: pageHadRows)
);
this.searchAppendError.set(false);
this.accumulatedSearchResults.set(merged);
return merged;
}
/**
* A failed FRESH search (page 1) must not keep rendering the previous
* query's cards; a failed append keeps the accumulated pages and flags
* the error so the next near-end retries this page instead of advancing.
*/
applySearchPageFailure(page: number): StalkerVodSource[] {
if (page === 1) {
this.accumulatedSearchResults.set([]);
this.searchHasMore.set(false);
this.searchAppendError.set(false);
return [];
}
this.searchAppendError.set(true);
return this.accumulatedSearchResults();
}
/**
* Result-set identity for the layout's near-end latch and auto-fill
* budget — term, filter, and portal, mirroring the paging identity.
*/
readonly searchScrollResetKey = computed(() =>
[
this.config.searchTerm(),
this.config.selectedFilterType(),
this.config.currentPlaylist()?._id ?? '',
].join('|')
);
readonly isInitialSearchLoading = computed(
() => this.searchResultsResource.isLoading() && this.searchPage() === 1
);
readonly isAppendingSearchResults = computed(
() => this.searchResultsResource.isLoading() && this.searchPage() > 1
);
loadMoreSearchResults(): void {
if (this.searchResultsResource.isLoading() || !this.searchHasMore()) {
return;
}
if (this.searchAppendError()) {
// Retry the SAME page — advancing would permanently omit it.
void this.retrySearchPage();
return;
}
this.searchPage.update((page) => page + 1);
}
/**
* Two failed search pages are exactly what opens the main process'
* connectivity guard, so the reset has to precede the reload — otherwise
* this retry fast-fails without contacting a portal that may have
* recovered, and keeps repeating the same error until the window expires.
*/
private async retrySearchPage(): Promise<void> {
// Clear the flag synchronously: awaiting first would leave this branch
// re-enterable, and the next `nearEnd` event would fire a second retry.
this.searchAppendError.set(false);
await resetHostConnectivityGuard(
this.config.dataService,
this.config.currentPlaylist()?.portalUrl
);
this.searchResultsResource.reload();
}
/**
* The infinite scroll gives up after a few loads that add no height, so
* a run of pages made only of parental-locked rows must advance by
* itself until a visible row (or the real end) is reached. Only a page
* that added withheld ids not seen before counts — a stalled portal
* repeating the same locked rows must still end the loop.
*/
advancePastWithheldPage(
page: number,
visibleCount: number,
newWithheldCount: number,
isCurrent: () => boolean
): void {
if (
visibleCount > 0 ||
newWithheldCount === 0 ||
!this.searchHasMore()
) {
return;
}
queueMicrotask(() => {
if (isCurrent()) {
this.searchPage.set(page + 1);
}
});
}
/**
* A lock change reached the results on screen, which were read under
* the old lock state: close an open detail of a now-withheld genre (the
* list hiding it is not enough) and drop the withheld rows NOW, before
* the replacement page is awaited — on page 1 too, or they stay
* clickable while (or, if it hangs, after) that request is pending.
*/
applyRelockToResults(
withheldCategoryIds: ReadonlySet<string>,
contentType: Parameters<typeof withoutWithheldStalkerItems>[1]
): void {
this.config.closeWithheldDetail(withheldCategoryIds);
this.accumulatedSearchResults.set(
withoutWithheldStalkerItems(
this.accumulatedSearchResults(),
contentType,
withheldCategoryIds
)
);
}
}
@@ -0,0 +1,138 @@
import {
StalkerContentTypes,
type StalkerVodSource,
stalkerWithheldRowKey,
} from '@iptvnator/portal/stalker/data-access';
import type { ParentalLockService } from '@iptvnator/services';
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
export type StalkerSearchContentType = 'vod' | 'series';
export interface StalkerSearchResponse {
js?: {
data?: StalkerVodSource[];
total_items?: number;
};
message?: string;
status?: number;
}
/** Portals can shift items between pages mid-append — drop duplicate ids. */
export function dedupeSearchResults(
items: StalkerVodSource[]
): StalkerVodSource[] {
const seenIds = new Set<string>();
return items.filter((item) => {
const id =
item.id === undefined || item.id === null ? null : String(item.id);
if (id === null) {
return true;
}
if (seenIds.has(id)) {
return false;
}
seenIds.add(id);
return true;
});
}
/**
* The dedicated search route has no category guard, so it filters the
* portal's rows itself: a locked genre's title must not reach the grid, its
* detail or playback through search.
*/
export function resolveSearchWithheldCategoryIds(
parentalLock: ParentalLockService,
playlistId: string,
contentType: StalkerSearchContentType
): ReadonlySet<string> {
return !parentalLock.active()
? new Set<string>()
: parentalLock.withholdsEverything?.()
? ALL_CATEGORIES_WITHHELD
: new Set(parentalLock.lockedStalkerIds(playlistId, contentType));
}
/**
* Mirror the catalog request shape: many Ministra portals return an empty
* list for get_ordered_list without the category/genre/sortby params the STB
* client always sends. `max_page_items` is a HINT — plenty of portals ignore
* it and return their own page size, which is why paging cannot rely on it
* (progress and `total_items` decide hasMore instead).
*/
export function buildStalkerSearchRequestParams(
contentType: StalkerSearchContentType,
search: string,
page: number
): Record<string, string | number> {
return {
action: StalkerContentTypes[contentType].getContentAction,
type: contentType,
sortby: 'added',
search,
p: page,
max_page_items: 100,
category: '*',
...(contentType === 'vod' ? { genre: '0' } : {}),
};
}
/**
* Records the rows of `rawItems` the parental lock withheld (those missing
* from `keptItems`) into `seenWithheldIds` and returns how many of them were
* not seen before.
*/
export function recordNewWithheldRows(
rawItems: StalkerVodSource[],
keptItems: StalkerVodSource[],
seenWithheldIds: Set<string>
): number {
let newWithheldCount = 0;
if (keptItems.length < rawItems.length) {
const kept = new Set(keptItems);
for (const item of rawItems) {
const id = stalkerWithheldRowKey(item);
if (!kept.has(item) && !seenWithheldIds.has(id)) {
seenWithheldIds.add(id);
newWithheldCount += 1;
}
}
}
return newWithheldCount;
}
/** Resolves a result row's portal-relative poster against the portal origin. */
export function withAbsoluteScreenshotUri(
item: StalkerVodSource,
portalUrl: string
): StalkerVodSource {
const processed = { ...item };
if (processed.screenshot_uri) {
processed.screenshot_uri = makeAbsoluteUrl(
portalUrl,
processed.screenshot_uri
);
}
return processed;
}
function makeAbsoluteUrl(baseUrl: string, relativePath: string): string {
if (!relativePath) return '';
if (
relativePath.startsWith('http://') ||
relativePath.startsWith('https://')
) {
return relativePath;
}
try {
const url = new URL(baseUrl);
const path = relativePath.startsWith('/')
? relativePath
: `/${relativePath}`;
return `${url.origin}${path}`;
} catch {
return relativePath;
}
}
@@ -4,7 +4,7 @@
[isLoading]="isInitialSearchLoading()"
[showResultsCount]="true"
[showSearchInput]="!isWorkspaceLayout"
[showBackButton]="isWorkspaceLayout"
[backAvailable]="isWorkspaceLayout"
[showDetails]="showingDetails"
[nearEndHasMore]="searchHasMore()"
[nearEndAppending]="isAppendingSearchResults()"
@@ -429,12 +429,12 @@ describe('StalkerSearchComponent result paging', () => {
...searchItems('page1', 3),
{ id: 'shared', name: 'Shared item' },
];
component.applySearchPageSuccess(1, pageOne, 7);
component.paging.applySearchPageSuccess(1, pageOne, 7);
expect(component.searchResults()).toHaveLength(4);
expect(component.searchHasMore()).toBe(true);
// The portal shifted `shared` between pages — it must not duplicate.
component.applySearchPageSuccess(
component.paging.applySearchPageSuccess(
2,
[...searchItems('page2', 2), { id: 'shared', name: 'Shared item' }],
7
@@ -442,31 +442,46 @@ describe('StalkerSearchComponent result paging', () => {
expect(component.searchResults()).toHaveLength(6);
expect(component.searchHasMore()).toBe(true);
component.applySearchPageSuccess(3, searchItems('page3', 1), 7);
component.paging.applySearchPageSuccess(3, searchItems('page3', 1), 7);
expect(component.searchResults()).toHaveLength(7);
expect(component.searchHasMore()).toBe(false);
});
it('advances past a page made only of parental-locked rows, but not past a repeated one', async () => {
component.applySearchPageSuccess(1, searchItems('page1', 3), 10);
const pageBefore = component.searchPage();
component.paging.applySearchPageSuccess(1, searchItems('page1', 3), 10);
const pageBefore = component.paging.searchPage();
// Locked rows the list had not seen: schedule the next page.
component.advancePastWithheldPage(pageBefore, 0, 2, () => true);
component.paging.advancePastWithheldPage(pageBefore, 0, 2, () => true);
await flushMicrotasks();
expect(component.searchPage()).toBe(pageBefore + 1);
expect(component.paging.searchPage()).toBe(pageBefore + 1);
// A page with visible rows, or one adding no new withheld ids, or a
// request that is no longer current: stay put.
component.advancePastWithheldPage(pageBefore + 1, 1, 2, () => true);
component.advancePastWithheldPage(pageBefore + 1, 0, 0, () => true);
component.advancePastWithheldPage(pageBefore + 1, 0, 2, () => false);
component.paging.advancePastWithheldPage(
pageBefore + 1,
1,
2,
() => true
);
component.paging.advancePastWithheldPage(
pageBefore + 1,
0,
0,
() => true
);
component.paging.advancePastWithheldPage(
pageBefore + 1,
0,
2,
() => false
);
await flushMicrotasks();
expect(component.searchPage()).toBe(pageBefore + 1);
expect(component.paging.searchPage()).toBe(pageBefore + 1);
});
it('drops withheld rows on screen at relock time, page 1 included', () => {
component.applySearchPageSuccess(
component.paging.applySearchPageSuccess(
1,
[
{ id: 'news-1', name: 'News', category_id: '5' },
@@ -476,7 +491,7 @@ describe('StalkerSearchComponent result paging', () => {
);
expect(component.searchResults()).toHaveLength(2);
component.applyRelockToResults(new Set(['9']), 'vod');
component.paging.applyRelockToResults(new Set(['9']), 'vod');
expect(component.searchResults().map((item) => item.id)).toEqual([
'news-1',
@@ -509,46 +524,54 @@ describe('StalkerSearchComponent result paging', () => {
});
it('keeps paging past a page whose rows were all withheld by the parental lock', () => {
component.applySearchPageSuccess(1, searchItems('page1', 3), 10);
component.paging.applySearchPageSuccess(1, searchItems('page1', 3), 10);
expect(component.searchHasMore()).toBe(true);
// The portal sent rows, every one of them locked: no visible growth,
// but not the end of the results either.
component.applySearchPageSuccess(2, [], 10, true);
component.paging.applySearchPageSuccess(2, [], 10, true);
expect(component.searchResults()).toHaveLength(3);
expect(component.searchHasMore()).toBe(true);
// An actually empty page still ends it.
component.applySearchPageSuccess(3, [], 10, false);
component.paging.applySearchPageSuccess(3, [], 10, false);
expect(component.searchHasMore()).toBe(false);
});
it('stops paging when a total-backed append makes no progress', () => {
component.applySearchPageSuccess(1, searchItems('page1', 3), 10);
component.paging.applySearchPageSuccess(1, searchItems('page1', 3), 10);
expect(component.searchHasMore()).toBe(true);
// The portal repeats page 1 under a larger claimed total — dedupe
// yields no growth, which must still end the paging loop.
component.applySearchPageSuccess(2, searchItems('page1', 3), 10);
component.paging.applySearchPageSuccess(2, searchItems('page1', 3), 10);
expect(component.searchResults()).toHaveLength(3);
expect(component.searchHasMore()).toBe(false);
});
it('stops paging without a total once pages stop making progress', () => {
component.applySearchPageSuccess(1, searchItems('page1', 3), undefined);
component.paging.applySearchPageSuccess(
1,
searchItems('page1', 3),
undefined
);
expect(component.searchHasMore()).toBe(true);
// The portal ignores paging and repeats the same page — dedupe
// yields no growth, which must terminate the loop.
component.applySearchPageSuccess(2, searchItems('page1', 3), undefined);
component.paging.applySearchPageSuccess(
2,
searchItems('page1', 3),
undefined
);
expect(component.searchHasMore()).toBe(false);
});
it('keeps accumulated pages on a failed append and retries the SAME page', async () => {
component.applySearchPageSuccess(1, searchItems('page1', 3), 6);
component.paging.applySearchPageSuccess(1, searchItems('page1', 3), 6);
expect(component.searchHasMore()).toBe(true);
component.applySearchPageFailure(2);
component.paging.applySearchPageFailure(2);
// The failed append kept page 1 on screen and flagged the error.
expect(component.searchResults()).toHaveLength(3);
expect(component.searchAppendError()).toBe(true);
@@ -557,16 +580,16 @@ describe('StalkerSearchComponent result paging', () => {
// The real resource never settles in this template-less harness —
// substitute a deterministic stand-in for the guard checks.
const reload = jest.fn(() => true);
Object.defineProperty(component, 'searchResultsResource', {
Object.defineProperty(component.paging, 'searchResultsResource', {
configurable: true,
value: { isLoading: () => false, reload },
});
// The next near-end must RETRY page 2 (page stays put, the error is
// consumed) instead of advancing to page 3 and skipping results.
const pageBefore = component.searchPage();
const pageBefore = component.paging.searchPage();
component.loadMoreSearchResults();
expect(component.searchPage()).toBe(pageBefore);
expect(component.paging.searchPage()).toBe(pageBefore);
// Cleared synchronously, so a second near-end cannot re-enter the retry
// while the connectivity-guard reset is still in flight.
expect(component.searchAppendError()).toBe(false);
@@ -583,15 +606,15 @@ describe('StalkerSearchComponent result paging', () => {
// With the error cleared, the following near-end advances normally.
component.loadMoreSearchResults();
expect(component.searchPage()).toBe(pageBefore + 1);
expect(component.paging.searchPage()).toBe(pageBefore + 1);
expect(reload).toHaveBeenCalledTimes(1);
});
it("clears the previous query's results when a fresh search fails", () => {
component.applySearchPageSuccess(1, searchItems('matrix', 3), 3);
component.paging.applySearchPageSuccess(1, searchItems('matrix', 3), 3);
expect(component.searchResults()).toHaveLength(3);
component.applySearchPageFailure(1);
component.paging.applySearchPageFailure(1);
expect(component.searchResults()).toHaveLength(0);
expect(component.searchHasMore()).toBe(false);
@@ -644,11 +667,15 @@ describe('StalkerSearchComponent result paging', () => {
// The loader calls this on every no-portal early return (deleted or
// malformed playlist on a reused route, short term) so the previous
// portal's cards cannot keep rendering under the new context.
component.applySearchPageSuccess(1, searchItems('portalA', 3), 6);
component.applySearchPageFailure(2);
component.paging.applySearchPageSuccess(
1,
searchItems('portalA', 3),
6
);
component.paging.applySearchPageFailure(2);
expect(component.searchResults()).toHaveLength(3);
component.resetSearchAccumulator();
component.paging.resetSearchAccumulator();
expect(component.searchResults()).toHaveLength(0);
expect(component.searchHasMore()).toBe(false);
@@ -659,13 +686,17 @@ describe('StalkerSearchComponent result paging', () => {
// Regression: /stalker/A/search -> /stalker/B/search reuses the
// component; a surviving page number would append portal B's later
// page onto portal A's results and skip B's first page.
Object.defineProperty(component, 'searchResultsResource', {
Object.defineProperty(component.paging, 'searchResultsResource', {
configurable: true,
value: { isLoading: () => false, reload: jest.fn(() => true) },
});
component.applySearchPageSuccess(1, searchItems('portalA', 3), 6);
component.paging.applySearchPageSuccess(
1,
searchItems('portalA', 3),
6
);
component.loadMoreSearchResults();
expect(component.searchPage()).toBe(2);
expect(component.paging.searchPage()).toBe(2);
activePlaylist.set({
_id: 'playlist|two',
@@ -674,7 +705,7 @@ describe('StalkerSearchComponent result paging', () => {
macAddress: '00:1A:79:00:00:02',
});
expect(component.searchPage()).toBe(1);
expect(component.paging.searchPage()).toBe(1);
expect(component.searchScrollResetKey()).toContain('playlist|two');
});
});
@@ -4,8 +4,6 @@ import {
computed,
effect,
inject,
linkedSignal,
resource,
signal,
untracked,
viewChild,
@@ -18,30 +16,24 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
executeStalkerRequest,
StalkerPortalRepairService,
StalkerSessionService,
stalkerWithheldRowKey,
withoutWithheldStalkerItems,
} from '@iptvnator/portal/stalker/data-access';
import {
DataService,
ParentalLockService,
PlaylistsService,
resetHostConnectivityGuard,
} from '@iptvnator/services';
import {
ALL_CATEGORIES_WITHHELD,
PlaybackPositionData,
ResolvedPortalPlayback,
StalkerPortalActions,
VodDetailsItem,
} from '@iptvnator/shared/interfaces';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import { ContentCardComponent } from '@iptvnator/portal/shared/ui';
import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui';
import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component';
import { StalkerContentTypes } from '@iptvnator/portal/stalker/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
@@ -71,7 +63,8 @@ import {
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';
import { StalkerSearchPagingController } from './stalker-search-paging.controller';
import type { StalkerSearchContentType } from './stalker-search-results.util';
interface StalkerFilter {
key: StalkerSearchContentType;
@@ -79,34 +72,6 @@ interface StalkerFilter {
translationKey: string;
}
type StalkerSearchContentType = 'vod' | 'series';
interface StalkerSearchResponse {
js?: {
data?: StalkerVodSource[];
total_items?: number;
};
message?: string;
status?: number;
}
/** Portals can shift items between pages mid-append — drop duplicate ids. */
function dedupeSearchResults(items: StalkerVodSource[]): StalkerVodSource[] {
const seenIds = new Set<string>();
return items.filter((item) => {
const id =
item.id === undefined || item.id === null ? null : String(item.id);
if (id === null) {
return true;
}
if (seenIds.has(id)) {
return false;
}
seenIds.add(id);
return true;
});
}
@Component({
selector: 'app-stalker-search',
imports: [
@@ -127,15 +92,6 @@ export class StalkerSearchComponent {
private readonly location = inject(Location);
private readonly dataService = inject(DataService);
private readonly parentalLock = inject(ParentalLockService);
/** Lock version the accumulated results were built under. */
private searchResultsLockVersion: number | null = null;
/**
* Withheld row ids seen for the current search identity. A page adding
* only new withheld ids is still progress and is skipped automatically;
* a page adding nothing new is the end of the results.
*/
private searchWithheldKey = '';
private readonly searchWithheldIds = new Set<string>();
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistService = inject(PlaylistsService);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
@@ -235,309 +191,28 @@ export class StalkerSearchComponent {
() => this.favoritesRefresh.refreshVersion()
);
/**
* Portal page for the current term+filter+portal; resets when any of
* them changes. The playlist belongs to the identity: Angular reuses the
* search route across `/stalker/A/search` -> `/stalker/B/search`, and a
* surviving page number would append portal B's later page onto portal
* A's accumulated results while skipping B's first page.
*/
readonly searchPage = linkedSignal({
source: () => ({
term: this.searchTerm(),
type: this.selectedFilterType(),
playlistId: this.currentPlaylist()?._id ?? null,
}),
computation: () => 1,
});
/** Pages accumulated into one continuous, deduplicated result list. */
private readonly accumulatedSearchResults = signal<StalkerVodSource[]>([]);
readonly searchResults = this.accumulatedSearchResults.asReadonly();
readonly searchHasMore = signal(false);
/**
* A failed append page. The next near-end RETRIES that page instead of
* advancing — incrementing past it would silently omit its results.
*/
readonly searchAppendError = signal(false);
readonly searchResultsResource = resource({
params: () => ({
contentType: this.selectedFilterType(),
search: this.searchTerm(),
page: this.searchPage(),
playlistId: this.currentPlaylist()?._id ?? null,
action: StalkerPortalActions.GetOrderedList,
// Lock/unlock re-fires the search: withheld rows are dropped at
// page time, so the results must be rebuilt when they change.
parentalLockVersion: this.parentalLock.version(),
}),
loader: async ({ params }) => {
if (params.search.length < 3) {
this.resetSearchAccumulator();
return [];
}
const playlist = this.currentPlaylist();
if (!playlist) {
// A reused route can land on a deleted/unresolved portal —
// the previous portal's cards must not keep rendering.
this.resetSearchAccumulator();
return [];
}
const { portalUrl, macAddress } = playlist;
if (!portalUrl || !macAddress) {
this.resetSearchAccumulator();
return [];
}
const contentType = params.contentType;
// The dedicated search route has no category guard, so it filters
// the portal's rows itself: a locked genre's title must not reach
// the grid, its detail or playback through search.
const withheldCategoryIds: ReadonlySet<string> =
!this.parentalLock.active()
? new Set<string>()
: this.parentalLock.withholdsEverything?.()
? ALL_CATEGORIES_WITHHELD
: new Set(
this.parentalLock.lockedStalkerIds(
playlist._id,
contentType
)
);
const lockVersionChanged =
this.searchResultsLockVersion !== null &&
this.searchResultsLockVersion !== params.parentalLockVersion;
this.searchResultsLockVersion = params.parentalLockVersion;
if (lockVersionChanged) {
this.applyRelockToResults(withheldCategoryIds, contentType);
}
if (lockVersionChanged && params.page > 1) {
// A lock flip past page 1: rebuild from page 1 rather than
// appending to pages accumulated under the old lock state.
this.searchPage.set(1);
return this.accumulatedSearchResults();
}
const withheldKey = JSON.stringify([
params.playlistId,
contentType,
params.search,
params.parentalLockVersion,
]);
if (params.page === 1 || this.searchWithheldKey !== withheldKey) {
this.searchWithheldKey = withheldKey;
this.searchWithheldIds.clear();
}
// Mirror the catalog request shape: many Ministra portals
// return an empty list for get_ordered_list without the
// category/genre/sortby params the STB client always sends.
// `max_page_items` is a HINT — plenty of portals ignore it and
// return their own page size, which is why paging cannot rely
// on it (progress and `total_items` decide hasMore instead).
const requestParams: Record<string, string | number> = {
action: StalkerContentTypes[contentType].getContentAction,
type: contentType,
sortby: 'added',
search: params.search,
p: params.page,
max_page_items: 100,
category: '*',
...(contentType === 'vod' ? { genre: '0' } : {}),
};
// A stale response (term/filter/page/portal — or the parental
// lock — moved on while this page was in flight) must not clobber
// the accumulated list: the request is not aborted, and a
// pre-relock response was filtered with the pre-relock set.
const isCurrent = (): boolean =>
isStalkerSearchRequestCurrent(params, {
search: this.searchTerm(),
contentType: this.selectedFilterType(),
page: this.searchPage(),
playlistId: this.currentPlaylist()?._id ?? null,
parentalLockVersion: this.parentalLock.version(),
});
try {
// executeStalkerRequest owns the portal-mode decision (shared
// predicate with URL fallback for legacy rows) and the lazy
// portal repair, so search cannot drift from the catalog
// paths.
const response =
await executeStalkerRequest<StalkerSearchResponse>(
{
/** Result paging: the portal page, the accumulated list and its flags. */
readonly paging = new StalkerSearchPagingController({
searchTerm: this.searchTerm,
selectedFilterType: this.selectedFilterType,
currentPlaylist: this.currentPlaylist,
dataService: this.dataService,
parentalLock: this.parentalLock,
stalkerSession: this.stalkerSession,
portalRepair: this.portalRepair,
},
playlist,
requestParams
);
const rawItems = (response.js?.data || []).map(
(item: StalkerVodSource) =>
this.processItemUrls(item, portalUrl)
);
const items = withoutWithheldStalkerItems(
rawItems,
contentType,
withheldCategoryIds
);
// Before the withheld-id bookkeeping: a stale page must not
// pre-record ids into a set a newer relock request cleared,
// or that request's page counts no new withheld rows and
// stops paging short of later visible matches.
if (!isCurrent()) {
return items;
}
let newWithheldCount = 0;
if (items.length < rawItems.length) {
const kept = new Set(items);
for (const item of rawItems) {
const id = stalkerWithheldRowKey(item);
if (
!kept.has(item) &&
!this.searchWithheldIds.has(id)
) {
this.searchWithheldIds.add(id);
newWithheldCount += 1;
}
}
}
const merged = this.applySearchPageSuccess(
params.page,
items,
response.js?.total_items,
// A page made only of withheld rows still is a page the
// portal served; judge progress on what it sent.
rawItems.length > 0 &&
(items.length > 0 || newWithheldCount > 0)
);
this.advancePastWithheldPage(
params.page,
items.length,
newWithheldCount,
isCurrent
);
return merged;
} catch (error) {
this.logger.warn('Stalker search page failed', {
page: params.page,
error,
logger: this.logger,
closeWithheldDetail: (withheldCategoryIds) =>
this.closeWithheldDetail(withheldCategoryIds),
});
if (!isCurrent()) {
return this.accumulatedSearchResults();
}
return this.applySearchPageFailure(params.page);
}
},
});
/**
* Empties the accumulator and every paging flag — used whenever there is
* no searchable portal (short term, missing playlist, malformed row).
*/
resetSearchAccumulator(): void {
this.accumulatedSearchResults.set([]);
this.searchHasMore.set(false);
this.searchAppendError.set(false);
}
/** Merges a successful portal page into the accumulated result list. */
applySearchPageSuccess(
page: number,
items: StalkerVodSource[],
totalItems: number | undefined,
pageHadRows: boolean = items.length > 0
): StalkerVodSource[] {
const previous = page === 1 ? [] : this.accumulatedSearchResults();
const merged =
page === 1 ? items : dedupeSearchResults([...previous, ...items]);
// Paging continues only while pages make progress — with OR without
// a reported total. Dedup after mid-list portal mutations can leave
// the unique list permanently shorter than total_items, and a
// repeated page dedupes to no growth; either way a no-progress
// append is the practical end of the results. A page whose rows were
// all withheld by the parental lock counts as progress too.
const withheldRows = pageHadRows && items.length === 0;
const madeProgress =
page === 1 || merged.length > previous.length || withheldRows;
this.searchHasMore.set(
madeProgress &&
(typeof totalItems === 'number' && totalItems >= 0
? merged.length < totalItems
: pageHadRows)
);
this.searchAppendError.set(false);
this.accumulatedSearchResults.set(merged);
return merged;
}
/**
* A failed FRESH search (page 1) must not keep rendering the previous
* query's cards; a failed append keeps the accumulated pages and flags
* the error so the next near-end retries this page instead of advancing.
*/
applySearchPageFailure(page: number): StalkerVodSource[] {
if (page === 1) {
this.accumulatedSearchResults.set([]);
this.searchHasMore.set(false);
this.searchAppendError.set(false);
return [];
}
this.searchAppendError.set(true);
return this.accumulatedSearchResults();
}
/**
* Result-set identity for the layout's near-end latch and auto-fill
* budget — term, filter, and portal, mirroring the paging identity.
*/
readonly searchScrollResetKey = computed(() =>
[
this.searchTerm(),
this.selectedFilterType(),
this.currentPlaylist()?._id ?? '',
].join('|')
);
readonly isInitialSearchLoading = computed(
() => this.searchResultsResource.isLoading() && this.searchPage() === 1
);
readonly isAppendingSearchResults = computed(
() => this.searchResultsResource.isLoading() && this.searchPage() > 1
);
readonly searchResults = this.paging.searchResults;
readonly searchHasMore = this.paging.searchHasMore;
readonly searchAppendError = this.paging.searchAppendError;
readonly searchScrollResetKey = this.paging.searchScrollResetKey;
readonly isInitialSearchLoading = this.paging.isInitialSearchLoading;
readonly isAppendingSearchResults = this.paging.isAppendingSearchResults;
loadMoreSearchResults(): void {
if (this.searchResultsResource.isLoading() || !this.searchHasMore()) {
return;
}
if (this.searchAppendError()) {
// Retry the SAME page — advancing would permanently omit it.
void this.retrySearchPage();
return;
}
this.searchPage.update((page) => page + 1);
}
/**
* Two failed search pages are exactly what opens the main process'
* connectivity guard, so the reset has to precede the reload — otherwise
* this retry fast-fails without contacting a portal that may have
* recovered, and keeps repeating the same error until the window expires.
*/
private async retrySearchPage(): Promise<void> {
// Clear the flag synchronously: awaiting first would leave this branch
// re-enterable, and the next `nearEnd` event would fire a second retry.
this.searchAppendError.set(false);
await resetHostConnectivityGuard(
this.dataService,
this.currentPlaylist()?.portalUrl
);
this.searchResultsResource.reload();
this.paging.loadMoreSearchResults();
}
readonly isSelectedVodFavorite = signal<boolean>(false);
@@ -713,59 +388,11 @@ export class StalkerSearchComponent {
});
}
/**
* The infinite scroll gives up after a few loads that add no height, so
* a run of pages made only of parental-locked rows must advance by
* itself until a visible row (or the real end) is reached. Only a page
* that added withheld ids not seen before counts — a stalled portal
* repeating the same locked rows must still end the loop.
*/
advancePastWithheldPage(
page: number,
visibleCount: number,
newWithheldCount: number,
isCurrent: () => boolean
): void {
if (
visibleCount > 0 ||
newWithheldCount === 0 ||
!this.searchHasMore()
) {
return;
}
queueMicrotask(() => {
if (isCurrent()) {
this.searchPage.set(page + 1);
}
});
}
/**
* Closes the open detail when its genre is withheld by the parental
* lock (Lock now, idle relock): the title, its playback actions and the
* store's selected item must not outlive the list row.
*/
/**
* A lock change reached the results on screen, which were read under
* the old lock state: close an open detail of a now-withheld genre (the
* list hiding it is not enough) and drop the withheld rows NOW, before
* the replacement page is awaited — on page 1 too, or they stay
* clickable while (or, if it hangs, after) that request is pending.
*/
applyRelockToResults(
withheldCategoryIds: ReadonlySet<string>,
contentType: Parameters<typeof withoutWithheldStalkerItems>[1]
): void {
this.closeWithheldDetail(withheldCategoryIds);
this.accumulatedSearchResults.set(
withoutWithheldStalkerItems(
this.accumulatedSearchResults(),
contentType,
withheldCategoryIds
)
);
}
closeWithheldDetail(withheldCategoryIds: ReadonlySet<string>): void {
const details = this.itemDetails();
if (!details) {
@@ -871,41 +498,6 @@ export class StalkerSearchComponent {
);
}
private processItemUrls(
item: StalkerVodSource,
portalUrl: string
): StalkerVodSource {
const processed = { ...item };
if (processed.screenshot_uri) {
processed.screenshot_uri = this.makeAbsoluteUrl(
portalUrl,
processed.screenshot_uri
);
}
return processed;
}
private makeAbsoluteUrl(baseUrl: string, relativePath: string): string {
if (!relativePath) return '';
if (
relativePath.startsWith('http://') ||
relativePath.startsWith('https://')
) {
return relativePath;
}
try {
const url = new URL(baseUrl);
const path = relativePath.startsWith('/')
? relativePath
: `/${relativePath}`;
return `${url.origin}${path}`;
} catch {
return relativePath;
}
}
readonly vodDetailActions = createStalkerVodDetailActions({
resolvePlayback: (cmd, title, thumbnail, startTime) =>
this.stalkerStore.resolveVodPlayback(
@@ -0,0 +1,12 @@
/**
* The numeric series id of a Stalker item id: the part before the first `:`
* when the id carries a suffix. An empty or non-numeric id resolves to 0,
* which callers treat as "no series".
*/
export function toStalkerSeriesId(id: string | number): number {
const raw = String(id ?? '').trim();
if (!raw) return 0;
const primary = raw.includes(':') ? raw.split(':')[0] : raw;
const parsed = Number(primary);
return Number.isFinite(parsed) ? parsed : 0;
}
@@ -0,0 +1,187 @@
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
/** The playlist and series one generation of position reads and writes belongs to. */
export interface StalkerSeriesPositionContext {
readonly generation: number;
readonly playlistId: string;
readonly seriesXtreamId: number;
readonly mutationKey: string;
}
/** What the queue needs from the owner of the position rows. */
export interface StalkerSeriesPositionQueueHost {
/** Whether the page still shows the playlist and series of `context`. */
readonly isShown: (context: StalkerSeriesPositionContext) => boolean;
/** Reads the persisted position rows of `context`. */
readonly read: (
context: StalkerSeriesPositionContext
) => Promise<PlaybackPositionData[]>;
/** Takes over rows read for a context that is still the active one. */
readonly publish: (
context: StalkerSeriesPositionContext,
positions: PlaybackPositionData[]
) => void;
}
/**
* Orders the position reads and writes of the Stalker series view. Writes of
* one playlist/series run one after another; a read waits for the writes
* queued before it, and its answer is dropped once a newer read or write
* started or the page moved on to another series. A write that overtakes a
* read still in flight re-reads the rows after the queue drained.
*/
export class StalkerSeriesPositionQueue {
private activeContext: StalkerSeriesPositionContext | null = null;
private contextGeneration = 0;
private readonly mutationQueues = new Map<string, Promise<void>>();
private readonly pendingLoads = new Map<
StalkerSeriesPositionContext,
Set<number>
>();
private readonly reloadKeys = new Set<string>();
private loadGeneration = 0;
constructor(private readonly host: StalkerSeriesPositionQueueHost) {}
/** Makes `playlistId`/`seriesXtreamId` the series reads and writes apply to. */
activate(
playlistId: string,
seriesXtreamId: number
): StalkerSeriesPositionContext {
const context: StalkerSeriesPositionContext = {
generation: ++this.contextGeneration,
playlistId,
seriesXtreamId,
mutationKey: JSON.stringify([playlistId, seriesXtreamId]),
};
this.activeContext = context;
return context;
}
/** No series is shown: retires the active context and every read in flight. */
deactivate(): void {
this.activeContext = null;
this.contextGeneration++;
this.loadGeneration++;
}
isActive(context: StalkerSeriesPositionContext): boolean {
const activeContext = this.activeContext;
return (
activeContext === context &&
activeContext.generation === context.generation &&
this.host.isShown(context)
);
}
/** The active context when it belongs to `playlistId` (and `seriesXtreamId`, if given). */
mutationContext(
playlistId: string,
seriesXtreamId?: number | null
): StalkerSeriesPositionContext | null {
const context = this.activeContext;
if (
!context ||
context.playlistId !== playlistId ||
(seriesXtreamId != null &&
context.seriesXtreamId !== seriesXtreamId)
) {
return null;
}
return context;
}
async load(context: StalkerSeriesPositionContext): Promise<void> {
const generation = ++this.loadGeneration;
this.trackPendingLoad(context, generation);
try {
await this.waitForMutations(context.mutationKey);
if (generation !== this.loadGeneration || !this.isActive(context)) {
return;
}
const positions = await this.host.read(context);
if (generation !== this.loadGeneration || !this.isActive(context)) {
return;
}
this.host.publish(context, positions);
} finally {
this.untrackPendingLoad(context, generation);
}
}
enqueue(
context: StalkerSeriesPositionContext,
operation: () => Promise<void>
): Promise<void> {
if (this.hasCurrentPendingLoad(context)) {
this.reloadKeys.add(context.mutationKey);
}
this.loadGeneration++;
const previous = this.waitForMutations(context.mutationKey);
const result = previous.then(operation);
const barrier = result.then(
() => undefined,
() => undefined
);
this.mutationQueues.set(context.mutationKey, barrier);
void barrier.then(() => {
if (this.mutationQueues.get(context.mutationKey) === barrier) {
this.mutationQueues.delete(context.mutationKey);
this.reloadAfterMutations(context.mutationKey);
}
});
return result;
}
private waitForMutations(mutationKey: string): Promise<void> {
return this.mutationQueues.get(mutationKey) ?? Promise.resolve();
}
private trackPendingLoad(
context: StalkerSeriesPositionContext,
generation: number
): void {
const generations = this.pendingLoads.get(context) ?? new Set<number>();
generations.add(generation);
this.pendingLoads.set(context, generations);
}
private untrackPendingLoad(
context: StalkerSeriesPositionContext,
generation: number
): void {
const generations = this.pendingLoads.get(context);
generations?.delete(generation);
if (generations?.size === 0) {
this.pendingLoads.delete(context);
}
}
private hasCurrentPendingLoad(
context: StalkerSeriesPositionContext
): boolean {
return Boolean(
this.pendingLoads.get(context)?.has(this.loadGeneration)
);
}
private reloadAfterMutations(mutationKey: string): void {
if (!this.reloadKeys.delete(mutationKey)) {
return;
}
const context = this.activeContext;
if (
!context ||
context.mutationKey !== mutationKey ||
!this.isActive(context) ||
this.hasCurrentPendingLoad(context)
) {
return;
}
void this.load(context);
}
}
@@ -0,0 +1,331 @@
import { Injectable, Signal, inject, signal, untracked } from '@angular/core';
import {
PORTAL_PLAYBACK_POSITIONS,
createLogger,
} from '@iptvnator/portal/shared/util';
import {
StalkerStore,
type StalkerSelectedVodItem,
} from '@iptvnator/portal/stalker/data-access';
import type {
PlaybackPositionData,
XtreamSerieEpisode,
} from '@iptvnator/shared/interfaces';
import { toStalkerSeriesId } from './stalker-series-id';
import {
clearStalkerSeriesPosition,
reconcileStalkerSeriesPositions,
saveStalkerSeriesPosition,
StalkerSeriesPositionPartialSaveError,
} from './stalker-series-position-compatibility';
import { StalkerSeriesPositionQueue } from './stalker-series-position-queue';
interface StalkerSeriesPositionsBindings {
readonly displayItem: Signal<StalkerSelectedVodItem | null>;
readonly mappedSeasons: Signal<Record<string, XtreamSerieEpisode[]>>;
}
/**
* Saved episode positions of the series the Stalker series view shows: the
* rows read from the repository, their mapping onto the episodes on the page
* (scoped rows plus compatible legacy promotions) and the writes that keep
* both in step. Component-scoped (provide it in the component's `providers`).
*/
@Injectable()
export class StalkerSeriesPositionsService {
private readonly stalkerStore = inject(StalkerStore);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly migrationPlaybackPositions = {
savePlaybackPosition: (
playlistId: string,
data: PlaybackPositionData
) =>
this.playbackPositions.savePlaybackPositionOrThrow(
playlistId,
data
),
clearPlaybackPosition: (
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
) =>
this.playbackPositions.clearPlaybackPositionOrThrow(
playlistId,
contentXtreamId,
contentType
),
};
private readonly logger = createLogger('StalkerSeriesView');
private readonly bindings = signal<StalkerSeriesPositionsBindings | null>(
null
);
readonly episodePlaybackPositions = signal<
Map<number, PlaybackPositionData>
>(new Map());
readonly rawSeriesPositions = signal<readonly PlaybackPositionData[]>([]);
/**
* `playlistId:seriesId` once the persisted positions for the shown
* series have been READ (a failed read leaves it null): the dashboard
* resume handoff must not start an episode from the beginning because
* the offsets have not arrived yet.
*/
readonly seriesPositionsLoadedKey = signal<string | null>(null);
private readonly legacyPositionByTrackingId = signal<
Map<number, PlaybackPositionData>
>(new Map());
private readonly queue = new StalkerSeriesPositionQueue({
isShown: (context) =>
this.stalkerStore.currentPlaylist()?._id === context.playlistId &&
this.shownSeriesId() === context.seriesXtreamId,
read: (context) =>
this.playbackPositions.getSeriesPlaybackPositions(
context.playlistId,
context.seriesXtreamId
),
publish: (context, positions) => {
this.rawSeriesPositions.set(positions);
this.seriesPositionsLoadedKey.set(
this.seriesPositionsKey(
context.playlistId,
context.seriesXtreamId
)
);
},
});
bind(bindings: StalkerSeriesPositionsBindings): void {
this.bindings.set(bindings);
}
/**
* Starts over for the series on screen: drops what the previous series
* left behind and reads the persisted rows. Reads the shown item and the
* playlist, so an `effect` calling it re-runs when either changes.
*/
loadShownSeries(): void {
const item = this.displayItem();
const playlist = this.stalkerStore.currentPlaylist();
const normalizedSeriesId = toStalkerSeriesId(item?.id ?? 0);
if (item && playlist?._id && normalizedSeriesId > 0) {
this.logger.debug('Loading positions for series', {
id: item.id,
seriesId: normalizedSeriesId,
isSeries: item.is_series,
});
this.rawSeriesPositions.set([]);
this.seriesPositionsLoadedKey.set(null);
this.episodePlaybackPositions.set(new Map());
this.legacyPositionByTrackingId.set(new Map());
const context = this.queue.activate(
playlist._id,
normalizedSeriesId
);
void this.queue.load(context);
} else {
this.queue.deactivate();
}
}
/**
* Maps the raw series position rows onto the currently mapped episodes
* (scoped rows plus compatible legacy promotions). Runs reactively from
* the component's constructor effect, and synchronously from the
* series-level watch toggle right after it hydrates lazy seasons — the
* effect only re-runs on the next change-detection tick, and enqueuing
* the batch against the stale maps would miss the hydrated episodes'
* legacy rows (an unwatch would leave rows behind that a later reconcile
* resurrects as watched).
*/
applyReconciledSeriesPositions(): void {
const item = this.displayItem();
const playlistId = this.stalkerStore.currentPlaylist()?._id;
const seriesXtreamId = toStalkerSeriesId(item?.id ?? 0);
const rawSeriesPositions = this.rawSeriesPositions();
const episodesBySeason = this.bindings()?.mappedSeasons() ?? {};
if (!item || !playlistId || seriesXtreamId <= 0) {
if (rawSeriesPositions.length > 0) {
this.rawSeriesPositions.set([]);
}
if (this.episodePlaybackPositions().size > 0) {
this.episodePlaybackPositions.set(new Map());
}
if (this.legacyPositionByTrackingId().size > 0) {
this.legacyPositionByTrackingId.set(new Map());
}
return;
}
const reconciled = reconcileStalkerSeriesPositions({
seriesXtreamId,
episodesBySeason,
seriesPositions: rawSeriesPositions,
});
if (
rawSeriesPositions.length === 0 &&
reconciled.positionsByTrackingId.size === 0 &&
untracked(() => this.episodePlaybackPositions().size) > 0
) {
return;
}
this.episodePlaybackPositions.set(reconciled.positionsByTrackingId);
this.legacyPositionByTrackingId.set(
reconciled.legacyPositionByTrackingId
);
}
seriesPositionsKey(playlistId: string, seriesXtreamId: number): string {
return `${playlistId}:${seriesXtreamId}`;
}
persistSeriesPosition(
playlistId: string,
position: PlaybackPositionData
): Promise<void> {
const context = this.queue.mutationContext(
playlistId,
position.seriesXtreamId
);
if (!context) {
return Promise.resolve();
}
const legacyPosition = this.legacyPositionByTrackingId().get(
position.contentXtreamId
);
return this.queue.enqueue(context, async () => {
let clearedLegacy: boolean;
try {
clearedLegacy = await saveStalkerSeriesPosition({
repository: this.migrationPlaybackPositions,
playlistId,
position,
legacyPosition,
});
} catch (error) {
if (
error instanceof StalkerSeriesPositionPartialSaveError &&
this.queue.isActive(context)
) {
this.publishSavedSeriesPosition(
position,
legacyPosition,
false
);
}
throw error;
}
if (!this.queue.isActive(context)) {
return;
}
this.publishSavedSeriesPosition(
position,
legacyPosition,
clearedLegacy
);
});
}
clearSeriesPosition(
playlistId: string,
contentXtreamId: number
): Promise<void> {
const context = this.queue.mutationContext(playlistId);
if (!context) {
return Promise.resolve();
}
const position = this.episodePlaybackPositions().get(
contentXtreamId
) ?? {
contentXtreamId,
contentType: 'episode',
positionSeconds: 0,
playlistId,
seriesXtreamId: context.seriesXtreamId,
};
const legacyPosition =
this.legacyPositionByTrackingId().get(contentXtreamId);
return this.queue.enqueue(context, async () => {
const clearedLegacy = await clearStalkerSeriesPosition({
repository: this.migrationPlaybackPositions,
playlistId,
position,
legacyPosition,
});
if (!this.queue.isActive(context)) {
return;
}
this.publishClearedSeriesPosition(
contentXtreamId,
legacyPosition,
clearedLegacy
);
});
}
private displayItem(): StalkerSelectedVodItem | null {
return this.bindings()?.displayItem() ?? null;
}
private shownSeriesId(): number {
return toStalkerSeriesId(this.displayItem()?.id ?? 0);
}
private publishSavedSeriesPosition(
position: PlaybackPositionData,
legacyPosition: PlaybackPositionData | undefined,
clearedLegacy: boolean
): void {
const removedTrackingIds = new Set([position.contentXtreamId]);
if (clearedLegacy && legacyPosition) {
removedTrackingIds.add(legacyPosition.contentXtreamId);
const legacyPositions = new Map(this.legacyPositionByTrackingId());
legacyPositions.delete(position.contentXtreamId);
this.legacyPositionByTrackingId.set(legacyPositions);
}
this.rawSeriesPositions.set([
...this.rawSeriesPositions().filter(
(candidate) =>
!removedTrackingIds.has(candidate.contentXtreamId)
),
position,
]);
this.updateEpisodePlaybackPosition(position);
}
private publishClearedSeriesPosition(
contentXtreamId: number,
legacyPosition: PlaybackPositionData | undefined,
clearedLegacy: boolean
): void {
const removedTrackingIds = new Set([contentXtreamId]);
if (clearedLegacy && legacyPosition) {
removedTrackingIds.add(legacyPosition.contentXtreamId);
const legacyPositions = new Map(this.legacyPositionByTrackingId());
legacyPositions.delete(contentXtreamId);
this.legacyPositionByTrackingId.set(legacyPositions);
}
this.rawSeriesPositions.set(
this.rawSeriesPositions().filter(
(candidate) =>
!removedTrackingIds.has(candidate.contentXtreamId)
)
);
this.removeEpisodePlaybackPosition(contentXtreamId);
}
private updateEpisodePlaybackPosition(
position: PlaybackPositionData
): void {
const updated = new Map(this.episodePlaybackPositions());
updated.set(position.contentXtreamId, position);
this.episodePlaybackPositions.set(updated);
}
private removeEpisodePlaybackPosition(contentXtreamId: number): void {
const updated = new Map(this.episodePlaybackPositions());
updated.delete(contentXtreamId);
this.episodePlaybackPositions.set(updated);
}
}
@@ -1,54 +1,30 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { SeasonContainerComponent } from '@iptvnator/ui/components';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import {
StalkerStore,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { TmdbEnrichmentService } from '@iptvnator/services';
import { EMPTY, of } from 'rxjs';
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
import { createStalkerSeriesViewHarness } from './stalker-series-view.test-harness';
import {
StubFavoritesButtonComponent,
StubPortalInlinePlayerComponent,
StubSeasonContainerComponent,
} from './stalker-series-view.test-helpers';
describe('StalkerSeriesViewComponent', () => {
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
const selectedContentType = signal<'series' | 'vod'>('series');
const selectedItem = signal<StalkerVodSource | null>(null);
const serialSeasonsResource = signal<unknown[]>([]);
const vodSeriesSeasonsResource = signal<unknown[]>([]);
const isSerialSeasonsLoading = signal(false);
const fetchVodSeriesEpisodes = jest.fn();
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();
const currentPlaylist = signal({
_id: 'stalker-1',
title: 'Living Room Portal',
portalUrl: 'https://stalker.example.test',
macAddress: '00:1A:79:12:34:56',
});
const {
selectedContentType,
selectedItem,
serialSeasonsResource,
vodSeriesSeasonsResource,
fetchVodSeriesEpisodes,
resolveVodPlayback,
openResolvedPlayback,
openExternalPlayback,
isEmbeddedPlayer,
fetchLinkToPlay,
currentPlaylist,
createFixture,
} = createStalkerSeriesViewHarness(() => jest.fn());
async function stabilize(): Promise<void> {
fixture.detectChanges();
@@ -63,184 +39,7 @@ describe('StalkerSeriesViewComponent', () => {
}
beforeEach(async () => {
selectedContentType.set('series');
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
description: 'Series description',
movie_image: 'poster.jpg',
},
});
serialSeasonsResource.set([
{
id: 'season-1',
name: 'Season 1',
cmd: '/media/file_30001.mpg',
series: [1, 2],
},
]);
vodSeriesSeasonsResource.set([]);
isSerialSeasonsLoading.set(false);
fetchVodSeriesEpisodes.mockReset();
resolveVodPlayback.mockReset();
resolveVodPlayback.mockImplementation(
async (
_cmd?: string,
title?: string,
thumbnail?: string,
_episodeNum?: number,
episodeId?: number,
startTime?: number
) => ({
streamUrl: 'http://stalker.example/episode.mpg',
title: title ?? 'Regular Series',
thumbnail: thumbnail ?? 'poster.jpg',
startTime,
contentInfo: {
playlistId: 'stalker-1',
contentXtreamId:
episodeId ?? Number(selectedItem()?.id ?? 0),
contentType: episodeId ? 'episode' : 'vod',
seriesXtreamId: episodeId
? Number(selectedItem()?.id ?? 0)
: undefined,
},
})
);
getSeriesPlaybackPositions.mockClear();
getSeriesPlaybackPositions.mockResolvedValue([]);
openResolvedPlayback.mockClear();
openExternalPlayback.mockReset();
isEmbeddedPlayer.mockReset();
isEmbeddedPlayer.mockReturnValue(false);
tmdbGetSeason.mockReset();
tmdbGetSeason.mockResolvedValue({
overview: 'Season overview from TMDB',
episodes: [],
});
fetchLinkToPlay
.mockReset()
.mockResolvedValue('https://cdn.example.test/episode.mpg');
currentPlaylist.set({
_id: 'stalker-1',
title: 'Living Room Portal',
portalUrl: 'https://stalker.example.test',
macAddress: '00:1A:79:12:34:56',
});
await TestBed.configureTestingModule({
imports: [StalkerSeriesViewComponent],
providers: [
{
provide: StalkerStore,
useValue: {
selectedItem,
selectedContentType,
currentPlaylist,
getSerialSeasonsResource: () => serialSeasonsResource(),
getVodSeriesSeasonsResource: () =>
vodSeriesSeasonsResource(),
isVodSeriesSeasonsLoading: signal(false),
isSerialSeasonsLoading,
fetchVodSeriesEpisodes,
resolveVodPlayback,
fetchLinkToPlay,
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
},
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getSeriesPlaybackPositions,
savePlaybackPosition: jest.fn(),
clearPlaybackPosition: jest.fn(),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer,
openResolvedPlayback,
openExternalPlayback,
},
},
{
provide: Router,
useValue: {
navigateByUrl: jest.fn(),
},
},
{
provide: TmdbEnrichmentService,
useValue: {
isEnabled: () => true,
getSeason: tmdbGetSeason,
},
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
currentLang: 'en',
defaultLang: 'en',
// EMPTY (not of(null)): the real TranslatePipe inside
// the detail shell reads `event.lang` from emissions.
onLangChange: EMPTY,
onTranslationChange: EMPTY,
onDefaultLangChange: EMPTY,
},
},
],
})
.overrideComponent(StalkerSeriesViewComponent, {
remove: {
imports: [
FavoritesButtonComponent,
PortalInlinePlayerComponent,
SeasonContainerComponent,
TranslatePipe,
],
},
add: {
imports: [
StubFavoritesButtonComponent,
StubPortalInlinePlayerComponent,
StubSeasonContainerComponent,
MockPipe(
TranslatePipe,
(
value: string | null | undefined,
params?: Record<string, number>
) => {
if (value === 'XTREAM.PLAY_EPISODE') {
return `Play episode ${
params?.['episode'] ?? '{{episode}}'
}`;
}
return value ?? '';
}
),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(StalkerSeriesViewComponent);
fixture = await createFixture();
});
afterEach(() => {
@@ -783,10 +582,12 @@ describe('StalkerSeriesViewComponent', () => {
}),
})
);
// Prettier expands these compact fixtures enough to breach the spec's
// hard 1,200-line lint limit.
// prettier-ignore
expect(inlinePlayer.episodeMetadata()).toEqual({ label: 'S01E01', title: 'Pilot', seasonNumber: 1, episodeNumber: 1 });
expect(inlinePlayer.episodeMetadata()).toEqual({
label: 'S01E01',
title: 'Pilot',
seasonNumber: 1,
episodeNumber: 1,
});
expect(inlinePlayer.seriesNavigation()).toEqual({
canPrevious: false,
canNext: true,
@@ -834,251 +635,6 @@ describe('StalkerSeriesViewComponent', () => {
);
});
it('prefetches the next unopened season so the Up Next rail can spill over', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
await stabilize();
// Season 1 loaded, season 2 still empty — the state a user is in
// right after opening the series and starting the first episode.
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
fetchVodSeriesEpisodes.mockResolvedValue([
{ id: 'episode-3', series_number: 1, name: 'Next Season' },
]);
const firstEpisode = fixture.componentInstance.mappedSeasons()['1'][0];
fixture.componentInstance.onEpisodeClicked(firstEpisode);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith(
'50001',
'season-2'
);
const inlinePlayer = fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
const railItems = inlinePlayer.upNextEpisodes() as Array<{
label: string;
}>;
expect(railItems.map((item) => item.label)).toEqual([
'S01E01',
'S02E01',
]);
});
it('does not re-request a spillover season that came back empty', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
await stabilize();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
{ id: 'episode-2', series_number: 2, name: 'Second' },
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
// Failed or genuinely empty season: isLoading returns to false while
// episodes stays empty — the retry trap.
fetchVodSeriesEpisodes.mockResolvedValue([]);
const seasonOne = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(seasonOne[0]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
// Further playback activity in the same season must not retrigger it.
fixture.componentInstance.onEpisodeClicked(seasonOne[1]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
});
it('retries a failed spillover prefetch on the next episode', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
await stabilize();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
{ id: 'episode-2', series_number: 2, name: 'Second' },
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
fetchVodSeriesEpisodes.mockRejectedValueOnce(new Error('network'));
const seasonOne = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(seasonOne[0]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
// The failure must not loop while the same episode keeps playing.
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
// Moving to the next episode gives the transient failure a new chance.
fetchVodSeriesEpisodes.mockResolvedValue([
{ id: 'episode-3', series_number: 1, name: 'Next Season' },
]);
fixture.componentInstance.onEpisodeClicked(seasonOne[1]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(2);
const inlinePlayer = fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
const railItems = inlinePlayer.upNextEpisodes() as Array<{
label: string;
}>;
expect(railItems.map((item) => item.label)).toEqual([
'S01E02',
'S02E01',
]);
});
it('loads the next unloaded VOD-series season after the loaded season is watched', async () => {
selectedContentType.set('vod');
selectedItem.set({
@@ -1180,162 +736,4 @@ describe('StalkerSeriesViewComponent', () => {
undefined
);
});
it('fetches the TMDB season once the show-level match arrives after auto-select', async () => {
await stabilize();
fixture.detectChanges();
// Season tabs auto-select immediately — usually before the async
// show-level enrichment has written tmdb_id.
const seasonContainer = fixture.debugElement.query(
By.directive(StubSeasonContainerComponent)
).componentInstance as StubSeasonContainerComponent;
seasonContainer.seasonSelected.emit('1');
await stabilize();
expect(tmdbGetSeason).not.toHaveBeenCalled();
// The TMDB match lands afterwards — the fetch must run now.
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
description: 'Series description',
movie_image: 'poster.jpg',
tmdb_id: 777,
},
} as never);
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 1);
});
it('waits for the season map before fetching so a per-season slice gets the title-marked season', async () => {
serialSeasonsResource.set([]);
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series (2 season)',
description: 'Series description',
movie_image: 'poster.jpg',
tmdb_id: 777,
},
} as never);
await stabilize();
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
// Season resource still loading — fetching now would pass a zero
// season count, suppress the title-marker override and cache the
// wrong season forever (fetchSeason is idempotent).
expect(tmdbGetSeason).not.toHaveBeenCalled();
serialSeasonsResource.set([
{
id: 'season-1',
name: 'Season 1',
cmd: '/media/file_30001.mpg',
series: [1, 2],
},
]);
await stabilize();
// Single-season slice whose provider season is renumbered to 1:
// the title marker names the real TMDB season.
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2);
});
it('gates the fetch on the reloading season resource during detail-to-detail navigation', async () => {
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
await stabilize();
// No show-level TMDB match yet — nothing fetched for the first item
expect(tmdbGetSeason).not.toHaveBeenCalled();
// Detail-to-detail navigation reuses the component; the new item's
// TMDB match can arrive while the season resource reloads and the
// map still shows the previous series' seasons.
isSerialSeasonsLoading.set(true);
selectedItem.set({
id: '30002',
cmd: '/media/file_30002.mpg',
info: {
name: 'Other Series (2 season)',
description: 'Other description',
movie_image: 'poster2.jpg',
tmdb_id: 888,
},
} as never);
await stabilize();
// The new tmdb_id must NOT pair with the previous series' season
// context while the resource reloads.
expect(tmdbGetSeason).not.toHaveBeenCalled();
// Once the new item's own seasons land, the fetch runs without a
// stale-season fetch. The container selects the corrected key once
// the new season map is rendered.
serialSeasonsResource.set([
{
id: 'season-1',
name: 'Season 1',
cmd: '/media/file_30002.mpg',
series: [1, 2],
},
]);
isSerialSeasonsLoading.set(false);
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(888, 2);
});
it('enriches after equal-id navigation once the season resource settles', async () => {
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
await stabilize();
// Distinct items can reuse a provider id; the loading gate (not an
// id comparison) keeps the stale map from being used.
isSerialSeasonsLoading.set(true);
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Different Series (3 season)',
description: 'Different description',
movie_image: 'poster3.jpg',
tmdb_id: 999,
},
} as never);
await stabilize();
expect(tmdbGetSeason).not.toHaveBeenCalled();
isSerialSeasonsLoading.set(false);
fixture.componentInstance.onSeasonSelected('3');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(999, 3);
});
it('reads the season marker from o_name when name is generic', async () => {
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
o_name: 'Regular Series (2 season)',
description: 'Series description',
movie_image: 'poster.jpg',
tmdb_id: 777,
},
} as never);
await stabilize();
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2);
});
});
@@ -0,0 +1,281 @@
import { ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
import { createStalkerSeriesViewHarness } from './stalker-series-view.test-harness';
import { StubPortalInlinePlayerComponent } from './stalker-series-view.test-helpers';
/**
* The inline player's "Up Next" rail across lazy Ministra VOD-series seasons:
* the season after the playing one is fetched ahead so the rail can spill
* over into it.
*/
describe('StalkerSeriesViewComponent Up Next spillover prefetch', () => {
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
const {
selectedContentType,
selectedItem,
serialSeasonsResource,
vodSeriesSeasonsResource,
fetchVodSeriesEpisodes,
isEmbeddedPlayer,
createFixture,
} = createStalkerSeriesViewHarness(() => jest.fn());
async function stabilize(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
}
beforeEach(async () => {
fixture = await createFixture();
});
afterEach(() => {
fixture?.destroy();
});
it('prefetches the next unopened season so the Up Next rail can spill over', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
await stabilize();
// Season 1 loaded, season 2 still empty — the state a user is in
// right after opening the series and starting the first episode.
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
fetchVodSeriesEpisodes.mockResolvedValue([
{ id: 'episode-3', series_number: 1, name: 'Next Season' },
]);
const firstEpisode = fixture.componentInstance.mappedSeasons()['1'][0];
fixture.componentInstance.onEpisodeClicked(firstEpisode);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith(
'50001',
'season-2'
);
const inlinePlayer = fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
const railItems = inlinePlayer.upNextEpisodes() as Array<{
label: string;
}>;
expect(railItems.map((item) => item.label)).toEqual([
'S01E01',
'S02E01',
]);
});
it('does not re-request a spillover season that came back empty', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
await stabilize();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
{ id: 'episode-2', series_number: 2, name: 'Second' },
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
// Failed or genuinely empty season: isLoading returns to false while
// episodes stays empty — the retry trap.
fetchVodSeriesEpisodes.mockResolvedValue([]);
const seasonOne = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(seasonOne[0]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
// Further playback activity in the same season must not retrigger it.
fixture.componentInstance.onEpisodeClicked(seasonOne[1]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
});
it('retries a failed spillover prefetch on the next episode', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
await stabilize();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
{ id: 'episode-2', series_number: 2, name: 'Second' },
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
fetchVodSeriesEpisodes.mockRejectedValueOnce(new Error('network'));
const seasonOne = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(seasonOne[0]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
// The failure must not loop while the same episode keeps playing.
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
// Moving to the next episode gives the transient failure a new chance.
fetchVodSeriesEpisodes.mockResolvedValue([
{ id: 'episode-3', series_number: 1, name: 'Next Season' },
]);
fixture.componentInstance.onEpisodeClicked(seasonOne[1]);
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(2);
const inlinePlayer = fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
const railItems = inlinePlayer.upNextEpisodes() as Array<{
label: string;
}>;
expect(railItems.map((item) => item.label)).toEqual([
'S01E02',
'S02E01',
]);
});
});
@@ -0,0 +1,273 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { SeasonContainerComponent } from '@iptvnator/ui/components';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import {
StalkerStore,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { TmdbEnrichmentService } from '@iptvnator/services';
import { EMPTY, of } from 'rxjs';
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
import {
StubFavoritesButtonComponent,
StubPortalInlinePlayerComponent,
StubSeasonContainerComponent,
} from './stalker-series-view.test-helpers';
/**
* The slice of a Jest mock the harness drives. This file is part of the
* library's own type-check, which has no Jest globals, so a spec hands in
* the factory (`() => jest.fn()`) and gets its own mock type back.
*/
export interface StalkerSeriesViewTestMock {
mockReset(): this;
mockClear(): this;
mockReturnValue(value: unknown): this;
mockResolvedValue(value: unknown): this;
mockImplementation(implementation: (...args: never[]) => unknown): this;
}
/**
* The TestBed the Stalker series-view component specs share: the store
* signals and collaborator mocks a case drives, and `createFixture()`, which
* puts them back to the state every case starts from (a regular series with
* one two-episode season, no embedded player) before building the component
* with its stubbed children.
*
* Call it once per `describe`; the mocks live as long as that block.
*/
export function createStalkerSeriesViewHarness<
TMock extends StalkerSeriesViewTestMock,
>(createMock: () => TMock) {
const selectedContentType = signal<'series' | 'vod'>('series');
const selectedItem = signal<StalkerVodSource | null>(null);
const serialSeasonsResource = signal<unknown[]>([]);
const vodSeriesSeasonsResource = signal<unknown[]>([]);
const isSerialSeasonsLoading = signal(false);
const fetchVodSeriesEpisodes = createMock();
const resolveVodPlayback = createMock();
const getSeriesPlaybackPositions = createMock().mockResolvedValue([]);
const openResolvedPlayback = createMock();
const openExternalPlayback = createMock();
const isEmbeddedPlayer = createMock();
const tmdbGetSeason = createMock();
const fetchLinkToPlay = createMock();
const currentPlaylist = signal({
_id: 'stalker-1',
title: 'Living Room Portal',
portalUrl: 'https://stalker.example.test',
macAddress: '00:1A:79:12:34:56',
});
async function createFixture(): Promise<
ComponentFixture<StalkerSeriesViewComponent>
> {
selectedContentType.set('series');
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
description: 'Series description',
movie_image: 'poster.jpg',
},
});
serialSeasonsResource.set([
{
id: 'season-1',
name: 'Season 1',
cmd: '/media/file_30001.mpg',
series: [1, 2],
},
]);
vodSeriesSeasonsResource.set([]);
isSerialSeasonsLoading.set(false);
fetchVodSeriesEpisodes.mockReset();
resolveVodPlayback.mockReset();
resolveVodPlayback.mockImplementation(
async (
_cmd?: string,
title?: string,
thumbnail?: string,
_episodeNum?: number,
episodeId?: number,
startTime?: number
) => ({
streamUrl: 'http://stalker.example/episode.mpg',
title: title ?? 'Regular Series',
thumbnail: thumbnail ?? 'poster.jpg',
startTime,
contentInfo: {
playlistId: 'stalker-1',
contentXtreamId:
episodeId ?? Number(selectedItem()?.id ?? 0),
contentType: episodeId ? 'episode' : 'vod',
seriesXtreamId: episodeId
? Number(selectedItem()?.id ?? 0)
: undefined,
},
})
);
getSeriesPlaybackPositions.mockClear();
getSeriesPlaybackPositions.mockResolvedValue([]);
openResolvedPlayback.mockClear();
openExternalPlayback.mockReset();
isEmbeddedPlayer.mockReset();
isEmbeddedPlayer.mockReturnValue(false);
tmdbGetSeason.mockReset();
tmdbGetSeason.mockResolvedValue({
overview: 'Season overview from TMDB',
episodes: [],
});
fetchLinkToPlay
.mockReset()
.mockResolvedValue('https://cdn.example.test/episode.mpg');
currentPlaylist.set({
_id: 'stalker-1',
title: 'Living Room Portal',
portalUrl: 'https://stalker.example.test',
macAddress: '00:1A:79:12:34:56',
});
await TestBed.configureTestingModule({
imports: [StalkerSeriesViewComponent],
providers: [
{
provide: StalkerStore,
useValue: {
selectedItem,
selectedContentType,
currentPlaylist,
getSerialSeasonsResource: () => serialSeasonsResource(),
getVodSeriesSeasonsResource: () =>
vodSeriesSeasonsResource(),
isVodSeriesSeasonsLoading: signal(false),
isSerialSeasonsLoading,
fetchVodSeriesEpisodes,
resolveVodPlayback,
fetchLinkToPlay,
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
},
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getSeriesPlaybackPositions,
savePlaybackPosition: createMock(),
clearPlaybackPosition: createMock(),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer,
openResolvedPlayback,
openExternalPlayback,
},
},
{
provide: Router,
useValue: {
navigateByUrl: createMock(),
},
},
{
provide: TmdbEnrichmentService,
useValue: {
isEnabled: () => true,
getSeason: tmdbGetSeason,
},
},
{
provide: MatSnackBar,
useValue: {
open: createMock(),
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
currentLang: 'en',
defaultLang: 'en',
// EMPTY (not of(null)): the real TranslatePipe inside
// the detail shell reads `event.lang` from emissions.
onLangChange: EMPTY,
onTranslationChange: EMPTY,
onDefaultLangChange: EMPTY,
},
},
],
})
.overrideComponent(StalkerSeriesViewComponent, {
remove: {
imports: [
FavoritesButtonComponent,
PortalInlinePlayerComponent,
SeasonContainerComponent,
TranslatePipe,
],
},
add: {
imports: [
StubFavoritesButtonComponent,
StubPortalInlinePlayerComponent,
StubSeasonContainerComponent,
MockPipe(
TranslatePipe,
(
value: string | null | undefined,
params?: Record<string, number>
) => {
if (value === 'XTREAM.PLAY_EPISODE') {
return `Play episode ${
params?.['episode'] ?? '{{episode}}'
}`;
}
return value ?? '';
}
),
],
},
})
.compileComponents();
return TestBed.createComponent(StalkerSeriesViewComponent);
}
return {
selectedContentType,
selectedItem,
serialSeasonsResource,
vodSeriesSeasonsResource,
isSerialSeasonsLoading,
fetchVodSeriesEpisodes,
resolveVodPlayback,
getSeriesPlaybackPositions,
openResolvedPlayback,
openExternalPlayback,
isEmbeddedPlayer,
tmdbGetSeason,
fetchLinkToPlay,
currentPlaylist,
createFixture,
};
}
@@ -0,0 +1,192 @@
import { ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
import { createStalkerSeriesViewHarness } from './stalker-series-view.test-harness';
import { StubSeasonContainerComponent } from './stalker-series-view.test-helpers';
/**
* The lazy TMDB season fetch of the Stalker series view: when it runs
* relative to the show-level TMDB match and the season resource, and which
* TMDB season it asks for.
*/
describe('StalkerSeriesViewComponent TMDB season fetch', () => {
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
const {
selectedItem,
serialSeasonsResource,
isSerialSeasonsLoading,
tmdbGetSeason,
createFixture,
} = createStalkerSeriesViewHarness(() => jest.fn());
async function stabilize(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
}
beforeEach(async () => {
fixture = await createFixture();
});
afterEach(() => {
fixture?.destroy();
});
it('fetches the TMDB season once the show-level match arrives after auto-select', async () => {
await stabilize();
fixture.detectChanges();
// Season tabs auto-select immediately — usually before the async
// show-level enrichment has written tmdb_id.
const seasonContainer = fixture.debugElement.query(
By.directive(StubSeasonContainerComponent)
).componentInstance as StubSeasonContainerComponent;
seasonContainer.seasonSelected.emit('1');
await stabilize();
expect(tmdbGetSeason).not.toHaveBeenCalled();
// The TMDB match lands afterwards — the fetch must run now.
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
description: 'Series description',
movie_image: 'poster.jpg',
tmdb_id: 777,
},
} as never);
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 1);
});
it('waits for the season map before fetching so a per-season slice gets the title-marked season', async () => {
serialSeasonsResource.set([]);
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series (2 season)',
description: 'Series description',
movie_image: 'poster.jpg',
tmdb_id: 777,
},
} as never);
await stabilize();
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
// Season resource still loading — fetching now would pass a zero
// season count, suppress the title-marker override and cache the
// wrong season forever (fetchSeason is idempotent).
expect(tmdbGetSeason).not.toHaveBeenCalled();
serialSeasonsResource.set([
{
id: 'season-1',
name: 'Season 1',
cmd: '/media/file_30001.mpg',
series: [1, 2],
},
]);
await stabilize();
// Single-season slice whose provider season is renumbered to 1:
// the title marker names the real TMDB season.
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2);
});
it('gates the fetch on the reloading season resource during detail-to-detail navigation', async () => {
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
await stabilize();
// No show-level TMDB match yet — nothing fetched for the first item
expect(tmdbGetSeason).not.toHaveBeenCalled();
// Detail-to-detail navigation reuses the component; the new item's
// TMDB match can arrive while the season resource reloads and the
// map still shows the previous series' seasons.
isSerialSeasonsLoading.set(true);
selectedItem.set({
id: '30002',
cmd: '/media/file_30002.mpg',
info: {
name: 'Other Series (2 season)',
description: 'Other description',
movie_image: 'poster2.jpg',
tmdb_id: 888,
},
} as never);
await stabilize();
// The new tmdb_id must NOT pair with the previous series' season
// context while the resource reloads.
expect(tmdbGetSeason).not.toHaveBeenCalled();
// Once the new item's own seasons land, the fetch runs without a
// stale-season fetch. The container selects the corrected key once
// the new season map is rendered.
serialSeasonsResource.set([
{
id: 'season-1',
name: 'Season 1',
cmd: '/media/file_30002.mpg',
series: [1, 2],
},
]);
isSerialSeasonsLoading.set(false);
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(888, 2);
});
it('enriches after equal-id navigation once the season resource settles', async () => {
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
await stabilize();
// Distinct items can reuse a provider id; the loading gate (not an
// id comparison) keeps the stale map from being used.
isSerialSeasonsLoading.set(true);
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Different Series (3 season)',
description: 'Different description',
movie_image: 'poster3.jpg',
tmdb_id: 999,
},
} as never);
await stabilize();
expect(tmdbGetSeason).not.toHaveBeenCalled();
isSerialSeasonsLoading.set(false);
fixture.componentInstance.onSeasonSelected('3');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(999, 3);
});
it('reads the season marker from o_name when name is generic', async () => {
selectedItem.set({
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
o_name: 'Regular Series (2 season)',
description: 'Series description',
movie_image: 'poster.jpg',
tmdb_id: 777,
},
} as never);
await stabilize();
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2);
});
});
@@ -0,0 +1,113 @@
import type { Logger } from '@iptvnator/portal/shared/util';
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import type { SeasonContainerSeriesPlaybackToggleRequest } from '@iptvnator/ui/components';
import { StalkerSeriesPositionPartialSaveError } from './stalker-series-position-compatibility';
export interface StalkerWatchToggleFeedback {
readonly marked: string;
readonly unmarked: string;
readonly partialMarked: string;
readonly partialUnmarked: string;
readonly failed: string;
}
// The marked and partial keys are scope-generic on purpose ("{{count}}
// episodes marked as watched", "{{count}} marked · {{failed}} failed");
// only unmark-success and failure name their scope.
export const SEASON_WATCH_FEEDBACK: StalkerWatchToggleFeedback = {
marked: 'XTREAM.SEASON_MARKED_WATCHED',
unmarked: 'XTREAM.SEASON_MARKED_UNWATCHED',
partialMarked: 'XTREAM.SEASON_MARKED_WATCHED_PARTIAL',
partialUnmarked: 'XTREAM.SEASON_MARKED_UNWATCHED_PARTIAL',
failed: 'XTREAM.SEASON_WATCH_UPDATE_FAILED',
};
export const SERIES_WATCH_FEEDBACK: StalkerWatchToggleFeedback = {
marked: 'XTREAM.SEASON_MARKED_WATCHED',
unmarked: 'XTREAM.SERIES_MARKED_UNWATCHED',
partialMarked: 'XTREAM.SEASON_MARKED_WATCHED_PARTIAL',
partialUnmarked: 'XTREAM.SEASON_MARKED_UNWATCHED_PARTIAL',
failed: 'XTREAM.SERIES_WATCH_UPDATE_FAILED',
};
/** One watched/unwatched batch and what it needs from the page running it. */
export interface StalkerWatchToggleBatch {
readonly request: SeasonContainerSeriesPlaybackToggleRequest;
readonly playlistId: string;
/** Whether the page still shows the series the batch was started on. */
readonly stillCurrent: () => boolean;
readonly feedback: StalkerWatchToggleFeedback;
readonly persist: (
playlistId: string,
position: PlaybackPositionData
) => Promise<void>;
readonly clear: (
playlistId: string,
contentXtreamId: number
) => Promise<void>;
/** Re-reads the catalog grid's progress badges; must not reject. */
readonly refreshCatalogPositions: (
playlistId: string
) => Promise<void> | undefined;
/** Shows the translated feedback for `key`. */
readonly notify: (key: string, params?: object) => void;
readonly logger: Logger;
}
export async function runStalkerWatchToggleBatch(
batch: StalkerWatchToggleBatch
): Promise<void> {
const { request, playlistId, feedback } = batch;
// Enqueue every episode synchronously: each mutation chains on
// the previous one's never-rejecting barrier, so the queue
// serializes the writes (incl. per-episode legacy-row cleanup)
// and reloads positions once after the whole chain drains.
const outcomes = await Promise.all(
request.requests.map((item) =>
(item.nextPosition
? batch.persist(playlistId, item.nextPosition)
: batch.clear(playlistId, item.contentXtreamId)
).then(
() => true,
// The scoped watched row was saved and published —
// only the legacy-row cleanup failed. The episode IS
// watched, so it must not count against the batch.
(error: unknown) =>
error instanceof StalkerSeriesPositionPartialSaveError
)
)
);
const failed = outcomes.filter((ok) => !ok).length;
const succeeded = outcomes.length - failed;
if (failed > 0) {
batch.logger.error(
`Watched toggle: ${failed} of ${outcomes.length} episodes failed`
);
}
if (succeeded > 0) {
// Partial successes changed rows too — the catalog badge
// must follow even when the user already moved on. A failed
// refresh must not break the feedback flow below.
await batch.refreshCatalogPositions(playlistId);
}
if (!batch.stillCurrent()) {
return;
}
if (failed === 0) {
batch.notify(
request.markWatched ? feedback.marked : feedback.unmarked,
{ count: succeeded }
);
} else if (succeeded > 0) {
batch.notify(
request.markWatched
? feedback.partialMarked
: feedback.partialUnmarked,
{ count: succeeded, failed }
);
} else {
batch.notify(feedback.failed);
}
}
@@ -0,0 +1,323 @@
import { Injectable, Signal, inject, signal } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import {
createLogger,
isPortalPlaybackWatched,
} from '@iptvnator/portal/shared/util';
import {
isVodSeasonHydrationPending,
StalkerStore,
type StalkerSelectedVodItem,
type VodSeriesSeasonVm,
} from '@iptvnator/portal/stalker/data-access';
import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import {
buildSeriesWatchToggleRequest,
type SeasonContainerPlaybackToggleRequest,
type SeasonContainerSeasonPlaybackToggleRequest,
type SeasonContainerSeriesPlaybackToggleRequest,
} from '@iptvnator/ui/components';
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
import { toStalkerSeriesId } from './stalker-series-id';
import { StalkerSeriesPositionsService } from './stalker-series-positions.service';
import {
runStalkerWatchToggleBatch,
SEASON_WATCH_FEEDBACK,
SERIES_WATCH_FEEDBACK,
type StalkerWatchToggleFeedback,
} from './stalker-series-watch-batch';
interface StalkerSeriesWatchToggleBindings {
readonly displayItem: Signal<StalkerSelectedVodItem | null>;
/** `playlist:series` of the series on screen. */
readonly currentSeriesKey: Signal<string>;
readonly isVodSeries: Signal<boolean>;
readonly vodSeriesSeasons: Signal<VodSeriesSeasonVm[]>;
readonly mappedSeasons: Signal<Record<string, XtreamSerieEpisode[]>>;
/** Episodes playing or launching, which a series toggle never marks. */
readonly excludedEpisodeIds: () => ReadonlySet<number>;
/** Resolves true when the portal answered, false when the request failed. */
readonly loadEpisodesForSeason: (
season: VodSeriesSeasonVm
) => Promise<boolean>;
}
/**
* Watched toggles of the Stalker series view: one episode, a season or the
* whole series (which also backs "Reset progress"). Component-scoped
* (provide it in the component's `providers`).
*/
@Injectable()
export class StalkerSeriesWatchToggleService {
private readonly stalkerStore = inject(StalkerStore);
private readonly positions = inject(StalkerSeriesPositionsService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
// Optional: absent in collection-detail mounts outside the catalog.
private readonly catalogFacade = inject(StalkerCatalogFacadeService, {
optional: true,
});
private readonly logger = createLogger('StalkerSeriesView');
private readonly bindings = signal<StalkerSeriesWatchToggleBindings | null>(
null
);
readonly seasonWatchBatchRunning = signal(false);
/**
* 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;
bind(bindings: StalkerSeriesWatchToggleBindings): void {
this.bindings.set(bindings);
}
/** Holds `play` until the running batch settled, replacing an earlier choice. */
holdChoice(play: () => void): void {
this.choiceHeldForBatch = {
seriesKey: this.currentSeriesKey(),
play,
};
}
async handlePlaybackToggleRequested(
request: SeasonContainerPlaybackToggleRequest
): Promise<void> {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (!playlistId) {
return;
}
if (request.nextPosition) {
await this.positions.persistSeriesPosition(
playlistId,
request.nextPosition
);
} else {
await this.positions.clearSeriesPosition(
playlistId,
request.contentXtreamId
);
}
// Keep the catalog grid's progress badge in sync (ownership-checked
// inside the facade; no-op outside the catalog context). A failed
// refresh keeps the cache populated-but-stale.
await this.catalogFacade
?.refreshPositions(playlistId)
.catch((error: unknown) =>
this.logger.warn('Catalog position refresh failed', error)
);
}
async handleSeasonPlaybackToggleRequested(
request: SeasonContainerSeasonPlaybackToggleRequest
): Promise<void> {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (
!playlistId ||
request.requests.length === 0 ||
this.seasonWatchBatchRunning()
) {
return;
}
// The mutation context already keeps a stale batch out of the next
// series' state; the snackbars need the same ownership so feedback
// for the old season is not presented on a newly opened page.
const seriesXtreamId = this.shownSeriesId();
const stillCurrent = () =>
this.stalkerStore.currentPlaylist()?._id === playlistId &&
this.shownSeriesId() === seriesXtreamId;
this.seasonWatchBatchRunning.set(true);
try {
await this.runWatchToggleBatch(
request,
playlistId,
stillCurrent,
SEASON_WATCH_FEEDBACK
);
} finally {
this.endWatchBatch();
}
}
async handleSeriesPlaybackToggleRequested(
request: SeasonContainerSeriesPlaybackToggleRequest
): Promise<void> {
const bindings = this.bindings();
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (!bindings || !playlistId || this.seasonWatchBatchRunning()) {
return;
}
const pendingSeasons = bindings.isVodSeries()
? bindings
.vodSeriesSeasons()
.filter((season) => isVodSeasonHydrationPending(season))
: [];
// An empty request is only meaningful when unloaded seasons remain:
// every loaded episode is watched, so the container could not build
// a target list, but hydration below may still surface unwatched
// episodes to mark.
if (request.requests.length === 0 && pendingSeasons.length === 0) {
return;
}
const seriesXtreamId = this.shownSeriesId();
const stillCurrent = () =>
this.stalkerStore.currentPlaylist()?._id === playlistId &&
this.shownSeriesId() === seriesXtreamId;
this.seasonWatchBatchRunning.set(true);
try {
let effective: SeasonContainerSeriesPlaybackToggleRequest | null =
request;
if (pendingSeasons.length > 0) {
const hydrated = await this.hydrateSeasonsForSeriesToggle(
bindings,
pendingSeasons,
stillCurrent
);
if (hydrated !== 'complete') {
if (hydrated === 'failed' && stillCurrent()) {
this.notifySeasonWatchToggle(
SERIES_WATCH_FEEDBACK.failed
);
}
return;
}
// The reconcile effect only flushes on the next change-
// detection tick; rebuild the maps synchronously so the
// batch below sees the hydrated episodes' scoped and legacy
// rows (see applyReconciledSeriesPositions).
this.positions.applyReconciledSeriesPositions();
effective = buildSeriesWatchToggleRequest({
seasons: bindings.mappedSeasons(),
seriesId: seriesXtreamId,
playlistId,
isEpisodeWatched: (episode) =>
isPortalPlaybackWatched(
this.positions
.episodePlaybackPositions()
.get(Number(episode.id))
),
excludedEpisodeIds: bindings.excludedEpisodeIds(),
// Keep the direction the user clicked; re-inference over
// the now-complete data could flip a "mark" into an
// unwatch when everything turned out watched.
markWatched: request.markWatched,
});
if (!effective) {
if (stillCurrent()) {
this.notifySeasonWatchToggle(
SERIES_WATCH_FEEDBACK.marked,
{ count: 0 }
);
}
return;
}
}
await this.runWatchToggleBatch(
effective,
playlistId,
stillCurrent,
SERIES_WATCH_FEEDBACK
);
} finally {
this.endWatchBatch();
}
}
private currentSeriesKey(): string {
return this.bindings()?.currentSeriesKey() ?? '';
}
private shownSeriesId(): number {
return toStalkerSeriesId(this.bindings()?.displayItem()?.id ?? 0);
}
/**
* 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();
}
}
/**
* Sequential on purpose: loadEpisodesForSeason snapshots the season VM
* array before its writes, so concurrent calls clobber each other's
* loading flags; and one request at a time keeps the portal load bounded.
*/
private async hydrateSeasonsForSeriesToggle(
bindings: StalkerSeriesWatchToggleBindings,
pendingSeasons: readonly VodSeriesSeasonVm[],
stillCurrent: () => boolean
): Promise<'complete' | 'failed' | 'superseded'> {
for (const season of pendingSeasons) {
// A tab click may have loaded this season meanwhile.
const current = bindings
.vodSeriesSeasons()
.find((candidate) => candidate.id === season.id);
if (!current || !isVodSeasonHydrationPending(current)) {
continue;
}
const answered = await bindings.loadEpisodesForSeason(current);
if (!stillCurrent()) {
return 'superseded';
}
if (!answered) {
return 'failed';
}
}
return 'complete';
}
private runWatchToggleBatch(
request: SeasonContainerSeriesPlaybackToggleRequest,
playlistId: string,
stillCurrent: () => boolean,
feedback: StalkerWatchToggleFeedback
): Promise<void> {
return runStalkerWatchToggleBatch({
request,
playlistId,
stillCurrent,
feedback,
persist: (id, position) =>
this.positions.persistSeriesPosition(id, position),
clear: (id, contentXtreamId) =>
this.positions.clearSeriesPosition(id, contentXtreamId),
refreshCatalogPositions: (id) =>
this.catalogFacade
?.refreshPositions(id)
.catch((error: unknown) =>
this.logger.warn(
'Catalog position refresh failed',
error
)
),
notify: (key, params) => this.notifySeasonWatchToggle(key, params),
logger: this.logger,
});
}
private notifySeasonWatchToggle(key: string, params?: object): void {
this.snackBar.open(
this.translateService.instant(key, params),
undefined,
{ duration: 5000 }
);
}
}
@@ -0,0 +1,125 @@
import type { WritableSignal } from '@angular/core';
import {
getVodSeriesSeasonKey,
type VodSeriesSeasonVm,
} from '@iptvnator/portal/stalker/data-access';
/** What the loader needs from the page that shows the seasons. */
export interface StalkerVodSeasonEpisodeLoaderHost {
/** Seasons of the series on screen; a load writes its loading flag and episodes here. */
readonly seasons: WritableSignal<VodSeriesSeasonVm[]>;
/** Identity of the series on screen: part of the key in-flight loads are shared by. */
readonly ownerKey: () => string;
readonly fetchEpisodes: (
videoId: string,
seasonId: string
) => Promise<VodSeriesSeasonVm['episodes']>;
readonly logError: (message: string, error: unknown) => void;
}
/** Lazy episode loads of Ministra VOD-series seasons (`is_series=1`). */
export class StalkerVodSeasonEpisodeLoader {
private readonly loads = new Map<
string,
{ season: VodSeriesSeasonVm; promise: Promise<boolean> }
>();
constructor(private readonly host: StalkerVodSeasonEpisodeLoaderHost) {}
/**
* Loads episodes for a specific VOD season.
*
* Single-flight per season: a tab click, the spillover prefetch, the
* quick-start recursion, and the series-toggle hydration can all ask for
* the same season — a second concurrent request would duplicate portal
* traffic, and its failure could abort a series toggle whose original
* request succeeded.
*/
/** Resolves true when the portal answered, false when the request failed. */
load(season: VodSeriesSeasonVm): Promise<boolean> {
const key = JSON.stringify([
this.host.ownerKey(),
season.video_id,
season.id,
getVodSeriesSeasonKey(season),
]);
const inFlight = this.loads.get(key);
if (inFlight && this.host.seasons().includes(inFlight.season)) {
return inFlight.promise;
}
const load = this.fetch(season).finally(() => {
if (this.loads.get(key)?.promise === load) {
this.loads.delete(key);
}
});
const loadingSeason = this.host
.seasons()
.find(
(candidate) =>
candidate.id === season.id &&
candidate.video_id === season.video_id
);
if (loadingSeason) {
this.loads.set(key, {
season: loadingSeason,
promise: load,
});
}
return load;
}
private async fetch(season: VodSeriesSeasonVm): Promise<boolean> {
// Set loading state in local signal
const seasons = this.host.seasons();
const index = seasons.findIndex(
(s) =>
s.id === season.id &&
s.video_id === season.video_id &&
getVodSeriesSeasonKey(s) === getVodSeriesSeasonKey(season)
);
if (index === -1) return false;
const updatedSeasons = [...seasons];
const loadingSeason = { ...updatedSeasons[index], isLoading: true };
updatedSeasons[index] = loadingSeason;
this.host.seasons.set(updatedSeasons);
try {
const episodes = await this.host.fetchEpisodes(
season.video_id,
season.id
);
// Update with loaded episodes
const newSeasons = [...this.host.seasons()];
// Only the exact loading VM owns this response. A navigation or
// refresh can reuse provider ids while replacing the season list.
const newIndex = newSeasons.indexOf(loadingSeason);
if (newIndex !== -1) {
newSeasons[newIndex] = {
...newSeasons[newIndex],
episodes: episodes,
// Even an EMPTY answer marks the season loaded: the
// portal spoke, so it must stop counting as "unloaded"
// (label/verdict gating and series-toggle hydration).
episodesLoaded: true,
isLoading: false,
};
this.host.seasons.set(newSeasons);
}
return newIndex !== -1;
} catch (error) {
this.host.logError('Failed to load episodes', error);
const newSeasons = [...this.host.seasons()];
const newIndex = newSeasons.indexOf(loadingSeason);
if (newIndex !== -1) {
newSeasons[newIndex] = {
...newSeasons[newIndex],
isLoading: false,
};
this.host.seasons.set(newSeasons);
}
return false;
}
}
}
@@ -3,7 +3,7 @@
[resultsCount]="xtreamStore.searchResults().length"
[isLoading]="xtreamStore.isSearching()"
[showCloseButton]="false"
[showBackButton]="showBackButton"
[backAvailable]="backAvailable"
[showResultsCount]="!isGlobalSearch || !groupByPlaylist()"
[showSearchInput]="showInlineSearchInput"
[minSearchLength]="minSearchLength"
@@ -575,11 +575,12 @@ export class SearchResultsComponent implements AfterViewInit {
/**
* The in-portal search is a nested view (reached from the toolbar
* search box or an actor page); the global search is a top-level
* sidebar destination, and the dialog has its own close button — a
* back arrow there would navigate the page behind the open dialog.
* search box or an actor page), so it offers the header's Back; the
* global search is a top-level sidebar destination, and the dialog has
* its own close button — a Back there would navigate the page behind
* the open dialog.
*/
get showBackButton(): boolean {
get backAvailable(): boolean {
return (
this.isWorkspaceLayout && !this.isGlobalSearch && !this.dialogRef
);
@@ -198,10 +198,11 @@ function closeOwnedEpisodeSession(
* 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.
* launches. A repeat of the same episode before its launch settled is
* ignored (the session cannot stand in for this guard: Electron publishes
* it only once the launch IPC arrives, as `launching` until that resolves);
* 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,
@@ -23,7 +23,6 @@ import {
ExternalPlayerName,
ExternalPlayerSession,
PlaybackPositionData,
PlayerContentInfo,
ResolvedPortalPlayback,
XtreamSerieDetails,
XtreamSerieEpisode,
@@ -37,7 +36,6 @@ import {
getSeriesPlaybackNavigation,
inlineSeriesEpisodeState,
type PlaybackFallbackRequest,
resolveSeriesPlaybackEpisodeState,
type SeriesPlaybackEpisodeState,
} from '@iptvnator/ui/playback';
import { injectXtreamRecentHistory } from '../xtream-recent-history';
@@ -53,6 +51,8 @@ import {
SerialDetailsSeasonWatchService,
type SerialDetailsWatchScope,
} from './serial-details-season-watch.service';
import { SerialDetailsWatchToggles } from './serial-details-watch-toggles';
import { buildSerialEpisodePlayback } from './serial-episode-playback';
export type XtreamSerieDetailsView = XtreamSerieDetails & {
readonly series_id: number;
@@ -97,6 +97,15 @@ export class SerialDetailsPlaybackService {
);
private readonly playbackPositionState =
new SerialDetailsPlaybackPositionState();
private readonly watchToggles = new SerialDetailsWatchToggles({
playbackPositions: this.playbackPositions,
seasonWatch: this.seasonWatch,
state: this.playbackPositionState,
playlistId: () => this.currentPlaylistId(),
seriesXtreamId: () => Number(this.selectedItem()?.series_id ?? 0),
reloadStorePositions: (playlistId) =>
this.xtreamStore.loadAllPositions(playlistId),
});
private lastSaveTime = 0;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
@@ -229,32 +238,15 @@ export class SerialDetailsPlaybackService {
contentType: 'series',
backdropUrl: selectedItem.info?.backdrop_path?.[0],
});
const contentInfo: PlayerContentInfo = {
playlistId: playlist.id,
contentXtreamId: Number(episode.id),
contentType: 'episode',
seriesXtreamId: Number(selectedItem.series_id),
seasonNumber: Number(episode.season),
episodeNumber: Number(episode.episode_num),
};
const position = this.episodePlaybackPositions().get(
Number(episode.id)
);
const playback: ResolvedPortalPlayback = {
const { playback, episodeState } = buildSerialEpisodePlayback({
playlistId: playlist.id,
selectedItem,
episode,
streamUrl,
title: episode.title,
thumbnail: selectedItem.info.cover,
startTime: position?.positionSeconds,
contentInfo,
};
const episodeState = resolveSeriesPlaybackEpisodeState({
episodesBySeason: selectedItem.episodes,
currentEpisodeId: episode.id,
fallbackSeasonNumber: Number(episode.season),
fallbackEpisodeNumber: Number(episode.episode_num),
});
return this.startPlayback(playback, episodeState, player);
}
@@ -322,68 +314,17 @@ export class SerialDetailsPlaybackService {
);
}
async handlePlaybackToggleRequested(
handlePlaybackToggleRequested(
request: SeasonContainerPlaybackToggleRequest
): Promise<void> {
const playlistId = this.currentPlaylistId();
if (!playlistId) {
return;
return this.watchToggles.toggleEpisode(request);
}
if (request.nextPosition) {
await this.playbackPositions.savePlaybackPosition(
playlistId,
request.nextPosition
);
this.playbackPositionState.update(request.nextPosition);
} else {
await this.playbackPositions.clearPlaybackPosition(
playlistId,
request.contentXtreamId,
'episode'
);
this.playbackPositionState.remove(request.contentXtreamId);
}
await this.refreshStorePositions(playlistId);
}
async handleWatchToggleRequested(
handleWatchToggleRequested(
request: SeasonContainerSeriesPlaybackToggleRequest,
scope: SerialDetailsWatchScope
): Promise<void> {
const playlistId = this.currentPlaylistId();
const seriesXtreamId = Number(this.selectedItem()?.series_id ?? 0);
const persisted = await this.seasonWatch.handle(
request,
playlistId,
this.playbackPositionState,
() =>
this.currentPlaylistId() === playlistId &&
Number(this.selectedItem()?.series_id ?? 0) === seriesXtreamId,
scope
);
if (persisted) {
await this.refreshStorePositions(playlistId);
}
}
/**
* The catalog reads series progress from XtreamStore, whose positions
* load once per playlist (XtreamCatalogFacadeService.initialize), so a
* toggle must push the change back or badges go stale on return. Skipped
* after a playlist switch — the store then holds the other playlist.
*/
private async refreshStorePositions(playlistId: string): Promise<void> {
if (this.currentPlaylistId() !== playlistId) {
return;
}
try {
await this.xtreamStore.loadAllPositions(playlistId);
} catch (error) {
// The toggle itself succeeded; a failed refresh keeps the store
// populated-but-stale, which beats wiping it with a bad read.
this.logger.warn('Store position refresh failed', error);
}
return this.watchToggles.toggleBatch(request, scope);
}
async loadSeriesPlaybackPositions(
@@ -0,0 +1,75 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { isProviderOnlyDetailState } from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { SerialDetailsPlaybackService } from './serial-details-playback.service';
/**
* Route side of the series page: its params, the provider-only flag of the
* navigation that opened it, and the (re)load of the series the route
* addresses.
*/
@Injectable()
export class SerialDetailsRouteService {
private readonly route = inject(ActivatedRoute);
private readonly xtreamStore = inject(XtreamStore);
private readonly playback = inject(SerialDetailsPlaybackService);
/** `playlistId:categoryId:serialId` of the last initialized view */
private readonly lastInitKey = signal<string | null>(null);
/**
* Reactive route params: the component is reused when navigating
* between two series details (e.g. via the Similar rail).
*/
readonly params = toSignal(this.route.params, {
initialValue: this.route.snapshot.params,
});
readonly providerOnly = computed(() => {
this.params();
return isProviderOnlyDetailState(window.history.state);
});
/**
* Initializes on first render and RE-initializes when the route params
* change while the component is reused (Similar rail). Reads signals, so
* the owning component runs it from an effect.
*/
loadAddressedSeries(): void {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
const { categoryId, serialId } = this.params();
if (!playlistId || !serialId) {
return;
}
const initKey = `${playlistId}:${categoryId}:${serialId}`;
if (this.lastInitKey() === initKey) {
return;
}
this.lastInitKey.set(initKey);
this.playback.resetForNewSeries();
this.initializeSerialDetails(playlistId, categoryId, serialId);
}
private initializeSerialDetails(
playlistId: string,
categoryId: string | number,
serialId: string
): void {
this.xtreamStore.fetchSerialDetailsWithMetadata({
serialId,
categoryId: Number(categoryId),
});
const serialXtreamId = Number(serialId);
this.xtreamStore.checkFavoriteStatus(
serialXtreamId,
playlistId,
'series'
);
void this.playback.loadSeriesPlaybackPositions(
playlistId,
serialXtreamId
);
}
}
@@ -0,0 +1,75 @@
import {
computed,
effect,
inject,
Injectable,
Signal,
signal,
untracked,
} from '@angular/core';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { buildSeasonDescriptions } from './season-descriptions.util';
import { buildSeasonPosters } from './season-posters.util';
import type { XtreamSerieDetailsView } from './serial-details-playback.service';
interface SerialDetailsSeasonsBindings {
readonly selectedItem: Signal<XtreamSerieDetailsView | null>;
}
/**
* Season presentation of the series page: the descriptions and posters the
* season container and the inline player show, and the TMDB enrichment of
* the season the viewer selected.
*/
@Injectable()
export class SerialDetailsSeasonsService {
private readonly xtreamStore = inject(XtreamStore);
private readonly bindings = signal<SerialDetailsSeasonsBindings | null>(
null
);
private readonly selectedItem = computed(
() => this.bindings()?.selectedItem() ?? null
);
/** Season currently selected in the season container. */
private readonly selectedSeasonKey = signal<string | null>(null);
/** Season descriptions (provider text, TMDB fallback, URL junk dropped). */
readonly descriptions = computed<Record<string, string>>(() =>
buildSeasonDescriptions(this.selectedItem())
);
/** Season posters (TMDB season poster first, provider season cover next). */
readonly posters = computed<Record<string, string>>(() =>
buildSeasonPosters(this.selectedItem())
);
constructor() {
// TMDB season enrichment, keyed on (tmdb_id, selected season). With
// season tabs the first seasonSelected fires as soon as seasons load —
// usually BEFORE the async show-level TMDB match has written
// info.tmdb_id, and enrichSelectedSerialSeason no-ops without it. So
// the call must re-run when the match arrives, not only on selection.
// The store-side enrichment is idempotent per (serial, season).
effect(() => {
const tmdbId = this.selectedItem()?.info?.tmdb_id;
const seasonKey = this.selectedSeasonKey();
if (tmdbId && seasonKey) {
untracked(() =>
this.xtreamStore.enrichSelectedSerialSeason(seasonKey)
);
}
});
}
/** Connects the service to the owning component's reactive state. */
bind(bindings: SerialDetailsSeasonsBindings): void {
this.bindings.set(bindings);
}
select(seasonKey: string): void {
// The enrichment call itself runs from the constructor effect keyed
// on (tmdb_id, selectedSeasonKey) — see the race note there.
this.selectedSeasonKey.set(seasonKey);
}
}
@@ -0,0 +1,104 @@
import {
createLogger,
type PortalPlaybackPositions,
} from '@iptvnator/portal/shared/util';
import type {
SeasonContainerPlaybackToggleRequest,
SeasonContainerSeriesPlaybackToggleRequest,
} from '@iptvnator/ui/components';
import type { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
import type {
SerialDetailsSeasonWatchService,
SerialDetailsWatchScope,
} from './serial-details-season-watch.service';
const logger = createLogger('SerialDetailsPlayback');
/** What the watched toggles of the series page persist through and report to. */
export interface SerialDetailsWatchToggleDeps {
readonly playbackPositions: Pick<
PortalPlaybackPositions,
'savePlaybackPosition' | 'clearPlaybackPosition'
>;
readonly seasonWatch: Pick<SerialDetailsSeasonWatchService, 'handle'>;
readonly state: SerialDetailsPlaybackPositionState;
/** Playlist on screen, empty while none is current. */
readonly playlistId: () => string;
/** Series on screen, 0 while none is selected. */
readonly seriesXtreamId: () => number;
/** Reloads the catalog store's positions of a playlist. */
readonly reloadStorePositions: (playlistId: string) => Promise<unknown>;
}
/**
* Episode, season and series watched toggles of the series page: persists
* the change, mirrors it into the page's position state, and pushes it back
* to the catalog store.
*/
export class SerialDetailsWatchToggles {
constructor(private readonly deps: SerialDetailsWatchToggleDeps) {}
async toggleEpisode(
request: SeasonContainerPlaybackToggleRequest
): Promise<void> {
const playlistId = this.deps.playlistId();
if (!playlistId) {
return;
}
if (request.nextPosition) {
await this.deps.playbackPositions.savePlaybackPosition(
playlistId,
request.nextPosition
);
this.deps.state.update(request.nextPosition);
} else {
await this.deps.playbackPositions.clearPlaybackPosition(
playlistId,
request.contentXtreamId,
'episode'
);
this.deps.state.remove(request.contentXtreamId);
}
await this.refreshStorePositions(playlistId);
}
async toggleBatch(
request: SeasonContainerSeriesPlaybackToggleRequest,
scope: SerialDetailsWatchScope
): Promise<void> {
const playlistId = this.deps.playlistId();
const seriesXtreamId = this.deps.seriesXtreamId();
const persisted = await this.deps.seasonWatch.handle(
request,
playlistId,
this.deps.state,
() =>
this.deps.playlistId() === playlistId &&
this.deps.seriesXtreamId() === seriesXtreamId,
scope
);
if (persisted) {
await this.refreshStorePositions(playlistId);
}
}
/**
* The catalog reads series progress from XtreamStore, whose positions
* load once per playlist (XtreamCatalogFacadeService.initialize), so a
* toggle must push the change back or badges go stale on return. Skipped
* after a playlist switch — the store then holds the other playlist.
*/
private async refreshStorePositions(playlistId: string): Promise<void> {
if (this.deps.playlistId() !== playlistId) {
return;
}
try {
await this.deps.reloadStorePositions(playlistId);
} catch (error) {
// The toggle itself succeeded; a failed refresh keeps the store
// populated-but-stale, which beats wiping it with a bad read.
logger.warn('Store position refresh failed', error);
}
}
}
@@ -1,361 +1,46 @@
import {
Component,
input,
output,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Location } from '@angular/common';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import {
ContentHeroComponent,
SeasonContainerComponent,
} from '@iptvnator/ui/components';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
SeriesResumeTarget,
} from '@iptvnator/portal/shared/util';
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
DownloadsService,
PlaybackPositionRuntimeBridgeService,
SettingsStore,
} from '@iptvnator/services';
import { ContentHeroComponent } from '@iptvnator/ui/components';
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
import {
PlaybackPositionData,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { BehaviorSubject, EMPTY, of } from 'rxjs';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { SerialDetailsComponent } from './serial-details.component';
import { SerialDetailsMenuService } from './serial-details-menu.service';
import { SerialDetailsPlaybackService } from './serial-details-playback.service';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
@Component({
selector: 'app-season-container',
standalone: true,
changeDetection: ChangeDetectionStrategy.Eager,
template: '<div data-testid="season-container"></div>',
})
class StubSeasonContainerComponent {
readonly seasons = input<unknown>(null);
readonly seriesId = input<number | string | null>(null);
readonly playlistId = input('');
readonly seriesTitle = input<string | undefined>(undefined);
readonly playbackPositions = input<unknown>(null);
readonly downloadAdapter = input<SeasonEpisodeDownloadAdapter | null>(null);
readonly downloadsEnabled = input(true);
readonly openingEpisodeId = input<number | null>(null);
readonly activeEpisodeId = input<number | null>(null);
readonly playingEpisodeId = input<number | null>(null);
readonly seasonDescriptions = input<unknown>(null);
readonly seasonPosters = input<unknown>(null);
readonly seasonWatchBatchRunning = input(false);
readonly episodeClicked = output<unknown>();
readonly playbackToggleRequested = output<unknown>();
readonly seasonPlaybackToggleRequested = output<unknown>();
}
@Component({
selector: 'app-portal-inline-player',
standalone: true,
changeDetection: ChangeDetectionStrategy.Eager,
template: '',
})
class StubPortalInlinePlayerComponent {
readonly playbackSessionKey = input.required<string>();
readonly playback = input<unknown>(null);
readonly episodeMetadata = input<unknown>(null);
readonly seriesTitle = input<string | null>(null);
readonly seriesNavigation = input<unknown>(null);
readonly upNextEpisodes = input<unknown>(null);
readonly seriesEpisodes = input<unknown>(null);
readonly seasonPosters = input<unknown>(null);
readonly episodePlaybackPositions = input<unknown>(null);
readonly seasonLoadStates = input<unknown>(null);
readonly timeUpdate = output<unknown>();
readonly closed = output<void>();
readonly streamUrlCopied = output<void>();
readonly externalFallbackRequested = output<unknown>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly upNextEpisodeSelected = output<unknown>();
readonly episodePanelSeasonSelected = output<string>();
}
@Component({
selector: 'mat-icon',
standalone: true,
changeDetection: ChangeDetectionStrategy.Eager,
template: '<ng-content />',
})
class StubMatIconComponent {}
import {
configureSerialDetailsTestBed,
createSerialDetailsStubs,
resetSerialDetailsStubs,
} from './serial-details.harness';
import {
StubPortalInlinePlayerComponent,
StubSeasonContainerComponent,
} from './serial-details.test-stubs';
describe('SerialDetailsComponent', () => {
let fixture: ComponentFixture<SerialDetailsComponent>;
const selectedItem = signal<unknown>(null);
const selectedContentType = signal<'series'>('series');
const isFavorite = signal(false);
const isLoadingDetails = signal(false);
const detailsError = signal<string | null>(null);
const currentPlaylist = signal({
id: 'xtream-1',
serverUrl: 'http://xtream.example',
username: 'user',
password: 'pass',
userAgent: 'ProtectedProvider/2.0',
referrer: 'https://referrer.example/series',
origin: 'https://origin.example',
});
const fetchSerialDetailsWithMetadata = jest.fn();
const cancelDetailsRequest = jest.fn();
const checkFavoriteStatus = jest.fn();
const constructEpisodeStreamUrl = jest.fn();
const addRecentItem = jest.fn();
const openResolvedPlayback = jest.fn();
const openExternalPlayback = jest.fn();
const savePlaybackPosition = jest.fn();
const clearPlaybackPosition = jest.fn();
const savePlaybackPositionsBatch = jest.fn();
const clearPlaybackPositionsBatch = jest.fn();
const loadAllPositions = jest.fn();
const isEmbeddedPlayer = jest.fn();
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
let positionUpdateCallback: ((data: PlaybackPositionData) => void) | null =
null;
let seriesResumeTarget: ReturnType<
typeof signal<SeriesResumeTarget | null>
>;
let routeParams: BehaviorSubject<{
categoryId: string;
serialId: string;
}>;
beforeEach(async () => {
window.history.replaceState({}, '', window.location.href);
selectedItem.set({
series_id: 103,
info: {
name: 'Series One',
plot: 'Series plot',
cover: 'cover.jpg',
backdrop_path: [],
genre: 'Drama',
category_id: '3',
tmdb_id: 901,
tmdb_cast: [{ name: 'Sienna Wave', character: 'Mara' }],
},
episodes: {
'1': [
{
id: '1001',
episode_num: 1,
title: 'Episode 1',
season: 1,
},
{
id: '1002',
episode_num: 2,
title: 'Episode 2',
season: 1,
},
],
'2': [
{
id: '2001',
episode_num: 1,
title: 'Season 2 Episode 1',
season: 2,
},
],
},
});
isFavorite.set(false);
isLoadingDetails.set(false);
detailsError.set(null);
fetchSerialDetailsWithMetadata.mockClear();
cancelDetailsRequest.mockClear();
checkFavoriteStatus.mockClear();
constructEpisodeStreamUrl.mockReset();
constructEpisodeStreamUrl.mockImplementation(
(episode: { id: string | number }) =>
`http://xtream.example/series/${episode.id}.mp4`
);
addRecentItem.mockClear();
openResolvedPlayback.mockReset();
openResolvedPlayback.mockResolvedValue(undefined);
openExternalPlayback.mockReset();
openExternalPlayback.mockResolvedValue(undefined);
savePlaybackPosition.mockReset();
savePlaybackPosition.mockResolvedValue(undefined);
clearPlaybackPosition.mockReset();
clearPlaybackPosition.mockResolvedValue(undefined);
savePlaybackPositionsBatch.mockReset();
savePlaybackPositionsBatch.mockResolvedValue(undefined);
clearPlaybackPositionsBatch.mockReset();
clearPlaybackPositionsBatch.mockResolvedValue(undefined);
loadAllPositions.mockReset();
loadAllPositions.mockResolvedValue(undefined);
positionUpdateCallback = null;
isEmbeddedPlayer.mockReset();
isEmbeddedPlayer.mockReturnValue(false);
getSeriesPlaybackPositions.mockClear();
getSeriesPlaybackPositions.mockResolvedValue([]);
seriesResumeTarget = signal<SeriesResumeTarget | null>(null);
routeParams = new BehaviorSubject({
categoryId: '3',
serialId: '103',
});
await TestBed.configureTestingModule({
imports: [SerialDetailsComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
params: routeParams,
snapshot: {
params: {
categoryId: '3',
serialId: '103',
},
},
},
},
{
provide: XtreamStore,
useValue: {
const stubs = createSerialDetailsStubs();
const {
selectedItem,
selectedContentType,
isFavorite,
isLoadingDetails,
detailsError,
currentPlaylist,
fetchSerialDetailsWithMetadata,
cancelDetailsRequest,
checkFavoriteStatus,
setSelectedItem: jest.fn((value: unknown) =>
selectedItem.set(value)
),
toggleFavorite: jest.fn(),
constructEpisodeStreamUrl,
addRecentItem,
backfillContentMetadata: jest.fn(),
loadAllPositions,
recentItems: signal([]),
serialCategories: signal([]),
loadRecentItems: jest.fn(),
},
},
{
provide: SettingsStore,
useValue: { player: signal(VideoPlayer.Html5Player) },
},
{
provide: DownloadsService,
useValue: { isAvailable: signal(false) },
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
},
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getSeriesPlaybackPositions,
savePlaybackPosition,
clearPlaybackPosition,
savePlaybackPositionsBatch,
clearPlaybackPositionsBatch,
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer,
openResolvedPlayback,
openExternalPlayback,
},
},
{
provide: PlaybackPositionRuntimeBridgeService,
useValue: {
onPlaybackPositionUpdate: (
callback: (data: PlaybackPositionData) => void
) => {
positionUpdateCallback = callback;
return () => {
positionUpdateCallback = null;
};
},
},
},
{
provide: XTREAM_SERIES_RESUME_TARGET,
useValue: seriesResumeTarget,
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: EMPTY,
onTranslationChange: EMPTY,
onDefaultLangChange: EMPTY,
},
},
{
provide: Location,
useValue: {
back: jest.fn(),
},
},
],
})
.overrideComponent(SerialDetailsComponent, {
remove: {
imports: [
MatIcon,
PortalInlinePlayerComponent,
SeasonContainerComponent,
TranslatePipe,
],
},
add: {
imports: [
StubMatIconComponent,
StubPortalInlinePlayerComponent,
StubSeasonContainerComponent,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
],
},
})
.compileComponents();
savePlaybackPosition,
isEmbeddedPlayer,
getSeriesPlaybackPositions,
seriesResumeTarget,
routeParams,
} = stubs;
beforeEach(async () => {
window.history.replaceState({}, '', window.location.href);
resetSerialDetailsStubs(stubs);
await configureSerialDetailsTestBed(stubs);
fixture = TestBed.createComponent(SerialDetailsComponent);
});
@@ -779,6 +464,7 @@ describe('SerialDetailsComponent', () => {
it('applies streamed playback-position updates for the selected series only', async () => {
fixture.detectChanges();
await fixture.whenStable();
const positionUpdateCallback = stubs.positionUpdates.callback;
if (!positionUpdateCallback) {
throw new Error('expected a playback-position subscription');
}
@@ -936,238 +622,6 @@ describe('SerialDetailsComponent', () => {
expect(savePlaybackPosition).not.toHaveBeenCalled();
});
it('saves and clears positions for season-container toggle requests', async () => {
fixture.detectChanges();
await fixture.whenStable();
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
await playbackService.handlePlaybackToggleRequested({
contentXtreamId: 1001,
nextPosition: {
playlistId: 'xtream-1',
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 950,
durationSeconds: 1000,
},
} as never);
expect(savePlaybackPosition).toHaveBeenCalledWith(
'xtream-1',
expect.objectContaining({ contentXtreamId: 1001 })
);
await playbackService.handlePlaybackToggleRequested({
contentXtreamId: 1001,
nextPosition: null,
} as never);
expect(clearPlaybackPosition).toHaveBeenCalledWith(
'xtream-1',
1001,
'episode'
);
});
it('marks a season watched through one batch save and updates rendered positions', async () => {
fixture.detectChanges();
await fixture.whenStable();
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
const snackBar = TestBed.inject(MatSnackBar);
const seasonPosition = (
contentXtreamId: number,
episodeNumber: number
) => ({
playlistId: 'xtream-1',
contentXtreamId,
contentType: 'episode' as const,
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber,
positionSeconds: 1200,
durationSeconds: 1200,
});
await playbackService.handleWatchToggleRequested(
{
seasonKey: '1',
markWatched: true,
requests: [
{
contentXtreamId: 1001,
nextPosition: seasonPosition(1001, 1),
},
{
contentXtreamId: 1002,
nextPosition: seasonPosition(1002, 2),
},
],
} as never,
'season'
);
expect(savePlaybackPositionsBatch).toHaveBeenCalledTimes(1);
expect(savePlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [
expect.objectContaining({ contentXtreamId: 1001 }),
expect.objectContaining({ contentXtreamId: 1002 }),
]);
expect(savePlaybackPosition).not.toHaveBeenCalled();
expect(playbackService.episodePlaybackPositions().get(1001)).toEqual(
expect.objectContaining({ positionSeconds: 1200 })
);
expect(
playbackService.episodePlaybackPositions().get(1002)
).toBeDefined();
expect(snackBar.open).toHaveBeenCalledWith(
'XTREAM.SEASON_MARKED_WATCHED',
undefined,
{ duration: 5000 }
);
// The catalog badge source must follow the batch.
expect(loadAllPositions).toHaveBeenCalledWith('xtream-1');
expect(playbackService.seasonWatchBatchRunning()).toBe(false);
});
it('unwatches a season through one batch clear', async () => {
fixture.detectChanges();
await fixture.whenStable();
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
await playbackService.handleWatchToggleRequested(
{
seasonKey: '1',
markWatched: false,
requests: [
{ contentXtreamId: 1001, nextPosition: null },
{ contentXtreamId: 1002, nextPosition: null },
],
} as never,
'season'
);
expect(clearPlaybackPositionsBatch).toHaveBeenCalledTimes(1);
expect(clearPlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [
{ contentXtreamId: 1001, contentType: 'episode' },
{ contentXtreamId: 1002, contentType: 'episode' },
]);
expect(clearPlaybackPosition).not.toHaveBeenCalled();
expect(playbackService.episodePlaybackPositions().has(1001)).toBe(
false
);
});
it('does not write a stale season batch into another playlist state', async () => {
fixture.detectChanges();
await fixture.whenStable();
let resolveBatch!: () => void;
savePlaybackPositionsBatch.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveBatch = resolve;
})
);
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
const pending = playbackService.handleWatchToggleRequested(
{
seasonKey: '1',
markWatched: true,
requests: [
{
contentXtreamId: 1001,
nextPosition: {
playlistId: 'xtream-1',
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 1200,
durationSeconds: 1200,
},
},
],
} as never,
'season'
);
// The user navigates to another playlist while the batch is pending.
const initialPlaylist = currentPlaylist();
currentPlaylist.set({ ...initialPlaylist, id: 'xtream-2' });
resolveBatch();
await pending;
expect(savePlaybackPositionsBatch).toHaveBeenCalledWith(
'xtream-1',
expect.anything()
);
expect(playbackService.episodePlaybackPositions().has(1001)).toBe(
false
);
expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled();
// The store now belongs to the other playlist — no stale refresh.
expect(loadAllPositions).not.toHaveBeenCalled();
expect(playbackService.seasonWatchBatchRunning()).toBe(false);
currentPlaylist.set(initialPlaylist);
});
it('keeps rendered positions and reports the error when the season batch fails', async () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
savePlaybackPositionsBatch.mockRejectedValue(new Error('batch failed'));
fixture.detectChanges();
await fixture.whenStable();
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
const snackBar = TestBed.inject(MatSnackBar);
await playbackService.handleWatchToggleRequested(
{
seasonKey: '1',
markWatched: true,
requests: [
{
contentXtreamId: 1001,
nextPosition: {
playlistId: 'xtream-1',
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 1200,
durationSeconds: 1200,
},
},
],
} as never,
'season'
);
expect(playbackService.episodePlaybackPositions().has(1001)).toBe(
false
);
expect(snackBar.open).toHaveBeenCalledWith(
'XTREAM.SEASON_WATCH_UPDATE_FAILED',
undefined,
{ duration: 5000 }
);
expect(playbackService.seasonWatchBatchRunning()).toBe(false);
consoleError.mockRestore();
});
it('passes inline episode metadata and autoplays only inside the current season', async () => {
isEmbeddedPlayer.mockReturnValue(true);
fixture.detectChanges();
@@ -6,13 +6,11 @@ import {
inject,
OnDestroy,
signal,
untracked,
ChangeDetectionStrategy,
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
CastCrewRowComponent,
@@ -49,24 +47,15 @@ import {
XtreamSerieEpisode,
XtreamSerieInfo,
} from '@iptvnator/shared/interfaces';
import { buildSeasonDescriptions } from './season-descriptions.util';
import { buildSeasonPosters } from './season-posters.util';
import {
createDiscoverFacetNavigation,
isProviderOnlyDetailState,
} from '@iptvnator/portal/shared/util';
import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
TmdbEnrichmentService,
} from '@iptvnator/services';
import { injectXtreamDetailNavigation } from '../xtream-detail-navigation';
import {
SerialDetailsPlaybackService,
type XtreamSerieDetailsView,
} from './serial-details-playback.service';
import { SerialDetailsRouteService } from './serial-details-route.service';
import { SerialDetailsSeasonWatchService } from './serial-details-season-watch.service';
import { SerialDetailsSeasonsService } from './serial-details-seasons.service';
import { SerialDetailsSimilarService } from './serial-details-similar.service';
import { SimilarCatalogItem } from '../tmdb-similar.util';
import { createSerialPlaybackSessionKey } from './serial-playback-session-key';
import { SerialDetailsHeroPresenter } from './serial-details-hero.presenter';
import { SerialDetailsMenuService } from './serial-details-menu.service';
@@ -90,7 +79,9 @@ import { SerialDetailsDownloadAdapterService } from './serial-details-download-a
],
providers: [
SerialDetailsPlaybackService,
SerialDetailsRouteService,
SerialDetailsSeasonWatchService,
SerialDetailsSeasonsService,
SerialDetailsSimilarService,
SerialDetailsHeroPresenter,
SerialDetailsMenuService,
@@ -118,8 +109,7 @@ import { SerialDetailsDownloadAdapterService } from './serial-details-download-a
export class SerialDetailsComponent implements OnDestroy {
private readonly location = inject(Location);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
private readonly navigation = injectXtreamDetailNavigation('tv');
private readonly xtreamStore = inject(XtreamStore);
private readonly playback = inject(SerialDetailsPlaybackService);
@@ -142,20 +132,9 @@ export class SerialDetailsComponent implements OnDestroy {
SerialDetailsDownloadAdapterService
);
readonly episodeDownloadAdapter = this.downloadAdapter.adapter;
/** `playlistId:categoryId:serialId` of the last initialized view */
private readonly lastInitKey = signal<string | null>(null);
/**
* Reactive route params: the component is reused when navigating
* between two series details (e.g. via the Similar rail).
*/
private readonly routeParams = toSignal(this.route.params, {
initialValue: this.route.snapshot.params,
});
readonly providerOnly = computed(() => {
this.routeParams();
return isProviderOnlyDetailState(window.history.state);
});
private readonly routeState = inject(SerialDetailsRouteService);
private readonly routeParams = this.routeState.params;
readonly providerOnly = this.routeState.providerOnly;
// Episode playback state, re-exposed for the template.
readonly inlinePlayback = this.playback.inlinePlayback;
@@ -182,27 +161,27 @@ export class SerialDetailsComponent implements OnDestroy {
})
);
/** Season currently selected in the season container. */
private readonly selectedSeasonKey = signal<string | null>(null);
/** Season descriptions (provider text, TMDB fallback, URL junk dropped). */
readonly seasonDescriptions = computed<Record<string, string>>(() =>
buildSeasonDescriptions(this.selectedItem())
);
/** Season posters (TMDB season poster first, provider season cover next). */
readonly seasonPosters = computed<Record<string, string>>(() =>
buildSeasonPosters(this.selectedItem())
);
/** The "Similar" rail: catalog matches plus cross-portal matches */
private readonly similar = inject(SerialDetailsSimilarService);
readonly similarItems = this.similar.similarItems;
readonly similarInPortals = this.similar.similarInPortals;
/**
* Season descriptions and posters, and the selected season's enrichment.
* Injected after the other services that register effects: its enrichment
* effect then runs after theirs and before the constructor's.
*/
private readonly seasons = inject(SerialDetailsSeasonsService);
readonly seasonDescriptions = this.seasons.descriptions;
readonly seasonPosters = this.seasons.posters;
/** Clickable year/genre/country chips (Discover pages) */
readonly discover = this.navigation.discover;
constructor() {
this.playback.bind({ selectedItem: this.selectedItem });
this.similar.bind({ selectedItem: this.selectedItem });
this.seasons.bind({ selectedItem: this.selectedItem });
this.downloadAdapter.bind(this.selectedItem);
this.heroPresenter.bind({
selectedItem: this.selectedItem,
@@ -210,8 +189,9 @@ export class SerialDetailsComponent implements OnDestroy {
yearLabel: (releaseDate) => this.discover.yearLabel(releaseDate),
similarItems: this.similarItems,
similarInPortals: this.similarInPortals,
openSimilar: (item) => this.openSimilar(item),
openSimilarInPortals: (item) => this.openSimilarInPortals(item),
openSimilar: (item) => this.navigation.openSimilar(item),
openSimilarInPortals: (item) =>
this.navigation.openSimilarInPortals(item),
});
this.menu.bind({
selectedItem: this.selectedItem,
@@ -235,22 +215,6 @@ export class SerialDetailsComponent implements OnDestroy {
this.playback.playEpisode(episode, player),
});
// TMDB season enrichment, keyed on (tmdb_id, selected season). With
// season tabs the first seasonSelected fires as soon as seasons load —
// usually BEFORE the async show-level TMDB match has written
// info.tmdb_id, and enrichSelectedSerialSeason no-ops without it. So
// the call must re-run when the match arrives, not only on selection.
// The store-side enrichment is idempotent per (serial, season).
effect(() => {
const tmdbId = this.selectedItem()?.info?.tmdb_id;
const seasonKey = this.selectedSeasonKey();
if (tmdbId && seasonKey) {
untracked(() =>
this.xtreamStore.enrichSelectedSerialSeason(seasonKey)
);
}
});
effect(() => {
const item = this.xtreamStore.selectedItem() as unknown as
| (XtreamSerieDetails & {
@@ -274,22 +238,7 @@ export class SerialDetailsComponent implements OnDestroy {
// Initializes on first render and RE-initializes when the route
// params change while the component is reused (Similar rail).
effect(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
const { categoryId, serialId } = this.routeParams();
if (!playlistId || !serialId) {
return;
}
const initKey = `${playlistId}:${categoryId}:${serialId}`;
if (this.lastInitKey() === initKey) {
return;
}
this.lastInitKey.set(initKey);
this.playback.resetForNewSeries();
this.initializeSerialDetails(playlistId, categoryId, serialId);
});
effect(() => this.routeState.loadAddressedSeries());
registerContentMetadataBackfill({
store: this.xtreamStore,
@@ -306,10 +255,6 @@ export class SerialDetailsComponent implements OnDestroy {
this.xtreamStore.setSelectedItem(null);
}
openSimilarInPortals(item: CrossPortalSimilarItem): void {
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
}
/** Clears every saved episode position of the series. */
resetProgress(): Promise<void> {
const request =
@@ -319,41 +264,12 @@ export class SerialDetailsComponent implements OnDestroy {
: Promise.resolve();
}
openSimilar(item: SimilarCatalogItem): void {
void this.router.navigate(['../..', item.categoryId, item.id], {
relativeTo: this.route,
});
}
openActor(member: TmdbEnrichedCastMember): void {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId || !member.tmdbPersonId) {
return;
this.navigation.openActor(member);
}
void this.router.navigate([
'/workspace/xtreams',
playlistId,
'actor',
member.tmdbPersonId,
]);
}
/** Clickable year/genre/country chips (Discover pages) */
private readonly tmdbEnrichment = inject(TmdbEnrichmentService);
readonly discover = createDiscoverFacetNavigation(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
// Discover reads its results from TMDB, so a chip must not offer a
// page that enrichment cannot fill
return playlistId && this.tmdbEnrichment.isEnabled()
? { portal: 'xtream', mediaType: 'tv', playlistId }
: null;
});
onSeasonSelected(seasonKey: string): void {
// The enrichment call itself runs from the constructor effect keyed
// on (tmdb_id, selectedSeasonKey) — see the race note there.
this.selectedSeasonKey.set(seasonKey);
this.seasons.select(seasonKey);
}
playEpisode(episode: XtreamSerieEpisode): void {
@@ -445,25 +361,4 @@ export class SerialDetailsComponent implements OnDestroy {
}
);
}
private initializeSerialDetails(
playlistId: string,
categoryId: string | number,
serialId: string
): void {
this.xtreamStore.fetchSerialDetailsWithMetadata({
serialId,
categoryId: Number(categoryId),
});
const serialXtreamId = Number(serialId);
this.xtreamStore.checkFavoriteStatus(
serialXtreamId,
playlistId,
'series'
);
void this.playback.loadSeriesPlaybackPositions(
playlistId,
serialXtreamId
);
}
}
@@ -0,0 +1,314 @@
import { Location } from '@angular/common';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
SeriesResumeTarget,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
DownloadsService,
PlaybackPositionRuntimeBridgeService,
SettingsStore,
} from '@iptvnator/services';
import {
PlaybackPositionData,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { SeasonContainerComponent } from '@iptvnator/ui/components';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { BehaviorSubject, EMPTY, of } from 'rxjs';
import { SerialDetailsComponent } from './serial-details.component';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
import {
StubMatIconComponent,
StubPortalInlinePlayerComponent,
StubSeasonContainerComponent,
} from './serial-details.test-stubs';
/**
* The TestBed every series-details component spec needs.
*
* The page pulls in the store, the playback ports, the position bridge,
* downloads, settings and the router, so standing it up costs ~200 lines.
* One harness keeps the specs split by concern from carrying a copy each.
*/
/** The series every spec starts from: two seasons, three episodes. */
function createSeriesFixture(): unknown {
return {
series_id: 103,
info: {
name: 'Series One',
plot: 'Series plot',
cover: 'cover.jpg',
backdrop_path: [],
genre: 'Drama',
category_id: '3',
tmdb_id: 901,
tmdb_cast: [{ name: 'Sienna Wave', character: 'Mara' }],
},
episodes: {
'1': [
{
id: '1001',
episode_num: 1,
title: 'Episode 1',
season: 1,
},
{
id: '1002',
episode_num: 2,
title: 'Episode 2',
season: 1,
},
],
'2': [
{
id: '2001',
episode_num: 1,
title: 'Season 2 Episode 1',
season: 2,
},
],
},
};
}
const initialPlaylist = {
id: 'xtream-1',
serverUrl: 'http://xtream.example',
username: 'user',
password: 'pass',
userAgent: 'ProtectedProvider/2.0',
referrer: 'https://referrer.example/series',
origin: 'https://origin.example',
};
/** Every stub the harness installs, so specs can drive and assert on them. */
export function createSerialDetailsStubs() {
return {
selectedItem: signal<unknown>(null),
selectedContentType: signal<'series'>('series'),
isFavorite: signal(false),
isLoadingDetails: signal(false),
detailsError: signal<string | null>(null),
currentPlaylist: signal(initialPlaylist),
fetchSerialDetailsWithMetadata: jest.fn(),
cancelDetailsRequest: jest.fn(),
checkFavoriteStatus: jest.fn(),
constructEpisodeStreamUrl: jest.fn(),
addRecentItem: jest.fn(),
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
savePlaybackPosition: jest.fn(),
clearPlaybackPosition: jest.fn(),
savePlaybackPositionsBatch: jest.fn(),
clearPlaybackPositionsBatch: jest.fn(),
loadAllPositions: jest.fn(),
isEmbeddedPlayer: jest.fn(),
getSeriesPlaybackPositions: jest.fn().mockResolvedValue([]),
seriesResumeTarget: signal<SeriesResumeTarget | null>(null),
routeParams: new BehaviorSubject({
categoryId: '3',
serialId: '103',
}),
/** The page's subscription to streamed position updates, once made. */
positionUpdates: {
callback: null as ((data: PlaybackPositionData) => void) | null,
},
};
}
export type SerialDetailsStubs = ReturnType<typeof createSerialDetailsStubs>;
/** Back to the state a fresh `beforeEach` expects. */
export function resetSerialDetailsStubs(stubs: SerialDetailsStubs): void {
stubs.selectedItem.set(createSeriesFixture());
stubs.isFavorite.set(false);
stubs.isLoadingDetails.set(false);
stubs.detailsError.set(null);
stubs.currentPlaylist.set(initialPlaylist);
stubs.fetchSerialDetailsWithMetadata.mockClear();
stubs.cancelDetailsRequest.mockClear();
stubs.checkFavoriteStatus.mockClear();
stubs.constructEpisodeStreamUrl.mockReset();
stubs.constructEpisodeStreamUrl.mockImplementation(
(episode: { id: string | number }) =>
`http://xtream.example/series/${episode.id}.mp4`
);
stubs.addRecentItem.mockClear();
stubs.openResolvedPlayback.mockReset();
stubs.openResolvedPlayback.mockResolvedValue(undefined);
stubs.openExternalPlayback.mockReset();
stubs.openExternalPlayback.mockResolvedValue(undefined);
stubs.savePlaybackPosition.mockReset();
stubs.savePlaybackPosition.mockResolvedValue(undefined);
stubs.clearPlaybackPosition.mockReset();
stubs.clearPlaybackPosition.mockResolvedValue(undefined);
stubs.savePlaybackPositionsBatch.mockReset();
stubs.savePlaybackPositionsBatch.mockResolvedValue(undefined);
stubs.clearPlaybackPositionsBatch.mockReset();
stubs.clearPlaybackPositionsBatch.mockResolvedValue(undefined);
stubs.loadAllPositions.mockReset();
stubs.loadAllPositions.mockResolvedValue(undefined);
stubs.positionUpdates.callback = null;
stubs.isEmbeddedPlayer.mockReset();
stubs.isEmbeddedPlayer.mockReturnValue(false);
stubs.getSeriesPlaybackPositions.mockClear();
stubs.getSeriesPlaybackPositions.mockResolvedValue([]);
stubs.seriesResumeTarget.set(null);
stubs.routeParams.next({ categoryId: '3', serialId: '103' });
}
export async function configureSerialDetailsTestBed(
stubs: SerialDetailsStubs
): Promise<void> {
await TestBed.configureTestingModule({
imports: [SerialDetailsComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
params: stubs.routeParams,
snapshot: {
params: {
categoryId: '3',
serialId: '103',
},
},
},
},
{
provide: XtreamStore,
useValue: {
selectedItem: stubs.selectedItem,
selectedContentType: stubs.selectedContentType,
isFavorite: stubs.isFavorite,
isLoadingDetails: stubs.isLoadingDetails,
detailsError: stubs.detailsError,
currentPlaylist: stubs.currentPlaylist,
fetchSerialDetailsWithMetadata:
stubs.fetchSerialDetailsWithMetadata,
cancelDetailsRequest: stubs.cancelDetailsRequest,
checkFavoriteStatus: stubs.checkFavoriteStatus,
setSelectedItem: jest.fn((value: unknown) =>
stubs.selectedItem.set(value)
),
toggleFavorite: jest.fn(),
constructEpisodeStreamUrl: stubs.constructEpisodeStreamUrl,
addRecentItem: stubs.addRecentItem,
backfillContentMetadata: jest.fn(),
loadAllPositions: stubs.loadAllPositions,
recentItems: signal([]),
serialCategories: signal([]),
loadRecentItems: jest.fn(),
},
},
{
provide: SettingsStore,
useValue: { player: signal(VideoPlayer.Html5Player) },
},
{
provide: DownloadsService,
useValue: { isAvailable: signal(false) },
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
},
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getSeriesPlaybackPositions:
stubs.getSeriesPlaybackPositions,
savePlaybackPosition: stubs.savePlaybackPosition,
clearPlaybackPosition: stubs.clearPlaybackPosition,
savePlaybackPositionsBatch:
stubs.savePlaybackPositionsBatch,
clearPlaybackPositionsBatch:
stubs.clearPlaybackPositionsBatch,
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: stubs.isEmbeddedPlayer,
openResolvedPlayback: stubs.openResolvedPlayback,
openExternalPlayback: stubs.openExternalPlayback,
},
},
{
provide: PlaybackPositionRuntimeBridgeService,
useValue: {
onPlaybackPositionUpdate: (
callback: (data: PlaybackPositionData) => void
) => {
stubs.positionUpdates.callback = callback;
return () => {
stubs.positionUpdates.callback = null;
};
},
},
},
{
provide: XTREAM_SERIES_RESUME_TARGET,
useValue: stubs.seriesResumeTarget,
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: EMPTY,
onTranslationChange: EMPTY,
onDefaultLangChange: EMPTY,
},
},
{
provide: Location,
useValue: {
back: jest.fn(),
},
},
],
})
.overrideComponent(SerialDetailsComponent, {
remove: {
imports: [
MatIcon,
PortalInlinePlayerComponent,
SeasonContainerComponent,
TranslatePipe,
],
},
add: {
imports: [
StubMatIconComponent,
StubPortalInlinePlayerComponent,
StubSeasonContainerComponent,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
],
},
})
.compileComponents();
}
@@ -0,0 +1,267 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { SerialDetailsComponent } from './serial-details.component';
import { SerialDetailsPlaybackService } from './serial-details-playback.service';
import {
configureSerialDetailsTestBed,
createSerialDetailsStubs,
resetSerialDetailsStubs,
} from './serial-details.harness';
describe('SerialDetailsComponent watched toggles', () => {
let fixture: ComponentFixture<SerialDetailsComponent>;
const stubs = createSerialDetailsStubs();
const {
currentPlaylist,
savePlaybackPosition,
clearPlaybackPosition,
savePlaybackPositionsBatch,
clearPlaybackPositionsBatch,
loadAllPositions,
} = stubs;
beforeEach(async () => {
window.history.replaceState({}, '', window.location.href);
resetSerialDetailsStubs(stubs);
await configureSerialDetailsTestBed(stubs);
fixture = TestBed.createComponent(SerialDetailsComponent);
});
afterEach(() => {
window.history.replaceState({}, '', window.location.href);
fixture?.destroy();
});
it('saves and clears positions for season-container toggle requests', async () => {
fixture.detectChanges();
await fixture.whenStable();
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
await playbackService.handlePlaybackToggleRequested({
contentXtreamId: 1001,
nextPosition: {
playlistId: 'xtream-1',
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 950,
durationSeconds: 1000,
},
} as never);
expect(savePlaybackPosition).toHaveBeenCalledWith(
'xtream-1',
expect.objectContaining({ contentXtreamId: 1001 })
);
await playbackService.handlePlaybackToggleRequested({
contentXtreamId: 1001,
nextPosition: null,
} as never);
expect(clearPlaybackPosition).toHaveBeenCalledWith(
'xtream-1',
1001,
'episode'
);
});
it('marks a season watched through one batch save and updates rendered positions', async () => {
fixture.detectChanges();
await fixture.whenStable();
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
const snackBar = TestBed.inject(MatSnackBar);
const seasonPosition = (
contentXtreamId: number,
episodeNumber: number
) => ({
playlistId: 'xtream-1',
contentXtreamId,
contentType: 'episode' as const,
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber,
positionSeconds: 1200,
durationSeconds: 1200,
});
await playbackService.handleWatchToggleRequested(
{
seasonKey: '1',
markWatched: true,
requests: [
{
contentXtreamId: 1001,
nextPosition: seasonPosition(1001, 1),
},
{
contentXtreamId: 1002,
nextPosition: seasonPosition(1002, 2),
},
],
} as never,
'season'
);
expect(savePlaybackPositionsBatch).toHaveBeenCalledTimes(1);
expect(savePlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [
expect.objectContaining({ contentXtreamId: 1001 }),
expect.objectContaining({ contentXtreamId: 1002 }),
]);
expect(savePlaybackPosition).not.toHaveBeenCalled();
expect(playbackService.episodePlaybackPositions().get(1001)).toEqual(
expect.objectContaining({ positionSeconds: 1200 })
);
expect(
playbackService.episodePlaybackPositions().get(1002)
).toBeDefined();
expect(snackBar.open).toHaveBeenCalledWith(
'XTREAM.SEASON_MARKED_WATCHED',
undefined,
{ duration: 5000 }
);
// The catalog badge source must follow the batch.
expect(loadAllPositions).toHaveBeenCalledWith('xtream-1');
expect(playbackService.seasonWatchBatchRunning()).toBe(false);
});
it('unwatches a season through one batch clear', async () => {
fixture.detectChanges();
await fixture.whenStable();
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
await playbackService.handleWatchToggleRequested(
{
seasonKey: '1',
markWatched: false,
requests: [
{ contentXtreamId: 1001, nextPosition: null },
{ contentXtreamId: 1002, nextPosition: null },
],
} as never,
'season'
);
expect(clearPlaybackPositionsBatch).toHaveBeenCalledTimes(1);
expect(clearPlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [
{ contentXtreamId: 1001, contentType: 'episode' },
{ contentXtreamId: 1002, contentType: 'episode' },
]);
expect(clearPlaybackPosition).not.toHaveBeenCalled();
expect(playbackService.episodePlaybackPositions().has(1001)).toBe(
false
);
});
it('does not write a stale season batch into another playlist state', async () => {
fixture.detectChanges();
await fixture.whenStable();
let resolveBatch!: () => void;
savePlaybackPositionsBatch.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveBatch = resolve;
})
);
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
const pending = playbackService.handleWatchToggleRequested(
{
seasonKey: '1',
markWatched: true,
requests: [
{
contentXtreamId: 1001,
nextPosition: {
playlistId: 'xtream-1',
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 1200,
durationSeconds: 1200,
},
},
],
} as never,
'season'
);
// The user navigates to another playlist while the batch is pending.
const initialPlaylist = currentPlaylist();
currentPlaylist.set({ ...initialPlaylist, id: 'xtream-2' });
resolveBatch();
await pending;
expect(savePlaybackPositionsBatch).toHaveBeenCalledWith(
'xtream-1',
expect.anything()
);
expect(playbackService.episodePlaybackPositions().has(1001)).toBe(
false
);
expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled();
// The store now belongs to the other playlist — no stale refresh.
expect(loadAllPositions).not.toHaveBeenCalled();
expect(playbackService.seasonWatchBatchRunning()).toBe(false);
currentPlaylist.set(initialPlaylist);
});
it('keeps rendered positions and reports the error when the season batch fails', async () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
savePlaybackPositionsBatch.mockRejectedValue(new Error('batch failed'));
fixture.detectChanges();
await fixture.whenStable();
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
const snackBar = TestBed.inject(MatSnackBar);
await playbackService.handleWatchToggleRequested(
{
seasonKey: '1',
markWatched: true,
requests: [
{
contentXtreamId: 1001,
nextPosition: {
playlistId: 'xtream-1',
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 1200,
durationSeconds: 1200,
},
},
],
} as never,
'season'
);
expect(playbackService.episodePlaybackPositions().has(1001)).toBe(
false
);
expect(snackBar.open).toHaveBeenCalledWith(
'XTREAM.SEASON_WATCH_UPDATE_FAILED',
undefined,
{ duration: 5000 }
);
expect(playbackService.seasonWatchBatchRunning()).toBe(false);
consoleError.mockRestore();
});
});
@@ -0,0 +1,70 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
/** Stand-ins for the heavy children of the series-details page in specs. */
@Component({
selector: 'app-season-container',
standalone: true,
changeDetection: ChangeDetectionStrategy.Eager,
template: '<div data-testid="season-container"></div>',
})
export class StubSeasonContainerComponent {
readonly seasons = input<unknown>(null);
readonly seriesId = input<number | string | null>(null);
readonly playlistId = input('');
readonly seriesTitle = input<string | undefined>(undefined);
readonly playbackPositions = input<unknown>(null);
readonly downloadAdapter = input<SeasonEpisodeDownloadAdapter | null>(null);
readonly downloadsEnabled = input(true);
readonly openingEpisodeId = input<number | null>(null);
readonly activeEpisodeId = input<number | null>(null);
readonly playingEpisodeId = input<number | null>(null);
readonly seasonDescriptions = input<unknown>(null);
readonly seasonPosters = input<unknown>(null);
readonly seasonWatchBatchRunning = input(false);
readonly episodeClicked = output<unknown>();
readonly playbackToggleRequested = output<unknown>();
readonly seasonPlaybackToggleRequested = output<unknown>();
}
@Component({
selector: 'app-portal-inline-player',
standalone: true,
changeDetection: ChangeDetectionStrategy.Eager,
template: '',
})
export class StubPortalInlinePlayerComponent {
readonly playbackSessionKey = input.required<string>();
readonly playback = input<unknown>(null);
readonly episodeMetadata = input<unknown>(null);
readonly seriesTitle = input<string | null>(null);
readonly seriesNavigation = input<unknown>(null);
readonly upNextEpisodes = input<unknown>(null);
readonly seriesEpisodes = input<unknown>(null);
readonly seasonPosters = input<unknown>(null);
readonly episodePlaybackPositions = input<unknown>(null);
readonly seasonLoadStates = input<unknown>(null);
readonly timeUpdate = output<unknown>();
readonly closed = output<void>();
readonly streamUrlCopied = output<void>();
readonly externalFallbackRequested = output<unknown>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly upNextEpisodeSelected = output<unknown>();
readonly episodePanelSeasonSelected = output<string>();
}
@Component({
selector: 'mat-icon',
standalone: true,
changeDetection: ChangeDetectionStrategy.Eager,
template: '<ng-content />',
})
export class StubMatIconComponent {}
@@ -0,0 +1,58 @@
import type {
PlayerContentInfo,
ResolvedPortalPlayback,
XtreamSerieEpisode,
} from '@iptvnator/shared/interfaces';
import {
resolveSeriesPlaybackEpisodeState,
type SeriesPlaybackEpisodeState,
} from '@iptvnator/ui/playback';
import type { XtreamSerieDetailsView } from './serial-details-playback.service';
export interface SerialEpisodePlaybackRequest {
readonly playlistId: string;
readonly selectedItem: XtreamSerieDetailsView;
readonly episode: XtreamSerieEpisode;
readonly streamUrl: string;
/** The episode's saved offset, when it has one. */
readonly startTime: number | undefined;
}
export interface SerialEpisodePlayback {
readonly playback: ResolvedPortalPlayback;
readonly episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null;
}
/**
* The playback an episode of the series on screen resolves to, and its place
* among the series' episodes for the inline player's navigation.
*/
export function buildSerialEpisodePlayback(
request: SerialEpisodePlaybackRequest
): SerialEpisodePlayback {
const { episode, selectedItem } = request;
const contentInfo: PlayerContentInfo = {
playlistId: request.playlistId,
contentXtreamId: Number(episode.id),
contentType: 'episode',
seriesXtreamId: Number(selectedItem.series_id),
seasonNumber: Number(episode.season),
episodeNumber: Number(episode.episode_num),
};
const playback: ResolvedPortalPlayback = {
streamUrl: request.streamUrl,
title: episode.title,
thumbnail: selectedItem.info.cover,
startTime: request.startTime,
contentInfo,
};
const episodeState = resolveSeriesPlaybackEpisodeState({
episodesBySeason: selectedItem.episodes,
currentEpisodeId: episode.id,
fallbackSeasonNumber: Number(episode.season),
fallbackEpisodeNumber: Number(episode.episode_num),
});
return { playback, episodeState };
}
@@ -2,6 +2,7 @@ import { Injectable, Signal, computed, inject, signal } from '@angular/core';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { createMovieDownloadSnapshot } from '@iptvnator/portal/shared/util';
import { DownloadsService } from '@iptvnator/services';
import { DialogService } from '@iptvnator/ui/components';
import { resolveXtreamVodPlaybackSource } from '@iptvnator/portal/xtream/data-access';
import {
getXtreamVodInfo,
@@ -54,6 +55,7 @@ export class VodDetailsDownloadsService {
private readonly downloadsService = inject(DownloadsService);
private readonly xtreamStore = inject(XtreamStore);
private readonly translateService = inject(TranslateService);
private readonly dialogService = inject(DialogService);
private routeContentId: Signal<number> = signal(NaN);
@@ -124,6 +126,20 @@ export class VodDetailsDownloadsService {
: null;
});
/** 2πr of the r=15.5 progress-ring circle in its 36×36 viewBox. */
readonly ringCircumference = 2 * Math.PI * 15.5;
/**
* Dash offset that leaves the arc at the real percent — or a fixed
* quarter arc when the total size is unknown and the ring spins instead.
*/
readonly ringOffset = computed(() => {
const percent = this.downloadPercent();
return percent === null
? this.ringCircumference * 0.75
: this.ringCircumference * (1 - percent / 100);
});
/** Cancel whatever download is running or queued for this movie. */
async cancelActive(): Promise<void> {
const item = this.activeDownload();
@@ -132,6 +148,28 @@ export class VodDetailsDownloadsService {
}
}
/**
* A running download is destroyed by one click, so the icon button asks
* first — there is no label left to warn what the click does.
*/
promptCancel(): void {
this.dialogService.openConfirmDialog({
title: this.translateService.instant(
'DOWNLOADS.CANCEL_CONFIRM_TITLE'
),
message: this.translateService.instant(
'DOWNLOADS.CANCEL_CONFIRM_MESSAGE'
),
confirmLabel: this.translateService.instant(
'DOWNLOADS.CANCEL_CONFIRM_TITLE'
),
// "Cancel" next to "Cancel download" would read as the same action.
cancelLabel: this.translateService.instant('CLOSE'),
tone: 'destructive',
onConfirm: () => void this.cancelActive(),
});
}
/** Open the finished file's location in the system file manager. */
async revealDownloaded(): Promise<void> {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
@@ -1,4 +1,9 @@
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import { signal } from '@angular/core';
import type {
ExternalPlayerSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { ExternalLaunchOwner } from './vod-details-external-launch-owner';
interface OwnedExternalLaunchOptions {
launch: Promise<ExternalPlayerSession | void>;
@@ -63,3 +68,44 @@ function isClosableExternalLaunch(
(session.status === 'error' && session.canClose))
);
}
/**
* The MPV/VLC launch a VOD page made last and the one it still waits on.
*
* What was launched is remembered independently of the controller's active
* source, so a refresh or an overlapping handoff cannot make the exact
* process this page launched look foreign before its teardown has been
* confirmed. Its route owner prevents a reused component from attributing
* that process to a different movie.
*/
export class VodExternalLaunchClaim {
private launchedGeneration = 0;
/** Start generation of the launch still awaiting its player, if any. */
readonly pendingGeneration = signal<number | null>(null);
constructor(private readonly owner: ExternalLaunchOwner) {}
claim(playback: ResolvedPortalPlayback, generation: number): void {
this.owner.set(playback.contentInfo);
this.launchedGeneration = generation;
this.pendingGeneration.set(generation);
}
/** An inline start took over: nothing external is owned or awaited. */
release(): void {
this.owner.clear();
this.pendingGeneration.set(null);
}
clearOwnership(generation: number): void {
if (this.launchedGeneration === generation) {
this.owner.clear();
}
}
clearPending(generation: number): void {
if (this.pendingGeneration() === generation) {
this.pendingGeneration.set(null);
}
}
}
@@ -10,17 +10,20 @@ import {
type RemainingTimeLabel,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import type {
PlaybackPositionData,
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
XtreamVodInfo,
import {
youtubeEmbedUrl,
type PlaybackPositionData,
type TmdbCountryFacet,
type TmdbEnrichedCastMember,
type TmdbGenreFacet,
type XtreamVodInfo,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import type { CrossPortalSimilarItem } from '@iptvnator/services';
import {
castMembersFromNames,
splitPeopleNames,
TrailerDialogService,
type DetailActionButtonState,
type SimilarRailItem,
} from '@iptvnator/ui/components';
@@ -54,17 +57,21 @@ interface VodDetailsHeroBindings {
readonly formatPosition: () => string;
readonly similarItems: Signal<readonly SimilarCatalogItem[]>;
readonly similarInPortals: Signal<readonly CrossPortalSimilarItem[]>;
readonly openSimilar: (item: SimilarCatalogItem) => void;
readonly openSimilarInPortals: (item: CrossPortalSimilarItem) => void;
}
/**
* Presentation of the Xtream movie hero: the kind label, the chips, the
* primary button's two lines, the resume bar, the credits and the Similar
* rail, all derived from the route's selected movie.
* primary button's two lines, the resume bar, the credits, the trailer and
* the Similar rail, all derived from the route's selected movie.
*/
@Injectable()
export class VodDetailsHeroPresenter {
private readonly translate = inject(TranslateService);
private readonly xtreamStore = inject(XtreamStore);
private readonly settingsStore = inject(SettingsStore);
private readonly trailerDialog = inject(TrailerDialogService);
private readonly bindings = signal<VodDetailsHeroBindings | null>(null);
bind(bindings: VodDetailsHeroBindings): void {
@@ -208,6 +215,45 @@ export class VodDetailsHeroPresenter {
return [...local, ...crossPortal];
});
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();
const title = this.info()?.name;
if (embedUrl) {
this.trailerDialog.open({ embedUrl, title: 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);
}
}
private label(label: RemainingTimeLabel | null): string | null {
return label ? this.translate.instant(label.key, label.params) : null;
}
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff. Show more