From fb04b0b0f5ebd131dee65325382141188eb852b8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 5 Apr 2026 18:44:42 +0200 Subject: [PATCH] feat: enhance live stream layout with EPG loading state, archive notices, and program activation handling --- .../live-stream-layout.component.html | 37 +- .../live-stream-layout.component.scss | 32 ++ .../live-stream-layout.component.spec.ts | 399 ++++++++++++++++++ .../live-stream-layout.component.ts | 199 ++++++++- .../portal-channels-list.component.spec.ts | 95 +++++ .../portal-channels-list.component.ts | 23 + 6 files changed, 779 insertions(+), 6 deletions(-) create mode 100644 libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 6ab8a57e5..60b6106f0 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -1,4 +1,6 @@ @let isEmbeddedPlayer = usesEmbeddedPlayer(); +@let streamUrl = activeStreamUrl(); +@let epgPrograms = controlledEpgPrograms(); @if (selectedCategoryId()) {
- @if (epgItems().length > 0) { + @if (isLoadingEpg()) { +
+ +
+ } @else if (isElectron && epgPrograms.length > 0) { + @if (showArchiveUnavailableNotice()) { +
+
+ {{ + 'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE' + | translate + }} +
+
+ {{ + 'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION' + | translate + }} +
+
+ } + + } @else if (!isElectron && epgItems().length > 0) { + } @else { + }
diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss index 3a3bd7b79..6cddd46bf 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss @@ -18,3 +18,35 @@ width: 100%; } } + +.loading-indicator { + display: flex; + justify-content: center; + padding: 16px; +} + +.archive-unavailable-banner { + margin: 12px 12px 0; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)), + rgba(127, 127, 127, 0.05); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 10px 20px -20px rgba(0, 0, 0, 0.35); +} + +.archive-unavailable-banner__title { + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.archive-unavailable-banner__description { + margin-top: 4px; + font-size: 0.78rem; + line-height: 1.45; + opacity: 0.74; +} diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts new file mode 100644 index 000000000..78e34caf7 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -0,0 +1,399 @@ +import { Directive, Component, input, output, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { MockPipe } from 'ng-mocks'; +import { TranslatePipe } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { PORTAL_PLAYER, ResizableDirective } from '@iptvnator/portal/shared/util'; +import { + FavoritesService, + XtreamStore, + XtreamUrlService, +} from '@iptvnator/portal/xtream/data-access'; +import { EpgListComponent, EpgProgramActivationEvent } from '@iptvnator/ui/epg'; +import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; +import { EpgItem, EpgProgram } from 'shared-interfaces'; +import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; +import { LiveStreamLayoutComponent } from './live-stream-layout.component'; + +@Component({ + selector: 'app-portal-channels-list', + standalone: true, + template: '', +}) +class StubPortalChannelsListComponent { + readonly sortMode = input<'server' | 'name-asc' | 'name-desc'>('server'); + readonly searchTermInput = input(''); + readonly playClicked = output(); +} + +@Component({ + selector: 'app-web-player-view', + standalone: true, + template: '', +}) +class StubWebPlayerViewComponent { + readonly streamUrl = input(''); +} + +@Component({ + selector: 'app-epg-view', + standalone: true, + template: '', +}) +class StubEpgViewComponent { + readonly epgItems = input([]); +} + +@Component({ + selector: 'app-epg-list', + standalone: true, + template: '', +}) +class StubEpgListComponent { + readonly controlledPrograms = input(null); + readonly controlledArchiveDays = input(null); + readonly programActivated = output(); +} + +@Directive({ + selector: '[appResizable]', + standalone: true, +}) +class StubResizableDirective {} + +describe('LiveStreamLayoutComponent', () => { + let fixture: ComponentFixture; + let component: LiveStreamLayoutComponent; + const fixedNow = new Date('2026-04-05T12:00:00.000Z'); + + const sampleChannel = { + xtream_id: 101, + name: 'Channel 101', + stream_icon: 'channel-101.png', + tv_archive: 1, + tv_archive_duration: 3, + }; + const playlist = { + id: 'playlist-1', + serverUrl: 'http://demo.example', + username: 'demo', + password: 'secret', + }; + + const categories = signal([{ category_id: 1, category_name: 'News' }]); + const categoryItemCounts = signal(new Map([[1, 1]])); + const epgItems = signal([]); + const currentEpgItem = signal(null); + const isLoadingEpg = signal(false); + const selectedCategoryId = signal(1); + const selectedContentType = signal<'live' | 'vod' | 'series'>('live'); + const selectedItem = signal(sampleChannel); + const currentPlaylist = signal(playlist); + + const xtreamStore = { + getCategoriesBySelectedType: categories, + getCategoryItemCounts: categoryItemCounts, + epgItems, + currentEpgItem, + isLoadingEpg, + selectedCategoryId, + selectedContentType, + selectedItem, + currentPlaylist, + selectItemsFromSelectedCategory: jest.fn(() => [sampleChannel]), + constructStreamUrl: jest.fn(() => 'https://example.com/live.ts'), + openPlayer: jest.fn(), + }; + const favoritesService = { + getFavorites: jest.fn().mockReturnValue(of([])), + }; + const xtreamUrlService = { + resolveCatchupUrl: jest.fn().mockResolvedValue( + 'https://example.com/timeshift.ts' + ), + }; + const portalPlayer = { + isEmbeddedPlayer: jest.fn().mockReturnValue(true), + }; + + const originalElectron = window.electron; + + beforeEach(async () => { + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + window.electron = { + updateRemoteControlStatus: jest.fn(), + } as typeof window.electron; + + xtreamStore.constructStreamUrl.mockClear(); + xtreamStore.openPlayer.mockClear(); + xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([ + sampleChannel, + ]); + favoritesService.getFavorites.mockClear(); + xtreamUrlService.resolveCatchupUrl.mockClear(); + portalPlayer.isEmbeddedPlayer.mockReset(); + portalPlayer.isEmbeddedPlayer.mockReturnValue(true); + + epgItems.set([]); + currentEpgItem.set(null); + isLoadingEpg.set(false); + selectedCategoryId.set(1); + selectedContentType.set('live'); + selectedItem.set(sampleChannel); + currentPlaylist.set(playlist); + + await TestBed.configureTestingModule({ + imports: [LiveStreamLayoutComponent, NoopAnimationsModule], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + data: {}, + queryParamMap: convertToParamMap({}), + }, + queryParamMap: of(convertToParamMap({})), + pathFromRoot: [ + { + snapshot: { + data: {}, + }, + }, + ], + }, + }, + { provide: XtreamStore, useValue: xtreamStore }, + { provide: FavoritesService, useValue: favoritesService }, + { provide: XtreamUrlService, useValue: xtreamUrlService }, + { provide: PORTAL_PLAYER, useValue: portalPlayer }, + ], + }) + .overrideComponent(LiveStreamLayoutComponent, { + remove: { + imports: [ + EpgListComponent, + EpgViewComponent, + PortalChannelsListComponent, + ResizableDirective, + TranslatePipe, + WebPlayerViewComponent, + ], + }, + add: { + imports: [ + StubEpgListComponent, + StubEpgViewComponent, + StubPortalChannelsListComponent, + StubResizableDirective, + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + StubWebPlayerViewComponent, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(LiveStreamLayoutComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + fixture.destroy(); + jest.useRealTimers(); + window.electron = originalElectron; + }); + + it('renders the controlled epg list for electron playback', () => { + epgItems.set([ + buildEpgItem( + '1', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ), + ]); + + component.playLive(sampleChannel); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('app-epg-list') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-view') + ).toBeNull(); + }); + + it('uses currentEpgItem instead of assuming the first schedule item is current', () => { + epgItems.set([ + buildEpgItem( + 'past', + 'Past Show', + '2026-04-05T08:00:00.000Z', + '2026-04-05T09:00:00.000Z' + ), + buildEpgItem( + 'current', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ), + ]); + currentEpgItem.set(epgItems()[1]); + + fixture.detectChanges(); + + expect(window.electron?.updateRemoteControlStatus).toHaveBeenCalledWith( + expect.objectContaining({ + epgTitle: 'Current Show', + }) + ); + }); + + it('resolves a catchup url for archived program activation', async () => { + portalPlayer.isEmbeddedPlayer.mockReturnValue(false); + + await component.onProgramActivated({ + type: 'timeshift', + program: { + start: '2026-04-04T10:00:00.000Z', + stop: '2026-04-04T11:00:00.000Z', + channel: 'channel-101', + title: 'Archived Show', + desc: null, + category: null, + startTimestamp: 1775296800, + stopTimestamp: 1775300400, + }, + }); + + expect(xtreamUrlService.resolveCatchupUrl).toHaveBeenCalledWith( + 'playlist-1', + { + serverUrl: 'http://demo.example', + username: 'demo', + password: 'secret', + }, + 101, + 1775296800, + 1775300400 + ); + expect(xtreamStore.openPlayer).toHaveBeenCalledWith( + 'https://example.com/timeshift.ts', + 'Channel 101 - Archived Show', + 'channel-101.png' + ); + }); + + it('shows an archive-unavailable notice when past programs exist but archive playback is unavailable', () => { + const nonArchiveChannel = { + ...sampleChannel, + tv_archive: 0, + tv_archive_duration: null, + }; + selectedItem.set(nonArchiveChannel); + epgItems.set([ + buildEpgItem( + 'past', + 'Past Show', + '2026-04-05T09:00:00.000Z', + '2026-04-05T10:00:00.000Z' + ), + buildEpgItem( + 'current', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ), + ]); + + component.playLive(nonArchiveChannel); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.archive-unavailable-banner') + ).not.toBeNull(); + }); + + it('hides the archive-unavailable notice when archive playback is available', () => { + selectedItem.set(sampleChannel); + epgItems.set([ + buildEpgItem( + 'past', + 'Past Show', + '2026-04-05T09:00:00.000Z', + '2026-04-05T10:00:00.000Z' + ), + buildEpgItem( + 'current', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ), + ]); + + component.playLive(sampleChannel); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.archive-unavailable-banner') + ).toBeNull(); + }); + + it('hides the archive-unavailable notice when there are no past programs yet', () => { + const nonArchiveChannel = { + ...sampleChannel, + tv_archive: 0, + tv_archive_duration: null, + }; + selectedItem.set(nonArchiveChannel); + epgItems.set([ + buildEpgItem( + 'current', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ), + buildEpgItem( + 'future', + 'Future Show', + '2026-04-05T12:30:00.000Z', + '2026-04-05T13:30:00.000Z' + ), + ]); + + component.playLive(nonArchiveChannel); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.archive-unavailable-banner') + ).toBeNull(); + }); +}); + +function buildEpgItem( + id: string, + title: string, + start: string, + stop: string +): EpgItem { + return { + id, + epg_id: `epg-${id}`, + title, + description: '', + lang: 'en', + start, + stop, + end: stop, + channel_id: 'channel-101', + start_timestamp: String(Math.floor(Date.parse(start) / 1000)), + stop_timestamp: String(Math.floor(Date.parse(stop) / 1000)), + }; +} diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index d2c8d95ba..95a03b2c1 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -25,10 +25,16 @@ import { import { FavoriteItem, FavoritesService, + XtreamUrlService, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; +import { + EpgListComponent, + EpgProgramActivationEvent, +} from '@iptvnator/ui/epg'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; -import { EpgItem } from 'shared-interfaces'; +import { EpgItem, EpgProgram } from 'shared-interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { ActivatedRoute } from '@angular/router'; @@ -40,6 +46,8 @@ interface XtreamLiveChannelItem { readonly poster_url?: string; readonly stream_icon?: string; readonly title?: string; + readonly tv_archive?: number | null; + readonly tv_archive_duration?: number | string | null; readonly xtream_id: number; } @@ -48,10 +56,12 @@ interface XtreamLiveChannelItem { templateUrl: './live-stream-layout.component.html', styleUrls: ['./live-stream-layout.component.scss'], imports: [ + EpgListComponent, EpgViewComponent, MatIcon, MatIconButton, MatMenuModule, + MatProgressSpinnerModule, MatTooltipModule, PortalChannelsListComponent, PortalEmptyStateComponent, @@ -65,13 +75,17 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { private readonly route = inject(ActivatedRoute); private readonly favoritesService = inject(FavoritesService); private readonly xtreamStore = inject(XtreamStore); + private readonly xtreamUrlService = inject(XtreamUrlService); private readonly portalPlayer = inject(PORTAL_PLAYER); readonly categories = this.xtreamStore.getCategoriesBySelectedType; readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts; readonly epgItems = this.xtreamStore.epgItems; + readonly currentEpgItem = this.xtreamStore.currentEpgItem; + readonly isLoadingEpg = this.xtreamStore.isLoadingEpg; readonly selectedCategoryId = this.xtreamStore.selectedCategoryId; readonly liveChannelSortMode = signal('server'); + readonly isElectron = Boolean(window.electron); readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route); private readonly routeSearchTerm = queryParamSignal( this.route, @@ -82,6 +96,52 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { this.isWorkspaceLayout ? this.routeSearchTerm() : '' ); private readonly pendingAutoOpenLiveItemId = signal(null); + readonly selectedLiveItem = computed(() => { + if (this.xtreamStore.selectedContentType() !== 'live') { + return null; + } + + const selectedItem = this.xtreamStore.selectedItem(); + if (!selectedItem || typeof selectedItem !== 'object') { + return null; + } + + const item = selectedItem as XtreamLiveChannelItem; + return item.xtream_id ? item : null; + }); + readonly controlledEpgPrograms = computed(() => + this.epgItems().map((program) => this.toControlledEpgProgram(program)) + ); + private readonly currentTimeMs = signal(Date.now()); + readonly controlledArchiveDays = computed(() => + Math.max( + 0, + Number(this.selectedLiveItem()?.tv_archive_duration ?? 0) || 0 + ) + ); + readonly archivePlaybackAvailable = computed(() => { + const selectedItem = this.selectedLiveItem(); + return ( + Number(selectedItem?.tv_archive ?? 0) === 1 && + this.controlledArchiveDays() > 0 + ); + }); + readonly hasPastPrograms = computed(() => { + const now = this.currentTimeMs(); + return this.controlledEpgPrograms().some((program) => { + const stop = this.getProgramTimestampMilliseconds( + program.stop, + program.stopTimestamp + ); + return stop !== null && stop < now; + }); + }); + readonly showArchiveUnavailableNotice = computed( + () => + this.controlledEpgPrograms().length > 0 && + this.hasPastPrograms() && + !this.archivePlaybackAvailable() + ); readonly liveChannelSortLabel = computed(() => { const mode = this.liveChannelSortMode(); if (mode === 'name-asc') return 'Name A-Z'; @@ -111,10 +171,18 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly usesEmbeddedPlayer = computed(() => this.portalPlayer.isEmbeddedPlayer() ); - streamUrl = ''; + readonly activeStreamUrl = signal(''); favorites = new Map(); constructor() { + effect((onCleanup) => { + const intervalId = window.setInterval(() => { + this.currentTimeMs.set(Date.now()); + }, 30_000); + + onCleanup(() => clearInterval(intervalId)); + }); + const requestedItemId = Number( (window.history.state as Record | null)?.[ 'openXtreamLiveItemId' @@ -156,7 +224,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { const selectedContentType = this.xtreamStore.selectedContentType(); const selectedItem = this.xtreamStore.selectedItem(); const channels = this.getVisibleChannels(); - const epgItems = this.xtreamStore.epgItems(); + const currentProgram = this.currentEpgItem(); if (selectedContentType !== 'live' || !selectedItem?.xtream_id) { window.electron.updateRemoteControlStatus({ @@ -171,7 +239,6 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { (item) => Number(item.xtream_id) === Number(selectedItem.xtream_id) ); - const currentProgram: EpgItem | undefined = epgItems?.[0]; window.electron.updateRemoteControlStatus({ portal: 'xtream', @@ -234,7 +301,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { playLive(item: XtreamLiveChannelItem) { const streamUrl = this.xtreamStore.constructStreamUrl(item); - this.streamUrl = streamUrl; + this.activeStreamUrl.set(streamUrl); if (this.usesEmbeddedPlayer()) { return; } @@ -245,6 +312,22 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { ); } + async onProgramActivated( + event: EpgProgramActivationEvent + ): Promise { + const selectedItem = this.selectedLiveItem(); + if (!selectedItem?.xtream_id) { + return; + } + + if (event.type === 'live') { + this.playLive(selectedItem); + return; + } + + await this.playCatchup(event.program, selectedItem); + } + setLiveChannelSortMode(mode: LiveChannelSortMode): void { this.liveChannelSortMode.set(mode); localStorage.setItem(LIVE_CHANNEL_SORT_STORAGE_KEY, mode); @@ -302,6 +385,112 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { XtreamLiveChannelItem[]; } + private async playCatchup( + program: EpgProgram, + item: XtreamLiveChannelItem + ): Promise { + const playlist = this.xtreamStore.currentPlaylist(); + if (!playlist) { + return; + } + + const startTimestamp = this.getProgramTimestampSeconds( + program.start, + program.startTimestamp + ); + const stopTimestamp = this.getProgramTimestampSeconds( + program.stop, + program.stopTimestamp + ); + + if (!startTimestamp || !stopTimestamp) { + return; + } + + const catchupUrl = await this.xtreamUrlService.resolveCatchupUrl( + playlist.id, + { + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + }, + item.xtream_id, + startTimestamp, + stopTimestamp + ); + + this.activeStreamUrl.set(catchupUrl); + if (this.usesEmbeddedPlayer()) { + return; + } + + this.xtreamStore.openPlayer( + catchupUrl, + this.getCatchupPlaybackTitle(item, program), + item.poster_url ?? item.stream_icon ?? null + ); + } + + private toControlledEpgProgram(program: EpgItem): EpgProgram { + return { + start: program.start, + stop: program.stop ?? program.end, + channel: program.channel_id ?? program.id, + title: program.title, + desc: program.description ?? null, + category: null, + startTimestamp: this.getProgramTimestampSeconds( + program.start, + program.start_timestamp + ), + stopTimestamp: this.getProgramTimestampSeconds( + program.stop ?? program.end, + program.stop_timestamp + ), + }; + } + + private getProgramTimestampSeconds( + dateValue: string, + unixTimestampValue?: number | string | null + ): number | null { + const unixTimestamp = Number.parseInt( + String(unixTimestampValue ?? ''), + 10 + ); + if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) { + return unixTimestamp; + } + + const parsedDate = Date.parse(dateValue); + return Number.isFinite(parsedDate) + ? Math.floor(parsedDate / 1000) + : null; + } + + private getProgramTimestampMilliseconds( + dateValue: string, + unixTimestampValue?: number | string | null + ): number | null { + const unixTimestamp = this.getProgramTimestampSeconds( + dateValue, + unixTimestampValue + ); + return unixTimestamp !== null ? unixTimestamp * 1000 : null; + } + + private getCatchupPlaybackTitle( + item: XtreamLiveChannelItem, + program: EpgProgram + ): string { + const channelTitle = item.title ?? item.name ?? ''; + if (!program.title) { + return channelTitle; + } + + return channelTitle ? `${channelTitle} - ${program.title}` : program.title; + } + private clearAutoOpenHistoryState(): void { try { const state = (window.history.state ?? {}) as Record< diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts index 04f80b1cc..91ebc1d7f 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts @@ -11,6 +11,29 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { PortalChannelsListComponent } from './portal-channels-list.component'; +function buildEpgItem(params: { + id: string; + title: string; + start: string; + stop: string; + startTimestamp: number; + stopTimestamp: number; +}) { + return { + id: params.id, + epg_id: `epg-${params.id}`, + title: params.title, + description: `${params.title} description`, + lang: 'en', + start: params.start, + end: params.stop, + stop: params.stop, + channel_id: 'channel-1', + start_timestamp: String(params.startTimestamp), + stop_timestamp: String(params.stopTimestamp), + }; +} + describe('PortalChannelsListComponent', () => { let fixture: ComponentFixture; const selectedChannels = signal([]); @@ -103,6 +126,10 @@ describe('PortalChannelsListComponent', () => { fixture = TestBed.createComponent(PortalChannelsListComponent); }); + afterEach(() => { + jest.useRealTimers(); + }); + it('renders a loading placeholder instead of the empty state while xtream live content is still loading', () => { fixture.detectChanges(); @@ -126,4 +153,72 @@ describe('PortalChannelsListComponent', () => { fixture.nativeElement.querySelector('.empty-search-state') ).not.toBeNull(); }); + + it('selects the current preview program by timestamps, preserves them, and updates progress from them', () => { + jest.useFakeTimers(); + const currentStartTimestamp = Math.floor( + Date.parse('2026-04-05T05:30:00.000Z') / 1000 + ); + const currentStopTimestamp = Math.floor( + Date.parse('2026-04-05T06:00:00.000Z') / 1000 + ); + const previousStartTimestamp = Math.floor( + Date.parse('2026-04-05T05:00:00.000Z') / 1000 + ); + const previousStopTimestamp = Math.floor( + Date.parse('2026-04-05T05:30:00.000Z') / 1000 + ); + + jest.setSystemTime(new Date('2026-04-05T05:45:00.000Z')); + + selectedTypeContentLoading.set(false); + selectedChannels.set([ + { + title: 'Cartoon Network', + xtream_id: 50, + }, + ]); + currentPlaylist.set({ + id: 'playlist-1', + password: 'secret', + serverUrl: 'http://demo.example', + username: 'demo', + }); + + fixture.detectChanges(); + + epgResults$.next({ + streamId: 50, + items: [ + buildEpgItem({ + id: 'previous', + title: 'Previous Show', + start: '2026-04-05T03:00:00.000Z', + stop: '2026-04-05T03:30:00.000Z', + startTimestamp: previousStartTimestamp, + stopTimestamp: previousStopTimestamp, + }), + buildEpgItem({ + id: 'current', + title: 'Current Show', + start: '2026-04-05T03:00:00.000Z', + stop: '2026-04-05T03:30:00.000Z', + startTimestamp: currentStartTimestamp, + stopTimestamp: currentStopTimestamp, + }), + ], + }); + + fixture.detectChanges(); + + const component = fixture.componentInstance; + expect(component.epgPrograms.get(50)).toEqual( + expect.objectContaining({ + title: 'Current Show', + startTimestamp: currentStartTimestamp, + stopTimestamp: currentStopTimestamp, + }) + ); + expect(component.currentProgramsProgress.get(50)).toBeCloseTo(50, 1); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index e3386a1af..c028b79a8 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -349,6 +349,29 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { title: program.title, desc: program.description ?? null, category: null, + startTimestamp: this.getProgramTimestampSeconds( + program.start, + program.start_timestamp + ), + stopTimestamp: this.getProgramTimestampSeconds( + program.stop ?? program.end, + program.stop_timestamp + ), }; } + + private getProgramTimestampSeconds( + dateValue: string | undefined, + unixTimestampValue: string | undefined + ): number | null { + const unixTimestamp = Number(unixTimestampValue); + if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) { + return unixTimestamp; + } + + const parsedDate = new Date(dateValue ?? '').getTime(); + return Number.isFinite(parsedDate) + ? Math.floor(parsedDate / 1000) + : null; + } }