mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
a84090026b
commit
3cb228d931
16 files changed
+372
-20
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,11 @@ export const selectActivePlaybackUrl = createSelector(
|
||||
fromPlaylistState.selectActivePlaybackUrlReducer
|
||||
);
|
||||
|
||||
export const selectActiveEpgProgram = createSelector(
|
||||
selectPlaylistState,
|
||||
fromPlaylistState.selectActiveEpgProgramReducer
|
||||
);
|
||||
|
||||
export const selectCurrentEpgProgram = createSelector(
|
||||
selectPlaylistState,
|
||||
fromPlaylistState.selectCurrentEpgProgramReducer
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user