feat: add live epg panel to collections

Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
4gray committed 2026-04-29 01:23:42 +02:00
1 parent 2db99769f9
commit 0babd92304
4 files changed
+381 -24

No files matched your search

+3 -2
View File
@@ -129,14 +129,15 @@ The shared row should be reused instead of rebuilding channel markup per view.
### Collapsible Live EPG
- Live TV layouts with an internal player use `app-live-epg-panel` around the
EPG content.
EPG content, including playlist-specific live pages and the global
favorites/recent live tabs.
- The live panel toolbar owns the current-program summary and live date
navigation together, so `app-epg-list` hides its internal date navigator when
projected inside that panel.
- Collapsed state is shared across M3U, Xtream, and Stalker with
`live-epg-panel-state`; missing or invalid values restore to expanded.
- The collapsed panel is a slim current-program strip with a trailing progress
line, an expand button, and a compact date chip.
line and an expand button. Date controls stay out of the collapsed strip.
- Do not render the collapsed strip for external MPV/VLC playback; those
layouts keep the full EPG-only panel.
- Keep the EPG content mounted while collapsed so current-program state can
@@ -23,7 +23,10 @@
<div class="content-container">
@if (activeDetail()) {
@if (shouldUseInlinePlayer()) {
<div class="video-player" [class.video-player--radio]="isRadioSelection()">
<div
class="video-player"
[class.video-player--radio]="isRadioSelection()"
>
@if (isRadioSelection()) {
<button
mat-icon-button
@@ -93,7 +96,12 @@
}
@if (!isRadioSelection()) {
<div class="epg">
<div
class="epg"
[class.epg-collapsed]="
shouldUseInlinePlayer() && isLiveEpgPanelCollapsed()
"
>
@if (!shouldUseInlinePlayer()) {
<div class="player-toolbar">
<span class="player-title">{{
@@ -108,19 +116,55 @@
</button>
</div>
}
<div class="epg-content">
@if (isM3uSelection()) {
<app-epg-list
[controlledChannel]="currentM3uChannel()"
[controlledPrograms]="currentM3uPrograms()"
[archivePlaybackAvailable]="
currentM3uArchivePlaybackAvailable()
"
/>
} @else {
<app-epg-view [epgItems]="currentPortalEpgItems()" />
}
</div>
@if (shouldUseInlinePlayer()) {
<app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[showDateNavigator]="isM3uSelection()"
[selectedDate]="selectedLiveEpgDate()"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
(dateNavigation)="onLiveEpgDateNavigation($event)"
>
<div class="epg-content">
@if (isM3uSelection()) {
<app-epg-list
[controlledChannel]="currentM3uChannel()"
[controlledPrograms]="currentM3uPrograms()"
[archivePlaybackAvailable]="
currentM3uArchivePlaybackAvailable()
"
[selectedDate]="selectedLiveEpgDate()"
[showDateNavigator]="false"
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
/>
} @else {
<app-epg-view
[epgItems]="currentPortalEpgItems()"
/>
}
</div>
</app-live-epg-panel>
} @else {
<div class="epg-content">
@if (isM3uSelection()) {
<app-epg-list
[controlledChannel]="currentM3uChannel()"
[controlledPrograms]="currentM3uPrograms()"
[archivePlaybackAvailable]="
currentM3uArchivePlaybackAvailable()
"
/>
} @else {
<app-epg-view
[epgItems]="currentPortalEpgItems()"
/>
}
</div>
}
</div>
}
} @else {
@@ -17,16 +17,24 @@ import { isM3uCatchupPlaybackSupported } from 'm3u-utils';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
FavoritesChannelSortMode,
LiveEpgPanelState,
matchesOpenLiveCollectionItem,
OpenLiveCollectionItemState,
PORTAL_PLAYER,
persistLiveEpgPanelState,
ResolvedLiveCollectionDetail,
restoreLiveEpgPanelState,
StreamResolverService,
UnifiedCollectionItem,
UnifiedFavoriteChannel,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/util';
import { EpgListComponent } from '@iptvnator/ui/epg';
import {
EpgDateNavigationDirection,
EpgListComponent,
getTodayEpgDateKey,
shiftEpgDateKey,
} from '@iptvnator/ui/epg';
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
import {
@@ -37,8 +45,12 @@ import {
} from '@iptvnator/ui/playback';
import { ResizableDirective } from 'components';
import { SettingsStore } from 'services';
import { Channel, EpgProgram } from 'shared-interfaces';
import { EpgViewComponent } from 'shared-portals';
import { Channel, EpgItem, EpgProgram } from 'shared-interfaces';
import {
EpgViewComponent,
LiveEpgPanelComponent,
LiveEpgPanelSummary,
} from 'shared-portals';
@Component({
selector: 'app-unified-live-tab',
@@ -52,6 +64,7 @@ import { EpgViewComponent } from 'shared-portals';
EpgViewComponent,
GlobalFavoritesListComponent,
HtmlVideoPlayerComponent,
LiveEpgPanelComponent,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
@@ -91,6 +104,10 @@ export class UnifiedLiveTabComponent {
readonly isSelecting = signal(false);
readonly epgMap = signal<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = signal(0);
readonly liveEpgPanelState = signal<LiveEpgPanelState>(
restoreLiveEpgPanelState()
);
readonly selectedLiveEpgDate = signal(getTodayEpgDateKey());
readonly currentStreamUrl = computed(
() => this.activeDetail()?.playback.streamUrl ?? ''
);
@@ -123,10 +140,19 @@ export class UnifiedLiveTabComponent {
const channel = this.currentM3uChannel();
return channel?.radio === 'true' ? channel : null;
});
readonly isRadioSelection = computed(() => this.activeRadioChannel() !== null);
readonly isRadioSelection = computed(
() => this.activeRadioChannel() !== null
);
readonly shouldUseInlinePlayer = computed(() => {
return this.isRadioSelection() || this.isEmbeddedPlayer();
});
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
readonly liveEpgPanelSummary = computed(() => {
this.progressTick();
return this.getLiveEpgPanelSummary(this.activeDetail());
});
readonly activeChannelForOverlay = computed((): Channel | undefined => {
const detail = this.activeDetail();
@@ -255,6 +281,22 @@ export class UnifiedLiveTabComponent {
this.reorderItems.emit(reordered);
}
onLiveEpgPanelCollapsedChange(collapsed: boolean): void {
const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded';
this.liveEpgPanelState.set(state);
persistLiveEpgPanelState(state);
}
onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void {
this.selectedLiveEpgDate.set(
shiftEpgDateKey(this.selectedLiveEpgDate(), direction)
);
}
onLiveEpgSelectedDateChange(selectedDate: string): void {
this.selectedLiveEpgDate.set(selectedDate);
}
onClose(): void {
this.selectionRequestId += 1;
this.isSelecting.set(false);
@@ -330,7 +372,9 @@ export class UnifiedLiveTabComponent {
private shouldOpenExternalPlayback(
detail: ResolvedLiveCollectionDetail
): boolean {
return !this.isRadioDetail(detail) && !this.portalPlayer.isEmbeddedPlayer();
return (
!this.isRadioDetail(detail) && !this.portalPlayer.isEmbeddedPlayer()
);
}
private isRadioDetail(
@@ -367,4 +411,99 @@ export class UnifiedLiveTabComponent {
};
});
}
private getLiveEpgPanelSummary(
detail: ResolvedLiveCollectionDetail | null
): LiveEpgPanelSummary | null {
if (!detail) {
return null;
}
if (detail.epgMode === 'm3u') {
return this.toLiveEpgPanelSummary(
this.findCurrentM3uProgram(detail.epgPrograms ?? [])
);
}
return this.toLiveEpgPanelSummary(
this.findCurrentPortalProgram(detail.epgItems ?? [])
);
}
private findCurrentM3uProgram(
programs: readonly EpgProgram[]
): EpgProgram | null {
const now = Date.now();
return (
programs.find((program) => {
const start = this.getProgramTimeMs(
program.start,
program.startTimestamp
);
const stop = this.getProgramTimeMs(
program.stop,
program.stopTimestamp
);
return (
start !== null &&
stop !== null &&
now >= start &&
now < stop
);
}) ?? null
);
}
private findCurrentPortalProgram(
programs: readonly EpgItem[]
): EpgItem | null {
const now = Date.now();
return (
programs.find((program) => {
const start = this.getProgramTimeMs(
program.start,
program.start_timestamp
);
const stop = this.getProgramTimeMs(
program.stop ?? program.end,
program.stop_timestamp
);
return (
start !== null &&
stop !== null &&
now >= start &&
now < stop
);
}) ?? null
);
}
private toLiveEpgPanelSummary(
program: EpgItem | EpgProgram | null | undefined
): LiveEpgPanelSummary | null {
if (!program) {
return null;
}
return {
title: program.title,
start: program.start,
stop: program.stop ?? ('end' in program ? program.end : null),
};
}
private getProgramTimeMs(
rawDate: string | null | undefined,
rawTimestamp?: number | string | null
): number | null {
const timestamp = Number.parseInt(String(rawTimestamp ?? ''), 10);
if (Number.isFinite(timestamp) && timestamp > 0) {
return timestamp * 1000;
}
const parsedDate = Date.parse(rawDate ?? '');
return Number.isFinite(parsedDate) ? parsedDate : null;
}
}
@@ -7,6 +7,7 @@ import {
signal,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import {
GlobalFavoritesListComponent,
@@ -18,11 +19,20 @@ import {
HtmlVideoPlayerComponent,
VjsPlayerComponent,
} from '@iptvnator/ui/playback';
import { EpgListComponent } from '@iptvnator/ui/epg';
import {
EpgDateNavigationDirection,
EpgListComponent,
getTodayEpgDateKey,
shiftEpgDateKey,
} from '@iptvnator/ui/epg';
import { ResizableDirective } from 'components';
import { SettingsStore } from 'services';
import { Channel, EpgItem, EpgProgram } from 'shared-interfaces';
import { EpgViewComponent } from 'shared-portals';
import {
EpgViewComponent,
LiveEpgPanelComponent,
LiveEpgPanelSummary,
} from 'shared-portals';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
PORTAL_PLAYER,
@@ -73,6 +83,9 @@ class StubEpgListComponent {
readonly controlledChannel = input<Channel | null>(null);
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
readonly selectedDate = input<string | null>(null);
readonly showDateNavigator = input(true);
readonly selectedDateChange = output<string>();
}
@Component({
@@ -84,6 +97,21 @@ class StubEpgViewComponent {
readonly epgItems = input<EpgItem[]>([]);
}
@Component({
selector: 'app-live-epg-panel',
template: '<section class="stub-live-epg-panel"><ng-content /></section>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubLiveEpgPanelComponent {
readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly loading = input(false);
readonly showDateNavigator = input(false);
readonly selectedDate = input<string | null>(null);
readonly collapsedChange = output<boolean>();
readonly dateNavigation = output<EpgDateNavigationDirection>();
}
@Component({
selector: 'app-vjs-player',
template: '',
@@ -145,6 +173,8 @@ describe('UnifiedLiveTabComponent', () => {
};
beforeEach(async () => {
localStorage.removeItem('live-epg-panel-state');
streamResolver = {
resolveLiveDetail: jest.fn(),
resolveM3uPlaybackDetail: jest.fn(),
@@ -182,6 +212,7 @@ describe('UnifiedLiveTabComponent', () => {
EpgViewComponent,
GlobalFavoritesListComponent,
HtmlVideoPlayerComponent,
LiveEpgPanelComponent,
ResizableDirective,
VjsPlayerComponent,
],
@@ -194,6 +225,7 @@ describe('UnifiedLiveTabComponent', () => {
StubEpgViewComponent,
StubGlobalFavoritesListComponent,
StubHtmlVideoPlayerComponent,
StubLiveEpgPanelComponent,
StubResizableDirective,
StubVjsPlayerComponent,
],
@@ -261,6 +293,115 @@ describe('UnifiedLiveTabComponent', () => {
expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull();
});
it('wraps inline M3U EPG in the live panel with shared date navigation', async () => {
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
const item = buildLiveItem('m3u');
const currentProgram = buildCurrentProgram('M3U Now');
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
epgMode: 'm3u',
playback: {
streamUrl: 'https://example.com/m3u.m3u8',
title: 'M3U Live',
},
channel: {
id: 'm3u-channel',
name: 'M3U Live',
url: 'https://example.com/m3u.m3u8',
group: { title: 'News' },
tvg: {
id: 'm3u-channel',
name: 'M3U Live',
url: '',
logo: 'm3u.png',
rec: '',
},
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
epgParams: '',
},
epgPrograms: [currentProgram],
});
streamResolver.loadM3uProgramsForItem.mockResolvedValue([
currentProgram,
]);
recentData.recordLivePlayback.mockResolvedValue({
...item,
viewedAt: '2026-03-26T12:00:00.000Z',
});
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
await fixture.whenStable();
await component.onChannelSelected(component.channelsForList()[0]);
fixture.detectChanges();
await fixture.whenStable();
const panel = fixture.debugElement.query(
By.directive(StubLiveEpgPanelComponent)
).componentInstance as StubLiveEpgPanelComponent;
const epgList = fixture.debugElement.query(
By.directive(StubEpgListComponent)
).componentInstance as StubEpgListComponent;
expect(panel.showDateNavigator()).toBe(true);
expect(panel.summary()).toEqual(
expect.objectContaining({ title: 'M3U Now' })
);
expect(epgList.showDateNavigator()).toBe(false);
expect(epgList.selectedDate()).toBe(getTodayEpgDateKey());
panel.dateNavigation.emit('next');
fixture.detectChanges();
const nextDate = shiftEpgDateKey(getTodayEpgDateKey(), 'next');
expect(component.selectedLiveEpgDate()).toBe(nextDate);
expect(epgList.selectedDate()).toBe(nextDate);
});
it('wraps inline portal EPG in the live panel without date navigation', async () => {
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
const item = buildLiveItem('xtream');
streamResolver.resolveLiveDetail.mockResolvedValue({
epgMode: 'portal',
playback: {
streamUrl: 'https://example.com/xtream.m3u8',
title: 'Xtream Live',
},
epgItems: [buildCurrentEpgItem('Xtream Now')],
});
recentData.recordLivePlayback.mockResolvedValue({
...item,
viewedAt: '2026-03-26T12:00:00.000Z',
});
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
await fixture.whenStable();
await component.onChannelSelected(component.channelsForList()[0]);
fixture.detectChanges();
await fixture.whenStable();
const panel = fixture.debugElement.query(
By.directive(StubLiveEpgPanelComponent)
).componentInstance as StubLiveEpgPanelComponent;
expect(panel.showDateNavigator()).toBe(false);
expect(panel.summary()).toEqual(
expect.objectContaining({ title: 'Xtream Now' })
);
expect(
fixture.nativeElement.querySelector('app-epg-view')
).not.toBeNull();
panel.collapsedChange.emit(true);
fixture.detectChanges();
expect(component.isLiveEpgPanelCollapsed()).toBe(true);
expect(localStorage.getItem('live-epg-panel-state')).toBe('collapsed');
});
it('does not wait for M3U program lookup before opening playback', async () => {
const item = buildLiveItem('m3u');
const pendingPrograms = createDeferred<EpgProgram[]>();
@@ -551,6 +692,18 @@ function buildProgram(title: string): EpgProgram {
};
}
function buildCurrentProgram(title: string): EpgProgram {
const now = Date.now();
return {
start: new Date(now - 10 * 60 * 1000).toISOString(),
stop: new Date(now + 10 * 60 * 1000).toISOString(),
channel: 'test-channel',
title,
desc: `${title} description`,
category: null,
};
}
function buildEpgItem(title: string): EpgItem {
return {
id: '1',
@@ -567,6 +720,26 @@ function buildEpgItem(title: string): EpgItem {
};
}
function buildCurrentEpgItem(title: string): EpgItem {
const now = Date.now();
const start = now - 10 * 60 * 1000;
const stop = now + 10 * 60 * 1000;
return {
id: '1',
epg_id: '',
title,
description: `${title} description`,
lang: '',
start: new Date(start).toISOString(),
end: new Date(stop).toISOString(),
stop: new Date(stop).toISOString(),
channel_id: '1',
start_timestamp: String(Math.floor(start / 1000)),
stop_timestamp: String(Math.floor(stop / 1000)),
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;