fix(downloads): preserve stalker provider handoff

This commit is contained in:
4gray committed 2026-08-01 16:29:51 +02:00
1 parent 72baf0bf1c
commit c813ecb7db
7 files changed
+149 -35

No files matched your search

@@ -1,6 +1,11 @@
@if (selectedCategory()) {
@if (selectedDetailComponent(); as detailComponentRef) {
<ng-container *ngComponentOutlet="detailComponentRef" />
<ng-container
*ngComponentOutlet="
detailComponentRef;
inputs: detailComponentInputs()
"
/>
} @else {
<div class="category-content-layout">
<div class="category-content-header">
@@ -1,6 +1,7 @@
import { Component, input, output, signal } from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
@@ -9,7 +10,7 @@ import { MatPaginatorModule } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ReplaySubject, of } from 'rxjs';
import { EMPTY, ReplaySubject, of } from 'rxjs';
import {
PORTAL_CATALOG_DETAIL_COMPONENT,
PORTAL_CATALOG_FACADE,
@@ -52,7 +53,9 @@ class MockPlaylistErrorViewComponent {
standalone: true,
template: '',
})
class MockDetailComponent {}
class MockDetailComponent {
readonly providerOnly = input(false);
}
describe('CategoryContentViewComponent', () => {
let fixture: ComponentFixture<CategoryContentViewComponent>;
@@ -63,8 +66,9 @@ describe('CategoryContentViewComponent', () => {
const categoryItemCount = signal(0);
const contentSortMode = signal<PortalCatalogSortMode | null>(null);
const minRating = signal<number | null>(null);
const selectedItem = signal<Record<string, unknown> | null>(null);
const catalog = {
provider: 'xtream' as const,
provider: 'xtream' as 'xtream' | 'stalker',
pageSizeOptions: [10, 25, 50],
contentType: signal('vod'),
limit: signal(25),
@@ -73,7 +77,7 @@ describe('CategoryContentViewComponent', () => {
paginatedContent: signal<unknown[]>([]),
selectedCategoryTitle: signal('Movies'),
categoryItemCount,
selectedItem: signal(null),
selectedItem,
totalPages: signal(0),
contentSortMode,
supportsRatingSort: true,
@@ -88,10 +92,14 @@ describe('CategoryContentViewComponent', () => {
setContentSortMode: jest.fn(),
setMinRating: jest.fn(),
selectItem: jest.fn().mockReturnValue(null),
refreshSnapshotSelection: jest.fn(),
getItemProgress: jest.fn().mockReturnValue({}),
};
beforeEach(async () => {
window.history.replaceState({}, '', window.location.href);
catalog.provider = 'xtream';
selectedItem.set(null);
isPaginatedContentLoading.set(true);
categoryItemCount.set(0);
contentSortMode.set(null);
@@ -105,6 +113,7 @@ describe('CategoryContentViewComponent', () => {
catalog.setMinRating.mockClear();
catalog.selectItem.mockClear();
catalog.selectItem.mockReturnValue(null);
catalog.refreshSnapshotSelection.mockClear();
router = {
navigate: jest.fn(),
};
@@ -133,9 +142,9 @@ describe('CategoryContentViewComponent', () => {
? 'Fetching playlist data from source...'
: key
),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
onLangChange: EMPTY,
onTranslationChange: EMPTY,
onDefaultLangChange: EMPTY,
currentLang: 'en',
defaultLang: 'en',
},
@@ -184,6 +193,10 @@ describe('CategoryContentViewComponent', () => {
fixture = TestBed.createComponent(CategoryContentViewComponent);
});
afterEach(() => {
window.history.replaceState({}, '', window.location.href);
});
it('shows loading copy in the subtitle instead of 0 items while xtream content is still warming up', () => {
fixture.detectChanges();
@@ -473,4 +486,73 @@ describe('CategoryContentViewComponent', () => {
queryParamsHandling: 'preserve',
});
});
it('hands provider-only presentation to the exact Stalker item after consuming navigation state', async () => {
const item = { id: '42', category_id: 'vod' };
catalog.provider = 'stalker';
catalog.selectItem.mockImplementation((selected) => {
selectedItem.set(selected);
return null;
});
window.history.replaceState(
{
detailPresentation: 'provider-only',
openStalkerItem: item,
preserved: 'value',
},
'',
window.location.href
);
fixture.detectChanges();
await fixture.whenStable();
const detail = fixture.debugElement.query(
By.directive(MockDetailComponent)
).componentInstance as MockDetailComponent;
expect(catalog.selectItem).toHaveBeenCalledWith(item);
expect(catalog.refreshSnapshotSelection).toHaveBeenCalled();
expect(detail.providerOnly()).toBe(true);
expect(window.history.state).toEqual({ preserved: 'value' });
});
it('does not retain the consumed provider-only presentation across identity, regular-open, or route changes', async () => {
const item = { id: '42', category_id: 'vod' };
catalog.provider = 'stalker';
catalog.selectItem.mockImplementation((selected) => {
selectedItem.set(selected);
return null;
});
window.history.replaceState(
{
detailPresentation: 'provider-only',
openStalkerItem: item,
},
'',
window.location.href
);
fixture.detectChanges();
await fixture.whenStable();
selectedItem.set({ id: '99', category_id: 'vod' });
await fixture.whenStable();
let detail = fixture.debugElement.query(
By.directive(MockDetailComponent)
).componentInstance as MockDetailComponent;
expect(detail.providerOnly()).toBe(false);
fixture.componentInstance.onItemClick(item);
await fixture.whenStable();
detail = fixture.debugElement.query(By.directive(MockDetailComponent))
.componentInstance as MockDetailComponent;
expect(detail.providerOnly()).toBe(false);
window.history.replaceState({}, '', window.location.href);
paramMap$.next(convertToParamMap({ categoryId: 'another-category' }));
selectedItem.set(item);
await fixture.whenStable();
detail = fixture.debugElement.query(By.directive(MockDetailComponent))
.componentInstance as MockDetailComponent;
expect(detail.providerOnly()).toBe(false);
});
});
@@ -7,6 +7,7 @@ import {
ElementRef,
inject,
OnInit,
signal,
} from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs/operators';
@@ -24,16 +25,19 @@ import {
import {
clearNavigationStateKeys,
getOpenStalkerItemState,
isProviderOnlyDetailState,
PortalCatalogFacade,
OPEN_STALKER_ITEM_STATE_KEY,
PORTAL_CATALOG_DETAIL_COMPONENT,
PORTAL_CATALOG_FACADE,
PROVIDER_ONLY_DETAIL_PRESENTATION_STATE_KEY,
PortalCatalogSortMode,
} from '@iptvnator/portal/shared/util';
interface CategoryContentItem {
id?: number | string;
is_series?: number | string | boolean;
movie_id?: number | string;
xtream_id?: number | string;
series_id?: number | string;
stream_id?: number | string;
@@ -64,6 +68,7 @@ export class CategoryContentViewComponent implements OnInit {
private readonly hostElement = inject(ElementRef<HTMLElement>);
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
private readonly providerOnlyStalkerItemId = signal<string | null>(null);
private hasAppliedInitialQueryParams = false;
private previousSearchQuery: string | null = null;
private readonly catalog = inject(
@@ -146,6 +151,21 @@ export class CategoryContentViewComponent implements OnInit {
readonly selectedDetailComponent = computed(() =>
this.selectedItem() ? this.detailComponent : null
);
readonly detailComponentInputs = computed<
Record<string, unknown> | undefined
>(() => {
if (this.catalog.provider !== 'stalker') {
return undefined;
}
const providerItemId = this.providerOnlyStalkerItemId();
return {
providerOnly:
providerItemId !== null &&
providerItemId ===
this.stalkerItemIdentity(this.selectedItem()),
};
});
readonly contentWithProgress = computed(() =>
(this.paginatedContent() ?? []).map((item: CategoryContentItem) => ({
...item,
@@ -165,6 +185,7 @@ export class CategoryContentViewComponent implements OnInit {
this.activatedRoute.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
this.providerOnlyStalkerItemId.set(null);
this.catalog.initialize(params.get('categoryId'));
this.openStalkerItemFromNavigationState();
});
@@ -216,6 +237,7 @@ export class CategoryContentViewComponent implements OnInit {
}
onItemClick(item: CategoryContentItem): void {
this.providerOnlyStalkerItemId.set(null);
const navigation = this.catalog.selectItem(item);
if (navigation?.length) {
this.router.navigate(navigation, {
@@ -258,14 +280,33 @@ export class CategoryContentViewComponent implements OnInit {
return;
}
const providerOnly = isProviderOnlyDetailState(window.history.state);
this.catalog.selectItem(item as CategoryContentItem);
// The item came from a stored snapshot, not the live list — let the
// provider refresh stale embedded data (e.g. episode lists).
this.catalog.refreshSnapshotSelection?.();
this.providerOnlyStalkerItemId.set(
providerOnly
? this.stalkerItemIdentity(item as CategoryContentItem)
: null
);
clearNavigationStateKeys([
OPEN_STALKER_ITEM_STATE_KEY,
'openFavoriteItem',
'openRecentItem',
PROVIDER_ONLY_DETAIL_PRESENTATION_STATE_KEY,
]);
}
private stalkerItemIdentity(
item: CategoryContentItem | null | undefined
): string | null {
const rawId =
item?.id ?? item?.series_id ?? item?.movie_id ?? item?.stream_id;
const normalized = String(rawId ?? '')
.trim()
.split(':')[0]
?.trim();
return normalized || null;
}
}
@@ -9,6 +9,7 @@ import {
import {
buildStalkerDetailNavigationTarget,
PROVIDER_ONLY_DETAIL_PRESENTATION,
PROVIDER_ONLY_DETAIL_PRESENTATION_STATE_KEY,
type WorkspaceNavigationTarget,
} from '@iptvnator/portal/shared/util';
import type { StalkerPortalItem } from '@iptvnator/shared/interfaces';
@@ -72,7 +73,8 @@ export class DownloadLibraryNavigationService {
return await this.router.navigate(target.link, {
state: {
...target.state,
detailPresentation: PROVIDER_ONLY_DETAIL_PRESENTATION,
[PROVIDER_ONLY_DETAIL_PRESENTATION_STATE_KEY]:
PROVIDER_ONLY_DETAIL_PRESENTATION,
},
});
} catch {
@@ -1,10 +1,13 @@
export const PROVIDER_ONLY_DETAIL_PRESENTATION = 'provider-only' as const;
export const PROVIDER_ONLY_DETAIL_PRESENTATION_STATE_KEY =
'detailPresentation' as const;
export function isProviderOnlyDetailState(state: unknown): boolean {
return (
typeof state === 'object' &&
state !== null &&
(state as { detailPresentation?: unknown }).detailPresentation ===
PROVIDER_ONLY_DETAIL_PRESENTATION
(state as Record<string, unknown>)[
PROVIDER_ONLY_DETAIL_PRESENTATION_STATE_KEY
] === PROVIDER_ONLY_DETAIL_PRESENTATION
);
}
@@ -55,14 +55,6 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
});
beforeEach(async () => {
window.history.replaceState(
{
detailPresentation: 'provider-only',
openStalkerItem: { id: '42' },
},
'',
window.location.href
);
contentType.set('vod');
selectedItem.set({
id: '42',
@@ -135,11 +127,11 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
});
afterEach(() => {
window.history.replaceState({}, '', window.location.href);
fixture.destroy();
});
it('passes provider-only mode to the matching regular VOD', async () => {
fixture.componentRef.setInput('providerOnly', true);
await fixture.whenStable();
const child = fixture.debugElement.query(
@@ -149,7 +141,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
expect(child.providerOnly()).toBe(true);
});
it('does not apply stale provider-only state to a different selected item', async () => {
it('keeps provider-only presentation disabled for a regular VOD open', async () => {
selectedItem.set({
id: '99',
cmd: '/media/99',
@@ -171,6 +163,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
] as const)(
'passes provider-only mode to %s',
async (_label, type, item) => {
fixture.componentRef.setInput('providerOnly', true);
contentType.set(type);
selectedItem.set({
...item,
@@ -4,6 +4,7 @@ import {
computed,
effect,
inject,
input,
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
@@ -16,15 +17,12 @@ import {
PORTAL_PLAYER,
createLogger,
createInlinePlaybackPositionWriter,
getOpenStalkerItemState,
isProviderOnlyDetailState,
} from '@iptvnator/portal/shared/util';
import {
createPortalFavoritesResource,
createRefreshTrigger,
isStalkerSeriesFlag,
isSelectedStalkerVodFavorite,
normalizeStalkerEntityId,
StalkerSelectedVodItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
@@ -86,17 +84,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
null
);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly providerOnly = computed(() => {
const selected = this.selectedItem();
const opened = getOpenStalkerItemState(window.history.state);
return (
isProviderOnlyDetailState(window.history.state) &&
!!selected &&
!!opened &&
normalizeStalkerEntityId(selected.id) ===
normalizeStalkerEntityId(opened.id ?? opened.stream_id)
);
});
readonly providerOnly = input(false);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);