feat: enhance live stream layout with EPG loading state, archive notices, and program activation handling

This commit is contained in:
4gray committed 2026-04-05 19:04:34 +02:00
1 parent 34098fd569
commit fb04b0b0f5
6 files changed
+779 -6

No files matched your search

@@ -1,4 +1,6 @@
@let isEmbeddedPlayer = usesEmbeddedPlayer();
@let streamUrl = activeStreamUrl();
@let epgPrograms = controlledEpgPrograms();
@if (selectedCategoryId()) {
<div
@@ -79,8 +81,41 @@
}
<div class="epg">
<div class="epg-content">
@if (epgItems().length > 0) {
@if (isLoadingEpg()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
} @else if (isElectron && epgPrograms.length > 0) {
@if (showArchiveUnavailableNotice()) {
<div class="archive-unavailable-banner" role="status">
<div class="archive-unavailable-banner__title">
{{
'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE'
| translate
}}
</div>
<div
class="archive-unavailable-banner__description"
>
{{
'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION'
| translate
}}
</div>
</div>
}
<app-epg-list
[controlledPrograms]="epgPrograms"
[controlledArchiveDays]="controlledArchiveDays()"
(programActivated)="onProgramActivated($event)"
/>
} @else if (!isElectron && epgItems().length > 0) {
<app-epg-view [epgItems]="epgItems()" />
} @else {
<app-portal-empty-state
icon="event_busy"
[message]="'EPG.EPG_NOT_AVAILABLE_CHANNEL_TITLE' | translate"
/>
}
</div>
</div>
@@ -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;
}
@@ -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<unknown>();
}
@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<EpgItem[]>([]);
}
@Component({
selector: 'app-epg-list',
standalone: true,
template: '',
})
class StubEpgListComponent {
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly programActivated = output<EpgProgramActivationEvent>();
}
@Directive({
selector: '[appResizable]',
standalone: true,
})
class StubResizableDirective {}
describe('LiveStreamLayoutComponent', () => {
let fixture: ComponentFixture<LiveStreamLayoutComponent>;
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<number, number>([[1, 1]]));
const epgItems = signal<EpgItem[]>([]);
const currentEpgItem = signal<EpgItem | null>(null);
const isLoadingEpg = signal(false);
const selectedCategoryId = signal<number | null>(1);
const selectedContentType = signal<'live' | 'vod' | 'series'>('live');
const selectedItem = signal<unknown>(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)),
};
}
@@ -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<LiveChannelSortMode>('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<number | null>(null);
readonly selectedLiveItem = computed<XtreamLiveChannelItem | null>(() => {
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<EpgProgram[]>(() =>
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<number, boolean>();
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<string, unknown> | 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<void> {
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<void> {
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<
@@ -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<PortalChannelsListComponent>;
const selectedChannels = signal<unknown[]>([]);
@@ -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);
});
});
@@ -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;
}
}