feat: implement cached offline notice component and enhance catalog facade with search query handling

This commit is contained in:
4gray committed 2026-04-06 12:57:03 +02:00
1 parent 3ddfeb9aae
commit d5f20d1df2
7 files changed
+325 -8

No files matched your search

@@ -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()) {
<div
class="xtream-cached-offline-notice"
data-test-id="xtream-offline-warning"
data-testid="xtream-offline-warning"
>
<mat-icon>cloud_off</mat-icon>
<div class="xtream-cached-offline-notice__copy">
<strong>
{{
'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE'
| translate
}}
</strong>
<span>
{{
'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION'
| translate
}}
</span>
</div>
</div>
}
`,
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()
);
}
@@ -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<string, unknown> & {
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<PortalCatalogPlaylistMeta | null>(() => {
@@ -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);
}
@@ -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: `
<app-xtream-cached-offline-notice />
<app-unified-collection-page
[mode]="mode()"
[portalType]="portalType()"
[playlistId]="playlistId()"
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class XtreamCollectionRouteComponent {
private readonly route = inject(ActivatedRoute);
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly portalType = input('xtream');
readonly playlistId = routeParamSignal<string | undefined>(
this.route,
'id',
(value) => value ?? undefined
);
}
@@ -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<XtreamContentGateComponent>;
const contentInitBlockReason =
signal<XtreamContentInitBlockReason | null>(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();
@@ -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 {
</div>
</div>
} @else {
<app-xtream-cached-offline-notice />
<router-outlet />
}
`,
@@ -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<XtreamContentInitBlockReason | null>(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');
@@ -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;
}