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 (!shouldUseInlinePlayer()) {
{{
@@ -108,19 +116,55 @@
}
-
- @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;