diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html index 6763eb864..7bf1aa86e 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html @@ -1,6 +1,11 @@ @if (selectedCategory()) { @if (selectedDetailComponent(); as detailComponentRef) { - + } @else {
diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts index e41e7c60b..369aab94d 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -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; @@ -63,8 +66,9 @@ describe('CategoryContentViewComponent', () => { const categoryItemCount = signal(0); const contentSortMode = signal(null); const minRating = signal(null); + const selectedItem = signal | 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([]), 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); + }); }); diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts index afc62d439..da4de7d9c 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts @@ -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); private readonly router = inject(Router); private readonly translate = inject(TranslateService); + private readonly providerOnlyStalkerItemId = signal(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 | 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; + } } diff --git a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts index 8b281c0bc..95f994c67 100644 --- a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts +++ b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts @@ -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 { diff --git a/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.ts b/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.ts index d9c1af96e..2e0e6b116 100644 --- a/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.ts +++ b/libs/portal/shared/util/src/lib/navigation/provider-detail-mode.ts @@ -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)[ + PROVIDER_ONLY_DETAIL_PRESENTATION_STATE_KEY + ] === PROVIDER_ONLY_DETAIL_PRESENTATION ); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts index a606ceef9..ca8342dab 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts @@ -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, diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index bdc5f2968..06373fb35 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -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(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( null );