mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
fix(downloads): preserve stalker provider handoff
This commit is contained in:
1 parent
72baf0bf1c
commit
c813ecb7db
7 files changed
+149
-35
No files matched your search
+6
-1
@@ -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">
|
||||
|
||||
+89
-7
@@ -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);
|
||||
});
|
||||
});
|
||||
+41
@@ -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
|
||||
);
|
||||
}
|
||||
+3
-10
@@ -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,
|
||||
|
||||
+2
-14
@@ -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
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user