From d5f20d1df2618d54ec30bd31cfbbff57fe9def73 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 6 Apr 2026 12:57:03 +0200 Subject: [PATCH] feat: implement cached offline notice component and enhance catalog facade with search query handling --- .../xtream-cached-offline-notice.component.ts | 81 +++++++++++++++++ .../src/lib/xtream-catalog-facade.service.ts | 86 ++++++++++++++++++- .../lib/xtream-collection-route.component.ts | 18 +++- .../lib/xtream-content-gate.component.spec.ts | 25 ++++++ .../src/lib/xtream-content-gate.component.ts | 3 + ...am-workspace-route-session.service.spec.ts | 51 +++++++++++ .../xtream-workspace-route-session.service.ts | 69 ++++++++++++++- 7 files changed, 325 insertions(+), 8 deletions(-) create mode 100644 libs/portal/xtream/feature/src/lib/xtream-cached-offline-notice.component.ts diff --git a/libs/portal/xtream/feature/src/lib/xtream-cached-offline-notice.component.ts b/libs/portal/xtream/feature/src/lib/xtream-cached-offline-notice.component.ts new file mode 100644 index 000000000..10b577a1d --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-cached-offline-notice.component.ts @@ -0,0 +1,81 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; + +@Component({ + selector: 'app-xtream-cached-offline-notice', + imports: [MatIconModule, TranslatePipe], + template: ` + @if (showWarning()) { +
+ cloud_off + +
+ + {{ + 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE' + | translate + }} + + + {{ + 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION' + | translate + }} + +
+
+ } + `, + styles: [ + ` + .xtream-cached-offline-notice { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 0 0 12px; + padding: 12px 14px; + border-radius: 12px; + background: var(--mat-sys-error-container); + color: var(--mat-sys-on-error-container); + } + + .xtream-cached-offline-notice__copy { + display: grid; + gap: 4px; + } + + .xtream-cached-offline-notice__copy strong { + font-size: 0.92rem; + font-weight: 700; + line-height: 1.2; + } + + .xtream-cached-offline-notice__copy span { + font-size: 0.84rem; + line-height: 1.35; + } + + .xtream-cached-offline-notice mat-icon { + margin-top: 1px; + flex-shrink: 0; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class XtreamCachedOfflineNoticeComponent { + private readonly xtreamStore = inject(XtreamStore); + + readonly showWarning = computed( + () => + this.xtreamStore.portalStatus() === 'unavailable' && + this.xtreamStore.isContentInitialized() && + !this.xtreamStore.contentInitBlockReason() + ); +} diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts index 7d6e8d923..5fe68444b 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts @@ -1,4 +1,4 @@ -import { Provider, Injectable, computed, inject } from '@angular/core'; +import { Provider, Injectable, computed, inject, signal } from '@angular/core'; import { PortalCatalogFacade, PortalCatalogItemProgress, @@ -9,6 +9,18 @@ import { import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; const SORT_STORAGE_KEY = 'xtream-category-sort-mode'; +const COLLATOR = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', +}); + +type XtreamCatalogItem = Record & { + added?: string; + category_id?: number | string; + last_modified?: string; + name?: string; + title?: string; +}; @Injectable() export class XtreamCatalogFacadeService @@ -17,6 +29,7 @@ export class XtreamCatalogFacadeService { private readonly xtreamStore = inject(XtreamStore); private savedPageBeforeDetail: number | null = null; + private readonly routeSearchQuery = signal(''); readonly provider = 'xtream' as const; readonly pageSizeOptions = [10, 25, 50, 100] as const; @@ -24,17 +37,78 @@ export class XtreamCatalogFacadeService readonly limit = this.xtreamStore.limit; readonly pageIndex = this.xtreamStore.page; readonly selectedCategory = this.xtreamStore.getSelectedCategory; - readonly paginatedContent = this.xtreamStore.getPaginatedContent; readonly selectedItem = this.xtreamStore.selectedItem; - readonly totalPages = this.xtreamStore.getTotalPages; readonly isPaginatedContentLoading = this.xtreamStore.isPaginatedContentLoading; + private readonly filteredAndSortedContent = computed(() => { + const contentType = this.contentType(); + const selectedCategoryId = this.xtreamStore.selectedCategoryId(); + const sortMode = this.contentSortMode(); + const searchQuery = this.routeSearchQuery().trim().toLocaleLowerCase(); + const content = + contentType === 'live' + ? this.xtreamStore.liveStreams() + : contentType === 'series' + ? this.xtreamStore.serialStreams() + : this.xtreamStore.vodStreams(); + + let items = [...content] as unknown as XtreamCatalogItem[]; + + if (selectedCategoryId !== null) { + items = items.filter( + (item) => Number(item.category_id) === selectedCategoryId + ); + } + + if ( + selectedCategoryId !== null && + (contentType === 'vod' || contentType === 'series') && + searchQuery.length > 0 + ) { + items = items.filter((item) => + String(item.title ?? item.name ?? '') + .toLocaleLowerCase() + .includes(searchQuery) + ); + } + + return items.sort((left, right) => { + const leftDate = + contentType === 'series' + ? Number(left.last_modified ?? left.added ?? 0) + : Number(left.added ?? 0); + const rightDate = + contentType === 'series' + ? Number(right.last_modified ?? right.added ?? 0) + : Number(right.added ?? 0); + + if (sortMode === 'date-asc') { + return leftDate - rightDate; + } + + if (sortMode === 'date-desc') { + return rightDate - leftDate; + } + + const leftTitle = String(left.title ?? left.name ?? ''); + const rightTitle = String(right.title ?? right.name ?? ''); + const byName = COLLATOR.compare(leftTitle, rightTitle); + return sortMode === 'name-desc' ? -byName : byName; + }); + }); + readonly paginatedContent = computed(() => { + const start = this.pageIndex() * this.limit(); + return this.filteredAndSortedContent().slice(start, start + this.limit()); + }); + readonly totalPages = computed(() => + Math.ceil(this.filteredAndSortedContent().length / this.limit()) + ); readonly selectedCategoryTitle = computed(() => { const category = this.selectedCategory(); return String(category?.['name'] ?? category?.['title'] ?? ''); }); readonly categoryItemCount = computed( - () => this.xtreamStore.selectItemsFromSelectedCategory().length + () => this.filteredAndSortedContent().length ); readonly contentSortMode = this.xtreamStore.contentSortMode; readonly playlist = computed(() => { @@ -83,6 +157,10 @@ export class XtreamCatalogFacadeService this.xtreamStore.setSelectedItem(null); } + setSearchQuery(query: string): void { + this.routeSearchQuery.set(query); + } + setPage(page: number): void { this.xtreamStore.setPage(page); } diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-route.component.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-route.component.ts index 6eb9284cd..312d559f0 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-route.component.ts @@ -1,22 +1,38 @@ import { + inject, ChangeDetectionStrategy, Component, input, } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui'; +import { routeParamSignal } from '@iptvnator/portal/shared/util'; +import { XtreamCachedOfflineNoticeComponent } from './xtream-cached-offline-notice.component'; @Component({ selector: 'app-xtream-collection-route', - imports: [UnifiedCollectionPageComponent], + imports: [ + UnifiedCollectionPageComponent, + XtreamCachedOfflineNoticeComponent, + ], template: ` + `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class XtreamCollectionRouteComponent { + private readonly route = inject(ActivatedRoute); + readonly mode = input<'favorites' | 'recent'>('favorites'); readonly portalType = input('xtream'); + readonly playlistId = routeParamSignal( + this.route, + 'id', + (value) => value ?? undefined + ); } diff --git a/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.spec.ts index ffe4cc547..30ae79211 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.spec.ts @@ -9,6 +9,7 @@ import { XtreamContentInitBlockReason, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; +import { XtreamCachedOfflineNoticeComponent } from './xtream-cached-offline-notice.component'; import { XtreamContentGateComponent } from './xtream-content-gate.component'; @Component({ @@ -30,10 +31,16 @@ describe('XtreamContentGateComponent', () => { let fixture: ComponentFixture; const contentInitBlockReason = signal(null); + const isContentInitialized = signal(false); + const portalStatus = signal<'active' | 'inactive' | 'expired' | 'unavailable'>( + 'active' + ); const retryContentInitialization = jest.fn().mockResolvedValue(undefined); beforeEach(async () => { contentInitBlockReason.set(null); + isContentInitialized.set(false); + portalStatus.set('active'); retryContentInitialization.mockClear(); await TestBed.configureTestingModule({ @@ -57,6 +64,8 @@ describe('XtreamContentGateComponent', () => { provide: XtreamStore, useValue: { contentInitBlockReason, + isContentInitialized, + portalStatus, retryContentInitialization, }, }, @@ -70,6 +79,7 @@ describe('XtreamContentGateComponent', () => { MockPlaylistErrorViewComponent, RouterOutlet, TranslatePipe, + XtreamCachedOfflineNoticeComponent, ], }, }) @@ -106,6 +116,21 @@ describe('XtreamContentGateComponent', () => { expect(fixture.nativeElement.querySelector('router-outlet')).not.toBeNull(); }); + it('shows an inline warning when cached content remains available offline', () => { + isContentInitialized.set(true); + portalStatus.set('unavailable'); + fixture.detectChanges(); + + const warning = fixture.nativeElement.querySelector( + '[data-testid="xtream-offline-warning"]' + ) as HTMLElement | null; + + expect(warning?.textContent).toContain( + 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE' + ); + expect(fixture.nativeElement.querySelector('router-outlet')).not.toBeNull(); + }); + it('retries content initialization from the blocked state', () => { contentInitBlockReason.set('cancelled'); fixture.detectChanges(); diff --git a/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.ts b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.ts index dd02b0509..420d545aa 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.ts @@ -10,6 +10,7 @@ import { XtreamContentInitBlockReason, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; +import { XtreamCachedOfflineNoticeComponent } from './xtream-cached-offline-notice.component'; @Component({ selector: 'app-xtream-content-gate', @@ -20,6 +21,7 @@ import { PlaylistErrorViewComponent, RouterOutlet, TranslatePipe, + XtreamCachedOfflineNoticeComponent, ], template: ` @if (contentInitBlockReason(); as blockReason) { @@ -42,6 +44,7 @@ import { } @else { + } `, diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts index 75f5fa894..220d99827 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts @@ -21,6 +21,10 @@ const ACTIVE_PLAYLIST: PlaylistMeta = { title: 'Test Xtream', username: 'user1', } as PlaylistMeta; +const UPDATED_ACTIVE_PLAYLIST: PlaylistMeta = { + ...ACTIVE_PLAYLIST, + serverUrl: 'http://localhost:65530', +} as PlaylistMeta; const XTREAM_PLAYLIST: XtreamPlaylistData = { id: PLAYLIST_ID, @@ -56,6 +60,7 @@ describe('XtreamWorkspaceRouteSession', () => { const selectedContentType = signal<'live' | 'vod' | 'series'>('vod'); const contentInitBlockReason = signal(null); + let hasUsableOfflineCache = false; const playlistContext = { routeProvider, @@ -78,6 +83,9 @@ describe('XtreamWorkspaceRouteSession', () => { }), fetchXtreamPlaylist: jest.fn().mockResolvedValue(undefined), checkPortalStatus: jest.fn(), + hasUsableOfflineCache: jest.fn().mockImplementation(async () => { + return hasUsableOfflineCache; + }), contentInitBlockReason, initializeContent: jest.fn().mockResolvedValue(undefined), setSelectedContentType: jest.fn( @@ -107,6 +115,7 @@ describe('XtreamWorkspaceRouteSession', () => { portalStatus.set('active'); selectedContentType.set('vod'); contentInitBlockReason.set(null); + hasUsableOfflineCache = false; playlistContext.syncFromUrl.mockImplementation((url: string) => ({ inWorkspace: true, @@ -126,6 +135,7 @@ describe('XtreamWorkspaceRouteSession', () => { xtreamStore.setCurrentPlaylist.mockClear(); xtreamStore.fetchXtreamPlaylist.mockClear(); xtreamStore.checkPortalStatus.mockReset(); + xtreamStore.hasUsableOfflineCache.mockClear(); xtreamStore.initializeContent.mockClear(); xtreamStore.setSelectedContentType.mockClear(); xtreamStore.setContentInitBlockReason.mockClear(); @@ -201,6 +211,47 @@ describe('XtreamWorkspaceRouteSession', () => { } ); + it('allows unavailable portals to initialize cached content', async () => { + hasUsableOfflineCache = true; + activePlaylist.set(UPDATED_ACTIVE_PLAYLIST); + xtreamStore.checkPortalStatus.mockImplementation(async () => { + portalStatus.set('unavailable'); + return 'unavailable'; + }); + + TestBed.inject(XtreamWorkspaceRouteSession); + await flushEffects(); + await flushEffects(); + + expect(xtreamStore.checkPortalStatus).toHaveBeenCalled(); + expect(xtreamStore.hasUsableOfflineCache).toHaveBeenCalled(); + expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith( + null + ); + expect(xtreamStore.initializeContent).toHaveBeenCalled(); + }); + + it('rebootstraps the current Xtream playlist when its connection details change', async () => { + activePlaylist.set(UPDATED_ACTIVE_PLAYLIST); + xtreamStore.checkPortalStatus.mockImplementation(async () => { + portalStatus.set('active'); + return 'active'; + }); + + TestBed.inject(XtreamWorkspaceRouteSession); + await flushEffects(); + await flushEffects(); + + expect(xtreamStore.resetStore).toHaveBeenCalledWith(PLAYLIST_ID); + expect(xtreamStore.setCurrentPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + id: PLAYLIST_ID, + serverUrl: UPDATED_ACTIVE_PLAYLIST.serverUrl, + }) + ); + expect(xtreamStore.checkPortalStatus).toHaveBeenCalled(); + }); + it('does not clear a cancelled block while an active portal bootstrap finishes', async () => { xtreamStore.checkPortalStatus.mockImplementation(async () => { portalStatus.set('active'); diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts index 18a6c4e19..41dfd322b 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts @@ -68,6 +68,48 @@ function toContentInitBlockReason( } } +function hasPlaylistConnectionChanges( + currentPlaylist: XtreamPlaylistData | null, + nextPlaylist: XtreamPlaylistData | null +): boolean { + if (!currentPlaylist || !nextPlaylist) { + return false; + } + + return ( + currentPlaylist.serverUrl !== nextPlaylist.serverUrl || + currentPlaylist.username !== nextPlaylist.username || + currentPlaylist.password !== nextPlaylist.password || + currentPlaylist.userAgent !== nextPlaylist.userAgent || + currentPlaylist.referrer !== nextPlaylist.referrer || + currentPlaylist.origin !== nextPlaylist.origin + ); +} + +function getXtreamRouteCategoryId( + url: string, + section: PortalRailSection | null +): number | null { + if ( + section !== 'live' && + section !== 'vod' && + section !== 'series' + ) { + return null; + } + + const path = url.split('?')[0] ?? ''; + const segments = path.split('/').filter(Boolean); + const categorySegment = segments[4]; + + if (!categorySegment) { + return null; + } + + const categoryId = Number(categorySegment); + return Number.isNaN(categoryId) ? null : categoryId; +} + @Injectable() export class XtreamWorkspaceRouteSession { private readonly destroyRef = inject(DestroyRef); @@ -150,7 +192,8 @@ export class XtreamWorkspaceRouteSession { playlistId && routePlaylist && (currentPlaylist?.id !== playlistId || - this.currentPlaylistUpdateDate !== routePlaylistUpdateDate) + this.currentPlaylistUpdateDate !== routePlaylistUpdateDate || + hasPlaylistConnectionChanges(currentPlaylist, routePlaylist)) ); let portalStatus = this.xtreamStore.portalStatus(); @@ -168,7 +211,14 @@ export class XtreamWorkspaceRouteSession { await this.xtreamStore.fetchXtreamPlaylist(); portalStatus = await this.xtreamStore.checkPortalStatus(); - const nextBlockReason = toContentInitBlockReason(portalStatus); + const hasUsableOfflineCache = + portalStatus === 'unavailable' + ? await this.xtreamStore.hasUsableOfflineCache() + : false; + const nextBlockReason = + portalStatus === 'unavailable' && hasUsableOfflineCache + ? null + : toContentInitBlockReason(portalStatus); const currentBlockReason = this.xtreamStore.contentInitBlockReason(); @@ -182,7 +232,16 @@ export class XtreamWorkspaceRouteSession { const section = this.syncRouteState(routeSection); - if (isImportDrivenSection(section) && portalStatus !== 'active') { + const canUseOfflineCache = + portalStatus === 'unavailable' + ? await this.xtreamStore.hasUsableOfflineCache() + : false; + + if ( + isImportDrivenSection(section) && + portalStatus !== 'active' && + !canUseOfflineCache + ) { return; } @@ -200,6 +259,10 @@ export class XtreamWorkspaceRouteSession { this.xtreamStore.setSelectedContentType(section); } + this.xtreamStore.setSelectedCategory( + getXtreamRouteCategoryId(this.router.url, section) + ); + return section; }