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
+10425 -4483

No files matched your search

@@ -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>
<h2>{{ title() | translate }}</h2>
<!-- 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(
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,
});
} finally {
this.pendingStart.settle(startId);
}
await startStalkerCatalogVodPlayback(this, {
resolvePlayback: () =>
this.catalog.resolveVodPlayback(
cmd,
title,
thumbnail,
startTime
),
portalPlayer: this.portalPlayer,
resetPositionWriter: () => this.positionWriter.reset(),
logger: this.logger,
translate: this.translateService,
snackBar: this.snackBar,
});
}
}
@@ -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,
/** 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,
logger: this.logger,
closeWithheldDetail: (withheldCategoryIds) =>
this.closeWithheldDetail(withheldCategoryIds),
});
/** 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>(
{
dataService: this.dataService,
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,
});
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;
}
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);
return this.watchToggles.toggleEpisode(request);
}
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;
}>;
const stubs = createSerialDetailsStubs();
const {
selectedItem,
currentPlaylist,
fetchSerialDetailsWithMetadata,
cancelDetailsRequest,
checkFavoriteStatus,
constructEpisodeStreamUrl,
addRecentItem,
openResolvedPlayback,
openExternalPlayback,
savePlaybackPosition,
isEmbeddedPlayer,
getSeriesPlaybackPositions,
seriesResumeTarget,
routeParams,
} = stubs;
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: {
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();
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;
}
void this.router.navigate([
'/workspace/xtreams',
playlistId,
'actor',
member.tmdbPersonId,
]);
this.navigation.openActor(member);
}
/** 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;
}
@@ -32,7 +32,6 @@ import {
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import {
closeRunningExternalSession,
ownsContent,
runningExternalSession,
} from './vod-details-external-session';
import {
@@ -40,13 +39,16 @@ import {
startRouteOwnedPlayback,
} from './vod-details-external-launch-owner';
import { injectXtreamRecentHistory } from '../xtream-recent-history';
import { settleOwnedExternalLaunch } from './vod-details-external-launch';
import {
settleOwnedExternalLaunch,
VodExternalLaunchClaim,
} from './vod-details-external-launch';
import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation';
import { VodDetailsPositionState } from './vod-details-position-state';
import {
hasVodResetTarget,
type VodResetTarget,
} from './vod-details-reset-target';
import { isResumablePosition } from './vod-primary-action-position';
export interface VodDetailsPlaybackBindings {
/** Current vod id resolved from the route */
@@ -83,8 +85,6 @@ export class VodDetailsPlaybackService {
PlaybackPositionRuntimeBridgeService
);
private readonly logger = createLogger('VodDetailsPlayback');
private positionLoadGeneration = 0;
private destroyed = false;
/** Signals bound from the host component via `bind()` */
private readonly bindings = signal<VodDetailsPlaybackBindings | null>(null);
@@ -92,27 +92,23 @@ export class VodDetailsPlaybackService {
() => this.xtreamStore.currentPlaylist()?.id,
() => this.bindings()?.vodId()
);
/** What this page launched externally, see {@link VodExternalLaunchClaim}. */
private readonly externalLaunch = new VodExternalLaunchClaim(
this.externalLaunchOwner
);
/** Stored positions of the page, see {@link VodDetailsPositionState}. */
private readonly positions = new VodDetailsPositionState({
playlistId: () => this.xtreamStore.currentPlaylist()?.id,
contentId: () => this.bindings()?.vodId(),
alternative: () => this.bindings()?.activeSource?.() ?? null,
});
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
/**
* The LAST position seen, whichever copy produced it.
*
* Multi-source can put playback on a copy in another playlist, and this
* follows it — the progress bar and the switch feed both want the stream
* on screen, not the one the route happens to address.
*/
readonly vodPlaybackPosition = signal<PlaybackPositionData | null>(null);
/**
* The ROUTE copy's own row.
*
* Everything that acts on the route's stream — Resume, its label, its
* timecode — has to read this instead. Positions are keyed by (playlist,
* stream), so once an alternative has played, `vodPlaybackPosition` names
* a different film's row entirely and resuming from it would jump the
* route copy to a timecode nobody reached in it.
*/
readonly routePlaybackPosition = signal<PlaybackPositionData | null>(null);
/** The LAST position seen, whichever copy produced it. */
readonly vodPlaybackPosition = this.positions.last;
/** The ROUTE copy's own row: what Resume, its label and its timecode read. */
readonly routePlaybackPosition = this.positions.route;
private readonly externalButton = createExternalPlaybackButtonState({
session: this.externalPlayback.activeSession,
@@ -131,59 +127,27 @@ export class VodDetailsPlaybackService {
readonly externalPrimaryIcon = this.externalButton.primaryIcon;
readonly isExternalLaunchPending = computed(
() =>
this.externalLaunchGeneration() !== null ||
this.externalLaunch.pendingGeneration() !== null ||
this.externalButton.isLaunchPending()
);
readonly isExternalStopAction = this.externalButton.isStopAction;
readonly externalPrimaryButtonState = this.externalButton.buttonState;
/** Mirrors an incoming position into the route's row when it owns it. */
private trackPosition(position: PlaybackPositionData | null): void {
this.vodPlaybackPosition.set(position);
if (this.isRouteContent(position)) {
this.routePlaybackPosition.set(position);
}
}
private isRouteContent(position: PlaybackPositionData | null): boolean {
return (
!!position &&
position.playlistId === this.xtreamStore.currentPlaylist()?.id &&
position.contentXtreamId === this.bindings()?.vodId()
);
}
/** Whether the ROUTE copy has somewhere to resume from. */
readonly hasPlaybackPosition = computed(() =>
isResumablePosition(this.routePlaybackPosition())
);
readonly hasPlaybackPosition = this.positions.hasResumable;
constructor() {
const unsubscribePositionUpdates =
this.playbackPositionBridge.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
// An external player on an ALTERNATIVE reports under
// that playlist's ids; dropping those rewinds a switch.
if (this.ownsContent(data)) {
this.trackPosition(data);
}
}
(data: PlaybackPositionData) => this.positions.trackOwned(data)
) ?? null;
inject(DestroyRef).onDestroy(() => {
this.destroyed = true;
this.positionLoadGeneration++;
this.positions.destroy();
unsubscribePositionUpdates?.();
});
}
private ownsContent(info: Parameters<typeof ownsContent>[0]): boolean {
return ownsContent(info, {
routePlaylistId: this.xtreamStore.currentPlaylist()?.id,
routeContentId: this.bindings()?.vodId(),
alternative: this.bindings()?.activeSource?.() ?? null,
});
}
/** Wires the host component's context signals. Call once at construction. */
bind(bindings: VodDetailsPlaybackBindings): void {
this.bindings.set(bindings);
@@ -285,7 +249,7 @@ export class VodDetailsPlaybackService {
playlistId,
position
),
onSaved: (position) => this.trackPosition(position),
onSaved: (position) => this.positions.track(position),
});
/**
@@ -318,7 +282,7 @@ export class VodDetailsPlaybackService {
const routeIdentity = this.externalLaunchOwner.captureRoute();
this.bindings()?.supersedePendingSwitch();
const generation = ++this.startGeneration;
this.claimExternalLaunch(playback, generation);
this.externalLaunch.claim(playback, generation);
const launch = this.portalPlayer.openExternalPlayback(playback, player);
void this.settleExternalLaunch(
generation,
@@ -334,32 +298,16 @@ export class VodDetailsPlaybackService {
* answer that read started from.
*/
discardPendingPositionLoads(): void {
this.positionLoadGeneration++;
this.positions.discardPendingLoads();
}
/** The route copy's row is in hand; fails closed while a read is in flight. */
readonly positionLoaded = signal(false);
readonly positionLoaded = this.positions.loaded;
async loadPosition(playlistId: string, vodId: number): Promise<void> {
const generation = ++this.positionLoadGeneration;
this.positionLoaded.set(false);
const position = await this.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
loadPosition(playlistId: string, vodId: number): Promise<void> {
return this.positions.load(playlistId, vodId, () =>
this.playbackPositions.getPlaybackPosition(playlistId, vodId, 'vod')
);
if (
this.destroyed ||
generation !== this.positionLoadGeneration ||
this.xtreamStore.currentPlaylist()?.id !== playlistId ||
this.bindings()?.vodId() !== vodId
) {
return;
}
this.vodPlaybackPosition.set(position);
this.routePlaybackPosition.set(position);
this.positionLoaded.set(true);
}
/**
@@ -374,18 +322,6 @@ export class VodDetailsPlaybackService {
/** Bumped by every start; only the newest may launch after its close. */
private startGeneration = 0;
/**
* What we last launched externally, remembered independently of the
* controller's active source.
*
* Kept independently of the controller 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.
*/
private launchedExternallyGeneration = 0;
private readonly externalLaunchGeneration = signal<number | null>(null);
/**
* Starts still between the click and `inlinePlayback` / the external
* launch (closing the previous player is a round-trip). The watched
@@ -421,7 +357,10 @@ export class VodDetailsPlaybackService {
isCurrent: () => boolean = () => true,
player?: ExternalPlayerName
): Promise<boolean> {
if (this.externalLaunchGeneration() !== null || this.resetPending()) {
if (
this.externalLaunch.pendingGeneration() !== null ||
this.resetPending()
) {
return false;
}
const runningSession = runningExternalSession(
@@ -497,28 +436,18 @@ export class VodDetailsPlaybackService {
this.positionWriter.reset();
if (!player && this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
this.externalLaunchOwner.clear();
this.externalLaunchGeneration.set(null);
this.externalLaunch.release();
return true;
}
this.closeInlinePlayer();
this.claimExternalLaunch(playback, generation);
this.externalLaunch.claim(playback, generation);
const launch = player
? this.portalPlayer.openExternalPlayback(playback, player)
: this.portalPlayer.openResolvedPlayback(playback, true);
return await this.settleExternalLaunch(generation, isCurrent, launch);
}
private claimExternalLaunch(
playback: ResolvedPortalPlayback,
generation: number
): void {
this.externalLaunchOwner.set(playback.contentInfo);
this.launchedExternallyGeneration = generation;
this.externalLaunchGeneration.set(generation);
}
private async settleExternalLaunch(
generation: number,
isCurrent: () => boolean,
@@ -535,20 +464,9 @@ export class VodDetailsPlaybackService {
),
reportLaunchFailure: (error) =>
this.bindings()?.reportExternalLaunchFailure?.(error),
clearPending: () => this.clearExternalLaunchPending(generation),
clearOwnership: () => this.clearExternalLaunchOwnership(generation),
clearPending: () => this.externalLaunch.clearPending(generation),
clearOwnership: () =>
this.externalLaunch.clearOwnership(generation),
});
}
private clearExternalLaunchOwnership(generation: number): void {
if (this.launchedExternallyGeneration === generation) {
this.externalLaunchOwner.clear();
}
}
private clearExternalLaunchPending(generation: number): void {
if (this.externalLaunchGeneration() === generation) {
this.externalLaunchGeneration.set(null);
}
}
}
@@ -0,0 +1,121 @@
import { computed, signal } from '@angular/core';
import type {
PlaybackPositionData,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { ownsContent } from './vod-details-external-session';
import { isResumablePosition } from './vod-primary-action-position';
/** The copies the page's position state belongs to. */
export interface VodDetailsPositionRoute {
readonly playlistId: () => string | undefined;
readonly contentId: () => number | undefined;
/** The alternative currently in use, if it is not the route's own. */
readonly alternative: () => PlayerContentInfo | null;
}
/**
* Stored playback positions of the VOD details page: the last one seen, the
* route copy's own row, and the guarded read that fills both.
*/
export class VodDetailsPositionState {
private loadGeneration = 0;
private destroyed = false;
/**
* The LAST position seen, whichever copy produced it.
*
* Multi-source can put playback on a copy in another playlist, and this
* follows it — the progress bar and the switch feed both want the stream
* on screen, not the one the route happens to address.
*/
readonly last = signal<PlaybackPositionData | null>(null);
/**
* The ROUTE copy's own row.
*
* Everything that acts on the route's stream — Resume, its label, its
* timecode — has to read this instead. Positions are keyed by (playlist,
* stream), so once an alternative has played, `last` names a different
* film's row entirely and resuming from it would jump the route copy to
* a timecode nobody reached in it.
*/
readonly route = signal<PlaybackPositionData | null>(null);
/** Whether the ROUTE copy has somewhere to resume from. */
readonly hasResumable = computed(() => isResumablePosition(this.route()));
/** The route copy's row is in hand; fails closed while a read is in flight. */
readonly loaded = signal(false);
constructor(private readonly routeCopy: VodDetailsPositionRoute) {}
/** Mirrors an incoming position into the route's row when it owns it. */
track(position: PlaybackPositionData | null): void {
this.last.set(position);
if (this.isRouteContent(position)) {
this.route.set(position);
}
}
/**
* Takes a streamed position when the page owns its content. An external
* player on an ALTERNATIVE reports under that playlist's ids; dropping
* those rewinds a switch.
*/
trackOwned(position: PlaybackPositionData): void {
const owned = {
routePlaylistId: this.routeCopy.playlistId(),
routeContentId: this.routeCopy.contentId(),
alternative: this.routeCopy.alternative(),
};
if (ownsContent(position, owned)) {
this.track(position);
}
}
/**
* Retires every stored-position read still in flight. A row written
* since (the manual watched toggle) must not be overwritten by the older
* answer that read started from.
*/
discardPendingLoads(): void {
this.loadGeneration++;
}
/** The page is gone: no read still in flight may land. */
destroy(): void {
this.destroyed = true;
this.loadGeneration++;
}
async load(
playlistId: string,
vodId: number,
read: () => Promise<PlaybackPositionData | null>
): Promise<void> {
const generation = ++this.loadGeneration;
this.loaded.set(false);
const position = await read();
if (
this.destroyed ||
generation !== this.loadGeneration ||
this.routeCopy.playlistId() !== playlistId ||
this.routeCopy.contentId() !== vodId
) {
return;
}
this.last.set(position);
this.route.set(position);
this.loaded.set(true);
}
private isRouteContent(position: PlaybackPositionData | null): boolean {
return (
!!position &&
position.playlistId === this.routeCopy.playlistId() &&
position.contentXtreamId === this.routeCopy.contentId()
);
}
}
@@ -8,13 +8,10 @@ import {
effect,
inject,
signal,
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
CastCrewRowComponent,
@@ -24,90 +21,40 @@ import {
DetailIconButtonComponent,
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
DialogService,
MetaChipComponent,
PortalDetailShellComponent,
SimilarRailComponent,
type SimilarRailItem,
TrailerDialogService,
ViewInPortalActionComponent,
VodMoreMenuComponent,
scrollToCastCrewRow,
} from '@iptvnator/ui/components';
import {
createDiscoverFacetNavigation,
createLogger,
isProviderOnlyDetailState,
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
registerContentMetadataBackfill,
resolveXtreamVodPlaybackSource,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import {
type PlaybackFallbackRequest,
PortalInlinePlayerComponent,
} from '@iptvnator/ui/playback';
import { DownloadsService, SettingsStore } from '@iptvnator/services';
import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
DownloadsService,
SettingsStore,
TmdbEnrichmentService,
} from '@iptvnator/services';
import {
getXtreamVodInfo,
normalizeTitleKeys,
playlistDisplayLabel,
reportsPlaybackFailures,
TmdbEnrichedCastMember,
XtreamCategory,
XtreamVodDetails,
XtreamVodInfo,
XtreamVodStream,
youtubeEmbedUrl,
type ExternalPlayerName,
type PlaybackPositionData,
type VodSourceCandidate,
type VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import {
SimilarCatalogItem,
matchRecommendationsToCatalog,
} from '../tmdb-similar.util';
import {
buildXtreamVodFallbackViewModel,
hasUsableXtreamVodMetadata,
} from './vod-details-fallback.util';
import { injectXtreamDetailNavigation } from '../xtream-detail-navigation';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { VodDetailsMultiSourceUiService } from './vod-details-multi-source-ui.service';
import { VodDetailsDownloadsService } from './vod-details-downloads.service';
import { VodDetailsWatchedService } from './vod-details-watched.service';
import { VodDetailsHeroPresenter } from './vod-details-hero.presenter';
import { VodDetailsMenuService } from './vod-details-menu.service';
import { VodDetailsSelectionService } from './vod-details-selection.service';
import { VodDetailsSimilarService } from './vod-details-similar.service';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
type XtreamVodIdentityItem = XtreamVodDetails & {
readonly id?: number | string;
readonly stream_id?: number | string;
readonly xtream_id?: number | string;
};
function resolveVodIdentity(item: XtreamVodDetails): number | null {
const candidate = item as XtreamVodIdentityItem;
const value =
item.movie_data?.stream_id ??
candidate.xtream_id ??
candidate.stream_id ??
candidate.id;
const id = typeof value === 'string' ? Number(value) : value;
return typeof id === 'number' && Number.isSafeInteger(id) && id > 0
? id
: null;
}
@Component({
templateUrl: './vod-details-route.component.html',
@@ -117,6 +64,7 @@ function resolveVodIdentity(item: XtreamVodDetails): number | null {
],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
VodDetailsSelectionService,
VodDetailsPlaybackService,
VodMultiSourceHostService,
VodDetailsMultiSourceUiService,
@@ -131,7 +79,6 @@ function resolveVodIdentity(item: XtreamVodDetails): number | null {
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
MatIcon,
MatTooltip,
NgTemplateOutlet,
PortalDetailShellComponent,
ViewInPortalActionComponent,
@@ -151,11 +98,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private readonly location = inject(Location);
private readonly settingsStore = inject(SettingsStore);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
private readonly navigation = injectXtreamDetailNavigation('movie');
private readonly xtreamStore = inject(XtreamStore);
private readonly downloadsService = inject(DownloadsService);
private readonly dialogService = inject(DialogService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly playback = inject(VodDetailsPlaybackService);
@@ -167,7 +112,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private readonly watched = inject(VodDetailsWatchedService);
readonly hero = inject(VodDetailsHeroPresenter);
readonly menu = inject(VodDetailsMenuService);
private readonly trailerDialog = inject(TrailerDialogService);
/** The movie the route addresses, see {@link VodDetailsSelectionService}. */
private readonly selection = inject(VodDetailsSelectionService);
private readonly logger = createLogger('VodDetailsRoute');
/** `playlistId:vodId` of the last initialized detail view */
private readonly lastInitKey = signal<string | null>(null);
@@ -176,167 +122,22 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
/** The route copy's own row — what Resume acts on. */
readonly routePlaybackPosition = this.playback.routePlaybackPosition;
/**
* Reactive route params: the component is reused when navigating
* between two VOD details (e.g. via the Similar rail), so computeds
* must not read the one-shot snapshot.
*/
private readonly routeParams = toSignal(this.route.params, {
initialValue: this.route.snapshot.params,
});
readonly theme = this.settingsStore.theme;
readonly isElectron = this.downloadsService.isAvailable;
readonly isFavorite = this.xtreamStore.isFavorite;
readonly isWatched = this.watched.isWatched;
readonly canToggleWatched = this.watched.canToggle;
readonly selectedVodId = computed(() =>
Number(this.routeParams()['vodId'])
);
readonly playbackSessionKey = computed(() => {
const sourceId = this.xtreamStore.currentPlaylist()?.id;
const contentId = this.selectedVodId();
return sourceId && Number.isFinite(contentId) && contentId > 0
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
: '';
});
readonly providerOnly = computed(() => {
this.routeParams();
return isProviderOnlyDetailState(window.history.state);
});
readonly selectedItem = computed(() => {
const item =
this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null;
return item && resolveVodIdentity(item) === this.selectedVodId()
? item
: null;
});
private readonly scopedVodCategories = computed(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
return playlistId &&
this.xtreamStore.vodCategoriesPlaylistId() === playlistId
? this.xtreamStore.vodCategories()
: [];
});
private readonly scopedVodStreams = computed(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
return playlistId &&
this.xtreamStore.vodStreamsPlaylistId() === playlistId
? this.xtreamStore.vodStreams()
: [];
});
readonly selectedCategory = computed<Partial<XtreamCategory> | null>(() => {
const categoryId = this.routeParams()['categoryId'];
if (!categoryId) {
return null;
}
return (
this.scopedVodCategories().find(
(category) =>
String(
(
category as XtreamCategory & {
id?: string | number;
}
).category_id ??
(
category as XtreamCategory & {
id?: string | number;
}
).id
) === String(categoryId)
) ?? null
);
});
readonly selectedCatalogItem = computed<
| (Partial<XtreamVodStream> & {
id?: string | number;
poster_url?: string;
title?: string;
xtream_id?: string | number;
})
| null
>(() => {
const vodId = this.selectedVodId();
if (!Number.isFinite(vodId) || vodId <= 0) {
return null;
}
return (
this.scopedVodStreams().find(
(item) =>
Number(
(
item as XtreamVodStream & {
id?: string | number;
xtream_id?: string | number;
}
).xtream_id ??
(
item as XtreamVodStream & {
id?: string | number;
}
).stream_id ??
(
item as XtreamVodStream & {
id?: string | number;
}
).id
) === vodId
) ?? null
);
});
/** Movie identity for multi-source discovery; null until a title exists */
private readonly multiSourceMovie = computed(() => {
// Electron stores categories under `name`, the live API under
// `category_name` — the same duality the fallback view reads.
const category = this.selectedCategory() as {
name?: string;
category_name?: string;
} | null;
return resolveVodMultiSourceMovie({
playlistId: this.xtreamStore.currentPlaylist()?.id,
// `title` is the alias the Xtream data source actually writes
// (createPlaylist maps name -> title), so reading only `name`
// would fall back to the raw playlist UUID in the sources list.
playlistName:
this.xtreamStore.currentPlaylist()?.name ??
this.xtreamStore.currentPlaylist()?.title,
vodId: this.selectedVodId(),
vodInfo: this.selectedVodInfo(),
catalogItem: this.selectedCatalogItem(),
containerExtension:
this.selectedItem()?.movie_data?.container_extension,
categoryName: category?.name ?? category?.category_name ?? null,
});
});
readonly selectedVodInfo = computed(() => {
const item = this.selectedItem();
return item && hasUsableXtreamVodMetadata(item)
? getXtreamVodInfo(item)
: null;
});
readonly playableVodItem = computed(() => {
const item = this.selectedItem();
return item && resolveXtreamVodPlaybackSource(item) ? item : null;
});
readonly fallbackView = computed(() => {
const item = this.selectedItem();
if (!item || this.selectedVodInfo()) {
return null;
}
return buildXtreamVodFallbackViewModel({
vodDetails: item,
catalogItem: this.selectedCatalogItem(),
category: this.selectedCategory(),
vodId: this.selectedVodId(),
});
});
readonly selectedVodId = this.selection.selectedVodId;
readonly playbackSessionKey = this.selection.playbackSessionKey;
readonly providerOnly = this.selection.providerOnly;
readonly selectedItem = this.selection.selectedItem;
readonly selectedCategory = this.selection.selectedCategory;
readonly selectedCatalogItem = this.selection.selectedCatalogItem;
private readonly multiSourceMovie = this.selection.multiSourceMovie;
readonly selectedVodInfo = this.selection.selectedVodInfo;
readonly playableVodItem = this.selection.playableVodItem;
readonly fallbackView = this.selection.fallbackView;
readonly isLoadingDetails = this.xtreamStore.isLoadingDetails;
readonly detailsError = this.xtreamStore.detailsError;
readonly matchedExternalPlayback = this.playback.matchedExternalPlayback;
@@ -362,38 +163,19 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.isDownloaded() && this.externalPrimaryButtonState() === 'idle'
);
/** 2πr of the r=15.5 progress-ring circle in its 36×36 viewBox. */
readonly downloadRingCircumference = 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 downloadRingOffset = computed(() => {
const percent = this.downloadPercent();
return percent === null
? this.downloadRingCircumference * 0.75
: this.downloadRingCircumference * (1 - percent / 100);
});
readonly downloadRingCircumference = this.downloads.ringCircumference;
readonly downloadRingOffset = this.downloads.ringOffset;
/** Drives the heart's brief scale pulse when favoriting toggles. */
readonly favoritePulse = signal(false);
private favoritePulseTimer: ReturnType<typeof setTimeout> | null = null;
readonly trailerEmbedUrl = computed(() =>
youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer)
);
/** `playlist:vod`: the hero keys its one-time layout decision on it. */
readonly contentKey = computed(
() =>
`xtream-vod:${this.xtreamStore.currentPlaylist()?.id ?? ''}:${this.selectedVodId()}`
);
/** Settings → Playback → Play trailers in details background. */
readonly trailerBackdropUrl = computed(() =>
this.settingsStore.detailTrailerBackdrop?.() === true
? this.trailerEmbedUrl()
: null
);
readonly trailerEmbedUrl = this.hero.trailerEmbedUrl;
readonly contentKey = this.selection.contentKey;
readonly trailerBackdropUrl = this.hero.trailerBackdropUrl;
readonly scrollToCast = scrollToCastCrewRow;
/** Clickable year/genre/country chips (Discover pages) */
readonly discover = this.navigation.discover;
readonly similarItems = this.similar.similarItems;
readonly similarInPortals = this.similar.similarInPortals;
@@ -511,6 +293,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
formatPosition: () => this.formatPosition(),
similarItems: this.similarItems,
similarInPortals: this.similarInPortals,
openSimilar: (item) => this.navigation.openSimilar(item),
openSimilarInPortals: (item) =>
this.navigation.openSimilarInPortals(item),
});
this.menu.bind({
item: this.playableVodItem,
@@ -538,73 +323,18 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
}
openSimilarInPortals(item: CrossPortalSimilarItem): void {
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
}
openSimilar(item: SimilarCatalogItem): void {
void this.router.navigate(['../..', item.categoryId, item.id], {
relativeTo: this.route,
});
}
openSimilarRailItem(item: SimilarRailItem): void {
const local = this.similarItems().find(
(candidate) => `c${candidate.id}` === item.key
);
if (local) {
this.openSimilar(local);
return;
}
const crossPortal = this.similarInPortals().find(
(candidate) =>
`x${candidate.match.playlistId}-${candidate.match.xtreamId}` ===
item.key
);
if (crossPortal) {
this.openSimilarInPortals(crossPortal);
}
this.hero.openSimilarRailItem(item);
}
openTrailer(): void {
const embedUrl = this.trailerEmbedUrl();
const title = this.selectedVodInfo()?.name;
if (embedUrl) {
this.trailerDialog.open({ embedUrl, title: title ?? '' });
}
}
scrollToCast(): void {
document
.getElementById('detail-cast-crew')
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
this.hero.openTrailer();
}
openActor(member: TmdbEnrichedCastMember): void {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId || !member.tmdbPersonId) {
return;
}
void this.router.navigate([
'/workspace/xtreams',
playlistId,
'actor',
member.tmdbPersonId,
]);
this.navigation.openActor(member);
}
/** 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: 'movie', playlistId }
: null;
});
ngOnDestroy(): void {
if (this.favoritePulseTimer) {
clearTimeout(this.favoritePulseTimer);
@@ -869,26 +599,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
return this.downloads.resumePaused();
}
/**
* 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.
*/
promptCancelDownload(): 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.downloads.cancelActive(),
});
this.downloads.promptCancel();
}
revealDownloadedFile(): Promise<void> {
@@ -0,0 +1,213 @@
import { Injectable, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { isProviderOnlyDetailState } from '@iptvnator/portal/shared/util';
import {
resolveXtreamVodPlaybackSource,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import {
getXtreamVodInfo,
XtreamCategory,
XtreamVodDetails,
XtreamVodStream,
} from '@iptvnator/shared/interfaces';
import {
buildXtreamVodFallbackViewModel,
hasUsableXtreamVodMetadata,
} from './vod-details-fallback.util';
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
type XtreamVodIdentityItem = XtreamVodDetails & {
readonly id?: number | string;
readonly stream_id?: number | string;
readonly xtream_id?: number | string;
};
function resolveVodIdentity(item: XtreamVodDetails): number | null {
const candidate = item as XtreamVodIdentityItem;
const value =
item.movie_data?.stream_id ??
candidate.xtream_id ??
candidate.stream_id ??
candidate.id;
const id = typeof value === 'string' ? Number(value) : value;
return typeof id === 'number' && Number.isSafeInteger(id) && id > 0
? id
: null;
}
/**
* What the VOD details route selects: the movie the params address, as the
* store, the loaded catalog and the route's category describe it. Read-only
* derivations of the route and the store, shared by the page and the
* services it binds.
*/
@Injectable()
export class VodDetailsSelectionService {
private readonly route = inject(ActivatedRoute);
private readonly xtreamStore = inject(XtreamStore);
/**
* Reactive route params: the component is reused when navigating
* between two VOD details (e.g. via the Similar rail), so computeds
* must not read the one-shot snapshot.
*/
private readonly routeParams = toSignal(this.route.params, {
initialValue: this.route.snapshot.params,
});
readonly selectedVodId = computed(() =>
Number(this.routeParams()['vodId'])
);
readonly playbackSessionKey = computed(() => {
const sourceId = this.xtreamStore.currentPlaylist()?.id;
const contentId = this.selectedVodId();
return sourceId && Number.isFinite(contentId) && contentId > 0
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
: '';
});
readonly providerOnly = computed(() => {
this.routeParams();
return isProviderOnlyDetailState(window.history.state);
});
readonly selectedItem = computed(() => {
const item =
this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null;
return item && resolveVodIdentity(item) === this.selectedVodId()
? item
: null;
});
private readonly scopedVodCategories = computed(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
return playlistId &&
this.xtreamStore.vodCategoriesPlaylistId() === playlistId
? this.xtreamStore.vodCategories()
: [];
});
private readonly scopedVodStreams = computed(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
return playlistId &&
this.xtreamStore.vodStreamsPlaylistId() === playlistId
? this.xtreamStore.vodStreams()
: [];
});
readonly selectedCategory = computed<Partial<XtreamCategory> | null>(() => {
const categoryId = this.routeParams()['categoryId'];
if (!categoryId) {
return null;
}
return (
this.scopedVodCategories().find(
(category) =>
String(
(
category as XtreamCategory & {
id?: string | number;
}
).category_id ??
(
category as XtreamCategory & {
id?: string | number;
}
).id
) === String(categoryId)
) ?? null
);
});
readonly selectedCatalogItem = computed<
| (Partial<XtreamVodStream> & {
id?: string | number;
poster_url?: string;
title?: string;
xtream_id?: string | number;
})
| null
>(() => {
const vodId = this.selectedVodId();
if (!Number.isFinite(vodId) || vodId <= 0) {
return null;
}
return (
this.scopedVodStreams().find(
(item) =>
Number(
(
item as XtreamVodStream & {
id?: string | number;
xtream_id?: string | number;
}
).xtream_id ??
(
item as XtreamVodStream & {
id?: string | number;
}
).stream_id ??
(
item as XtreamVodStream & {
id?: string | number;
}
).id
) === vodId
) ?? null
);
});
/** Movie identity for multi-source discovery; null until a title exists */
readonly multiSourceMovie = computed(() => {
// Electron stores categories under `name`, the live API under
// `category_name` — the same duality the fallback view reads.
const category = this.selectedCategory() as {
name?: string;
category_name?: string;
} | null;
return resolveVodMultiSourceMovie({
playlistId: this.xtreamStore.currentPlaylist()?.id,
// `title` is the alias the Xtream data source actually writes
// (createPlaylist maps name -> title), so reading only `name`
// would fall back to the raw playlist UUID in the sources list.
playlistName:
this.xtreamStore.currentPlaylist()?.name ??
this.xtreamStore.currentPlaylist()?.title,
vodId: this.selectedVodId(),
vodInfo: this.selectedVodInfo(),
catalogItem: this.selectedCatalogItem(),
containerExtension:
this.selectedItem()?.movie_data?.container_extension,
categoryName: category?.name ?? category?.category_name ?? null,
});
});
readonly selectedVodInfo = computed(() => {
const item = this.selectedItem();
return item && hasUsableXtreamVodMetadata(item)
? getXtreamVodInfo(item)
: null;
});
readonly playableVodItem = computed(() => {
const item = this.selectedItem();
return item && resolveXtreamVodPlaybackSource(item) ? item : null;
});
readonly fallbackView = computed(() => {
const item = this.selectedItem();
if (!item || this.selectedVodInfo()) {
return null;
}
return buildXtreamVodFallbackViewModel({
vodDetails: item,
catalogItem: this.selectedCatalogItem(),
category: this.selectedCategory(),
vodId: this.selectedVodId(),
});
});
/** `playlist:vod`: the hero keys its one-time layout decision on it. */
readonly contentKey = computed(
() =>
`xtream-vod:${this.xtreamStore.currentPlaylist()?.id ?? ''}:${this.selectedVodId()}`
);
}
@@ -0,0 +1,114 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
CrossPortalSimilarService,
TmdbEnrichmentService,
} from '@iptvnator/services';
import {
injectXtreamDetailNavigation,
type XtreamDetailNavigation,
} from './xtream-detail-navigation';
describe('injectXtreamDetailNavigation', () => {
const route = { snapshot: { params: {} } };
const navigate = jest.fn();
const buildLink = jest.fn();
const currentPlaylist = signal<{ id: string } | null>(null);
const enrichmentEnabled = signal(true);
function create(mediaType: 'movie' | 'tv'): XtreamDetailNavigation {
return TestBed.runInInjectionContext(() =>
injectXtreamDetailNavigation(mediaType)
);
}
beforeEach(() => {
navigate.mockReset();
buildLink.mockReset();
currentPlaylist.set({ id: 'xtream-1' });
enrichmentEnabled.set(true);
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: { navigate } },
{ provide: ActivatedRoute, useValue: route },
{ provide: XtreamStore, useValue: { currentPlaylist } },
{ provide: CrossPortalSimilarService, useValue: { buildLink } },
{
provide: TmdbEnrichmentService,
useValue: { isEnabled: () => enrichmentEnabled() },
},
],
});
});
it('opens the actor page of a TMDB-matched cast member', () => {
create('movie').openActor({ name: 'Mara', tmdbPersonId: 77 } as never);
expect(navigate).toHaveBeenCalledWith([
'/workspace/xtreams',
'xtream-1',
'actor',
77,
]);
});
it('opens no actor page without a playlist or a TMDB person', () => {
const navigation = create('movie');
navigation.openActor({ name: 'Mara' } as never);
currentPlaylist.set(null);
navigation.openActor({ name: 'Mara', tmdbPersonId: 77 } as never);
expect(navigate).not.toHaveBeenCalled();
});
it('opens a similar title of this playlist relative to the details route', () => {
create('tv').openSimilar({ categoryId: 12, id: 345 } as never);
expect(navigate).toHaveBeenCalledWith(['../..', 12, 345], {
relativeTo: route,
});
});
it('opens a similar title of another portal through its link', () => {
const item = { match: { playlistId: 'xtream-2' } } as never;
const link = ['/workspace/xtreams', 'xtream-2', 'series', '3', '9'];
buildLink.mockReturnValue(link);
create('tv').openSimilarInPortals(item);
expect(buildLink).toHaveBeenCalledWith(item);
expect(navigate).toHaveBeenCalledWith(link);
});
it.each(['movie', 'tv'] as const)(
'sends the facet chips to the %s Discover page of the playlist',
(mediaType) => {
const { discover } = create(mediaType);
expect(discover.canOpenYear('1999-03-31')).toBe(true);
discover.openYear('1999-03-31');
expect(navigate).toHaveBeenCalledWith(
['/workspace', 'xtreams', 'xtream-1', 'discover'],
{ queryParams: { type: mediaType, year: '1999' } }
);
}
);
it('offers no facet chip while enrichment cannot fill Discover', () => {
const { discover } = create('movie');
enrichmentEnabled.set(false);
expect(discover.canOpenYear('1999-03-31')).toBe(false);
enrichmentEnabled.set(true);
currentPlaylist.set(null);
expect(discover.canOpenYear('1999-03-31')).toBe(false);
discover.openYear('1999-03-31');
expect(navigate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,73 @@
import { inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
createDiscoverFacetNavigation,
type DiscoverFacetNavigation,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
TmdbEnrichmentService,
} from '@iptvnator/services';
import type { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces';
import type { SimilarCatalogItem } from './tmdb-similar.util';
/** Where an Xtream movie or series details page can send the viewer. */
export interface XtreamDetailNavigation {
/** Clickable year/genre/country chips (Discover pages) */
readonly discover: DiscoverFacetNavigation;
/** The actor page of a TMDB-matched cast member. */
openActor(member: TmdbEnrichedCastMember): void;
/** A Similar-rail title of this playlist: the same route, another id. */
openSimilar(item: SimilarCatalogItem): void;
/** A Similar-rail title found in another of the user's portals. */
openSimilarInPortals(item: CrossPortalSimilarItem): void;
}
/**
* Outbound navigation shared by the Xtream movie (`movie`) and series (`tv`)
* details pages.
*
* Must run in an injection context.
*/
export function injectXtreamDetailNavigation(
mediaType: 'movie' | 'tv'
): XtreamDetailNavigation {
const router = inject(Router);
const route = inject(ActivatedRoute);
const xtreamStore = inject(XtreamStore);
const crossPortalSimilar = inject(CrossPortalSimilarService);
const tmdbEnrichment = inject(TmdbEnrichmentService);
return {
discover: createDiscoverFacetNavigation(() => {
const playlistId = xtreamStore.currentPlaylist()?.id;
// Discover reads its results from TMDB, so a chip must not offer a
// page that enrichment cannot fill
return playlistId && tmdbEnrichment.isEnabled()
? { portal: 'xtream', mediaType, playlistId }
: null;
}),
openActor(member) {
const playlistId = xtreamStore.currentPlaylist()?.id;
if (!playlistId || !member.tmdbPersonId) {
return;
}
void router.navigate([
'/workspace/xtreams',
playlistId,
'actor',
member.tmdbPersonId,
]);
},
openSimilar(item) {
void router.navigate(['../..', item.categoryId, item.id], {
relativeTo: route,
});
},
openSimilarInPortals(item) {
void router.navigate(crossPortalSimilar.buildLink(item));
},
};
}
@@ -8,6 +8,7 @@ import {
StreamFormat,
Theme,
VideoPlayer,
watchEmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
/** Defaults and boot-time helpers of `SettingsStore`, split out for size. */
@@ -115,3 +116,52 @@ export function scheduleEmbeddedMpvPrepare(): void {
window.setTimeout(prepare, 2000);
}
}
/**
* Checks a saved Embedded MPV selection against this machine: schedules the
* idle prepare when it is supported, and calls `fallBack` when it is not or
* when the check itself fails. An inconclusive answer is no verdict: the
* selection stays and the answer is followed until it is final. Nothing is
* done once `isSaved` turns false, because the user picked another player
* meanwhile. Resolves when the first answer has been handled.
*/
export function verifySavedEmbeddedMpvPlayer(
isSaved: () => boolean,
fallBack: () => Promise<void>
): Promise<void> {
const electron =
typeof window === 'undefined' ? undefined : window.electron;
if (!electron?.getEmbeddedMpvSupport) {
return fallBack();
}
return new Promise<void>((handled, failed) => {
const stop = watchEmbeddedMpvSupport(
() => electron.getEmbeddedMpvSupport(),
(support) => {
if (!isSaved()) {
stop();
handled();
} else if (support.supported) {
scheduleEmbeddedMpvPrepare();
handled();
} else if (support.inconclusive) {
handled();
} else {
fallBack().then(handled, failed);
}
},
(error) => {
console.warn(
'Failed to verify embedded MPV support; reverting to the default inline player.',
error
);
if (isSaved()) {
fallBack().then(handled, failed);
} else {
handled();
}
}
);
});
}
@@ -0,0 +1,190 @@
import { Injector } from '@angular/core';
import { StorageMap } from '@ngx-pwa/local-storage';
import { of } from 'rxjs';
import {
EMBEDDED_MPV_SUPPORT_RECHECK_MS,
EmbeddedMpvSupport,
Settings,
STORE_KEY,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { EpgSourceSettingsService } from './epg-source-settings.service';
import { SettingsStore } from './settings-store.service';
/** What the main process answers when the Linux `mpv` probe finds nothing. */
const MPV_MISSING: EmbeddedMpvSupport = {
supported: false,
platform: 'linux',
reason: 'Embedded MPV on Linux requires the mpv executable on PATH.',
frameCopyAvailable: false,
frameCopyUnavailableReason: 'helper-probe-failed',
};
/** A slow login shell: mpv was looked up before its PATH arrived. */
const INCONCLUSIVE: EmbeddedMpvSupport = { ...MPV_MISSING, inconclusive: true };
const SUPPORTED: EmbeddedMpvSupport = {
supported: true,
platform: 'linux',
engine: 'native',
};
describe('SettingsStore saved Embedded MPV selection', () => {
const testWindow = window as unknown as {
electron?: { getEmbeddedMpvSupport: jest.Mock };
};
const originalElectron = testWindow.electron;
let getEmbeddedMpvSupport: jest.Mock;
let storage: { get: jest.Mock; set: jest.Mock };
let injector: Injector;
/** Loads settings as on startup and lets the first answer be handled. */
async function start(): Promise<InstanceType<typeof SettingsStore>> {
const store = injector.get(SettingsStore);
await store.loadSettings();
await jest.advanceTimersByTimeAsync(0);
expect(getEmbeddedMpvSupport).toHaveBeenCalled();
return store;
}
const persistedPlayers = () =>
storage.set.mock.calls.map(([, settings]) => settings.player);
beforeEach(() => {
jest.useFakeTimers();
const saved: Partial<Settings> = { player: VideoPlayer.EmbeddedMpv };
storage = {
get: jest.fn(() => of(saved)),
set: jest.fn(() => of(undefined)),
};
getEmbeddedMpvSupport = jest.fn();
testWindow.electron = { getEmbeddedMpvSupport };
injector = Injector.create({
providers: [
SettingsStore,
EpgSourceSettingsService,
{ provide: StorageMap, useValue: storage },
],
});
jest.spyOn(
injector.get(EpgSourceSettingsService),
'synchronize'
).mockResolvedValue(undefined);
});
afterEach(() => {
jest.useRealTimers();
testWindow.electron = originalElectron;
});
it('keeps the saved player while the support check is inconclusive', async () => {
getEmbeddedMpvSupport.mockResolvedValue(INCONCLUSIVE);
const store = await start();
await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS);
expect(store.player()).toBe(VideoPlayer.EmbeddedMpv);
expect(storage.set).not.toHaveBeenCalled();
});
it('falls back to the default player on a final unsupported answer', async () => {
getEmbeddedMpvSupport.mockResolvedValue(MPV_MISSING);
const store = await start();
expect(store.player()).toBe(VideoPlayer.VideoJs);
expect(storage.set).toHaveBeenCalledWith(
STORE_KEY.Settings,
expect.objectContaining({ player: VideoPlayer.VideoJs })
);
});
it('keeps the saved player when Embedded MPV is supported', async () => {
getEmbeddedMpvSupport.mockResolvedValue(SUPPORTED);
const store = await start();
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(store.player()).toBe(VideoPlayer.EmbeddedMpv);
expect(storage.set).not.toHaveBeenCalled();
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(1);
});
it('follows an inconclusive answer and falls back once mpv is finally missing', async () => {
getEmbeddedMpvSupport
.mockResolvedValueOnce(INCONCLUSIVE)
.mockResolvedValue(MPV_MISSING);
const store = await start();
expect(store.player()).toBe(VideoPlayer.EmbeddedMpv);
// The login shell answered: mpv really is not installed.
await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS);
expect(store.player()).toBe(VideoPlayer.VideoJs);
expect(persistedPlayers()).toEqual([VideoPlayer.VideoJs]);
});
it('follows an inconclusive answer and keeps the player once mpv is found', async () => {
getEmbeddedMpvSupport
.mockResolvedValueOnce(INCONCLUSIVE)
.mockResolvedValue(SUPPORTED);
const store = await start();
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 20
);
expect(store.player()).toBe(VideoPlayer.EmbeddedMpv);
expect(storage.set).not.toHaveBeenCalled();
// The final answer ended the checks.
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
});
it('leaves a player the user picked meanwhile alone and stops checking', async () => {
getEmbeddedMpvSupport
.mockResolvedValueOnce(INCONCLUSIVE)
.mockResolvedValue(MPV_MISSING);
const store = await start();
await store.updateSettings({ player: VideoPlayer.MPV });
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 20
);
expect(store.player()).toBe(VideoPlayer.MPV);
expect(persistedPlayers()).toEqual([VideoPlayer.MPV]);
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
});
it('leaves a player picked while the first answer was pending alone', async () => {
let answer: (support: EmbeddedMpvSupport) => void = () => undefined;
getEmbeddedMpvSupport.mockReturnValue(
new Promise<EmbeddedMpvSupport>((resolve) => {
answer = resolve;
})
);
const store = await start();
await store.updateSettings({ player: VideoPlayer.VLC });
answer(MPV_MISSING);
await jest.advanceTimersByTimeAsync(0);
expect(store.player()).toBe(VideoPlayer.VLC);
expect(persistedPlayers()).toEqual([VideoPlayer.VLC]);
});
it('falls back to the default player when the support check fails', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation();
getEmbeddedMpvSupport.mockRejectedValue(new Error('bridge failed'));
try {
const store = await start();
expect(store.player()).toBe(VideoPlayer.VideoJs);
expect(persistedPlayers()).toEqual([VideoPlayer.VideoJs]);
} finally {
warn.mockRestore();
}
});
});
@@ -41,6 +41,7 @@ import {
DEFAULT_SETTINGS,
scheduleEmbeddedMpvPrepare,
SettingsStorageState,
verifySavedEmbeddedMpvPlayer,
} from './settings-store.defaults';
export type { SettingsStorageFailure } from './settings-store.defaults';
@@ -324,39 +325,14 @@ export const SettingsStore = signalStore(
},
async sanitizeEmbeddedMpvSelection() {
if (store.player() !== VideoPlayer.EmbeddedMpv) {
return;
}
if (
typeof window === 'undefined' ||
!window.electron?.getEmbeddedMpvSupport
) {
await this.updateSettings({
player: DEFAULT_SETTINGS.player,
});
return;
}
try {
const support =
await window.electron.getEmbeddedMpvSupport();
if (!support.supported) {
await this.updateSettings({
const isSaved = () =>
store.player() === VideoPlayer.EmbeddedMpv;
if (isSaved()) {
await verifySavedEmbeddedMpvPlayer(isSaved, () =>
this.updateSettings({
player: DEFAULT_SETTINGS.player,
});
return;
}
scheduleEmbeddedMpvPrepare();
} catch (error) {
console.warn(
'Failed to verify embedded MPV support; reverting to the default inline player.',
error
})
);
await this.updateSettings({
player: DEFAULT_SETTINGS.player,
});
}
},
};
+1
View File
@@ -6,6 +6,7 @@ export * from './lib/content-metadata.interface';
export * from './lib/dev-logger.util';
export * from './lib/download-metadata.interface';
export * from './lib/embedded-mpv-session.interface';
export * from './lib/embedded-mpv-support-watch.util';
export * from './lib/subtitle-style.util';
export * from './lib/zoom-level.util';
export * from './lib/electron-api.interface';
@@ -39,6 +39,13 @@ export interface EmbeddedMpvSupport {
supported: boolean;
platform: string;
reason?: string;
/**
* True when `supported: false` is not a verdict on this machine yet: the
* Linux native-view `mpv` executable was looked up before the login shell
* PATH arrived, and is looked up again once the shell answers. Never
* persist a decision made from such an answer; ask again later.
*/
inconclusive?: boolean;
capabilities?: EmbeddedMpvCapabilities;
/**
* Rendering engine the main process will use for new sessions.
@@ -0,0 +1,169 @@
import type { EmbeddedMpvSupport } from './embedded-mpv-session.interface';
import {
EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS,
EMBEDDED_MPV_SUPPORT_RECHECK_MS,
watchEmbeddedMpvSupport,
} from './embedded-mpv-support-watch.util';
const SUPPORTED: EmbeddedMpvSupport = { supported: true, platform: 'linux' };
const UNSUPPORTED: EmbeddedMpvSupport = {
supported: false,
platform: 'linux',
reason: 'no mpv',
};
const INCONCLUSIVE: EmbeddedMpvSupport = { ...UNSUPPORTED, inconclusive: true };
describe('watchEmbeddedMpvSupport', () => {
let onAnswer: jest.Mock;
let onError: jest.Mock;
beforeEach(() => {
jest.useFakeTimers();
onAnswer = jest.fn();
onError = jest.fn();
});
afterEach(() => {
jest.useRealTimers();
});
it.each([SUPPORTED, UNSUPPORTED])(
'delivers a final answer once and asks no more: %j',
async (answer) => {
const getSupport = jest.fn().mockResolvedValue(answer);
watchEmbeddedMpvSupport(getSupport, onAnswer, onError);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(getSupport).toHaveBeenCalledTimes(1);
expect(onAnswer.mock.calls).toEqual([[answer]]);
expect(onError).not.toHaveBeenCalled();
}
);
it('asks again while the answer is inconclusive, until a final one', async () => {
const getSupport = jest
.fn()
.mockResolvedValueOnce(INCONCLUSIVE)
.mockResolvedValueOnce(INCONCLUSIVE)
.mockResolvedValue(SUPPORTED);
watchEmbeddedMpvSupport(getSupport, onAnswer, onError);
await jest.advanceTimersByTimeAsync(0);
expect(getSupport).toHaveBeenCalledTimes(1);
expect(onAnswer).toHaveBeenLastCalledWith(INCONCLUSIVE);
await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS);
expect(getSupport).toHaveBeenCalledTimes(2);
expect(onAnswer).toHaveBeenLastCalledWith(INCONCLUSIVE);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 2
);
expect(getSupport).toHaveBeenCalledTimes(3);
expect(onAnswer).toHaveBeenLastCalledWith(SUPPORTED);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS * 3
);
expect(getSupport).toHaveBeenCalledTimes(3);
});
it('backs off to the slowest rate while the answer stays inconclusive', async () => {
const getSupport = jest.fn().mockResolvedValue(INCONCLUSIVE);
watchEmbeddedMpvSupport(getSupport, onAnswer, onError);
// Rechecks after 3, 6, 12 and 24 s, then every 30 s.
await jest.advanceTimersByTimeAsync(3000 + 6000 + 12_000 + 24_000);
expect(getSupport).toHaveBeenCalledTimes(5);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS - 1
);
expect(getSupport).toHaveBeenCalledTimes(5);
await jest.advanceTimersByTimeAsync(1);
expect(getSupport).toHaveBeenCalledTimes(6);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS
);
expect(getSupport).toHaveBeenCalledTimes(7);
});
it('asks no more when the answer handler stops the watch', async () => {
const getSupport = jest.fn().mockResolvedValue(INCONCLUSIVE);
const stop: () => void = watchEmbeddedMpvSupport(
getSupport,
() => stop(),
onError
);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS * 3
);
expect(getSupport).toHaveBeenCalledTimes(1);
});
it('asks no more once stopped', async () => {
const getSupport = jest.fn().mockResolvedValue(INCONCLUSIVE);
const stop = watchEmbeddedMpvSupport(getSupport, onAnswer, onError);
await jest.advanceTimersByTimeAsync(0);
stop();
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS * 3
);
expect(getSupport).toHaveBeenCalledTimes(1);
expect(onAnswer).toHaveBeenCalledTimes(1);
});
it('drops an answer that arrives after it was stopped', async () => {
let answer: (support: EmbeddedMpvSupport) => void = () => undefined;
const getSupport = jest.fn(
() =>
new Promise<EmbeddedMpvSupport>((resolve) => {
answer = resolve;
})
);
const stop = watchEmbeddedMpvSupport(getSupport, onAnswer, onError);
stop();
answer(INCONCLUSIVE);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS * 3
);
expect(onAnswer).not.toHaveBeenCalled();
expect(getSupport).toHaveBeenCalledTimes(1);
});
it.each([
['rejects', () => Promise.reject(new Error('bridge failed'))],
[
'throws',
() => {
throw new Error('bridge failed');
},
],
])(
'ends the watch with the error when the request %s',
async (_how, request) => {
const getSupport = jest.fn(request);
watchEmbeddedMpvSupport(getSupport, onAnswer, onError);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0][0]).toEqual(
new Error('bridge failed')
);
expect(onAnswer).not.toHaveBeenCalled();
expect(getSupport).toHaveBeenCalledTimes(1);
}
);
});
@@ -0,0 +1,64 @@
import type { EmbeddedMpvSupport } from './embedded-mpv-session.interface';
/**
* How soon an inconclusive support answer is asked for again. The main
* process answers from its cached probe meanwhile, so a recheck costs one
* IPC round trip and spawns nothing.
*/
export const EMBEDDED_MPV_SUPPORT_RECHECK_MS = 3000;
/**
* Each further recheck waits twice as long, up to this: a login shell that
* never answers must not keep the app polling at the first rate for good.
*/
export const EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS = 30_000;
/**
* Asks for embedded MPV support and hands every answer to `onAnswer`. An
* `inconclusive` answer is not final, so it is asked for again until a final
* one arrives or the returned function is called. A failed request ends the
* watch through `onError`, as a final answer would.
*
* For whatever holds on to one answer: the surfaces that stay mounted on it
* (the player, the settings page) and the settings store, which has to reach
* a decision. A surface that asks on demand simply asks again the next time.
*/
export function watchEmbeddedMpvSupport(
getSupport: () => Promise<EmbeddedMpvSupport>,
onAnswer: (support: EmbeddedMpvSupport) => void,
onError: (error: unknown) => void
): () => void {
let stopped = false;
let recheck: ReturnType<typeof setTimeout> | undefined;
let recheckMs = EMBEDDED_MPV_SUPPORT_RECHECK_MS;
const ask = async (): Promise<void> => {
let support: EmbeddedMpvSupport;
try {
support = await getSupport();
} catch (error) {
if (!stopped) {
onError(error);
}
return;
}
if (stopped) {
return;
}
onAnswer(support);
// `onAnswer` may have stopped the watch.
if (support.inconclusive && !stopped) {
recheck = setTimeout(() => void ask(), recheckMs);
recheckMs = Math.min(
recheckMs * 2,
EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS
);
}
};
void ask();
return () => {
stopped = true;
clearTimeout(recheck);
};
}
@@ -29,8 +29,7 @@ export interface ConfirmDialogData {
@Component({
imports: [MatButtonModule, MatDialogModule, TranslateModule],
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h2 mat-dialog-title>
{{ dialogData.title }}
@@ -34,8 +34,7 @@ export type ContentHeroLayout = 'stage' | 'compact';
TranslateModule,
],
templateUrl: './content-hero.component.html',
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./content-hero.component.scss'],
})
export class ContentHeroComponent {
@@ -47,8 +47,7 @@ import { TranslateModule } from '@ngx-translate/core';
</button>
}
`,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
@@ -56,8 +56,7 @@ import { TranslateModule } from '@ngx-translate/core';
</div>
</section>
`,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./content-about.component.scss'],
})
export class ContentAboutComponent {
@@ -12,8 +12,7 @@ import {
output,
ChangeDetectionStrategy,
} from '@angular/core';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access';
import { ContentHeroComponent } from '../content-hero/content-hero.component';
import { ContentAboutComponent } from './content-about.component';
import {
@@ -44,8 +43,7 @@ import {
imports: [ContentHeroComponent, ContentAboutComponent, NgTemplateOutlet],
templateUrl: './portal-detail-shell.component.html',
styleUrls: ['./portal-detail-shell.component.scss'],
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
tabindex: '0',
role: 'region',
@@ -61,7 +59,6 @@ import {
export class PortalDetailShellComponent {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);
private readonly backNavigation = inject(WorkspaceBackNavigationService);
readonly title = input<string>();
/** "Movie · playlist name" eyebrow above the hero title. */
@@ -96,13 +93,13 @@ export class PortalDetailShellComponent {
readonly isWatch = computed(() => this.playbackActive());
private readonly backTarget: WorkspaceBackTarget = {
label: computed(() => this.backLabel() || null),
escapeShortcut: computed(() => !this.isWatch()),
run: () => this.backClicked.emit(),
};
constructor() {
registerWorkspaceBack({
available: this.backAvailable,
label: computed(() => this.backLabel() || null),
escapeShortcut: computed(() => !this.isWatch()),
run: () => this.backClicked.emit(),
});
afterNextRender(() => {
const element = this.host.nativeElement;
const active = element.ownerDocument.activeElement;
@@ -115,10 +112,6 @@ export class PortalDetailShellComponent {
element.focus({ preventScroll: true });
}
});
effect((onCleanup) => {
if (!this.backAvailable()) return;
onCleanup(this.backNavigation.register(this.backTarget));
});
let wasWatch = false;
effect(() => {
const watch = this.isWatch();
@@ -19,8 +19,7 @@ import {
></div>
</div>
`,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
.progress-capsule {
@@ -105,8 +105,7 @@ export const EPISODE_INFO_PLAY = 'play' as const;
</button>
</div>
`,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
@@ -0,0 +1,37 @@
import type { TranslateService } from '@ngx-translate/core';
import {
formatDurationLabel,
formatRemainingLabel,
isPortalPlaybackWatched,
} from '@iptvnator/portal/shared/util';
import {
PlaybackPositionData,
XtreamSerieEpisodeInfo,
} from '@iptvnator/shared/interfaces';
import {
episodeRuntimeSeconds,
formatEpisodePositionText,
} from './episode-progress.util';
/**
* The line under an episode card's title, built from the episode's runtime
* and its saved playback position:
* "42 min · 18m left", "42 min · watched", "42 min" — or null.
*/
export function buildEpisodeSubline(
info: XtreamSerieEpisodeInfo | undefined,
position: PlaybackPositionData | undefined,
translate: Pick<TranslateService, 'instant'>
): string | null {
const duration = formatDurationLabel(episodeRuntimeSeconds(info));
const remaining = formatRemainingLabel(position);
const parts = [
duration ? translate.instant(duration.key, duration.params) : null,
isPortalPlaybackWatched(position)
? translate.instant('PORTALS.DETAIL.WATCHED')
: remaining
? translate.instant(remaining.key, remaining.params)
: formatEpisodePositionText(position),
].filter((part): part is string => !!part);
return parts.length ? parts.join(' · ') : null;
}
@@ -0,0 +1,136 @@
import {
type Signal,
type WritableSignal,
computed,
effect,
untracked,
} from '@angular/core';
import {
PlaybackPositionData,
XtreamSerieEpisode,
} from '@iptvnator/shared/interfaces';
import { resolveAutoSelectedSeason } from './season-auto-select.util';
export interface SeasonAutoSelectSources {
/**
* The container's selection (see `SeasonContainerComponent.selectedSeason`).
* Auto-select writes to it; user tab clicks write to it too and stick
* until the auto-select key changes.
*/
readonly selectedSeason: WritableSignal<string | undefined>;
readonly seasons: Signal<Record<string, XtreamSerieEpisode[]>>;
/** Season keys in display order. */
readonly sortedSeasonKeys: Signal<readonly string[]>;
/** Season key of the inline-playing episode, if it is in the loaded set. */
readonly playingSeasonKey: Signal<string | null>;
readonly playbackPositions: Signal<
ReadonlyMap<number, PlaybackPositionData>
>;
readonly positionOf: (
episode: XtreamSerieEpisode
) => PlaybackPositionData | undefined;
/** Stalker lazy-VOD: some seasons' episode lists are not loaded yet. */
readonly hasUnloadedSeasons: Signal<boolean>;
readonly episodeCounts: Signal<Record<string, number>>;
readonly watchedCounts: Signal<Record<string, number>>;
readonly emitSeasonSelected: (seasonKey: string) => void;
}
export interface SeasonAutoSelectState {
/**
* Record that this session toggled watched state itself: the positions
* flip that follows is then not treated as an initial load.
*/
markLocalWatchedMutation(): void;
}
/** Key of the loaded season that holds the episode with this content id. */
export function findSeasonOfEpisode(
seasons: Record<string, XtreamSerieEpisode[]>,
episodeId: number
): string | null {
for (const [key, episodes] of Object.entries(seasons)) {
if (episodes?.some((episode) => Number(episode.id) === episodeId)) {
return key;
}
}
return null;
}
/**
* Season auto-selection and `seasonSelected` emission of
* `SeasonContainerComponent`. The selection re-resolves when the season key
* set changes or when playback positions first arrive; ongoing position
* saves do not reset it — only the empty→loaded transition of the positions
* map does, and even that is ignored once this session toggled watched state
* itself.
*
* Registers two effects, so it must be called in an injection context (the
* container's constructor).
*/
export function createSeasonAutoSelectState(
sources: SeasonAutoSelectSources
): SeasonAutoSelectState {
const autoSelectKey = computed(
() =>
`${sources.sortedSeasonKeys().join('|')}::${
sources.playbackPositions().size > 0 ? '1' : '0'
}`
);
let lastAutoSelectKey: string | null = null;
let lastAutoSelectSeasonSet: string | null = null;
/**
* True once this session toggled watched state itself. From then on an
* empty↔loaded flip of the positions map is the echo of that action, not
* an async initial load — re-resolving on it would yank the user off the
* season they just marked (e.g. all-watched season 1 → jump to season 2).
*/
let hasLocalWatchedMutation = false;
let lastEmittedSeason: string | undefined;
/** Auto-select rules live in season-auto-select.util.ts. */
const resolveAutoSeason = (): string | undefined =>
resolveAutoSelectedSeason({
keys: sources.sortedSeasonKeys(),
playingSeasonKey: sources.playingSeasonKey(),
seasons: sources.seasons(),
positionOf: sources.positionOf,
hasUnloadedSeasons: sources.hasUnloadedSeasons(),
episodeCounts: sources.episodeCounts(),
watchedCounts: sources.watchedCounts(),
});
effect(() => {
const key = autoSelectKey();
if (key === lastAutoSelectKey) {
return;
}
const seasonSet = untracked(() => sources.sortedSeasonKeys().join('|'));
const seasonSetUnchanged = seasonSet === lastAutoSelectSeasonSet;
lastAutoSelectKey = key;
lastAutoSelectSeasonSet = seasonSet;
// A positions-emptiness flip after a local watched toggle keeps
// the current selection; only the async initial positions load
// (or a season-set change) re-resolves the season.
if (seasonSetUnchanged && hasLocalWatchedMutation) {
return;
}
sources.selectedSeason.set(untracked(() => resolveAutoSeason()));
});
// Fire the lazy-load/enrichment hooks for auto-selected seasons too —
// with tabs there is no initial "pick a season" click anymore.
effect(() => {
const selected = sources.selectedSeason();
if (selected && selected !== lastEmittedSeason) {
lastEmittedSeason = selected;
sources.emitSeasonSelected(selected);
}
});
return {
markLocalWatchedMutation: () => {
hasLocalWatchedMutation = true;
},
};
}
@@ -4,12 +4,10 @@ import {
ElementRef,
OnInit,
computed,
effect,
inject,
input,
output,
signal,
untracked,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
@@ -21,8 +19,6 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { type SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import {
createLogger,
formatDurationLabel,
formatRemainingLabel,
getPortalPlaybackProgressPercent,
isPortalPlaybackInProgress,
isPortalPlaybackWatched,
@@ -38,11 +34,13 @@ import {
EpisodeInfoDialogComponent,
buildEpisodeInfoDialogData,
} from './episode-info-dialog.component';
import { formatEpisodePositionText } from './episode-progress.util';
import { buildEpisodeSubline } from './episode-subline.util';
import {
formatEpisodePositionText,
episodeRuntimeSeconds,
} from './episode-progress.util';
import { resolveAutoSelectedSeason } from './season-auto-select.util';
type SeasonAutoSelectState,
createSeasonAutoSelectState,
findSeasonOfEpisode,
} from './season-auto-select.state';
import { SeasonDownloadPresenter } from './season-download-presenter';
import { SeasonTabsComponent } from './season-tabs.component';
import { SeasonWatchPresenter } from './season-watch-presenter';
@@ -80,7 +78,8 @@ export class SeasonContainerComponent implements OnInit {
private readonly translate = inject(TranslateService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly logger = createLogger('SeasonContainer');
private lastEmittedSeason: string | undefined;
/** Auto-select + `seasonSelected` emission; wired in the constructor. */
private readonly autoSelect: SeasonAutoSelectState;
readonly downloadPresenter = inject(SeasonDownloadPresenter);
readonly watchPresenter = inject(SeasonWatchPresenter);
@@ -148,9 +147,12 @@ export class SeasonContainerComponent implements OnInit {
});
/** Season key of the inline-playing episode, if it is in the loaded set. */
readonly playingSeasonKey = computed(() =>
this.findSeasonOfEpisode(this.playingEpisodeId())
);
readonly playingSeasonKey = computed(() => {
const episodeId = this.playingEpisodeId();
return episodeId === null
? null
: findSeasonOfEpisode(this.seasons(), episodeId);
});
/**
* Selected season. Auto-resolves when the season key set changes or when
@@ -171,22 +173,6 @@ export class SeasonContainerComponent implements OnInit {
return selected ? (this.seasons()[selected] ?? []) : [];
});
private readonly autoSelectKey = computed(
() =>
`${this.sortedSeasonKeys().join('|')}::${
this.playbackPositions().size > 0 ? '1' : '0'
}`
);
private lastAutoSelectKey: string | null = null;
private lastAutoSelectSeasonSet: string | null = null;
/**
* True once this session toggled watched state itself. From then on an
* empty↔loaded flip of the positions map is the echo of that action, not
* an async initial load — re-resolving on it would yank the user off the
* season they just marked (e.g. all-watched season 1 → jump to season 2).
*/
private hasLocalWatchedMutation = false;
/**
* Show thumbnails in the list view only when episodes have genuinely
* distinct stills (TMDB or per-episode provider art). When every episode
@@ -254,44 +240,29 @@ export class SeasonContainerComponent implements OnInit {
openingEpisodeId: this.openingEpisodeId,
isEpisodeWatched: (episode) => this.isEpisodeWatched(episode),
emitSeasonToggle: (request) => {
this.hasLocalWatchedMutation = true;
this.autoSelect.markLocalWatchedMutation();
this.seasonPlaybackToggleRequested.emit(request);
},
emitSeriesToggle: (request) => {
this.hasLocalWatchedMutation = true;
this.autoSelect.markLocalWatchedMutation();
this.seriesPlaybackToggleRequested.emit(request);
},
});
effect(() => {
const key = this.autoSelectKey();
if (key === this.lastAutoSelectKey) {
return;
}
const seasonSet = untracked(() =>
this.sortedSeasonKeys().join('|')
);
const seasonSetUnchanged =
seasonSet === this.lastAutoSelectSeasonSet;
this.lastAutoSelectKey = key;
this.lastAutoSelectSeasonSet = seasonSet;
// A positions-emptiness flip after a local watched toggle keeps
// the current selection; only the async initial positions load
// (or a season-set change) re-resolves the season.
if (seasonSetUnchanged && this.hasLocalWatchedMutation) {
return;
}
this.selectedSeason.set(untracked(() => this.resolveAutoSeason()));
});
// Fire the lazy-load/enrichment hooks for auto-selected seasons too —
// with tabs there is no initial "pick a season" click anymore.
effect(() => {
const selected = this.selectedSeason();
if (selected && selected !== this.lastEmittedSeason) {
this.lastEmittedSeason = selected;
this.seasonSelected.emit(selected);
}
// Auto-select rules and the effects driving them live in
// season-auto-select.state.ts / season-auto-select.util.ts.
this.autoSelect = createSeasonAutoSelectState({
selectedSeason: this.selectedSeason,
seasons: this.seasons,
sortedSeasonKeys: this.sortedSeasonKeys,
playingSeasonKey: this.playingSeasonKey,
playbackPositions: this.playbackPositions,
positionOf: (episode) => this.getEpisodePosition(episode),
hasUnloadedSeasons: this.hasUnloadedSeasons,
episodeCounts: this.episodeCounts,
watchedCounts: this.watchedCounts,
emitSeasonSelected: (seasonKey) =>
this.seasonSelected.emit(seasonKey),
});
}
@@ -376,7 +347,7 @@ export class SeasonContainerComponent implements OnInit {
this.logger.warn('Cannot toggle watched: no playlist ID');
return;
}
this.hasLocalWatchedMutation = true;
this.autoSelect.markLocalWatchedMutation();
const contentXtreamId = this.getEpisodeContentId(episode);
const currentPosition = this.getEpisodePosition(episode);
@@ -434,21 +405,11 @@ export class SeasonContainerComponent implements OnInit {
/** "42 min · 18m left", "42 min · watched", "42 min" — or null. */
getEpisodeSubline(episode: XtreamSerieEpisode): string | null {
const info = this.getEpisodeInfo(episode);
const duration = formatDurationLabel(episodeRuntimeSeconds(info));
const position = this.playbackPositions().get(Number(episode.id));
const remaining = formatRemainingLabel(position);
const parts = [
duration
? this.translate.instant(duration.key, duration.params)
: null,
this.isEpisodeWatched(episode)
? this.translate.instant('PORTALS.DETAIL.WATCHED')
: remaining
? this.translate.instant(remaining.key, remaining.params)
: this.getEpisodePositionText(episode),
].filter((part): part is string => !!part);
return parts.length ? parts.join(' · ') : null;
return buildEpisodeSubline(
this.getEpisodeInfo(episode),
this.getEpisodePosition(episode),
this.translate
);
}
getEpisodePositionText(episode: XtreamSerieEpisode): string | null {
@@ -464,33 +425,4 @@ export class SeasonContainerComponent implements OnInit {
): PlaybackPositionData | undefined {
return this.playbackPositions().get(this.getEpisodeContentId(episode));
}
private findSeasonOfEpisode(episodeId: number | null): string | null {
if (episodeId === null) {
return null;
}
for (const [key, episodes] of Object.entries(this.seasons())) {
if (
episodes?.some(
(episode) => this.getEpisodeContentId(episode) === episodeId
)
) {
return key;
}
}
return null;
}
/** Auto-select rules live in season-auto-select.util.ts. */
private resolveAutoSeason(): string | undefined {
return resolveAutoSelectedSeason({
keys: this.sortedSeasonKeys(),
playingSeasonKey: this.playingSeasonKey(),
seasons: this.seasons(),
positionOf: (episode) => this.getEpisodePosition(episode),
hasUnloadedSeasons: this.hasUnloadedSeasons(),
episodeCounts: this.episodeCounts(),
watchedCounts: this.watchedCounts(),
});
}
}
@@ -12,8 +12,7 @@ import { MatIcon } from '@angular/material/icon';
</div>
}
`,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
.watched-badge {
@@ -1,4 +1,4 @@
import { signal } from '@angular/core';
import { ChangeDetectorRef, signal } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { By } from '@angular/platform-browser';
@@ -72,8 +72,12 @@ describe('EpgItemDescriptionComponent', () => {
});
it('offers archive URL copying only with explicit capability and explains credentials', () => {
// The component is OnPush and dialog data is fixed once the dialog
// opens, so the test marks the component's own view after mutating it.
const componentView =
fixture.debugElement.injector.get(ChangeDetectorRef);
Object.assign(component.dialogData, { archiveUrlAvailable: true });
fixture.changeDetectorRef.markForCheck();
componentView.markForCheck();
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
@@ -84,7 +88,7 @@ describe('EpgItemDescriptionComponent', () => {
'EPG.PROGRAM_DIALOG.ARCHIVE_URL_NOTICE'
);
Object.assign(component.dialogData, { archiveUrlAvailable: false });
fixture.changeDetectorRef.markForCheck();
componentView.markForCheck();
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
@@ -38,7 +38,7 @@ export type EpgItemDialogData = EpgProgram & {
selector: 'app-epg-item-description',
templateUrl: './epg-item-description.component.html',
styleUrls: ['./epg-item-description.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DatePipe, MatDialogModule, MatIcon, TranslatePipe],
})
export class EpgItemDescriptionComponent {
@@ -32,7 +32,7 @@ interface EpgTrustConfirmDialogData {
@Component({
selector: 'app-epg-trust-confirm-dialog',
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h2 mat-dialog-title>{{ data.title }}</h2>
<mat-dialog-content class="mat-typography">
@@ -64,7 +64,7 @@ class EpgTrustConfirmDialogComponent {
TranslatePipe,
],
templateUrl: './epg-progress-panel.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './epg-progress-panel.component.scss',
})
export class EpgProgressPanelComponent {
@@ -30,7 +30,7 @@ type BadgeStatus =
TranslatePipe,
],
templateUrl: './epg-source-status.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './epg-source-status.component.scss',
})
export class EpgSourceStatusComponent implements OnInit {
@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import {
EMBEDDED_MPV_SUPPORT_RECHECK_MS,
EmbeddedMpvEngine,
EmbeddedMpvSession,
ResolvedPortalPlayback,
@@ -106,6 +107,58 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
});
});
describe('an inconclusive support answer', () => {
// A slow login shell: mpv was looked up before its PATH arrived.
const inconclusive = {
supported: false,
platform: 'linux',
reason: 'mpv executable missing',
inconclusive: true,
};
it('is asked for again, so a mounted player recovers by itself', async () => {
const settled = createSupport('native');
electron.getEmbeddedMpvSupport
.mockResolvedValueOnce(inconclusive)
.mockResolvedValue(settled);
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(
() => controller.support() !== null,
'the first support answer'
);
expect(controller.support()).toBe(inconclusive);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS
);
expect(controller.support()).toBe(settled);
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
// A final answer is kept.
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
});
it('is no longer asked for once the player is gone', async () => {
electron.getEmbeddedMpvSupport.mockResolvedValue(inconclusive);
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(
() => controller.support() !== null,
'the first support answer'
);
TestBed.resetTestingModule();
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(1);
});
});
it('preserves the constructor support probe after preparing a session', async () => {
const probedSupport = createSupport('native');
const preparedSupport = createSupport('frame-copy');
@@ -15,6 +15,7 @@ import {
EmbeddedMpvSupport,
RecordingStartMetadata,
ResolvedPortalPlayback,
watchEmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
import { EmbeddedMpvCommandRunner } from './embedded-mpv-command-runner';
import { measureBounds } from './embedded-mpv-format.utils';
@@ -95,8 +96,9 @@ export class EmbeddedMpvSessionController {
this.session.set(session);
});
let stopSupportWatch: (() => void) | undefined;
if (typeof window.electron?.getEmbeddedMpvSupport === 'function') {
void this.loadSupport();
stopSupportWatch = this.watchSupport();
} else {
this.support.set({
supported: false,
@@ -114,6 +116,7 @@ export class EmbeddedMpvSessionController {
this.destroyRef.onDestroy(() => {
this.unsubscribeSessionUpdate?.();
stopSupportWatch?.();
this.stalledTracker.cancel();
if (this.boundsAnimationFrame !== null) {
cancelAnimationFrame(this.boundsAnimationFrame);
@@ -381,22 +384,31 @@ export class EmbeddedMpvSessionController {
EmbeddedMpvSession['recording'] | null
> => this.commands.stopRecording();
private async loadSupport(): Promise<void> {
try {
const electron = this.getElectronBridge();
if (!electron?.getEmbeddedMpvSupport) {
throw new Error(
'Embedded MPV requires the Electron desktop build.'
);
}
this.support.set(await electron.getEmbeddedMpvSupport());
} catch (error) {
this.support.set({
supported: false,
platform: window.electron?.platform ?? 'unknown',
reason: error instanceof Error ? error.message : String(error),
});
}
/**
* Loads support and keeps asking while the answer is inconclusive: the
* session effect starts playback as soon as `support` turns supported,
* so a player mounted during that window recovers by itself.
*/
private watchSupport(): () => void {
return watchEmbeddedMpvSupport(
() => {
const electron = this.getElectronBridge();
if (!electron?.getEmbeddedMpvSupport) {
throw new Error(
'Embedded MPV requires the Electron desktop build.'
);
}
return electron.getEmbeddedMpvSupport();
},
(support) => this.support.set(support),
(error) =>
this.support.set({
supported: false,
platform: window.electron?.platform ?? 'unknown',
reason:
error instanceof Error ? error.message : String(error),
})
);
}
private getElectronBridge(): ElectronBridge | undefined {
@@ -0,0 +1,43 @@
import type { CrossPortalSimilarItem } from '@iptvnator/services';
import type {
TmdbEnrichedCastMember,
VodDetailsItem,
} from '@iptvnator/shared/interfaces';
/**
* Pure helpers resolving where the VOD details page's extras rows (cast &
* crew, Similar rail) lead. The component owns the actual navigation.
*/
/**
* Router commands of the actor page for a cast/crew member, inside the
* item's own portal workspace. Null when the member has no TMDB person id
* (names parsed from provider text): there is no actor page to open.
*/
export function buildVodActorRoute(
item: VodDetailsItem,
member: TmdbEnrichedCastMember
): (string | number)[] | null {
if (!member.tmdbPersonId) {
return null;
}
const basePath =
item.type === 'stalker' ? '/workspace/stalker' : '/workspace/xtreams';
return [basePath, item.playlistId, 'actor', member.tmdbPersonId];
}
/**
* The cross-portal match behind a Similar-rail card. Rail keys are built by
* `buildSimilarRailItems` (vod-details-presentation.ts) as
* `x<playlistId>-<xtreamId>`.
*/
export function findSimilarByRailKey(
items: readonly CrossPortalSimilarItem[],
railKey: string
): CrossPortalSimilarItem | undefined {
return items.find(
(candidate) =>
`x${candidate.match.playlistId}-${candidate.match.xtreamId}` ===
railKey
);
}
@@ -1,12 +1,9 @@
import {
Component,
computed,
effect,
inject,
input,
output,
signal,
untracked,
ChangeDetectionStrategy,
} from '@angular/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
@@ -53,9 +50,14 @@ import {
} from '@iptvnator/services';
import { VOD_DETAILS_MENU_ACTION } from './vod-details-presentation';
import { createVodDetailsHeroState } from './vod-details-hero.state';
import {
buildVodActorRoute,
findSimilarByRailKey,
} from './vod-details-navigation.util';
import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
import { createVodDownloadState } from './vod-download-state.util';
import { createVodLocalDownloadState } from './vod-download-state.util';
import { createVodSimilarInPortals } from './vod-similar-in-portals.state';
/**
* Unified VOD details component for both Xtream and Stalker portals.
@@ -231,38 +233,12 @@ export class VodDetailsComponent {
* TMDB recommendations found in the user's OTHER portals (batched DB
* match, Electron only). Loaded async — the section appears when
* resolved; staleness-guarded against item changes in flight.
* Filtered on read: a relock hides matches cached while unlocked.
*/
private readonly similarInPortalsMatched = signal<CrossPortalSimilarItem[]>(
[]
readonly similarInPortals = createVodSimilarInPortals(
this.normalizedMeta,
this.crossPortalSimilar
);
/** Filtered on read: a relock hides matches cached while unlocked. */
readonly similarInPortals = computed(() =>
this.crossPortalSimilar.visible(this.similarInPortalsMatched())
);
private readonly loadSimilarInPortals = effect(() => {
const meta = this.normalizedMeta();
const recommendations = meta.tmdbRecommendations;
untracked(() => {
this.similarInPortalsMatched.set([]);
if (
!recommendations?.length ||
!this.crossPortalSimilar.isAvailable
) {
return;
}
void this.crossPortalSimilar
.matchRecommendations(recommendations, 'movie')
.then((items) => {
if (
this.normalizedMeta().tmdbRecommendations ===
recommendations
) {
this.similarInPortalsMatched.set(items);
}
});
});
});
openSimilarInPortals(item: CrossPortalSimilarItem): void {
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
@@ -276,19 +252,14 @@ export class VodDetailsComponent {
() => (this.playbackPosition() ?? 0) > 0 && !this.isWatched()
);
private readonly downloadState = createVodDownloadState(
this.downloadsService,
this.item
);
readonly isDownloaded = computed(
() => !this.providerOnly() && this.downloadState.isDownloaded()
);
readonly isDownloading = computed(
() => !this.providerOnly() && this.downloadState.isDownloading()
);
readonly isPausedDownload = computed(
() => !this.providerOnly() && this.downloadState.isPausedDownload()
);
private readonly localDownload = createVodLocalDownloadState({
downloadsService: this.downloadsService,
item: this.item,
providerOnly: this.providerOnly,
});
readonly isDownloaded = this.localDownload.isDownloaded;
readonly isDownloading = this.localDownload.isDownloading;
readonly isPausedDownload = this.localDownload.isPausedDownload;
private readonly externalButton = createExternalPlaybackButtonState({
session: this.externalPlayback,
@@ -360,11 +331,7 @@ export class VodDetailsComponent {
readonly scrollToCast = scrollToCastCrewRow;
openSimilarRailItem(item: SimilarRailItem): void {
const match = this.similarInPortals().find(
(candidate) =>
`x${candidate.match.playlistId}-${candidate.match.xtreamId}` ===
item.key
);
const match = findSimilarByRailKey(this.similarInPortals(), item.key);
if (match) {
this.openSimilarInPortals(match);
}
@@ -450,20 +417,10 @@ export class VodDetailsComponent {
/** Handle back navigation - emit event for parent to handle */
openActor(member: TmdbEnrichedCastMember): void {
if (!member.tmdbPersonId) {
return;
const commands = buildVodActorRoute(this.item(), member);
if (commands) {
void this.router.navigate(commands);
}
const item = this.item();
const basePath =
item.type === 'stalker'
? '/workspace/stalker'
: '/workspace/xtreams';
void this.router.navigate([
basePath,
item.playlistId,
'actor',
member.tmdbPersonId,
]);
}
goBack(): void {
@@ -494,13 +451,8 @@ export class VodDetailsComponent {
}
/** Resume the paused download of this VOD */
async resumePausedDownload(): Promise<void> {
const item = this.item();
await this.downloadsService.resumeDownloadByContent(
getVodNumericId(item),
item.playlistId,
'vod'
);
resumePausedDownload(): Promise<void> {
return this.localDownload.resumePausedDownload();
}
onInlineTimeUpdate(event: { currentTime: number; duration: number }): void {
@@ -526,18 +478,7 @@ export class VodDetailsComponent {
}
/** Play from local downloaded file */
async playFromLocal(): Promise<void> {
const item = this.item();
const vodId = getVodNumericId(item);
const filePath = this.downloadsService.getDownloadedFilePath(
vodId,
item.playlistId,
'vod'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
}
playFromLocal(): Promise<void> {
return this.localDownload.playFromLocal();
}
}
@@ -32,3 +32,58 @@ export function createVodDownloadState(
),
};
}
export interface VodLocalDownloadStateDeps {
readonly downloadsService: DownloadsService;
readonly item: Signal<VodDetailsItem>;
/** Provider detail handoff hides local/download presentation only. */
readonly providerOnly: Signal<boolean>;
}
/**
* The VOD details page's view of its own download: the download-state
* signals (always false in provider-only mode) and the two actions that
* act on the local copy.
*/
export function createVodLocalDownloadState(deps: VodLocalDownloadStateDeps) {
const { downloadsService, item, providerOnly } = deps;
const downloadState = createVodDownloadState(downloadsService, item);
return {
isDownloaded: computed(
() => !providerOnly() && downloadState.isDownloaded()
),
isDownloading: computed(
() => !providerOnly() && downloadState.isDownloading()
),
isPausedDownload: computed(
() => !providerOnly() && downloadState.isPausedDownload()
),
/** Resume the paused download of this VOD */
async resumePausedDownload(): Promise<void> {
const currentItem = item();
await downloadsService.resumeDownloadByContent(
getVodNumericId(currentItem),
currentItem.playlistId,
'vod'
);
},
/** Play from local downloaded file */
async playFromLocal(): Promise<void> {
const currentItem = item();
const vodId = getVodNumericId(currentItem);
const filePath = downloadsService.getDownloadedFilePath(
vodId,
currentItem.playlistId,
'vod'
);
if (filePath) {
await downloadsService.playDownload(filePath);
}
},
};
}
@@ -0,0 +1,51 @@
import {
type Signal,
computed,
effect,
signal,
untracked,
} from '@angular/core';
import type {
CrossPortalSimilarItem,
CrossPortalSimilarService,
} from '@iptvnator/services';
import type { NormalizedVodMeta } from '@iptvnator/shared/interfaces';
/**
* TMDB recommendations found in the user's OTHER portals (batched DB
* match, Electron only). Loaded async — the section appears when
* resolved; staleness-guarded against item changes in flight.
*
* Registers the loading effect, so call it from a field initializer
* (injection context).
*/
export function createVodSimilarInPortals(
normalizedMeta: Signal<NormalizedVodMeta>,
crossPortalSimilar: CrossPortalSimilarService
): Signal<CrossPortalSimilarItem[]> {
const matched = signal<CrossPortalSimilarItem[]>([]);
/** Filtered on read: a relock hides matches cached while unlocked. */
const visible = computed(() => crossPortalSimilar.visible(matched()));
effect(() => {
const meta = normalizedMeta();
const recommendations = meta.tmdbRecommendations;
untracked(() => {
matched.set([]);
if (!recommendations?.length || !crossPortalSimilar.isAvailable) {
return;
}
void crossPortalSimilar
.matchRecommendations(recommendations, 'movie')
.then((items) => {
if (
normalizedMeta().tmdbRecommendations === recommendations
) {
matched.set(items);
}
});
});
});
return visible;
}
@@ -1,13 +1,4 @@
<div class="actor-view">
<button
class="actor-view__back"
type="button"
(click)="backClicked.emit()"
[attr.aria-label]="'BACK' | translate"
>
<mat-icon>arrow_back</mat-icon>
</button>
@if (isLoading()) {
<div class="actor-view__loading">
<mat-spinner diameter="48" />
@@ -32,24 +32,6 @@
padding: 24px 32px 48px;
position: relative;
&__back {
align-items: center;
background: var(--chip-bg);
border: none;
border-radius: 50%;
color: var(--text-primary);
cursor: pointer;
display: inline-flex;
height: 40px;
justify-content: center;
margin-bottom: 16px;
width: 40px;
&:hover {
background: var(--chip-border);
}
}
&__loading {
align-items: center;
display: flex;
@@ -0,0 +1,28 @@
import { TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import { ActorViewComponent } from './actor-view.component';
describe('ActorViewComponent', () => {
it('offers its Back in the workspace header instead of its own arrow', () => {
TestBed.configureTestingModule({
imports: [ActorViewComponent, TranslateModule.forRoot()],
});
const fixture = TestBed.createComponent(ActorViewComponent);
const backClicked = jest.fn();
fixture.componentInstance.backClicked.subscribe(backClicked);
fixture.detectChanges();
const backNavigation = TestBed.inject(WorkspaceBackNavigationService);
// Available while the profile loads, too.
expect(
(fixture.nativeElement as HTMLElement).querySelector('button')
).toBeNull();
expect(backNavigation.target()?.escapeShortcut()).toBe(false);
backNavigation.goBack();
expect(backClicked).toHaveBeenCalledTimes(1);
fixture.destroy();
expect(backNavigation.target()).toBeNull();
});
});
@@ -4,9 +4,9 @@ import {
input,
output,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access';
import {
ActorFilmographyCredit,
ActorProfile,
@@ -32,7 +32,6 @@ export type ActorViewScope = TitleResultsScope;
@Component({
selector: 'app-actor-view',
imports: [
MatIcon,
MatProgressSpinnerModule,
TranslatePipe,
TitleResultsComponent,
@@ -54,6 +53,11 @@ export class ActorViewComponent {
readonly scope = input<ActorViewScope>('portal');
readonly itemClicked = output<ActorViewItem>();
/** The workspace header's Back on this page. */
readonly backClicked = output<void>();
readonly scopeChanged = output<ActorViewScope>();
constructor() {
registerWorkspaceBack({ run: () => this.backClicked.emit() });
}
}
@@ -1,13 +1,4 @@
<div class="discover-view">
<button
class="discover-view__back"
type="button"
(click)="backClicked.emit()"
[attr.aria-label]="'BACK' | translate"
>
<mat-icon>arrow_back</mat-icon>
</button>
<header class="discover-view__header">
<h1>{{ 'XTREAM.DISCOVER_TITLE' | translate }}</h1>
<div class="discover-view__facets">
@@ -30,24 +30,6 @@
padding: 24px 32px 48px;
position: relative;
&__back {
align-items: center;
background: var(--chip-bg);
border: none;
border-radius: 50%;
color: var(--text-primary);
cursor: pointer;
display: inline-flex;
height: 40px;
justify-content: center;
margin-bottom: 16px;
width: 40px;
&:hover {
background: var(--chip-border);
}
}
&__loading {
align-items: center;
display: flex;
@@ -0,0 +1,37 @@
import { TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import { DiscoverViewComponent } from './discover-view.component';
describe('DiscoverViewComponent', () => {
it('offers its Back in the workspace header instead of its own arrow', () => {
TestBed.configureTestingModule({
imports: [DiscoverViewComponent, TranslateModule.forRoot()],
});
const fixture = TestBed.createComponent(DiscoverViewComponent);
fixture.componentRef.setInput('facets', {
type: 'movie',
genreId: 18,
genreLabel: 'Drama',
year: null,
countryCode: null,
countryLabel: null,
});
const backClicked = jest.fn();
fixture.componentInstance.backClicked.subscribe(backClicked);
fixture.detectChanges();
const backNavigation = TestBed.inject(WorkspaceBackNavigationService);
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'.discover-view > button'
)
).toBeNull();
expect(backNavigation.target()?.escapeShortcut()).toBe(false);
backNavigation.goBack();
expect(backClicked).toHaveBeenCalledTimes(1);
fixture.destroy();
expect(backNavigation.target()).toBeNull();
});
});
@@ -5,9 +5,9 @@ import {
input,
output,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access';
import {
TitleResultItem,
TitleResultsComponent,
@@ -23,7 +23,6 @@ import { DiscoverRouteParams } from './discover-params';
@Component({
selector: 'app-discover-view',
imports: [
MatIcon,
MatProgressSpinnerModule,
TranslatePipe,
TitleResultsComponent,
@@ -45,6 +44,7 @@ export class DiscoverViewComponent<T extends TitleResultItem> {
readonly scope = input<TitleResultsScope>('portal');
readonly itemClicked = output<T>();
/** The workspace header's Back on this page. */
readonly backClicked = output<void>();
readonly scopeChanged = output<TitleResultsScope>();
@@ -63,4 +63,8 @@ export class DiscoverViewComponent<T extends TitleResultItem> {
facets.countryLabel ?? facets.countryCode,
].filter((label): label is string => label !== null);
});
constructor() {
registerWorkspaceBack({ run: () => this.backClicked.emit() });
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
"types": ["jest", "node"],
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
@@ -221,7 +221,6 @@ export class DashboardHeroSlidesPresenter {
{
backdropUrl: item.backdrop_url || extras?.backdropUrl || null,
posterUrl: item.poster_url,
title: item.title,
},
this.failedImages()
);
@@ -26,14 +26,7 @@
[class.hero__backdrop--live]="slide.contentType === 'live'"
[style.--hero-hue]="slide.accentHue"
>
<span
class="hero__backdrop-fallback"
[style.background]="
slide.contentType === 'live'
? null
: slide.fallbackBackdropBackground
"
></span>
<span class="hero__backdrop-fallback"></span>
@if (slide.backdropUrl; as backdropUrl) {
<img
class="hero__backdrop-image"
@@ -80,7 +73,6 @@
count: slides().length,
}
"
[attr.aria-live]="canRotate() && !paused() ? 'off' : 'polite'"
>
<p class="hero__eyebrow">
<span
@@ -92,7 +84,7 @@
<span>{{ slide.reasonLabelKey | translate }}</span>
</p>
<h1 class="hero__title">{{ slide.title }}</h1>
<h2 class="hero__title">{{ slide.title }}</h2>
<p class="hero__meta" data-test-id="dashboard-hero-badges">
@if (slide.episodeBadge) {
@@ -154,6 +146,13 @@
aria-valuemin="0"
aria-valuemax="100"
[attr.aria-valuenow]="slide.progress"
[attr.aria-label]="slide.programmeTitle ?? slide.title"
[attr.aria-valuetext]="
slide.contentType === 'live'
? null
: ('WORKSPACE.DASHBOARD.PERCENT_WATCHED'
| translate: { value: slide.progress })
"
>
<i [style.--hero-progress]="slide.progress"></i>
</span>
@@ -196,6 +195,22 @@
</div>
</div>
}
<!-- Outside the @for: a live region has to be in the page before
its text changes, or the change is not announced. Silent while
the slides rotate on their own. -->
<p
class="visually-hidden"
data-test-id="dashboard-hero-announcement"
aria-atomic="true"
[attr.aria-live]="canRotate() && !paused() ? 'off' : 'polite'"
>
{{
'WORKSPACE.DASHBOARD.HERO_SLIDE_POSITION'
| translate
: { index: activeIndex() + 1, count: slides().length }
}}
{{ active.title }}
</p>
@if (slides().length > 1) {
<div class="hero__controls">
@@ -24,13 +24,22 @@
var(--dashboard-gutter, 28px) +
max(0px, (100cqw - var(--dashboard-max-width, 1440px)) / 2)
);
// The slide's widest extent, and its right edge measured from the
// hero's left edge: the scrim holds its text-safe opacity up to there.
--hero-text-width: min(560px, 55%);
--hero-text-edge: calc(var(--hero-inset) + var(--hero-text-width));
--hero-scrim: var(--app-content-bg, #161a22);
--hero-heading: var(--app-heading-color, #d8dce8);
--hero-body: color-mix(
in srgb,
var(--app-heading-color, #d8dce8) 72%,
var(--app-heading-color, #d8dce8) 85%,
var(--hero-scrim)
);
// Saturation and lightness of the no-artwork gradient. Light by
// default: a near-black gradient under the light theme's page-coloured
// scrim turned into a grey slab behind dark text.
--hero-fallback-from: 55% 90%;
--hero-fallback-to: 60% 78%;
--hero-pill-bg: color-mix(
in srgb,
var(--app-on-surface, #e6e1e5) 12%,
@@ -61,6 +70,11 @@
}
}
:host-context(.dark-theme) .hero {
--hero-fallback-from: 50% 15%;
--hero-fallback-to: 80% 5%;
}
// ── Backdrops ──────────────────────────────────────────────────────────────
.hero__stage {
position: absolute;
@@ -88,6 +102,15 @@
height: 100%;
}
// No artwork at all: a gradient tinted by the title's hue (`--hero-hue`).
.hero__backdrop-fallback {
background: linear-gradient(
135deg,
hsl(var(--hero-hue) var(--hero-fallback-from)) 0%,
hsl(calc(var(--hero-hue) + 60) var(--hero-fallback-to)) 100%
);
}
.hero__backdrop-image {
display: block;
object-fit: cover;
@@ -173,6 +196,9 @@
}
}
// Text never sits on less than 88% of the page colour: the side gradient
// holds that up to the slide's right edge and only then opens onto the art.
// Fixed stops at 30% / 60% left the end of a full-width slide on ~45%.
.hero__scrim {
position: absolute;
inset: 0;
@@ -180,8 +206,10 @@
linear-gradient(
90deg,
color-mix(in srgb, var(--hero-scrim) 96%, transparent) 0%,
color-mix(in srgb, var(--hero-scrim) 85%, transparent) 30%,
color-mix(in srgb, var(--hero-scrim) 35%, transparent) 60%,
color-mix(in srgb, var(--hero-scrim) 88%, transparent)
var(--hero-text-edge),
color-mix(in srgb, var(--hero-scrim) 35%, transparent)
calc(var(--hero-text-edge) + 18%),
transparent 100%
),
linear-gradient(
@@ -205,7 +233,7 @@
flex-direction: column;
align-items: flex-start;
gap: 12px;
max-width: min(560px, 55%);
max-width: var(--hero-text-width);
animation: hero-content-in 420ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
@@ -220,6 +248,17 @@
}
}
// The narrow layout's entrance: no fade, because the slide carries its own
// scrim there and a fading scrim would flash the art on every rotation.
@keyframes hero-content-rise {
from {
transform: translateY(8px);
}
to {
transform: none;
}
}
.hero__eyebrow {
display: flex;
align-items: center;
@@ -356,6 +395,10 @@
padding: 0 18px 0 14px;
box-sizing: border-box;
border-radius: 10px;
// A long label (or translation) ends in an ellipsis instead of pushing
// the button past a narrow slide; the icon keeps its size.
max-width: 100%;
min-width: 0;
font-size: 13px;
font-weight: 600;
line-height: 1;
@@ -366,7 +409,14 @@
box-shadow 0.15s ease,
transform 0.15s ease;
> span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
mat-icon {
flex: 0 0 auto;
width: 20px;
height: 20px;
font-size: 20px;
@@ -471,15 +521,16 @@
.hero__dots {
display: flex;
align-items: center;
gap: 2px;
}
// 6px dots inside a 20px hit area; the active one stretches into a pill
// whose fill runs for one rotation interval.
// 6px dots inside a 24px hit area (WCAG 2.5.8 target size), 2px apart; the
// active one stretches into a pill whose fill runs for one rotation interval.
.hero__dot {
display: grid;
place-items: center;
width: 20px;
height: 20px;
width: 24px;
height: 24px;
padding: 0;
border: 0;
background: transparent;
@@ -516,7 +567,7 @@
}
&--active {
width: 30px;
width: 32px;
&::before {
width: 18px;
@@ -527,8 +578,8 @@
.hero__dot-fill {
grid-area: 1 / 1;
justify-self: start;
// Aligns with the 18px pill centred in the 30px button.
margin-left: 6px;
// Aligns with the 18px pill centred in the 32px button.
margin-left: 7px;
width: 0;
height: 6px;
border-radius: 3px;
@@ -632,6 +683,29 @@
// Clears the rotation controls, which move to the top here.
padding: 64px 0 28px;
max-width: none;
animation-name: hero-content-rise;
text-shadow: 0 1px 2px
color-mix(in srgb, var(--hero-scrim) 80%, transparent);
}
// The slide spans the whole width here, over the art: the full-bleed
// scrim behind the text block keeps it on at least 90% of the page
// colour, fading in just above the eyebrow.
.hero__content::before {
content: '';
position: absolute;
z-index: -1;
inset: 24px calc(-1 * var(--hero-inset)) 0;
background: linear-gradient(
180deg,
transparent,
color-mix(in srgb, var(--hero-scrim) 90%, transparent) 40px
);
pointer-events: none;
}
.hero__button {
text-shadow: none;
}
.hero__controls {
@@ -27,8 +27,6 @@ const slide = (id: string, title: string): DashboardHeroSlide => ({
progress: null,
accentHue: 200,
backdropSource: 'fallback',
fallbackBackdropBackground: 'none',
fallbackPosterBackground: 'none',
hasBackdrop: false,
primaryAction: {
labelKey: 'WORKSPACE.DASHBOARD.HERO_CONTINUE',
@@ -46,7 +44,7 @@ describe('DashboardHeroComponent', () => {
const host = () => fixture.nativeElement as HTMLElement;
const activeTitle = () =>
host().querySelector('[data-test-id=dashboard-hero-slide] h1')
host().querySelector('[data-test-id=dashboard-hero-slide] h2')
?.textContent;
const dots = () =>
Array.from(
@@ -256,6 +254,78 @@ describe('DashboardHeroComponent', () => {
).toBe('/workspace/a');
});
it('titles each slide with an h2 and leaves the h1 to the page', () => {
render();
expect(host().querySelector('h1')).toBeNull();
expect(activeTitle()).toBe('First');
});
it('announces slide changes through one live region that outlives the slides', () => {
// A live region inserted together with its text is not announced,
// so it must not belong to the slide that the @for re-creates.
render();
const announcement = () =>
host().querySelector<HTMLElement>(
'[data-test-id=dashboard-hero-announcement]'
);
const region = announcement();
expect(region).not.toBeNull();
expect(
region?.closest('[data-test-id=dashboard-hero-slide]')
).toBeNull();
expect(
host()
.querySelector('[data-test-id=dashboard-hero-slide]')
?.hasAttribute('aria-live')
).toBe(false);
// Rotating on its own: silent.
expect(region?.getAttribute('aria-live')).toBe('off');
expect(region?.textContent).toContain('First');
const section = host().querySelector(
'[data-test-id=dashboard-hero]'
) as HTMLElement;
section.dispatchEvent(new Event('focusin'));
dots()[1].click();
fixture.detectChanges();
expect(announcement()).toBe(region);
expect(region?.getAttribute('aria-live')).toBe('polite');
expect(region?.getAttribute('aria-atomic')).toBe('true');
expect(region?.textContent).toContain('Second');
});
it('names the progress bar after what is progressing', () => {
slides.set([
{ ...slide('a', 'Movie'), progress: 42 },
{
...slide('b', 'Channel'),
contentType: 'live',
programmeTitle: 'Evening programme',
progress: 10,
},
]);
render();
const bar = () =>
host().querySelector(
'[data-test-id=dashboard-hero-slide] [role=progressbar]'
);
expect(bar()?.getAttribute('aria-label')).toBe('Movie');
// The translate pipe echoes the key in this test bed.
expect(bar()?.getAttribute('aria-valuetext')).toBe(
'WORKSPACE.DASHBOARD.PERCENT_WATCHED'
);
dots()[1].click();
fixture.detectChanges();
expect(bar()?.getAttribute('aria-label')).toBe('Evening programme');
// Elapsed share of a live programme: the plain percentage.
expect(bar()?.hasAttribute('aria-valuetext')).toBe(false);
expect(bar()?.getAttribute('aria-valuenow')).toBe('10');
});
it('positions the progress fill through a custom property, never its width', () => {
// Animating width re-lays out the page on every live-EPG tick; the
// stylesheet slides the fill with a transform driven by this property.
@@ -40,7 +40,7 @@ export interface DashboardHeroSlide extends DashboardHeroArtwork {
readonly description: string | null;
/** 0-100: watched share, or elapsed share of the live programme */
readonly progress: number | null;
/** Title-derived hue for the live slide's glow (0-359) */
/** Title-derived hue (0-359): the live glow and the no-artwork gradient */
readonly accentHue: number;
readonly primaryAction: DashboardHeroAction;
readonly secondaryAction: DashboardHeroAction | null;
@@ -51,14 +51,11 @@ export type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback';
export interface DashboardHeroArtworkInput {
readonly backdropUrl?: string | null;
readonly posterUrl?: string | null;
readonly title: string;
}
export interface DashboardHeroArtwork {
readonly backdropUrl?: string;
readonly backdropSource: DashboardHeroBackdropSource;
readonly fallbackBackdropBackground: string;
readonly fallbackPosterBackground: string;
readonly hasBackdrop: boolean;
readonly posterUrl?: string;
}
@@ -85,49 +82,17 @@ export function resolveDashboardHeroArtwork(
return {
backdropUrl,
backdropSource,
fallbackBackdropBackground: buildFallbackBackground(
item.title,
50,
15,
80,
5,
60
),
fallbackPosterBackground: buildFallbackBackground(
item.title,
40,
25,
50,
15,
40
),
hasBackdrop: backdropSource === 'backdrop',
posterUrl,
};
}
function buildFallbackBackground(
title: string,
saturationA: number,
lightnessA: number,
saturationB: number,
lightnessB: number,
hueOffset: number
): string {
const hue = calculateHue(title || 'placeholder');
const h2 = (hue + hueOffset) % 360;
return `linear-gradient(135deg, hsl(${hue}, ${saturationA}%, ${lightnessA}%) 0%, hsl(${h2}, ${saturationB}%, ${lightnessB}%) 100%)`;
}
/** Stable hue for a title, shared by every generated fallback surface. */
export function dashboardHeroHue(text: string): number {
return calculateHue(text || 'placeholder');
}
function calculateHue(text: string): number {
const key = text || 'placeholder';
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
for (let i = 0; i < key.length; i++) {
hash = key.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
return Math.abs(hash) % 360;
@@ -3,6 +3,11 @@
[class.rails-page--empty]="ready() && !hasPlaylists()"
data-test-id="dashboard-page"
>
<!-- The page's one stable h1: the rail already names the page, and the
hero's rotating title is only the current slide (an h2). -->
<h1 class="visually-hidden" data-test-id="dashboard-page-heading">
{{ 'WORKSPACE.SHELL.RAIL_DASHBOARD' | translate }}
</h1>
@if (data.playlistsLoaded() && !hasPlaylists()) {
<app-empty-state
[type]="'welcome-dashboard'"
@@ -146,7 +146,6 @@ describe('buildDashboardContinueWatchingActions', () => {
describe('resolveDashboardHeroArtwork', () => {
const item = {
title: 'Broken Hero',
posterUrl: 'https://images.example.test/poster.jpg',
backdropUrl: 'https://images.example.test/backdrop.jpg',
};
@@ -173,7 +172,8 @@ describe('resolveDashboardHeroArtwork', () => {
});
});
it('uses generated fallback artwork when backdrop and poster both fail', () => {
// The stylesheet paints the no-artwork gradient from the slide's hue.
it('reports no artwork when backdrop and poster both fail', () => {
const artwork = resolveDashboardHeroArtwork(item, {
[item.backdropUrl]: true,
[item.posterUrl]: true,
@@ -185,8 +185,6 @@ describe('resolveDashboardHeroArtwork', () => {
posterUrl: undefined,
backdropUrl: undefined,
});
expect(artwork.fallbackBackdropBackground).toContain('linear-gradient');
expect(artwork.fallbackPosterBackground).toContain('linear-gradient');
});
});
@@ -32,17 +32,14 @@
}
// `height: 100%` here does not resolve against a stretched flex item, so the
// list kept its full content height and painted over the footer whenever the
// list kept its full content height and overflowed the panel whenever the
// panel was shorter than its sections (a phone, or a short desktop window).
.settings-sections-list {
flex: 1;
min-height: 0;
}
.settings-panel-footer {
flex-shrink: 0;
padding: 12px 8px calc(20px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--app-separator);
// The list now reaches the panel's bottom edge (Back moved to the
// header), so it clears the home indicator itself.
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
}
// Sections without settings-search matches stay reachable but recede.
@@ -50,16 +47,13 @@
opacity: 0.55;
}
.settings-section-item:hover:not(.active):not(.selected),
.settings-back-button:hover:not(.active):not(.selected) {
.settings-section-item:hover:not(.active):not(.selected) {
background: var(--app-selection-surface);
}
// On a landscape phone the stacked panel gets ~108-148px, and this panel's
// fixed chrome (title + footer) alone is about that tall — the section list
// was collapsing to nothing behind an overlapping footer. The rail already
// labels the page, so the caption is what gives way, and the footer sheds
// the padding it carries for taller screens.
// On a landscape phone the panel is only ~108-148px tall, and its caption
// would take a third of that from the section list. The rail already labels
// the page, so the caption is what gives way.
@media (max-width: 640px) and (max-height: 599px) {
.panel-title {
display: none;
@@ -68,8 +62,4 @@
.settings-panel-body {
padding-top: 8px;
}
.settings-panel-footer {
padding: 6px 8px calc(6px + env(safe-area-inset-bottom, 0px));
}
}
@@ -1,7 +1,9 @@
import { Location } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context';
import { WorkspaceSettingsContextPanelComponent } from './workspace-settings-context-panel.component';
@@ -46,6 +48,29 @@ describe('WorkspaceSettingsContextPanelComponent', () => {
return { ctx, fixture, link };
}
it('offers Back in the header, beside the phone drawer toggle', () => {
const { fixture } = setup();
const back = jest
.spyOn(TestBed.inject(Location), 'back')
.mockImplementation(() => undefined);
const backNavigation = TestBed.inject(WorkspaceBackNavigationService);
// The panel carries no footer arrow of its own.
expect(
(fixture.nativeElement as HTMLElement).querySelector('button')
).toBeNull();
const target = backNavigation.target();
// The drawer holds the sections, so its toggle must stay reachable.
expect(target?.phoneDrawerToggle).toBe('beside');
expect(target?.label()).toBeNull();
backNavigation.goBack();
expect(back).toHaveBeenCalledTimes(1);
fixture.destroy();
expect(backNavigation.target()).toBeNull();
});
it('shows no counts while settings search is idle', () => {
const { link } = setup();
@@ -3,6 +3,7 @@ import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context';
@@ -18,7 +19,7 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings
@for (section of ctx.sections(); track section.id) {
<!-- replaceUrl keeps a single settings entry in the
browser history: switching sections must not turn
"Back" (footer or browser) into a walk through every
"Back" (header or browser) into a walk through every
visited section page before finally leaving. -->
<a
class="nav-item settings-section-item"
@@ -51,16 +52,6 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings
}
</div>
</div>
<div class="settings-panel-footer">
<button
type="button"
class="nav-item settings-back-button"
(click)="onBack()"
>
<mat-icon>arrow_back</mat-icon>
<span>{{ 'SETTINGS.BACK_TO_HOME' | translate }}</span>
</button>
</div>
`,
})
export class WorkspaceSettingsContextPanelComponent {
@@ -77,11 +68,17 @@ export class WorkspaceSettingsContextPanelComponent {
}
);
constructor() {
// The panel exists exactly while the settings route shows, so it
// offers the page's Back in the header. On a phone the toggle for
// this drawer stays beside it: the drawer holds the sections.
registerWorkspaceBack({
phoneDrawerToggle: 'beside',
run: () => this.location.back(),
});
}
onSectionClicked() {
this.contextDrawer?.close();
}
onBack() {
this.location.back();
}
}
@@ -53,7 +53,7 @@ describe('WorkspacePlayerCommandsContributor', () => {
let electronStub:
| {
getEmbeddedMpvSupport: jest.Mock<
Promise<{ supported: boolean }>,
Promise<{ supported: boolean; inconclusive?: boolean }>,
[]
>;
}
@@ -63,7 +63,10 @@ describe('WorkspacePlayerCommandsContributor', () => {
function bootstrap(options: {
supportsManagedExternalPlayers: boolean;
supportsEmbeddedMpv?: boolean;
embeddedMpvSupportResult?: { supported: boolean } | null;
embeddedMpvSupportResult?: {
supported: boolean;
inconclusive?: boolean;
} | null;
}) {
viewCommands = {
registerCommand: jest.fn().mockReturnValue(() => undefined),
@@ -200,6 +203,77 @@ describe('WorkspacePlayerCommandsContributor', () => {
expect(resolveBoolean(embedded?.visible)).toBe(false);
});
it('keeps a final unsupported answer, but asks again after an inconclusive one', async () => {
const contributor = bootstrap({
supportsManagedExternalPlayers: true,
supportsEmbeddedMpv: true,
// A slow login shell: mpv was looked up before its PATH arrived.
embeddedMpvSupportResult: { supported: false, inconclusive: true },
});
const embedded = getRegistered(viewCommands).find(
(c) => c.id === 'switch-player-embedded-mpv'
);
await contributor.ensureEmbeddedMpvSupportLoaded();
expect(resolveBoolean(embedded?.visible)).toBe(false);
// The shell answered without mpv: that answer is final.
electronStub?.getEmbeddedMpvSupport.mockResolvedValue({
supported: false,
});
await contributor.ensureEmbeddedMpvSupportLoaded();
expect(resolveBoolean(embedded?.visible)).toBe(false);
expect(contributor.ensureEmbeddedMpvSupportLoaded()).toBeUndefined();
expect(electronStub?.getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
});
it('shows embedded MPV once an inconclusive answer turns into supported', async () => {
const contributor = bootstrap({
supportsManagedExternalPlayers: true,
supportsEmbeddedMpv: true,
embeddedMpvSupportResult: { supported: false, inconclusive: true },
});
const embedded = getRegistered(viewCommands).find(
(c) => c.id === 'switch-player-embedded-mpv'
);
await contributor.ensureEmbeddedMpvSupportLoaded();
electronStub?.getEmbeddedMpvSupport.mockResolvedValue({
supported: true,
});
await contributor.ensureEmbeddedMpvSupportLoaded();
expect(resolveBoolean(embedded?.visible)).toBe(true);
expect(contributor.ensureEmbeddedMpvSupportLoaded()).toBeUndefined();
});
it('asks again after a failed request instead of giving up for the session', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation();
const contributor = bootstrap({
supportsManagedExternalPlayers: true,
supportsEmbeddedMpv: true,
});
const embedded = getRegistered(viewCommands).find(
(c) => c.id === 'switch-player-embedded-mpv'
);
electronStub?.getEmbeddedMpvSupport.mockRejectedValueOnce(
new Error('bridge failed')
);
try {
await contributor.ensureEmbeddedMpvSupportLoaded();
expect(resolveBoolean(embedded?.visible)).toBe(false);
await contributor.ensureEmbeddedMpvSupportLoaded();
expect(resolveBoolean(embedded?.visible)).toBe(true);
expect(electronStub?.getEmbeddedMpvSupport).toHaveBeenCalledTimes(
2
);
} finally {
warn.mockRestore();
}
});
it('switches to embedded MPV on run', () => {
bootstrap({
supportsManagedExternalPlayers: true,
@@ -120,16 +120,20 @@ export class WorkspacePlayerCommandsContributor {
}
private async loadEmbeddedMpvSupport(): Promise<void> {
// Only a final answer is kept. An inconclusive one, or a failed
// request, hides the command for now: the next palette open asks again.
let final = false;
try {
const support = await window.electron?.getEmbeddedMpvSupport?.();
this.embeddedMpvSupported.set(!!support?.supported);
final = !support?.inconclusive;
} catch (error) {
console.warn(
'Failed to verify embedded MPV support for the command palette.',
error
);
} finally {
this.embeddedMpvSupportChecked = true;
this.embeddedMpvSupportChecked = final;
this.embeddedMpvSupportLoad = null;
}
}
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff. Show more