fix(xtream): pagination behavior (back & scroll)

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-25 14:11:36 +02:00
1 parent cd111b6c36
commit a2e2959b89
6 files changed
+270 -34

No files matched your search

@@ -7,11 +7,7 @@ import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import {
ActivatedRoute,
convertToParamMap,
Router,
} from '@angular/router';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ReplaySubject, of } from 'rxjs';
import {
@@ -58,6 +54,7 @@ class MockDetailComponent {}
describe('CategoryContentViewComponent', () => {
let fixture: ComponentFixture<CategoryContentViewComponent>;
let router: { navigate: jest.Mock };
const paramMap$ = new ReplaySubject(1);
const queryParamMap$ = new ReplaySubject(1);
const isPaginatedContentLoading = signal(true);
@@ -94,6 +91,13 @@ describe('CategoryContentViewComponent', () => {
contentSortMode.set(null);
catalog.initialize.mockClear();
catalog.setSearchQuery.mockClear();
catalog.setPage.mockClear();
catalog.setLimit.mockClear();
catalog.selectItem.mockClear();
catalog.selectItem.mockReturnValue(null);
router = {
navigate: jest.fn(),
};
paramMap$.next(convertToParamMap({}));
queryParamMap$.next(convertToParamMap({}));
@@ -146,9 +150,7 @@ describe('CategoryContentViewComponent', () => {
},
{
provide: Router,
useValue: {
navigate: jest.fn(),
},
useValue: router,
},
],
})
@@ -197,4 +199,109 @@ describe('CategoryContentViewComponent', () => {
expect(catalog.setSearchQuery).toHaveBeenCalledWith('matrix');
});
it('restores the zero-based catalog page from the one-based page query param', () => {
fixture.detectChanges();
catalog.setPage.mockClear();
queryParamMap$.next(
convertToParamMap({
page: '3',
})
);
expect(catalog.setPage).toHaveBeenCalledWith(2);
});
it('falls back to the first catalog page when the page query param is absent or invalid', () => {
fixture.detectChanges();
catalog.setPage.mockClear();
queryParamMap$.next(convertToParamMap({}));
queryParamMap$.next(
convertToParamMap({
page: 'not-a-page',
})
);
expect(catalog.setPage).toHaveBeenNthCalledWith(1, 0);
expect(catalog.setPage).toHaveBeenNthCalledWith(2, 0);
});
it('writes one-based page query params when the paginator changes', () => {
fixture.detectChanges();
fixture.componentInstance.onPageChange({
length: 100,
pageIndex: 1,
pageSize: 25,
previousPageIndex: 0,
});
expect(catalog.setPage).toHaveBeenCalledWith(1);
expect(catalog.setLimit).toHaveBeenCalledWith(25);
expect(router.navigate).toHaveBeenCalledWith([], {
relativeTo: expect.any(Object),
queryParams: {
page: 2,
},
queryParamsHandling: 'merge',
replaceUrl: true,
});
});
it('removes the page query param when returning to the first page', () => {
fixture.detectChanges();
fixture.componentInstance.onPageChange({
length: 100,
pageIndex: 0,
pageSize: 25,
previousPageIndex: 1,
});
expect(router.navigate).toHaveBeenCalledWith([], {
relativeTo: expect.any(Object),
queryParams: {
page: null,
},
queryParamsHandling: 'merge',
replaceUrl: true,
});
});
it('scrolls the grid list host to the top when the paginator changes', () => {
fixture.detectChanges();
const gridList = fixture.nativeElement.querySelector(
'app-grid-list'
) as HTMLElement;
const scrollTo = jest.fn();
Object.defineProperty(gridList, 'scrollTo', {
configurable: true,
value: scrollTo,
});
fixture.componentInstance.onPageChange({
length: 100,
pageIndex: 1,
pageSize: 25,
previousPageIndex: 0,
});
expect(scrollTo).toHaveBeenCalledWith({ top: 0 });
});
it('preserves query params when navigating from an item to Xtream details', () => {
catalog.selectItem.mockReturnValue(['42']);
fixture.detectChanges();
fixture.componentInstance.onItemClick({
xtream_id: 42,
});
expect(router.navigate).toHaveBeenCalledWith(['42'], {
relativeTo: expect.any(Object),
queryParamsHandling: 'preserve',
});
});
});
@@ -3,6 +3,7 @@ import {
Component,
computed,
DestroyRef,
ElementRef,
inject,
OnInit,
} from '@angular/core';
@@ -57,9 +58,12 @@ interface CategoryContentItem {
export class CategoryContentViewComponent implements OnInit {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
private readonly hostElement = inject(ElementRef<HTMLElement>);
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
private readonly catalog = inject(PORTAL_CATALOG_FACADE) as PortalCatalogFacade<
private readonly catalog = inject(
PORTAL_CATALOG_FACADE
) as PortalCatalogFacade<
CategoryContentItem,
CategoryContentItem,
CategoryContentItem
@@ -77,8 +81,7 @@ export class CategoryContentViewComponent implements OnInit {
readonly selectedItem = this.catalog.selectedItem;
readonly totalPages = this.catalog.totalPages;
readonly contentSortMode = this.catalog.contentSortMode;
readonly isPaginatedContentLoading =
this.catalog.isPaginatedContentLoading;
readonly isPaginatedContentLoading = this.catalog.isPaginatedContentLoading;
readonly isXtreamLoadingSubtitle = computed(
() =>
this.catalog.provider === 'xtream' &&
@@ -121,12 +124,23 @@ export class CategoryContentViewComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
this.catalog.setSearchQuery?.(params.get('q') ?? '');
this.catalog.setPage(this.toPageIndex(params.get('page')));
});
}
onPageChange(event: PageEvent): void {
this.catalog.setPage(event.pageIndex);
this.catalog.setLimit(event.pageSize);
this.scrollGridToTop();
void this.router.navigate([], {
relativeTo: this.activatedRoute,
queryParams: {
page: event.pageIndex > 0 ? event.pageIndex + 1 : null,
},
queryParamsHandling: 'merge',
replaceUrl: true,
});
}
onItemClick(item: CategoryContentItem): void {
@@ -134,10 +148,23 @@ export class CategoryContentViewComponent implements OnInit {
if (navigation?.length) {
this.router.navigate(navigation, {
relativeTo: this.activatedRoute,
queryParamsHandling: 'preserve',
});
}
}
private toPageIndex(value: string | null): number {
const page = Number(value);
return Number.isInteger(page) && page > 0 ? page - 1 : 0;
}
private scrollGridToTop(): void {
const gridList = this.hostElement.nativeElement.querySelector(
'app-grid-list'
) as HTMLElement | null;
gridList?.scrollTo?.({ top: 0 });
}
private openStalkerItemFromNavigationState(): void {
if (this.catalog.provider !== 'stalker') {
return;
@@ -19,7 +19,12 @@ import {
ResizableDirective,
} from 'components';
import { PlaylistsService } from 'services';
import { Channel, EpgItem, EpgProgram } from 'shared-interfaces';
import {
Channel,
EpgItem,
EpgProgram,
ResolvedPortalPlayback,
} from 'shared-interfaces';
import { EpgListComponent } from '@iptvnator/ui/epg';
import { WebPlayerViewComponent } from 'shared-portals';
import {
@@ -89,7 +94,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly usesEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
streamUrl = '';
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly streamUrl = computed(
() => this.activePlayback()?.streamUrl ?? ''
);
/** EPG */
readonly fallbackEpgPrograms = signal<EpgProgram[]>([]);
@@ -116,7 +124,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
return {
id: channelId,
name: channelName,
url: this.streamUrl || String(selectedItem.cmd ?? ''),
url: this.streamUrl() || String(selectedItem.cmd ?? ''),
group: { title: '' },
tvg: {
id: channelId,
@@ -294,7 +302,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
void this.loadEpgForChannel(item);
if (this.usesEmbeddedPlayer()) {
this.streamUrl = playback.streamUrl;
this.activePlayback.set(playback);
} else {
void this.portalPlayer.openResolvedPlayback(playback, true);
}
@@ -0,0 +1,99 @@
import { TestBed } from '@angular/core/testing';
import { signalStore, withState } from '@ngrx/signals';
import { withSelection } from './with-selection.feature';
const TestSelectionStore = signalStore(
withState({
contentLoadStateByType: {
live: 'ready',
vod: 'ready',
series: 'ready',
},
liveCategories: [],
liveStreams: [],
vodCategories: [
{
id: 10,
category_id: '10',
category_name: 'Movies',
type: 'vod',
},
],
vodStreams: [
{
xtream_id: 1,
category_id: '10',
title: 'First',
added: '4',
},
{
xtream_id: 2,
category_id: '10',
title: 'Second',
added: '3',
},
{
xtream_id: 3,
category_id: '10',
title: 'Third',
added: '2',
},
{
xtream_id: 4,
category_id: '10',
title: 'Fourth',
added: '1',
},
],
serialCategories: [],
serialStreams: [],
}),
withSelection()
);
describe('withSelection', () => {
let store: InstanceType<typeof TestSelectionStore>;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [TestSelectionStore],
});
store = TestBed.inject(TestSelectionStore);
});
afterEach(() => {
localStorage.clear();
});
it('keeps the current page when the category search term is unchanged', () => {
store.setSelectedContentType('vod');
store.setSelectedCategory(10);
store.setLimit(2);
store.setPage(1);
store.setCategorySearchTerm('');
expect(store.page()).toBe(1);
expect(store.getPaginatedContent().map((item) => item.title)).toEqual([
'Third',
'Fourth',
]);
});
it('resets the current page when the category search term changes', () => {
store.setSelectedContentType('vod');
store.setSelectedCategory(10);
store.setLimit(2);
store.setPage(1);
store.setCategorySearchTerm('first');
expect(store.page()).toBe(0);
expect(store.getPaginatedContent().map((item) => item.title)).toEqual([
'First',
]);
});
});
@@ -245,8 +245,8 @@ export function withSelection() {
const selectedTypeContentReady = computed(
() => selectedTypeContentState() === 'ready'
);
const selectedTypeCountsReady = computed(
() => selectedTypeContentReady()
const selectedTypeCountsReady = computed(() =>
selectedTypeContentReady()
);
// ---------------------------------------------------------------------------
@@ -502,6 +502,10 @@ export function withSelection() {
* Set selected category search term
*/
setCategorySearchTerm(term: string): void {
if (store.categorySearchTerm() === term) {
return;
}
patchState(store, {
categorySearchTerm: term,
page: 0,
@@ -11,12 +11,12 @@ import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
const SORT_STORAGE_KEY = 'xtream-category-sort-mode';
@Injectable()
export class XtreamCatalogFacadeService
implements
PortalCatalogFacade<Record<string, unknown>, Record<string, unknown>, unknown>
{
export class XtreamCatalogFacadeService implements PortalCatalogFacade<
Record<string, unknown>,
Record<string, unknown>,
unknown
> {
private readonly xtreamStore = inject(XtreamStore);
private savedPageBeforeDetail: number | null = null;
private loadedPositionsPlaylistId: string | null = null;
readonly provider = 'xtream' as const;
@@ -34,8 +34,8 @@ export class XtreamCatalogFacadeService
const category = this.selectedCategory();
return String(category?.['name'] ?? category?.['title'] ?? '');
});
readonly categoryItemCount = computed(() =>
this.xtreamStore.selectItemsFromSelectedCategory().length
readonly categoryItemCount = computed(
() => this.xtreamStore.selectItemsFromSelectedCategory().length
);
readonly contentSortMode = this.xtreamStore.contentSortMode;
readonly playlist = computed<PortalCatalogPlaylistMeta | null>(() => {
@@ -74,11 +74,6 @@ export class XtreamCatalogFacadeService
} else {
this.xtreamStore.setSelectedCategory(null);
}
if (this.savedPageBeforeDetail !== null) {
this.xtreamStore.setPage(this.savedPageBeforeDetail);
this.savedPageBeforeDetail = null;
}
}
clearSelectedItem(): void {
@@ -103,8 +98,6 @@ export class XtreamCatalogFacadeService
}
selectItem(item: Record<string, unknown>): string[] | null {
this.savedPageBeforeDetail = this.xtreamStore.page();
const xtreamId = item['xtream_id'];
if (xtreamId === undefined || xtreamId === null) {
return null;
@@ -123,9 +116,7 @@ export class XtreamCatalogFacadeService
return [String(categoryId), String(xtreamId)];
}
getItemProgress(
item: Record<string, unknown>
): PortalCatalogItemProgress {
getItemProgress(item: Record<string, unknown>): PortalCatalogItemProgress {
const isSeries = this.contentType() === 'series';
const itemId = Number(
item['xtream_id'] ?? item['series_id'] ?? item['stream_id']