mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: implement cached offline notice component and enhance catalog facade with search query handling
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user