mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(xtream): pagination behavior (back & scroll)
Entire-Checkpoint: c6e522b4276c
This commit is contained in:
1 parent
cd111b6c36
commit
a2e2959b89
6 files changed
+270
-34
No files matched your search
+115
-8
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+30
-3
@@ -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;
|
||||
|
||||
+12
-4
@@ -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']
|
||||
|
||||
Reference in new issue
Block a user