fix(m3u): highlight active archive epg program (#1097)

fix(m3u): highlight active archive epg program

* Store the active archive EPG program with the resolved playback URL so the clicked M3U catch-up row stays highlighted during archive playback.
* Show archive playback state and return-to-live controls in the inline player panel.
* Hide duplicate EPG program rows that share the same time slot while keeping the richer provider entry.
This commit is contained in:
4gray authored and GitHub committed 2026-06-28 14:34:51 +02:00
1 parent a84090026b
commit 3cb228d931
16 files changed
+372 -20

No files matched your search

+4 -1
View File
@@ -55,7 +55,10 @@ export const EpgActions = createActionGroup({
source: 'EPG',
events: {
'Set Active Epg Program': props<{ program: EpgProgram }>(),
'Set Active Playback Url': props<{ playbackUrl: string }>(),
'Set Active Playback Url': props<{
playbackUrl: string;
program?: EpgProgram;
}>(),
'Set Current Epg Program': props<{ program: EpgProgram }>(),
'Reset Active Epg Program': emptyProps(),
'Return To Live Playback': emptyProps(),
+4 -1
View File
@@ -118,7 +118,10 @@ export class PlaylistEffects {
: null;
return playbackUrl
? EpgActions.setActivePlaybackUrl({ playbackUrl })
? EpgActions.setActivePlaybackUrl({
playbackUrl,
program: action.program,
})
: EpgActions.resetActiveEpgProgram();
})
);
@@ -1,5 +1,5 @@
import { createReducer } from '@ngrx/store';
import { Channel } from '@iptvnator/shared/interfaces';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { ChannelActions } from '../actions';
import { initialState } from '../state';
import { channelReducers } from './channel.reducers';
@@ -27,6 +27,17 @@ describe('channelReducers', () => {
url: 'https://example.com/live.m3u8',
} as Channel;
const archivedProgram: EpgProgram = {
channel: 'sample-tvg-id',
start: '2026-06-28T09:00:00.000Z',
stop: '2026-06-28T10:00:00.000Z',
title: 'Archived Show',
desc: null,
category: null,
startTimestamp: 1782637200,
stopTimestamp: 1782640800,
};
it('tracks explicit channel loading state', () => {
const nextState = reducer(
initialState,
@@ -50,4 +61,18 @@ describe('channelReducers', () => {
expect(nextState.channels).toEqual([sampleChannel]);
expect(nextState.channelsLoading).toBe(false);
});
it('clears archive playback state when a new channel becomes active', () => {
const nextState = reducer(
{
...initialState,
activePlaybackUrl: 'https://archive.example.com/catchup.m3u8',
activeEpgProgram: archivedProgram,
},
ChannelActions.setActiveChannelSuccess({ channel: sampleChannel })
);
expect(nextState.activePlaybackUrl).toBeNull();
expect(nextState.activeEpgProgram).toBeUndefined();
});
});
@@ -18,6 +18,7 @@ export const channelReducers = [
...state,
active: { ...channel, epgParams: '' } as Channel,
activePlaybackUrl: null,
activeEpgProgram: undefined,
};
}
),
@@ -26,6 +27,7 @@ export const channelReducers = [
...state,
active: undefined,
activePlaybackUrl: null,
activeEpgProgram: undefined,
};
}),
on(ChannelActions.setChannels, (state, action): PlaylistState => {
@@ -1,4 +1,5 @@
import { createReducer } from '@ngrx/store';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { EpgActions } from '../actions';
import { initialState } from '../state';
import { epgReducers } from './epg.reducers';
@@ -6,6 +7,17 @@ import { epgReducers } from './epg.reducers';
const reducer = createReducer(initialState, ...epgReducers);
describe('epgReducers', () => {
const archivedProgram: EpgProgram = {
channel: 'sample-tv',
start: '2026-06-28T09:00:00.000Z',
stop: '2026-06-28T10:00:00.000Z',
title: 'Archived Show',
desc: null,
category: null,
startTimestamp: 1782637200,
stopTimestamp: 1782640800,
};
it('does not recreate the active channel when epg params are already empty', () => {
const state = {
...initialState,
@@ -108,4 +120,52 @@ describe('epgReducers', () => {
);
expect(nextState.active?.url).toBe('https://example.com/live.m3u8');
});
it('stores the active archive program with the resolved playback url', () => {
const nextState = reducer(
initialState,
EpgActions.setActivePlaybackUrl({
playbackUrl: 'https://archive.example.com/catchup.m3u8',
program: archivedProgram,
})
);
expect(nextState.activePlaybackUrl).toBe(
'https://archive.example.com/catchup.m3u8'
);
expect(nextState.activeEpgProgram).toEqual(archivedProgram);
});
it('clears the active archive program when returning to live playback', () => {
const state = {
...initialState,
active: {
id: 'channel-1',
name: 'ARD-alpha',
url: 'https://example.com/live.m3u8',
tvg: {
id: '',
name: 'ARD-alpha',
url: '',
logo: '',
rec: '',
},
group: { title: '' },
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
epgParams: '',
},
activePlaybackUrl: 'https://archive.example.com/catchup.m3u8',
activeEpgProgram: archivedProgram,
};
const nextState = reducer(state, EpgActions.returnToLivePlayback());
expect(nextState.activePlaybackUrl).toBeNull();
expect(nextState.activeEpgProgram).toBeUndefined();
});
});
@@ -7,22 +7,32 @@ export const epgReducers = [
on(EpgActions.setActivePlaybackUrl, (state, action): PlaylistState => ({
...state,
activePlaybackUrl: action.playbackUrl,
activeEpgProgram: action.program,
})),
on(
EpgActions.resetActiveEpgProgram,
EpgActions.returnToLivePlayback,
(state): PlaylistState => {
if (!state.active && !state.activePlaybackUrl) {
if (
!state.active &&
!state.activePlaybackUrl &&
!state.activeEpgProgram
) {
return state;
}
if (!state.activePlaybackUrl && !state.active?.epgParams) {
if (
!state.activePlaybackUrl &&
!state.activeEpgProgram &&
!state.active?.epgParams
) {
return state;
}
return {
...state,
activePlaybackUrl: null,
activeEpgProgram: undefined,
active: state.active
? ({ ...state.active, epgParams: '' } as Channel)
: undefined,
+2
View File
@@ -25,6 +25,8 @@ export const selectIsEpgAvailableReducer = (state: PlaylistState) =>
export const selectActiveReducer = (state: PlaylistState) => state.active;
export const selectActivePlaybackUrlReducer = (state: PlaylistState) =>
state.activePlaybackUrl;
export const selectActiveEpgProgramReducer = (state: PlaylistState) =>
state.activeEpgProgram;
export const selectCurrentEpgProgramReducer = (state: PlaylistState) =>
state.currentEpgProgram;
export const selectChannelsLoadingReducer = (state: PlaylistState) =>
+22 -1
View File
@@ -1,6 +1,10 @@
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { PlaylistState } from './state';
import { selectActivePlaylist, selectChannelsLoading } from './selectors';
import {
selectActiveEpgProgram,
selectActivePlaylist,
selectChannelsLoading,
} from './selectors';
describe('selectors', () => {
const playlistOne = {
@@ -42,4 +46,21 @@ describe('selectors', () => {
).toBe(false);
});
});
describe('selectActiveEpgProgram', () => {
it('returns the active archive EPG program', () => {
const program = {
channel: 'sample-tvg-id',
start: '2026-06-28T09:00:00.000Z',
stop: '2026-06-28T10:00:00.000Z',
title: 'Archived Show',
};
expect(
selectActiveEpgProgram.projector({
activeEpgProgram: program,
} as PlaylistState)
).toBe(program);
});
});
});
+5
View File
@@ -22,6 +22,11 @@ export const selectActivePlaybackUrl = createSelector(
fromPlaylistState.selectActivePlaybackUrlReducer
);
export const selectActiveEpgProgram = createSelector(
selectPlaylistState,
fromPlaylistState.selectActiveEpgProgramReducer
);
export const selectCurrentEpgProgram = createSelector(
selectPlaylistState,
fromPlaylistState.selectCurrentEpgProgramReducer
+2
View File
@@ -6,6 +6,7 @@ import { EpgProgram } from '@iptvnator/shared/interfaces';
export interface PlaylistState {
active: Channel | undefined;
activePlaybackUrl: string | null;
activeEpgProgram: EpgProgram | undefined;
currentEpgProgram: EpgProgram | undefined;
epgAvailable: boolean;
channelsLoading: boolean;
@@ -16,6 +17,7 @@ export interface PlaylistState {
export const initialState: PlaylistState = {
active: undefined,
activePlaybackUrl: null,
activeEpgProgram: undefined,
currentEpgProgram: undefined,
epgAvailable: false,
channelsLoading: false,
@@ -101,15 +101,19 @@
<app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
[showDateNavigator]="true"
[selectedDate]="selectedLiveEpgDate()"
[showReturnToLive]="showReturnToLive()"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
(dateNavigation)="onLiveEpgDateNavigation($event)"
(returnToLive)="returnToLivePlayback()"
>
<div class="epg-content">
<app-epg-list
[activeProgram]="activeEpgProgramOrNull()"
[selectedDate]="selectedLiveEpgDate()"
[showDateNavigator]="false"
[archivePlaybackAvailable]="
@@ -124,6 +128,7 @@
} @else {
<div class="epg-content">
<app-epg-list
[activeProgram]="activeEpgProgramOrNull()"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
@@ -17,7 +17,9 @@ import { MockPipe } from 'ng-mocks';
import { BehaviorSubject, of } from 'rxjs';
import {
ChannelActions,
EpgActions,
selectActive,
selectActiveEpgProgram,
selectActivePlaybackUrl,
selectChannels,
selectChannelsLoading,
@@ -66,10 +68,13 @@ class StubLiveEpgPanelComponent {
readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly loading = input(false);
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
readonly showDateNavigator = input(false);
readonly selectedDate = input<string | null>(null);
readonly showReturnToLive = input(false);
readonly collapsedChange = output<boolean>();
readonly dateNavigation = output<'next' | 'prev'>();
readonly returnToLive = output<void>();
}
@Component({
@@ -141,6 +146,7 @@ class StubWebPlayerViewComponent {
template: '',
})
class StubEpgListComponent {
readonly activeProgram = input<EpgProgram | null>(null);
readonly selectedDate = input<string | null>(null);
readonly showDateNavigator = input(false);
readonly archivePlaybackAvailable = input(false);
@@ -172,6 +178,7 @@ describe('VideoPlayerComponent', () => {
const channels = signal<Channel[]>([]);
const channelsLoading = signal(false);
const currentEpgProgram = signal<EpgProgram | null>(null);
const activeEpgProgram = signal<EpgProgram | null>(null);
const channels$ = new BehaviorSubject<Channel[]>([]);
const activeChannel$ = new BehaviorSubject<Channel | null>(null);
@@ -211,6 +218,8 @@ describe('VideoPlayerComponent', () => {
return channelsLoading;
case selectCurrentEpgProgram:
return currentEpgProgram;
case selectActiveEpgProgram:
return activeEpgProgram;
default:
return signal(null);
}
@@ -302,6 +311,7 @@ describe('VideoPlayerComponent', () => {
activePlaybackUrl.set(null);
channelsLoading.set(false);
currentEpgProgram.set(null);
activeEpgProgram.set(null);
currentEpgProgram$.next(null);
overlayMock.create.mockClear();
overlayRef.attach.mockClear();
@@ -664,11 +674,80 @@ describe('VideoPlayerComponent', () => {
expect(component.playbackChannel()?.url).toBe(
'http://localhost/archive.m3u8?utc=123&lutc=456'
);
expect(component.embeddedPlayback()?.isLive).toBe(false);
activePlaybackUrl.set(null);
fixture.detectChanges();
expect(component.playbackChannel()?.url).toBe(sampleChannel.url);
expect(component.embeddedPlayback()?.isLive).toBe(true);
});
it('passes the active archive EPG program to the EPG list for highlighting', () => {
const archivedProgram: EpgProgram = {
channel: 'sample-tvg-id',
start: '2026-06-28T09:00:00.000Z',
stop: '2026-06-28T10:00:00.000Z',
title: 'Archived Show',
desc: null,
category: null,
startTimestamp: 1782637200,
stopTimestamp: 1782640800,
};
syncStoreState(sampleChannel);
player.set(VideoPlayer.VideoJs);
activePlaybackUrl.set('http://localhost/archive.m3u8?utc=123&lutc=456');
activeEpgProgram.set(archivedProgram);
fixture.detectChanges();
const epgList = fixture.debugElement.query(
By.directive(StubEpgListComponent)
);
expect(epgList.componentInstance.activeProgram()).toEqual(
archivedProgram
);
});
it('shows the active archive EPG program in the inline panel summary', () => {
const archivedProgram = buildProgram('Archived Show');
syncStoreState(sampleChannel);
player.set(VideoPlayer.VideoJs);
activePlaybackUrl.set('http://localhost/archive.m3u8?utc=123&lutc=456');
activeEpgProgram.set(archivedProgram);
fixture.detectChanges();
const panel = fixture.debugElement.query(
By.directive(StubLiveEpgPanelComponent)
);
expect(panel.componentInstance.summary()).toEqual(
expect.objectContaining({ title: 'Archived Show' })
);
expect(panel.componentInstance.summaryLabelKey()).toBe(
'EPG.ARCHIVE_PLAYBACK'
);
expect(panel.componentInstance.showReturnToLive()).toBe(true);
});
it('dispatches return-to-live from the inline EPG panel', () => {
syncStoreState(sampleChannel);
player.set(VideoPlayer.VideoJs);
activePlaybackUrl.set('http://localhost/archive.m3u8?utc=123&lutc=456');
activeEpgProgram.set(buildProgram('Archived Show'));
fixture.detectChanges();
const panel = fixture.debugElement.query(
By.directive(StubLiveEpgPanelComponent)
);
panel.componentInstance.returnToLive.emit();
expect(storeMock.dispatch).toHaveBeenCalledWith(
EpgActions.returnToLivePlayback()
);
});
it('updates the outer sidebar width while grouped view requests a larger total width', () => {
@@ -33,9 +33,11 @@ import {
} from '@iptvnator/ui/epg';
import {
ChannelActions,
EpgActions,
PlaylistActions,
buildExternalPlayerPayload,
selectActive,
selectActiveEpgProgram,
selectActivePlaybackUrl,
selectChannels,
selectChannelsLoading,
@@ -150,6 +152,12 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
readonly activePlaybackUrl = this.store.selectSignal(
selectActivePlaybackUrl
);
readonly activeEpgProgram = this.store.selectSignal(
selectActiveEpgProgram
);
readonly activeEpgProgramOrNull = computed(
() => this.activeEpgProgram() ?? null
);
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
readonly channels = this.store.selectSignal(selectChannels);
readonly channelsLoading = this.store.selectSignal(selectChannelsLoading);
@@ -200,7 +208,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
activeChannel.tvg?.name ||
playbackTarget.url,
thumbnail: activeChannel.tvg?.logo ?? null,
isLive: true,
isLive: !this.activePlaybackUrl(),
headers: Object.keys(headers).length > 0 ? headers : undefined,
userAgent: http['user-agent'] || undefined,
referer: http.referrer || undefined,
@@ -237,7 +245,17 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
/** Current epg program */
readonly epgProgram = this.store.selectSignal(selectCurrentEpgProgram);
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(this.epgProgram())
this.toLiveEpgPanelSummary(
this.activeEpgProgramOrNull() ?? this.epgProgram()
)
);
readonly liveEpgPanelSummaryLabelKey = computed(() =>
this.activeEpgProgramOrNull()
? 'EPG.ARCHIVE_PLAYBACK'
: 'EPG.CURRENT_PROGRAM'
);
readonly showReturnToLive = computed(
() => this.activeEpgProgramOrNull() !== null
);
/** Active M3U view (all, groups, favorites, recent) */
@@ -539,6 +557,10 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
this.selectedLiveEpgDate.set(selectedDate);
}
returnToLivePlayback(): void {
this.store.dispatch(EpgActions.returnToLivePlayback());
}
/**
* Opens a playlist provided as a url param
* e.g. iptvnat.or?url=http://...
@@ -321,6 +321,62 @@ describe('EpgListComponent', () => {
}
});
it('shows one row per duplicated time slot and keeps the richer program entry', () => {
const sparseProgram = buildProgram(
'duplicate-a',
'World Cup Breakfast',
'2026-04-05T09:00:00.000Z',
'2026-04-05T10:00:00.000Z',
{
channel: 'channel-101',
desc: null,
}
);
const richProgram = buildProgram(
'duplicate-b',
'World Cup Breakfast',
'2026-04-05T09:00:00.000Z',
'2026-04-05T10:00:00.000Z',
{
channel: 'channel-101',
desc: 'A preview of the day ahead.',
category: 'Sports',
}
);
const nextProgram = buildProgram(
'next',
'World Cup Matchday',
'2026-04-05T10:00:00.000Z',
'2026-04-05T11:00:00.000Z',
{
channel: 'channel-101',
}
);
const emitted: unknown[] = [];
component.programActivated.subscribe((event) => emitted.push(event));
fixture.componentRef.setInput('controlledPrograms', [
sparseProgram,
richProgram,
nextProgram,
]);
fixture.componentRef.setInput('archivePlaybackAvailable', true);
fixture.detectChanges();
const rows =
fixture.nativeElement.querySelectorAll<HTMLElement>(
'.program-item'
);
rows[0].click();
expect(component.filteredItems()).toEqual([
richProgram,
nextProgram,
]);
expect(rows).toHaveLength(2);
expect(emitted).toEqual([{ program: richProgram, type: 'timeshift' }]);
});
it('updates the selected-day header when the app language changes', () => {
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
fixture.detectChanges();
@@ -389,7 +445,8 @@ function buildProgram(
channelSuffix: string,
title: string,
start: string,
stop: string
stop: string,
overrides: Partial<EpgProgram> = {}
): EpgProgram {
return {
start,
@@ -400,6 +457,7 @@ function buildProgram(
category: null,
startTimestamp: Math.floor(Date.parse(start) / 1000),
stopTimestamp: Math.floor(Date.parse(stop) / 1000),
...overrides,
};
}
@@ -32,6 +32,7 @@ import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
import {
areProgramsSame,
buildScrollContextKey,
deduplicateProgramsByTimeSlot,
getProgramDateKey,
getProgramTimeMs,
trackProgram,
@@ -144,17 +145,19 @@ export class EpgListComponent {
() => this.archivePlaybackAvailable() ?? this.archiveDays() > 0
);
readonly filteredItems = computed(() =>
[...this.items()]
.filter(
(item) =>
getProgramDateKey(item.start, item.startTimestamp) ===
this.selectedDateKey()
)
.sort(
(left, right) =>
getProgramTimeMs(left.start, left.startTimestamp) -
getProgramTimeMs(right.start, right.startTimestamp)
)
deduplicateProgramsByTimeSlot(
[...this.items()]
.filter(
(item) =>
getProgramDateKey(item.start, item.startTimestamp) ===
this.selectedDateKey()
)
.sort(
(left, right) =>
getProgramTimeMs(left.start, left.startTimestamp) -
getProgramTimeMs(right.start, right.startTimestamp)
)
)
);
private scrollScheduled = false;
@@ -15,6 +15,26 @@ export function trackProgram(index: number, program: EpgProgram): string {
].join('|');
}
export function deduplicateProgramsByTimeSlot(
programs: EpgProgram[]
): EpgProgram[] {
const programsByTimeSlot = new Map<string, EpgProgram>();
for (const program of programs) {
const timeSlotKey = buildProgramTimeSlotKey(program);
const existingProgram = programsByTimeSlot.get(timeSlotKey);
programsByTimeSlot.set(
timeSlotKey,
existingProgram
? selectMoreInformativeProgram(existingProgram, program)
: program
);
}
return Array.from(programsByTimeSlot.values());
}
export function buildScrollContextKey(
channel: Channel | null,
programs: EpgProgram[]
@@ -71,3 +91,35 @@ export function areProgramsSame(
getProgramTimeMs(right.stop, right.stopTimestamp)
);
}
function buildProgramTimeSlotKey(program: EpgProgram): string {
return [
getProgramTimeMs(program.start, program.startTimestamp),
getProgramTimeMs(program.stop, program.stopTimestamp),
].join('|');
}
function selectMoreInformativeProgram(
existingProgram: EpgProgram,
candidateProgram: EpgProgram
): EpgProgram {
return getProgramMetadataScore(candidateProgram) >
getProgramMetadataScore(existingProgram)
? candidateProgram
: existingProgram;
}
function getProgramMetadataScore(program: EpgProgram): number {
return (
getTextScore(program.desc) * 8 +
getTextScore(program.category) * 4 +
getTextScore(program.iconUrl) * 2 +
getTextScore(program.rating) * 2 +
getTextScore(program.episodeNum) * 2 +
getTextScore(program.title)
);
}
function getTextScore(value: string | null | undefined): number {
return value?.trim() ? 1 : 0;
}