mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
feat: enhance live stream layout with EPG loading state, archive notices, and program activation handling
This commit is contained in:
1 parent
34098fd569
commit
fb04b0b0f5
6 files changed
+779
-6
No files matched your search
+36
-1
@@ -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>
|
||||
|
||||
+32
@@ -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;
|
||||
}
|
||||
+399
@@ -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)),
|
||||
};
|
||||
}
|
||||
+194
-5
@@ -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<
|
||||
|
||||
+95
@@ -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);
|
||||
});
|
||||
});
|
||||
+23
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user