From 0babd92304200309a25ee987b191b2f4b846bfa6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 29 Apr 2026 01:23:42 +0200 Subject: [PATCH] feat: add live epg panel to collections Entire-Checkpoint: c2cea9c530e6 --- docs/architecture/iptvnator-ui-guidelines.md | 5 +- .../unified-live-tab.component.html | 74 ++++++-- .../unified-live-tab.component.ts | 149 ++++++++++++++- .../unified-live-tab.component.spec.ts | 177 +++++++++++++++++- 4 files changed, 381 insertions(+), 24 deletions(-) diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index da6637338..429d7559b 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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 diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 4ea757eb9..38737eb8e 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -23,7 +23,10 @@
@if (activeDetail()) { @if (shouldUseInlinePlayer()) { -
+
@if (isRadioSelection()) {
} -
- @if (isM3uSelection()) { - - } @else { - - } -
+ @if (shouldUseInlinePlayer()) { + +
+ @if (isM3uSelection()) { + + } @else { + + } +
+
+ } @else { +
+ @if (isM3uSelection()) { + + } @else { + + } +
+ }
} } @else { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index e742fa4b7..574bf08aa 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -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>(new Map()); readonly progressTick = signal(0); + readonly liveEpgPanelState = signal( + 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; + } } diff --git a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts index 1bc2b286a..610b8dbb8 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts @@ -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(null); readonly controlledPrograms = input(null); readonly archivePlaybackAvailable = input(null); + readonly selectedDate = input(null); + readonly showDateNavigator = input(true); + readonly selectedDateChange = output(); } @Component({ @@ -84,6 +97,21 @@ class StubEpgViewComponent { readonly epgItems = input([]); } +@Component({ + selector: 'app-live-epg-panel', + template: '
', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class StubLiveEpgPanelComponent { + readonly collapsed = input(false); + readonly summary = input(null); + readonly loading = input(false); + readonly showDateNavigator = input(false); + readonly selectedDate = input(null); + readonly collapsedChange = output(); + readonly dateNavigation = output(); +} + @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(); @@ -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() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void;