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;
}