mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
Merge branch 'master' into perf/j3-epg-ribbon-window
This commit is contained in:
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;
|
||||
|
||||
+1
-13
@@ -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;
|
||||
|
||||
+25
-13
@@ -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;
|
||||
}
|
||||
+35
-126
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+84
@@ -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);
|
||||
}
|
||||
}
|
||||
+110
@@ -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);
|
||||
}
|
||||
}
|
||||
+391
@@ -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()"
|
||||
|
||||
+66
-35
@@ -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;
|
||||
}
|
||||
+187
@@ -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);
|
||||
}
|
||||
}
|
||||
+331
@@ -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);
|
||||
}
|
||||
}
|
||||
+23
-625
@@ -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);
|
||||
});
|
||||
});
|
||||
+67
-867
File diff suppressed because it is too large.
Load diff
+281
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+273
@@ -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,
|
||||
};
|
||||
}
|
||||
+192
@@ -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);
|
||||
});
|
||||
});
|
||||
+113
@@ -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);
|
||||
}
|
||||
}
|
||||
+323
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+125
@@ -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,
|
||||
|
||||
+19
-78
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-577
@@ -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();
|
||||
}
|
||||
+267
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+53
@@ -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() });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+5
@@ -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'"
|
||||
|
||||
+2
-4
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+8
-18
@@ -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));
|
||||
}
|
||||
}
|
||||
+25
@@ -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();
|
||||
|
||||
|
||||
+12
-15
@@ -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();
|
||||
}
|
||||
}
|
||||
+76
-2
@@ -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,
|
||||
|
||||
+5
-1
@@ -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
Reference in new issue
Block a user