fix(portals): preserve playlist ownership during detail handoffs (#1825)

* fix(portals): preserve playlist ownership during detail handoffs

* fix(portals): reload Stalker categories only for a held destination

Review follow-ups (Greptile, Codex): resetCategories() reloaded the
category resource, and the route session calls it on a portal switch
before the destination is resolved and on teardown, so it asked the
portal being left, and a failed destination lookup could let that answer
repopulate the sidebar. resetCategories() now only clears; the session
calls the new reloadCategories() after installing the destination, and
only when a handoff had already put that playlist in the store (the
owner, and so the resource params, did not change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-07 00:21:07 +02:00
1 parent 0a6f54ca15
commit 93c5f1a051
23 files changed
+1001 -52

No files matched your search

@@ -76,7 +76,10 @@ describe('CategoryContentViewComponent', () => {
const hasMore = signal(false);
const isAppending = signal(false);
const appendError = signal(false);
const routeReady = signal(true);
const playlist = signal<{ id: string } | null>(null);
const catalog = {
routeReady,
provider: 'xtream' as 'xtream' | 'stalker',
contentType: signal('vod'),
selectedCategory: signal({ id: 1 }),
@@ -90,7 +93,7 @@ describe('CategoryContentViewComponent', () => {
contentSortMode,
supportsRatingSort: true,
minRating,
playlist: signal(null),
playlist,
isPaginatedContentLoading,
initialize: jest.fn(),
setSearchQuery: jest.fn(),
@@ -107,6 +110,8 @@ describe('CategoryContentViewComponent', () => {
};
beforeEach(async () => {
routeReady.set(true);
playlist.set(null);
window.history.replaceState({}, '', window.location.href);
catalog.provider = 'xtream';
selectedItem.set(null);
@@ -239,6 +244,20 @@ describe('CategoryContentViewComponent', () => {
expect(catalog.setSearchQuery).toHaveBeenCalledWith('matrix');
});
it('preserves route search after category initialization resets the store filter', () => {
let search = '';
catalog.initialize.mockImplementation(() => {
search = '';
});
catalog.setSearchQuery.mockImplementation((query: string) => {
search = query;
});
queryParamMap$.next(convertToParamMap({ q: 'matrix' }));
paramMap$.next(convertToParamMap({ categoryId: '5' }));
fixture.detectChanges();
expect(search).toBe('matrix');
});
it('groups catalog sort and rating filters behind one refine menu trigger', () => {
contentSortMode.set('date-desc');
categoryItemCount.set(12);
@@ -353,6 +372,56 @@ describe('CategoryContentViewComponent', () => {
});
});
it('waits for the destination portal before initializing and consuming a Stalker handoff', () => {
catalog.provider = 'stalker';
routeReady.set(false);
const item = { id: '42', name: 'Destination movie' };
window.history.replaceState(
{ openStalkerItem: item },
'',
window.location.href
);
paramMap$.next(convertToParamMap({ categoryId: '5' }));
fixture.detectChanges();
expect(catalog.initialize).not.toHaveBeenCalled();
expect(catalog.selectItem).not.toHaveBeenCalled();
expect(window.history.state.openStalkerItem).toEqual(item);
routeReady.set(true);
fixture.detectChanges();
expect(catalog.initialize).toHaveBeenCalledWith('5');
expect(catalog.selectItem).toHaveBeenCalledWith(item);
expect(window.history.state.openStalkerItem).toBeUndefined();
routeReady.set(false);
fixture.detectChanges();
routeReady.set(true);
fixture.detectChanges();
expect(catalog.initialize).toHaveBeenCalledTimes(1);
});
it('reinitializes a reused category route when the ready playlist changes', () => {
catalog.provider = 'stalker';
playlist.set({ id: 'a' });
paramMap$.next(convertToParamMap({ categoryId: '5' }));
fixture.detectChanges();
routeReady.set(false);
fixture.detectChanges();
const item = { id: '42', name: 'Movie from B' };
window.history.replaceState(
{ openStalkerItem: item },
'',
window.location.href
);
playlist.set({ id: 'b' });
fixture.detectChanges();
expect(catalog.initialize).toHaveBeenCalledTimes(1);
routeReady.set(true);
fixture.detectChanges();
expect(catalog.initialize).toHaveBeenCalledTimes(2);
expect(catalog.selectItem).toHaveBeenCalledWith(item);
});
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';
@@ -10,6 +10,7 @@ import {
OnDestroy,
OnInit,
signal,
untracked,
} from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs/operators';
@@ -193,8 +194,36 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
});
private previousGridResetKey: string | null = null;
private hasAttemptedScrollRestore = false;
private readonly routeCategory = signal<{ id: string | null } | null>(null);
private initializedRoute: {
category: { id: string | null };
playlistId: string | null;
} | null = null;
constructor() {
effect(() => {
const category = this.routeCategory();
if (!category || this.catalog.routeReady?.() === false) {
return;
}
const playlistId = this.catalog.playlist()?.id ?? null;
// Query-only navigation toggles readiness too; only a new
// category arrival or playlist should reset the open detail.
if (
this.initializedRoute?.category === category &&
this.initializedRoute.playlistId === playlistId
) {
return;
}
this.initializedRoute = { category, playlistId };
untracked(() => {
this.providerOnlyStalkerItemId.set(null);
this.catalog.initialize(category.id);
this.catalog.setSearchQuery?.(this.searchTerm());
this.openStalkerItemFromNavigationState();
});
});
effect(() => {
const resetKey = this.gridResetKey();
if (
@@ -253,9 +282,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
this.activatedRoute.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
this.providerOnlyStalkerItemId.set(null);
this.catalog.initialize(params.get('categoryId'));
this.openStalkerItemFromNavigationState();
this.routeCategory.set({ id: params.get('categoryId') });
});
this.activatedRoute.queryParamMap
@@ -38,6 +38,8 @@ export interface PortalCatalogFacade<
TSelectedItem = unknown,
> {
readonly provider: PortalCatalogProvider;
/** Arrival state may be consumed only after the destination portal is installed. */
readonly routeReady?: Signal<boolean>;
readonly contentType: Signal<string | null | undefined>;
readonly selectedCategory: Signal<TCategory | null | undefined>;
readonly paginatedContent: Signal<readonly TItem[] | undefined>;
@@ -139,6 +139,7 @@ describe('StalkerStore API compatibility smoke', () => {
'clearSelectedItem',
'setCategories',
'resetCategories',
'reloadCategories',
'setItvChannels',
'setSearchPhrase',
'fetchVodSeriesEpisodes',
@@ -0,0 +1,210 @@
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService, ParentalLockService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { StalkerItvCacheService } from '../../stalker-itv-cache.service';
import { StalkerSessionService } from '../../stalker-session.service';
import { StalkerContentType } from '../stalker-store.contracts';
import { withStalkerContent } from './with-stalker-content.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({ warn: jest.fn(), error: jest.fn() }),
}));
const Store = signalStore(
withState({
currentPlaylist: undefined as PlaylistMeta | undefined,
selectedContentType: 'vod' as StalkerContentType,
selectedCategoryId: null as string | null,
searchPhrase: '',
page: 0,
}),
withMethods((store) => ({
selectPlaylist(id: string) {
patchState(store, {
currentPlaylist: {
_id: id,
portalUrl: `http://${id}.example/portal.php`,
macAddress: '00:1A:79:00:00:01',
isFullStalkerPortal: false,
} as PlaylistMeta,
});
},
selectType(selectedContentType: StalkerContentType) {
patchState(store, { selectedContentType });
},
})),
withStalkerContent()
);
function deferred() {
let resolve!: (value: unknown) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function settle() {
for (let i = 0; i < 10; i++) {
TestBed.tick();
await Promise.resolve();
}
}
describe('Stalker category ownership', () => {
let store: InstanceType<typeof Store>;
let request: jest.Mock;
beforeEach(() => {
request = jest.fn().mockResolvedValue({ js: [] });
TestBed.configureTestingModule({
providers: [
Store,
{ provide: DataService, useValue: { sendIpcEvent: request } },
{
provide: ParentalLockService,
useValue: { active: () => false, version: () => 0 },
},
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
{
provide: StalkerSessionService,
useValue: {
ensureToken: jest
.fn()
.mockResolvedValue({ token: null }),
},
},
{
provide: StalkerItvCacheService,
useValue: {
versionFor: () => 0,
getChannels: () => null,
isReady: () => false,
isLoading: () => false,
progressOf: () => null,
isUnsupported: () => false,
},
},
],
});
store = TestBed.inject(Store);
void store.isCategoryResourceLoading();
});
it('sends no request on a reset alone', async () => {
request.mockResolvedValueOnce({
js: [{ id: '1', title: 'Before reset' }],
});
store.selectPlaylist('a');
await settle();
request.mockClear();
store.resetCategories();
await settle();
expect(request).not.toHaveBeenCalled();
});
it('reloads categories when the active portal cache is explicitly reloaded after a reset', async () => {
request.mockResolvedValueOnce({
js: [{ id: '1', title: 'Before reset' }],
});
store.selectPlaylist('a');
await settle();
request.mockResolvedValueOnce({
js: [{ id: '1', title: 'After reset' }],
});
store.resetCategories();
store.reloadCategories();
await settle();
expect(
store.getCategoryResource().map((c) => c.category_name)
).toContain('After reset');
});
it('ignores the abandoned request after switching away and back to the same portal', async () => {
const old = deferred();
request.mockReturnValueOnce(old.promise);
store.selectPlaylist('a');
await settle();
store.selectPlaylist('b');
await settle();
request.mockResolvedValueOnce({ js: [{ id: '1', title: 'Fresh A' }] });
store.selectPlaylist('a');
await settle();
old.resolve({ js: [{ id: '1', title: 'Obsolete A' }] });
await settle();
expect(
store.getCategoryResource().map((c) => c.category_name)
).toContain('Fresh A');
});
it('loads the new portal categories even when the previous portal cache is populated', async () => {
request.mockResolvedValueOnce({ js: [{ id: '1', title: 'Portal A' }] });
store.selectPlaylist('a');
await settle();
expect(request).toHaveBeenCalledTimes(1);
expect(store.isCategoryResourceFailed()).toBeNull();
expect(
store.getCategoryResource().map((c) => c.category_name)
).toContain('Portal A');
request.mockResolvedValueOnce({ js: [{ id: '1', title: 'Portal B' }] });
store.selectPlaylist('b');
expect(
store.getCategoryResource().map((c) => c.category_name)
).not.toContain('Portal A');
await settle();
expect(
store.getCategoryResource().map((c) => c.category_name)
).toContain('Portal B');
expect(request).toHaveBeenCalledTimes(2);
});
it.each(['success', 'failure'] as const)(
'ignores a late category %s from the previous portal',
async (outcome) => {
const old = deferred();
request.mockReturnValueOnce(old.promise);
store.selectPlaylist('a');
await settle();
request.mockResolvedValueOnce({
js: [{ id: '1', title: 'Portal B' }],
});
store.selectPlaylist('b');
await settle();
if (outcome === 'success')
old.resolve({ js: [{ id: '1', title: 'Portal A' }] });
else old.reject(new Error('Old portal offline'));
await settle();
expect(
store.getCategoryResource().map((c) => c.category_name)
).toContain('Portal B');
expect(store.isCategoryResourceFailed()).toBeNull();
}
);
it('does not reuse another section cache from the previous portal', async () => {
request.mockResolvedValueOnce({ js: [{ id: '1', title: 'A movies' }] });
store.selectPlaylist('a');
await settle();
store.selectType('series');
request.mockResolvedValueOnce({ js: [{ id: '1', title: 'A series' }] });
await settle();
store.selectPlaylist('b');
request.mockResolvedValueOnce({ js: [{ id: '1', title: 'B series' }] });
await settle();
store.selectType('vod');
request.mockResolvedValueOnce({ js: [{ id: '1', title: 'B movies' }] });
await settle();
expect(
store.getCategoryResource().map((c) => c.category_name)
).toContain('B movies');
});
});
@@ -204,11 +204,11 @@ describe('withStalkerContent failure states', () => {
: pending.promise
);
store.setSelectedContentType('itv');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('itv', [
{ category_id: '5', category_name: 'Five' },
]);
store.setSelectedCategory('5');
store.setCurrentPlaylist(PLAYLIST);
patchState(store, { searchPhrase: 'sidebar' });
await flushResources();
const orderedCalls = () =>
@@ -305,6 +305,7 @@ describe('withStalkerContent failure states', () => {
);
store.setSelectedContentType('itv');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('itv', [
{
category_id: '5',
@@ -312,7 +313,6 @@ describe('withStalkerContent failure states', () => {
},
]);
store.setSelectedCategory('5');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(
@@ -361,6 +361,7 @@ describe('withStalkerContent failure states', () => {
);
store.setSelectedContentType('itv');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('itv', [
{
category_id: '5',
@@ -368,7 +369,6 @@ describe('withStalkerContent failure states', () => {
},
]);
store.setSelectedCategory('5');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.itvChannels().length === 1);
@@ -424,11 +424,11 @@ describe('withStalkerContent failure states', () => {
);
store.setSelectedContentType('itv');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('itv', [
{ category_id: '5', category_name: 'News' },
{ category_id: '9', category_name: 'Sports' },
]);
store.setCurrentPlaylist(PLAYLIST);
expect(store.itvChannelsCategory()).toBeNull();
store.setSelectedCategory('5');
@@ -460,10 +460,10 @@ describe('withStalkerContent failure states', () => {
);
store.setSelectedContentType('itv');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('itv', [
{ category_id: '5', category_name: 'News' },
]);
store.setCurrentPlaylist(PLAYLIST);
store.setSelectedCategory('5');
void store.isPaginatedContentLoading();
await waitForCondition(() => store.itvChannelsCategory() === '5');
@@ -487,11 +487,11 @@ describe('withStalkerContent failure states', () => {
);
store.setSelectedContentType('itv');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('itv', [
{ category_id: '5', category_name: 'News' },
{ category_id: '9', category_name: 'Sports' },
]);
store.setCurrentPlaylist(PLAYLIST);
store.setSelectedCategory('5');
void store.isPaginatedContentLoading();
await waitForCondition(() => store.itvChannelsCategory() === '5');
@@ -537,11 +537,11 @@ describe('withStalkerContent failure states', () => {
);
store.setSelectedContentType('vod');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('vod', [
{ category_id: '5', category_name: 'Action' },
]);
store.setSelectedCategory('5');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.getPaginatedContent().length === 2);
@@ -587,11 +587,11 @@ describe('withStalkerContent failure states', () => {
);
store.setSelectedContentType('vod');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('vod', [
{ category_id: '5', category_name: 'Action' },
]);
store.setSelectedCategory('5');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.getPaginatedContent().length === 2);
@@ -634,12 +634,12 @@ describe('withStalkerContent failure states', () => {
);
store.setSelectedContentType('vod');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('vod', [
{ category_id: '5', category_name: 'Action' },
{ category_id: '9', category_name: 'Adult' },
]);
store.setSelectedCategory('*');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.getPaginatedContent().length === 1);
@@ -678,12 +678,12 @@ describe('withStalkerContent failure states', () => {
);
store.setSelectedContentType('vod');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('vod', [
{ category_id: '5', category_name: 'Action' },
{ category_id: '9', category_name: 'Adult' },
]);
store.setSelectedCategory('*');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.getPaginatedContent().length === 2);
@@ -728,12 +728,12 @@ describe('withStalkerContent failure states', () => {
})
);
store.setSelectedContentType('vod');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('vod', [
{ category_id: '5', category_name: 'Action' },
{ category_id: '9', category_name: 'Adult' },
]);
store.setSelectedCategory('*');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.getPaginatedContent().length === 2);
@@ -774,11 +774,11 @@ describe('withStalkerContent failure states', () => {
);
store.setSelectedContentType('vod');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('vod', [
{ category_id: '5', category_name: 'Action' },
]);
store.setSelectedCategory('5');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.getPaginatedContent().length === 1);
@@ -868,6 +868,7 @@ describe('withStalkerContent failure states', () => {
});
store.setSelectedContentType('radio');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('radio', [
{
category_id: 'radio-all',
@@ -875,7 +876,6 @@ describe('withStalkerContent failure states', () => {
},
]);
store.setSelectedCategory('radio-all');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.radioChannels().length === 1);
@@ -915,6 +915,7 @@ describe('withStalkerContent failure states', () => {
);
store.setSelectedContentType('vod');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('vod', [
{
category_id: '5',
@@ -922,7 +923,6 @@ describe('withStalkerContent failure states', () => {
},
]);
store.setSelectedCategory('5');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => pendingByPage.get(1)?.length === 1);
@@ -1010,12 +1010,12 @@ describe('withStalkerContent full ITV channel list cache', () => {
function enterItvCategory(categoryId: string) {
store.setSelectedContentType('itv');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('itv', [
{ category_id: '5', category_name: 'News' },
{ category_id: '9', category_name: 'Sports' },
]);
store.setSelectedCategory(categoryId);
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
}
@@ -1073,12 +1073,12 @@ describe('withStalkerContent full ITV channel list cache', () => {
// Genre '19' has no channels in the cached full list.
store.setSelectedContentType('itv');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('itv', [
{ category_id: '5', category_name: 'News' },
{ category_id: '19', category_name: 'For adults', censored: true },
]);
store.setSelectedCategory('19');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.itvChannels().length === 1);
@@ -1115,12 +1115,12 @@ describe('withStalkerContent full ITV channel list cache', () => {
it('omits genres without cached channels from the count map (adult genres)', () => {
setup(CACHED_CHANNELS);
store.setSelectedContentType('itv');
store.setCurrentPlaylist(PLAYLIST);
store.setCategories('itv', [
{ category_id: '*', category_name: 'All' },
{ category_id: '5', category_name: 'News' },
{ category_id: '19', category_name: 'For adults', censored: true },
]);
store.setCurrentPlaylist(PLAYLIST);
const counts = store.itvCategoryItemCounts();
expect(counts.get(5)).toBe(2);
@@ -64,6 +64,7 @@ function withheldStalkerCategoryIds(
* Content/categories/channels feature state.
*/
export interface StalkerContentState {
categoryPlaylistKey: string | null;
totalCount: number;
vodCategories: StalkerCategoryItem[];
seriesCategories: StalkerCategoryItem[];
@@ -96,6 +97,7 @@ export interface StalkerContentState {
}
const initialContentState: StalkerContentState = {
categoryPlaylistKey: null,
totalCount: 0,
vodCategories: [],
seriesCategories: [],
@@ -134,9 +136,13 @@ interface StalkerContentResourceStoreContract extends StalkerContentFeatureStore
}
function getCategoriesByType(
store: StalkerCategorySliceContract,
contentType: StalkerContentType
store: StalkerCategorySliceContract & {
categoryPlaylistKey(): string | null;
},
contentType: StalkerContentType,
playlistKey: string | null
): StalkerCategoryItem[] {
if (store.categoryPlaylistKey() !== playlistKey) return [];
switch (contentType) {
case 'vod':
return store.vodCategories();
@@ -304,7 +310,26 @@ export function withStalkerContent() {
}),
loader: async ({
params,
abortSignal,
}): Promise<StalkerCategoryItem[]> => {
const playlistKey = stalkerPlaylistKey(
params.currentPlaylist
);
if (store.categoryPlaylistKey() !== playlistKey) {
patchState(store, {
categoryPlaylistKey: playlistKey,
vodCategories: [],
seriesCategories: [],
itvCategories: [],
radioCategories: [],
categoryError: null,
});
}
const isCurrent = () =>
!abortSignal.aborted &&
stalkerPlaylistKey(
storeContext.currentPlaylist()
) === playlistKey;
if (!params.currentPlaylist) {
patchState(store, { categoryError: null });
return [];
@@ -312,7 +337,8 @@ export function withStalkerContent() {
const cachedCategories = getCategoriesByType(
store,
params.contentType
params.contentType,
playlistKey
);
if (cachedCategories.length > 0) {
patchState(store, { categoryError: null });
@@ -332,6 +358,7 @@ export function withStalkerContent() {
}
);
if (!isCurrent()) return [];
if (!Array.isArray(response?.js)) {
const invalidResponseError = new Error(
'Invalid categories response'
@@ -391,6 +418,7 @@ export function withStalkerContent() {
return categories;
} catch (error) {
if (!isCurrent()) return [];
logger.warn('Error loading categories', {
contentType: params.contentType,
error,
@@ -442,7 +470,10 @@ export function withStalkerContent() {
: 0,
availableCategoryCount: getCategoriesByType(
store,
storeContext.selectedContentType()
storeContext.selectedContentType(),
stalkerPlaylistKey(
storeContext.currentPlaylist()
)
).filter(
(category) =>
String(category.category_id) !== '*'
@@ -945,7 +976,11 @@ export function withStalkerContent() {
storeContext.currentPlaylist(),
contentType
);
const categories = getCategoriesByType(store, contentType);
const categories = getCategoriesByType(
store,
contentType,
stalkerPlaylistKey(storeContext.currentPlaylist())
);
return withheld.size === 0
? categories
: categories.filter(
@@ -1070,7 +1105,11 @@ export function withStalkerContent() {
}
const contentType = storeContext.selectedContentType();
const categories = getCategoriesByType(store, contentType);
const categories = getCategoriesByType(
store,
contentType,
stalkerPlaylistKey(storeContext.currentPlaylist())
);
return (
categories.find(
@@ -1093,7 +1132,8 @@ export function withStalkerContent() {
const category = getCategoriesByType(
store,
storeContext.selectedContentType()
storeContext.selectedContentType(),
stalkerPlaylistKey(storeContext.currentPlaylist())
).find(
(item) =>
String(item.category_id) ===
@@ -1112,7 +1152,8 @@ export function withStalkerContent() {
getAllCategoriesForSelectedType: computed(() =>
getCategoriesByType(
store,
storeContext.selectedContentType()
storeContext.selectedContentType(),
stalkerPlaylistKey(storeContext.currentPlaylist())
)
),
isCategoryResourceLoading: computed(() =>
@@ -1166,8 +1207,15 @@ export function withStalkerContent() {
type: StalkerContentType,
categories: StalkerCategoryItem[]
) {
patchState(store, buildCategoryPatch(type, categories));
patchState(store, {
categoryPlaylistKey: stalkerPlaylistKey(
storeContext.currentPlaylist()
),
...buildCategoryPatch(type, categories),
});
},
// Clears only: called on portal switches and route teardown,
// where a reload would ask the portal being left.
resetCategories() {
patchState(store, {
vodCategories: [],
@@ -1177,6 +1225,14 @@ export function withStalkerContent() {
categoryError: null,
});
},
/**
* Refetches the current portal's categories. For a reset that
* keeps the owner: the resource's params do not change then,
* so it would not load on its own.
*/
reloadCategories() {
storeContext.categoryResource.reload();
},
setItvChannels(channels: StalkerItvChannel[]) {
patchState(store, {
itvChannels: channels,
@@ -5,6 +5,7 @@ import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { StalkerCatalogFacadeService } from './stalker-catalog-facade.service';
import { StalkerWorkspaceRouteSession } from './stalker-workspace-route-session.service';
describe('StalkerCatalogFacadeService', () => {
const playlist = {
@@ -101,6 +102,10 @@ describe('StalkerCatalogFacadeService', () => {
TestBed.configureTestingModule({
providers: [
StalkerCatalogFacadeService,
{
provide: StalkerWorkspaceRouteSession,
useValue: { isReady: signal(true) },
},
{
provide: StalkerStore,
useValue: stalkerStoreMock,
@@ -27,6 +27,7 @@ import {
resolvePortalWatchState,
} from '@iptvnator/portal/shared/util';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { StalkerWorkspaceRouteSession } from './stalker-workspace-route-session.service';
@Injectable()
export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
@@ -52,6 +53,7 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
private positionsLoadGeneration = 0;
readonly provider = 'stalker' as const;
readonly routeReady = inject(StalkerWorkspaceRouteSession).isReady;
readonly contentType = this.stalkerStore.selectedContentType;
readonly selectedCategory = this.stalkerStore.getSelectedCategory;
readonly paginatedContent = computed(
@@ -281,6 +281,26 @@ describe('StalkerCollectionDetailComponent', () => {
fixture?.destroy();
});
it('does not repoint the portal after a pending collection load outlives its view', async () => {
const pending = new Subject<Playlist>();
playlistsService.getPlaylistById.mockReturnValue(pending);
fixture.componentRef.setInput('item', buildCollectionItem({}));
fixture.detectChanges();
TestBed.tick();
expect(playlistsService.getPlaylistById).toHaveBeenCalled();
fixture.destroy();
const destination = { ...playlist, _id: 'destination' };
await stalkerStore.setCurrentPlaylist(destination);
stalkerStore.setSelectedItem({ id: 'destination-item' });
pending.next(playlist);
pending.complete();
for (let i = 0; i < 10; i++) await Promise.resolve();
expect(currentPlaylist()).toBe(destination);
expect(selectedItem()).toEqual({ id: 'destination-item' });
});
it('opens legacy VOD is_series favorites through the lazy VOD-series flow', async () => {
fixture.componentRef.setInput(
'item',
@@ -258,6 +258,7 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
}
ngOnDestroy(): void {
++this.initRequestId;
restoreStalkerCollectionStoreState(
this.stalkerStore,
this.originalState
@@ -1,6 +1,11 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import {
NavigationCancel,
NavigationEnd,
NavigationStart,
Router,
} from '@angular/router';
import { EMPTY, Observable, Subject, of } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
@@ -49,8 +54,11 @@ function getStalkerSectionFromUrl(url: string): string | null {
}
describe('StalkerWorkspaceRouteSession', () => {
const routerEvents = new Subject<NavigationEnd>();
const routerEvents = new Subject<
NavigationStart | NavigationEnd | NavigationCancel
>();
const activePlaylist = signal<PlaylistMeta | null>(ACTIVE_PLAYLIST);
const currentPlaylist = signal<PlaylistMeta | undefined>(undefined);
const selectedContentType = signal<'vod' | 'itv' | 'series' | 'radio'>(
'vod'
);
@@ -61,10 +69,16 @@ describe('StalkerWorkspaceRouteSession', () => {
};
const stalkerStore = {
currentPlaylist,
resetCategories: jest.fn(),
reloadCategories: jest.fn(),
setSelectedCategory: jest.fn(),
clearSelectedItem: jest.fn(),
setCurrentPlaylist: jest.fn().mockResolvedValue(undefined),
setCurrentPlaylist: jest.fn(
async (playlist: PlaylistMeta | undefined) => {
currentPlaylist.set(playlist);
}
),
setSelectedContentType: jest.fn(
(type: 'vod' | 'itv' | 'series' | 'radio') => {
selectedContentType.set(type);
@@ -85,6 +99,7 @@ describe('StalkerWorkspaceRouteSession', () => {
beforeEach(async () => {
router.url = `/workspace/stalker/${PLAYLIST_ID}/vod`;
activePlaylist.set(ACTIVE_PLAYLIST);
currentPlaylist.set(undefined);
selectedContentType.set('vod');
playlistContext.syncFromUrl.mockImplementation((url: string) => ({
@@ -103,6 +118,7 @@ describe('StalkerWorkspaceRouteSession', () => {
}));
stalkerStore.resetCategories.mockClear();
stalkerStore.reloadCategories.mockClear();
stalkerStore.setSelectedCategory.mockClear();
stalkerStore.clearSelectedItem.mockClear();
stalkerStore.setCurrentPlaylist.mockClear();
@@ -155,6 +171,96 @@ describe('StalkerWorkspaceRouteSession', () => {
);
});
// A switch must not ask the portal being left for categories: the new
// owner's params make the resource load on their own.
it('does not reload categories when the store switches portals', async () => {
currentPlaylist.set(OTHER_PLAYLIST);
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(stalkerStore.resetCategories).toHaveBeenCalled();
expect(currentPlaylist()?._id).toBe(PLAYLIST_ID);
expect(stalkerStore.reloadCategories).not.toHaveBeenCalled();
});
// A handoff installed the destination in the store first: the reset
// keeps the owner, so only an explicit reload brings the categories back.
it('reloads the cleared categories once a held destination is installed', async () => {
currentPlaylist.set(ACTIVE_PLAYLIST);
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(stalkerStore.reloadCategories).toHaveBeenCalledTimes(1);
expect(
stalkerStore.reloadCategories.mock.invocationCallOrder[0]
).toBeGreaterThan(
stalkerStore.setCurrentPlaylist.mock.invocationCallOrder[0]
);
});
it('does not reload categories when the route is torn down', async () => {
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
stalkerStore.reloadCategories.mockClear();
TestBed.resetTestingModule();
expect(stalkerStore.resetCategories).toHaveBeenCalled();
expect(stalkerStore.reloadCategories).not.toHaveBeenCalled();
});
it('reconciles a revisited route when collection detail changed the shared store', async () => {
const session = TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
currentPlaylist.set(OTHER_PLAYLIST);
routerEvents.next(new NavigationEnd(1, router.url, router.url));
await flushEffects();
expect(currentPlaylist()?._id).toBe(PLAYLIST_ID);
expect(session.isReady()).toBe(true);
});
it('withholds readiness during navigation and restores it after cancellation', async () => {
const session = TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(session.isReady()).toBe(true);
routerEvents.next(
new NavigationStart(1, '/workspace/stalker/stalker-2/vod')
);
expect(session.isReady()).toBe(false);
routerEvents.next(
new NavigationCancel(
1,
'/workspace/stalker/stalker-2/vod',
'cancelled'
)
);
await flushEffects();
expect(session.isReady()).toBe(true);
expect(currentPlaylist()?._id).toBe(PLAYLIST_ID);
});
it('does not install a pending playlist after navigation leaves Stalker', async () => {
const pending = new Subject<PlaylistMeta>();
playlistsService.getPlaylistById.mockReturnValue(pending);
const session = TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
router.url = '/workspace/global-recent';
playlistContext.syncFromUrl.mockReturnValue({
provider: null,
playlistId: null,
section: null,
});
routerEvents.next(new NavigationEnd(1, router.url, router.url));
currentPlaylist.set(OTHER_PLAYLIST);
pending.next(FULL_STALKER_PLAYLIST);
pending.complete();
await flushEffects();
expect(currentPlaylist()).toBe(OTHER_PLAYLIST);
expect(session.isReady()).toBe(false);
});
it('keeps the radio route selection after playlist bootstrap', async () => {
router.url = `/workspace/stalker/${PLAYLIST_ID}/radio`;
@@ -7,7 +7,13 @@ import {
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import {
NavigationCancel,
NavigationEnd,
NavigationError,
NavigationStart,
Router,
} from '@angular/router';
import { filter, firstValueFrom } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PortalRailSection } from '@iptvnator/portal/shared/util';
@@ -27,6 +33,7 @@ export class StalkerWorkspaceRouteSession {
private readonly stalkerStore = inject(StalkerStore);
private currentPlaylistId: string | null = null;
private targetPlaylistId: string | null = null;
private readonly currentSection = signal<PortalRailSection | null>(null);
private readonly synced = signal(false);
@@ -56,17 +63,25 @@ export class StalkerWorkspaceRouteSession {
this.router.events
.pipe(
filter(
(event): event is NavigationEnd =>
event instanceof NavigationEnd
(event) =>
event instanceof NavigationStart ||
event instanceof NavigationEnd ||
event instanceof NavigationCancel ||
event instanceof NavigationError
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
.subscribe((event) => {
// Synchronous, and before the async sync: this session's
// subscription is registered from an ENVIRONMENT_INITIALIZER
// when the route injector is created, so it runs ahead of the
// components that read `isReady`.
this.synced.set(false);
if (event instanceof NavigationStart) {
this.targetPlaylistId = null;
++this.syncGeneration;
return;
}
void this.syncRouteContext();
});
@@ -85,6 +100,10 @@ export class StalkerWorkspaceRouteSession {
// playlist context, and deferring it onto the queue would leave every
// consumer of that context a tick behind the navigation.
const routeContext = this.playlistContext.syncFromUrl(this.router.url);
this.targetPlaylistId =
routeContext.provider === 'stalker'
? routeContext.playlistId
: null;
const run = async (): Promise<void> => {
try {
await this.applyRouteContext(generation, routeContext);
@@ -109,17 +128,42 @@ export class StalkerWorkspaceRouteSession {
? routeContext.playlistId
: null;
if (playlistId && this.currentPlaylistId !== playlistId) {
if (
!playlistId ||
this.targetPlaylistId !== playlistId ||
this.destroyRef.destroyed
) {
return;
}
if (
this.currentPlaylistId !== playlistId ||
this.stalkerStore.currentPlaylist()?._id !== playlistId
) {
// A handoff can install the destination in the store before this
// session does; the categories it held are cleared all the same.
const storeHeldDestination =
this.stalkerStore.currentPlaylist()?._id === playlistId;
this.stalkerStore.resetCategories();
this.stalkerStore.setSelectedCategory(null);
this.stalkerStore.clearSelectedItem();
const playlist = await this.resolveStalkerPlaylist(playlistId);
if (
this.targetPlaylistId !== playlistId ||
this.destroyRef.destroyed ||
playlist?._id !== playlistId
) {
return;
}
await this.stalkerStore.setCurrentPlaylist(playlist);
this.currentPlaylistId = playlistId;
// The owner did not change, so the category resource keeps its
// params and would not reload the cleared categories by itself.
if (storeHeldDestination) this.stalkerStore.reloadCategories();
}
if (generation !== this.syncGeneration) {
if (generation !== this.syncGeneration || this.destroyRef.destroyed) {
// A newer arrival is queued behind this one and owns readiness.
return;
}
@@ -166,7 +210,7 @@ export class StalkerWorkspaceRouteSession {
const activePlaylist = this.playlistContext.activePlaylist();
if (this.hasExplicitStalkerPortalMode(playlistId, activePlaylist)) {
return activePlaylist;
return activePlaylist ?? undefined;
}
const storedPlaylist = await firstValueFrom(
@@ -174,13 +218,17 @@ export class StalkerWorkspaceRouteSession {
{ defaultValue: null }
);
return storedPlaylist ?? activePlaylist ?? undefined;
return storedPlaylist?._id === playlistId
? storedPlaylist
: activePlaylist?._id === playlistId
? activePlaylist
: undefined;
}
private hasExplicitStalkerPortalMode(
playlistId: string,
playlist: PlaylistMeta | null
): playlist is PlaylistMeta {
): boolean {
return (
playlist?._id === playlistId &&
playlist.isFullStalkerPortal !== undefined
@@ -33,18 +33,30 @@ const SERVER_EPOCH = 1_788_723_000;
const TestPortalStore = signalStore(withPortal());
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe('withPortal', () => {
let store: InstanceType<typeof TestPortalStore>;
let apiService: {
getAccountInfo: jest.Mock;
};
let rememberServerTimezone: jest.Mock;
let getPlaylist: jest.Mock;
let createPlaylist: jest.Mock;
beforeEach(() => {
apiService = {
getAccountInfo: jest.fn(),
};
rememberServerTimezone = jest.fn().mockResolvedValue(undefined);
getPlaylist = jest.fn();
createPlaylist = jest.fn().mockResolvedValue(undefined);
TestBed.configureTestingModule({
providers: [
@@ -56,7 +68,8 @@ describe('withPortal', () => {
{
provide: XTREAM_DATA_SOURCE,
useValue: {
getPlaylist: jest.fn(),
getPlaylist,
createPlaylist,
rememberServerTimezone,
},
},
@@ -67,6 +80,103 @@ describe('withPortal', () => {
store.setCurrentPlaylist(PLAYLIST);
});
describe('playlist loading', () => {
beforeEach(() => {
store.setPlaylistId(PLAYLIST.id);
});
it('refreshes the selected playlist from storage while its owner is unchanged', async () => {
const refreshed = { ...PLAYLIST, name: 'Renamed portal' };
getPlaylist.mockResolvedValue(refreshed);
await store.fetchPlaylist();
expect(store.currentPlaylist()).toEqual(refreshed);
});
it('ignores a playlist response after switching to another portal', async () => {
const answer = deferred<XtreamPlaylistData>();
getPlaylist.mockReturnValue(answer.promise);
const pending = store.fetchPlaylist();
const other = { ...PLAYLIST, id: 'playlist-2' };
store.setPlaylistId(other.id);
store.setCurrentPlaylist(other);
answer.resolve(PLAYLIST);
await pending;
expect(store.playlistId()).toBe(other.id);
expect(store.currentPlaylist()).toEqual(other);
});
it.each(['setCurrentPlaylist', 'updatePlaylist'] as const)(
'keeps same-id connection edits made with %s while a playlist load is pending',
async (updateMethod) => {
const answer = deferred<XtreamPlaylistData>();
getPlaylist.mockReturnValue(answer.promise);
const pending = store.fetchPlaylist();
const edited = {
...PLAYLIST,
serverUrl: 'https://moved.example.com',
userAgent: 'New player',
};
store[updateMethod](edited);
answer.resolve(PLAYLIST);
await pending;
expect(store.currentPlaylist()).toEqual(edited);
}
);
it('does not repopulate a reset portal from a pending playlist load', async () => {
const answer = deferred<XtreamPlaylistData>();
getPlaylist.mockReturnValue(answer.promise);
const pending = store.fetchPlaylist();
store.resetPortal();
answer.resolve(PLAYLIST);
await pending;
expect(store.playlistId()).toBeNull();
expect(store.currentPlaylist()).toBeNull();
});
it('keeps a newer refresh when two reads of the same playlist finish out of order', async () => {
const first = deferred<XtreamPlaylistData>();
const second = deferred<XtreamPlaylistData>();
getPlaylist
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const firstPending = store.fetchPlaylist();
const secondPending = store.fetchPlaylist();
const refreshed = { ...PLAYLIST, name: 'Renamed portal' };
second.resolve(refreshed);
await secondPending;
first.resolve(PLAYLIST);
await firstPending;
expect(store.currentPlaylist()).toEqual(refreshed);
});
it('does not restore the old portal after pending playlist creation completes', async () => {
const creation = deferred<void>();
getPlaylist.mockResolvedValue(null);
createPlaylist.mockReturnValue(creation.promise);
const pending = store.fetchPlaylist();
await Promise.resolve();
expect(createPlaylist).toHaveBeenCalledWith(PLAYLIST);
const other = { ...PLAYLIST, id: 'playlist-2' };
store.setPlaylistId(other.id);
store.setCurrentPlaylist(other);
creation.resolve();
await pending;
expect(store.currentPlaylist()).toEqual(other);
});
});
function respondWith(serverInfo: Record<string, unknown> | undefined) {
apiService.getAccountInfo.mockResolvedValue({
user_info: {
@@ -54,6 +54,9 @@ export function withPortal() {
withMethods((store) => {
const apiService = inject(XtreamApiService);
const dataSource = inject(XTREAM_DATA_SOURCE);
// Explicit selection/metadata writes and newer reads supersede
// pending storage reads, even when the playlist id is unchanged.
let playlistRequestId = 0;
/**
* Whether a playlist (the store's current one, or the stored
@@ -108,6 +111,9 @@ export function withPortal() {
* Set the current playlist ID
*/
setPlaylistId(playlistId: string): void {
if (store.playlistId() !== playlistId) {
playlistRequestId++;
}
patchState(store, { playlistId });
},
@@ -119,10 +125,14 @@ export function withPortal() {
if (!playlistId) {
return;
}
const requestId = ++playlistRequestId;
try {
const playlist =
await dataSource.getPlaylist(playlistId);
if (requestId !== playlistRequestId) {
return;
}
if (playlist) {
patchState(store, { currentPlaylist: playlist });
@@ -138,6 +148,9 @@ export function withPortal() {
}
await dataSource.createPlaylist(currentPlaylist);
if (requestId !== playlistRequestId) {
return;
}
patchState(store, { currentPlaylist });
} catch (error) {
logger.error('Error fetching playlist', error);
@@ -145,6 +158,7 @@ export function withPortal() {
},
setCurrentPlaylist(playlist: XtreamPlaylistData | null): void {
playlistRequestId++;
patchState(store, { currentPlaylist: playlist });
},
@@ -243,6 +257,7 @@ export function withPortal() {
updatePlaylist(updates: Partial<XtreamPlaylistData>): void {
const current = store.currentPlaylist();
if (current) {
playlistRequestId++;
patchState(store, {
currentPlaylist: { ...current, ...updates },
});
@@ -253,6 +268,7 @@ export function withPortal() {
* Reset portal state
*/
resetPortal(): void {
playlistRequestId++;
patchState(store, initialPortalState);
},
};
@@ -1,5 +1,10 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
ComponentFixture,
TestBed,
fakeAsync,
flushMicrotasks,
} from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { VIEW_IN_PORTAL_HANDOFF } from '@iptvnator/ui/components';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
@@ -9,7 +14,7 @@ import {
} from '@iptvnator/portal/xtream/data-access';
import { PlaylistsService } from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { firstValueFrom, of } from 'rxjs';
import { Subject, firstValueFrom, of } from 'rxjs';
import { SerialDetailsComponent } from './serial-details/serial-details.component';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details/serial-details-resume-target.token';
import { XtreamCollectionDetailComponent } from './xtream-collection-detail.component';
@@ -17,9 +22,7 @@ import { XtreamCollectionDetailComponent } from './xtream-collection-detail.comp
describe('XtreamCollectionDetailComponent', () => {
let fixture: ComponentFixture<XtreamCollectionDetailComponent>;
let playlistId: ReturnType<typeof signal<string>>;
let currentPlaylist: ReturnType<
typeof signal<XtreamPlaylistData | null>
>;
let currentPlaylist: ReturnType<typeof signal<XtreamPlaylistData | null>>;
let selectedContentType: ReturnType<
typeof signal<'live' | 'vod' | 'series'>
>;
@@ -66,9 +69,8 @@ describe('XtreamCollectionDetailComponent', () => {
(value: 'live' | 'vod' | 'series') =>
selectedContentType.set(value)
),
setSelectedCategory: jest.fn(
(value: number | null) =>
selectedCategoryId.set(value)
setSelectedCategory: jest.fn((value: number | null) =>
selectedCategoryId.set(value)
),
setSelectedItem: jest.fn((value: unknown) =>
selectedItem.set(value)
@@ -178,6 +180,53 @@ describe('XtreamCollectionDetailComponent', () => {
expect(cancelDetailsRequest).toHaveBeenCalledTimes(1);
});
it('ignores a pending playlist load after the collection detail is destroyed', fakeAsync(() => {
const pendingPlaylist = new Subject<Playlist>();
jest.spyOn(
TestBed.inject(PlaylistsService),
'getPlaylistById'
).mockReturnValue(pendingPlaylist);
fixture.componentRef.setInput('item', {
uid: 'xtream::xtream-1::movie:99',
name: 'Movie One',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream Portal',
xtreamId: 99,
categoryId: 42,
} satisfies UnifiedCollectionItem);
fixture.detectChanges();
fixture.destroy();
const nextPlaylist: XtreamPlaylistData = {
id: 'xtream-2',
name: 'Next Portal',
serverUrl: 'http://next.example',
username: 'next-user',
password: 'next-pass',
type: 'xtream',
};
playlistId.set(nextPlaylist.id);
currentPlaylist.set(nextPlaylist);
selectedContentType.set('live');
pendingPlaylist.next({
_id: 'xtream-1',
title: 'Xtream Portal',
serverUrl: 'http://xtream.example',
username: 'user',
password: 'pass',
} as Playlist);
flushMicrotasks();
expect(playlistId()).toBe('xtream-2');
expect(currentPlaylist()).toBe(nextPlaylist);
expect(selectedContentType()).toBe('live');
expect(fixture.componentInstance.detailComponent()).toBeNull();
expect(fixture.componentInstance.detailInjector()).toBeNull();
}));
it('provides itself as the view-in-portal handoff to the inline detail', async () => {
fixture.componentRef.setInput('item', {
uid: 'xtream::xtream-1::movie:99',
@@ -117,6 +117,7 @@ export class XtreamCollectionDetailComponent implements ViewInPortalHandoff {
}
ngOnDestroy(): void {
this.initRequestId++;
this.xtreamStore.cancelDetailsRequest();
this.restoreStoreState();
}