refactor: improve archive playback support and enhance EPG item display

This commit is contained in:
4gray committed 2026-04-10 07:53:48 +02:00
1 parent c2768ef687
commit 4005687f18
24 files changed
+775 -132

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
export * from './lib/actions';
export * from './lib/effects';
export * from './lib/external-player-payload.util';
export * from './lib/reducers';
export * from './lib/router.selectors';
export * from './lib/selectors';
+2
View File
@@ -47,8 +47,10 @@ export const EpgActions = createActionGroup({
source: 'EPG',
events: {
'Set Active Epg Program': props<{ program: EpgProgram }>(),
'Set Active Playback Url': props<{ playbackUrl: string }>(),
'Set Current Epg Program': props<{ program: EpgProgram }>(),
'Reset Active Epg Program': emptyProps(),
'Return To Live Playback': emptyProps(),
'Set Epg Available Flag': props<{ value: boolean }>(),
},
});
+45
View File
@@ -0,0 +1,45 @@
import { Channel } from 'shared-interfaces';
import { buildExternalPlayerPayload } from './external-player-payload.util';
describe('buildExternalPlayerPayload', () => {
const activeChannel: Channel = {
id: 'channel-1',
name: 'Sample TV',
url: 'https://streams.example.com/live.m3u8',
group: { title: 'News' },
tvg: {
id: 'sample-tv',
name: 'Sample TV',
url: '',
logo: '',
rec: '3',
},
catchup: {
type: 'shift',
days: '3',
},
timeshift: '3',
http: {
referrer: 'https://referrer.example.com',
'user-agent': 'Codex Test Agent',
origin: 'https://origin.example.com',
},
radio: 'false',
epgParams: '',
};
it('uses the resolved archive url while preserving headers and title', () => {
expect(
buildExternalPlayerPayload(
activeChannel,
'https://archive.example.com/replay.m3u8?utc=123&lutc=456'
)
).toEqual({
url: 'https://archive.example.com/replay.m3u8?utc=123&lutc=456',
title: 'Sample TV',
'user-agent': 'Codex Test Agent',
referer: 'https://referrer.example.com',
origin: 'https://origin.example.com',
});
});
});
+68 -36
View File
@@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { EpgService } from '@iptvnator/epg/data-access';
import { resolveM3uCatchupUrl } from 'm3u-utils';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
@@ -19,6 +20,7 @@ import { DataService, PlaylistsService } from 'services';
import {
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
Channel,
Playlist,
STORE_KEY,
VideoPlayer,
@@ -35,6 +37,7 @@ import {
selectChannels,
selectFavorites,
} from './selectors';
import { buildExternalPlayerPayload } from './external-player-payload.util';
@Injectable({ providedIn: 'any' })
export class PlaylistEffects {
@@ -91,45 +94,48 @@ export class PlaylistEffects {
}
);
setActiveEpgProgram$ = createEffect(
resolveActiveEpgProgram$ = createEffect(() => {
return this.actions$.pipe(
ofType(EpgActions.setActiveEpgProgram),
withLatestFrom(this.store.select(selectActive)),
map(([action, activeChannel]) => {
const playbackUrl = activeChannel
? resolveM3uCatchupUrl(activeChannel, action.program)
: null;
return playbackUrl
? EpgActions.setActivePlaybackUrl({ playbackUrl })
: EpgActions.resetActiveEpgProgram();
})
);
});
openArchivedPlayback$ = createEffect(
() => {
return this.actions$.pipe(
ofType(EpgActions.setActiveEpgProgram),
ofType(EpgActions.setActivePlaybackUrl),
withLatestFrom(this.store.select(selectActive)),
map(([, activeChannel]) => {
firstValueFrom(this.storage.get(STORE_KEY.Settings)).then(
(settings: any) => {
if (
settings &&
Object.keys(settings).length > 0 &&
settings.player === VideoPlayer.MPV
)
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url:
activeChannel?.url +
(activeChannel?.epgParams ?? ''),
title: activeChannel?.name ?? '',
'user-agent':
activeChannel?.http?.['user-agent'],
referer: activeChannel?.http?.referrer,
origin: activeChannel?.http?.origin,
});
else if (
settings &&
Object.keys(settings).length > 0 &&
settings.player === VideoPlayer.VLC
)
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url:
activeChannel?.url +
(activeChannel?.epgParams ?? ''),
title: activeChannel?.name ?? '',
'user-agent':
activeChannel?.http?.['user-agent'],
referer: activeChannel?.http?.referrer,
origin: activeChannel?.http?.origin,
});
}
tap(([action, activeChannel]) => {
void this.openWithConfiguredExternalPlayer(
action.playbackUrl,
activeChannel
);
})
);
},
{ dispatch: false }
);
returnToLivePlayback$ = createEffect(
() => {
return this.actions$.pipe(
ofType(EpgActions.returnToLivePlayback),
withLatestFrom(this.store.select(selectActive)),
filter(([, activeChannel]) => Boolean(activeChannel?.url)),
tap(([, activeChannel]) => {
void this.openWithConfiguredExternalPlayer(
activeChannel?.url ?? '',
activeChannel
);
})
);
@@ -212,6 +218,32 @@ export class PlaylistEffects {
);
});
private async openWithConfiguredExternalPlayer(
playbackUrl: string,
activeChannel: Channel | undefined | null
): Promise<void> {
const payload = buildExternalPlayerPayload(activeChannel, playbackUrl);
if (!payload) {
return;
}
const settings: any = await firstValueFrom(
this.storage.get(STORE_KEY.Settings)
);
if (!settings || Object.keys(settings).length === 0) {
return;
}
if (settings.player === VideoPlayer.MPV) {
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, payload);
return;
}
if (settings.player === VideoPlayer.VLC) {
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, payload);
}
}
removePlaylist$ = createEffect(
() => {
return this.actions$.pipe(
@@ -0,0 +1,26 @@
import { Channel } from 'shared-interfaces';
export function buildExternalPlayerPayload(
activeChannel: Channel | undefined | null,
playbackUrl: string
):
| {
url: string;
title: string;
'user-agent': string | undefined;
referer: string | undefined;
origin: string | undefined;
}
| null {
if (!playbackUrl || !activeChannel) {
return null;
}
return {
url: playbackUrl,
title: activeChannel.name ?? '',
'user-agent': activeChannel.http?.['user-agent'],
referer: activeChannel.http?.referrer,
origin: activeChannel.http?.origin,
};
}
@@ -11,6 +11,7 @@ export const channelReducers = [
return {
...state,
active: { ...channel, epgParams: '' } as Channel,
activePlaybackUrl: null,
};
}
),
@@ -18,6 +19,7 @@ export const channelReducers = [
return {
...state,
active: undefined,
activePlaybackUrl: null,
};
}),
on(ChannelActions.setChannels, (state, action): PlaylistState => {
@@ -29,6 +29,7 @@ describe('epgReducers', () => {
radio: 'false',
epgParams: '',
},
activePlaybackUrl: null,
};
const nextState = reducer(state, EpgActions.resetActiveEpgProgram());
@@ -59,11 +60,52 @@ describe('epgReducers', () => {
radio: 'false',
epgParams: '?utc=123&lutc=456',
},
activePlaybackUrl: 'https://archive.example.com/catchup.m3u8',
};
const nextState = reducer(state, EpgActions.resetActiveEpgProgram());
expect(nextState).not.toBe(state);
expect(nextState.active?.epgParams).toBe('');
expect(nextState.activePlaybackUrl).toBeNull();
});
it('stores the resolved playback url without mutating the active channel', () => {
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: null,
};
const nextState = reducer(
state,
EpgActions.setActivePlaybackUrl({
playbackUrl: 'https://archive.example.com/catchup.m3u8',
})
);
expect(nextState.activePlaybackUrl).toBe(
'https://archive.example.com/catchup.m3u8'
);
expect(nextState.active?.url).toBe('https://example.com/live.m3u8');
});
});
+11 -46
View File
@@ -3,64 +3,29 @@ import { Channel } from 'shared-interfaces';
import { EpgActions } from '../actions';
import { PlaylistState } from '../state';
function toUnixTimestamp(value: string): number {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) {
return Math.floor(parsed / 1000);
}
const match = value.match(
/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{2})(\d{2})$/
);
if (!match) {
return Math.floor(Date.now() / 1000);
}
const [, year, month, day, hour, minute, offsetHours, offsetMinutes] =
match;
const utcMillis = Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute)
);
const offsetTotalMinutes =
Number(offsetHours) * 60 +
Math.sign(Number(offsetHours)) * Number(offsetMinutes);
return Math.floor(
(utcMillis - offsetTotalMinutes * 60_000) / 1000
);
}
export const epgReducers = [
on(EpgActions.setActiveEpgProgram, (state, action): PlaylistState => {
const { program } = action;
const from = toUnixTimestamp(program.start);
const now = Math.floor(Date.now() / 1000);
const epgParams = `?utc=${from}&lutc=${now}`;
return {
...state,
active: state.active
? ({ ...state.active, epgParams } as Channel)
: undefined,
};
}),
on(EpgActions.setActivePlaybackUrl, (state, action): PlaylistState => ({
...state,
activePlaybackUrl: action.playbackUrl,
})),
on(
EpgActions.resetActiveEpgProgram,
EpgActions.returnToLivePlayback,
(state): PlaylistState => {
if (!state.active) {
if (!state.active && !state.activePlaybackUrl) {
return state;
}
if (!state.active.epgParams) {
if (!state.activePlaybackUrl && !state.active?.epgParams) {
return state;
}
return {
...state,
active: { ...state.active, epgParams: '' } as Channel,
activePlaybackUrl: null,
active: state.active
? ({ ...state.active, epgParams: '' } as Channel)
: undefined,
};
}
),
+2
View File
@@ -23,6 +23,8 @@ export const selectPlaylistId = (state: PlaylistState) =>
export const selectIsEpgAvailableReducer = (state: PlaylistState) =>
state.epgAvailable;
export const selectActiveReducer = (state: PlaylistState) => state.active;
export const selectActivePlaybackUrlReducer = (state: PlaylistState) =>
state.activePlaybackUrl;
export const selectCurrentEpgProgramReducer = (state: PlaylistState) =>
state.currentEpgProgram;
export const selectChannelsReducer = (state: PlaylistState) => state.channels;
+5
View File
@@ -17,6 +17,11 @@ export const selectActive = createSelector(
fromPlaylistState.selectActiveReducer
);
export const selectActivePlaybackUrl = createSelector(
selectPlaylistState,
fromPlaylistState.selectActivePlaybackUrlReducer
);
export const selectCurrentEpgProgram = createSelector(
selectPlaylistState,
fromPlaylistState.selectCurrentEpgProgramReducer
+2
View File
@@ -5,6 +5,7 @@ import { EpgProgram } from 'shared-interfaces';
export interface PlaylistState {
active: Channel | undefined;
activePlaybackUrl: string | null;
currentEpgProgram: EpgProgram | undefined;
epgAvailable: boolean;
channels: Channel[]; // TODO: use entity store
@@ -13,6 +14,7 @@ export interface PlaylistState {
export const initialState: PlaylistState = {
active: undefined,
activePlaybackUrl: null,
currentEpgProgram: undefined,
epgAvailable: false,
channels: [],
@@ -1,13 +1,13 @@
import { inject } from '@angular/core';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { DataService } from 'services';
import { StalkerSessionService } from '../../stalker-session.service';
import {
EpgItem,
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
interface EpgStoreContext {
currentPlaylist(): Playlist | undefined;
@@ -66,18 +66,18 @@ export function withStalkerEpg() {
} else {
response =
await dataService.sendIpcEvent<StalkerEpgResponse>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
const epgData = Array.isArray(response?.js)
? response.js
: response?.js?.data ?? [];
: (response?.js?.data ?? []);
const items = Array.isArray(epgData) ? epgData : [];
return items.map((item) => ({
@@ -12,7 +12,9 @@ export function createChannel(params: Partial<Channel>): Channel {
url: params.url,
tvg: params.tvg,
timeshift: params.timeshift,
catchup: params.catchup,
http: params.http,
radio: params.radio,
} as Channel;
}
@@ -26,4 +26,11 @@ export interface ParsedPlaylistItem {
};
url: string;
raw: string;
catchup?: {
type?: string;
source?: string;
days?: string;
};
timeshift?: string;
radio?: string;
}
+1
View File
@@ -1 +1,2 @@
export * from './lib/playlist.utils';
export * from './lib/catchup.utils';
@@ -0,0 +1,254 @@
import { Channel, EpgProgram } from 'shared-interfaces';
import {
getM3uArchiveDays,
isM3uCatchupPlaybackSupported,
resolveM3uCatchupUrl,
} from './catchup.utils';
import { createPlaylistObject } from './playlist.utils';
describe('catchup.utils', () => {
const baseChannel: Channel = {
id: 'channel-1',
name: 'News One',
url: 'https://streams.example.com/live/channel-1.m3u8',
group: { title: 'News' },
tvg: {
id: 'news-1',
name: 'News One',
url: '',
logo: '',
rec: '3',
},
catchup: {
type: 'shift',
days: '3',
},
timeshift: '3',
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
epgParams: '',
};
const archivedProgram: EpgProgram = {
start: '2026-04-10T08:00:00.000Z',
stop: '2026-04-10T09:00:00.000Z',
channel: 'news-1',
title: 'Morning News',
desc: null,
category: null,
startTimestamp: 1_775_808_800,
stopTimestamp: 1_775_812_400,
};
it('reports archive days from catchup metadata before legacy fields', () => {
expect(
getM3uArchiveDays({
...baseChannel,
catchup: { type: 'shift', days: '5' },
timeshift: '3',
tvg: { ...baseChannel.tvg, rec: '2' },
})
).toBe(5);
});
it('falls back to tvg-rec when catchup-days and timeshift are blank strings', () => {
expect(
getM3uArchiveDays({
...baseChannel,
catchup: { type: '', days: '' },
timeshift: '',
tvg: { ...baseChannel.tvg, rec: '7' },
})
).toBe(7);
});
it('supports legacy same-stream shift playback when catchup type is shift', () => {
expect(isM3uCatchupPlaybackSupported(baseChannel)).toBe(true);
expect(resolveM3uCatchupUrl(baseChannel, archivedProgram, 1_775_820_000))
.toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775808800&lutc=1775820000'
);
});
it('supports tvg-rec-only legacy same-stream playback when the channel url is reusable', () => {
const channel: Channel = {
...baseChannel,
tvg: {
...baseChannel.tvg,
rec: '7',
},
catchup: undefined,
timeshift: undefined,
};
expect(isM3uCatchupPlaybackSupported(channel)).toBe(true);
expect(resolveM3uCatchupUrl(channel, archivedProgram, 1_775_820_000))
.toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775808800&lutc=1775820000'
);
});
it('rewrites utc and lutc query params on catchup-source urls', () => {
const playbackUrl = resolveM3uCatchupUrl(
{
...baseChannel,
catchup: {
type: 'append',
days: '3',
source:
'https://archive.example.com/catchup.m3u8?utc=1&lutc=2&token=abc',
},
},
archivedProgram,
1_775_820_000
);
expect(playbackUrl).toBe(
'https://archive.example.com/catchup.m3u8?utc=1775808800&lutc=1775820000&token=abc'
);
});
it('returns null when the channel metadata declares an unsupported replay scheme', () => {
expect(
isM3uCatchupPlaybackSupported({
...baseChannel,
catchup: {
type: 'append',
days: '3',
source: '',
},
timeshift: '3',
})
).toBe(false);
expect(
resolveM3uCatchupUrl(
{
...baseChannel,
catchup: {
type: 'append',
days: '3',
source: '',
},
timeshift: '3',
},
archivedProgram
)
).toBeNull();
});
it('returns null for tvg-rec-only channels when the stream url is not http', () => {
expect(
isM3uCatchupPlaybackSupported({
...baseChannel,
url: 'udp://239.0.0.1:1234',
tvg: {
...baseChannel.tvg,
rec: '7',
},
catchup: undefined,
timeshift: undefined,
})
).toBe(false);
expect(
resolveM3uCatchupUrl(
{
...baseChannel,
url: 'udp://239.0.0.1:1234',
tvg: {
...baseChannel.tvg,
rec: '7',
},
catchup: undefined,
timeshift: undefined,
},
archivedProgram
)
).toBeNull();
});
it('returns null when archive days are missing', () => {
expect(
resolveM3uCatchupUrl(
{
...baseChannel,
tvg: { ...baseChannel.tvg, rec: '' },
timeshift: '',
catchup: {
type: 'shift',
days: '',
},
},
archivedProgram
)
).toBeNull();
});
it('falls back to parsing the program start string when the unix timestamp is unavailable', () => {
expect(
resolveM3uCatchupUrl(
baseChannel,
{
...archivedProgram,
start: '202604100800 +0000',
startTimestamp: null,
},
1_775_820_000
)
).toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775808000&lutc=1775820000'
);
});
it('preserves catchup metadata when storing parsed playlist items', () => {
const playlist = createPlaylistObject('Catchup playlist', {
header: {
attrs: {
'x-tvg-url': 'https://example.com/guide.xml',
},
raw: '#EXTM3U x-tvg-url="https://example.com/guide.xml"',
},
items: [
{
name: 'News One',
tvg: {
id: 'news-1',
name: 'News One',
url: '',
logo: '',
rec: '3',
},
group: { title: 'News' },
http: {
referrer: '',
'user-agent': '',
},
url: 'https://streams.example.com/live/channel-1.m3u8',
raw: '#EXTINF:-1,News One',
catchup: {
type: 'shift',
source: 'https://archive.example.com/catchup.m3u8',
days: '3',
},
timeshift: '3',
radio: 'false',
},
],
});
expect(playlist.playlist?.items[0]).toEqual(
expect.objectContaining({
catchup: {
type: 'shift',
source: 'https://archive.example.com/catchup.m3u8',
days: '3',
},
timeshift: '3',
radio: 'false',
})
);
});
});
@@ -0,0 +1,155 @@
import { Channel, EpgProgram } from 'shared-interfaces';
const XMLTV_TIMESTAMP_PATTERN =
/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{2})(\d{2})$/;
type CatchupSupportMode = 'none' | 'source' | 'shift';
export function getM3uArchiveDays(
channel: Pick<Channel, 'tvg' | 'timeshift' | 'catchup'> | null | undefined
): number {
const value = getFirstNonBlankValue(
channel?.catchup?.days,
channel?.timeshift,
channel?.tvg?.rec
);
return Math.max(0, Number(value ?? 0) || 0);
}
export function isM3uCatchupPlaybackSupported(
channel: Channel | null | undefined
): boolean {
return getM3uCatchupSupportMode(channel) !== 'none';
}
export function resolveM3uCatchupUrl(
channel: Channel | null | undefined,
program: Pick<EpgProgram, 'start' | 'startTimestamp'>,
nowTimestampSeconds = Math.floor(Date.now() / 1000)
): string | null {
const supportMode = getM3uCatchupSupportMode(channel);
if (supportMode === 'none') {
return null;
}
const startTimestamp = getEpgProgramTimestampSeconds(
program.start,
program.startTimestamp
);
if (startTimestamp === null) {
return null;
}
const playbackBaseUrl =
supportMode === 'source'
? channel?.catchup?.source
: channel?.url;
if (!playbackBaseUrl?.trim()) {
return null;
}
const normalizedNow =
Number.isFinite(nowTimestampSeconds) && nowTimestampSeconds > 0
? Math.floor(nowTimestampSeconds)
: Math.floor(Date.now() / 1000);
return setCatchupQueryParams(playbackBaseUrl, {
utc: startTimestamp,
lutc: normalizedNow,
});
}
function getM3uCatchupSupportMode(
channel: Channel | null | undefined
): CatchupSupportMode {
if (getM3uArchiveDays(channel) <= 0) {
return 'none';
}
const catchupSource = channel?.catchup?.source?.trim() ?? '';
if (isHttpUrl(catchupSource)) {
return 'source';
}
const streamUrl = channel?.url?.trim() ?? '';
const catchupType = channel?.catchup?.type?.trim().toLowerCase() ?? '';
if (catchupType === 'shift' && isHttpUrl(streamUrl)) {
return 'shift';
}
if (!catchupType && isHttpUrl(streamUrl)) {
return 'shift';
}
return 'none';
}
function getEpgProgramTimestampSeconds(
dateValue: string,
unixTimestampValue?: number | string | null
): number | null {
const unixTimestamp = Number.parseInt(String(unixTimestampValue ?? ''), 10);
if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) {
return unixTimestamp;
}
const parsed = Date.parse(dateValue);
if (Number.isFinite(parsed)) {
return Math.floor(parsed / 1000);
}
const match = dateValue.match(XMLTV_TIMESTAMP_PATTERN);
if (!match) {
return null;
}
const [, year, month, day, hour, minute, offsetHours, offsetMinutes] =
match;
const utcMillis = Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute)
);
const offsetTotalMinutes =
Number(offsetHours) * 60 +
Math.sign(Number(offsetHours)) * Number(offsetMinutes);
return Math.floor((utcMillis - offsetTotalMinutes * 60_000) / 1000);
}
function setCatchupQueryParams(
rawUrl: string,
params: Record<'utc' | 'lutc', number>
): string | null {
try {
const url = new URL(rawUrl.trim());
url.searchParams.set('utc', String(params.utc));
url.searchParams.set('lutc', String(params.lutc));
return url.toString();
} catch {
return null;
}
}
function isHttpUrl(value: string): boolean {
if (!value) {
return false;
}
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function getFirstNonBlankValue(
...values: Array<string | null | undefined>
): string | undefined {
return values.find(
(value): value is string => value != null && value.trim() !== ''
);
}
@@ -3,18 +3,24 @@
-
{{ item.stop | momentDate: 'HH:mm' }}</span
>
@if (timeNow >= item.start && timeNow <= item.stop) {
@if (isLive) {
<span class="live badge" [matTooltip]="'EPG.LIVE_NOW' | translate"
>live</span
>
}
@if (
item.start < timeNow && item.stop < timeNow && item.start > timeshiftUntil
) {
<span class="rec badge" [matTooltip]="'EPG.TIMESHIFT_AVAILABLE' | translate"
>r</span
@if (showArchiveBadge) {
<span
class="archive-playback badge"
[matTooltip]="'EPG.TIMESHIFT_AVAILABLE' | translate"
>
<mat-icon class="archive-playback__icon" aria-hidden="true"
>replay</mat-icon
>
<span class="archive-playback__label"
>{{ 'XTREAM.PLAY' | translate }}</span
>
</span>
}
@if (item?.desc) {
@@ -20,27 +20,58 @@
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 42px;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.68rem;
line-height: 1.35;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.02em;
border: 1px solid transparent;
white-space: nowrap;
}
.live {
color: #ff8f8f;
background: rgba(255, 95, 95, 0.12);
border-color: rgba(255, 95, 95, 0.28);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.rec {
color: #9abfff;
background: color-mix(in srgb, var(--app-selection-color) 14%, transparent);
border-color: color-mix(in srgb, var(--app-selection-border) 72%, transparent);
.archive-playback {
color: color-mix(in srgb, var(--app-selection-color) 92%, white);
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--app-selection-color) 16%, transparent),
color-mix(in srgb, var(--app-selection-color) 9%, transparent)
),
color-mix(in srgb, var(--app-selection-surface) 42%, transparent);
border-color: color-mix(
in srgb,
var(--app-selection-border) 78%,
transparent
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 6px 14px -12px var(--app-selection-glow);
padding-inline: 7px 10px;
}
.archive-playback__icon {
width: 14px;
height: 14px;
font-size: 14px;
color: inherit;
opacity: 0.92;
}
.archive-playback__label {
font-size: 0.69rem;
line-height: 1;
font-weight: 700;
}
.description-icon {
@@ -4,7 +4,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MomentDatePipe } from '@iptvnator/pipes';
import { TranslateModule } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import moment from 'moment';
import { MockModule, MockPipe } from 'ng-mocks';
import { EpgProgram } from 'shared-interfaces';
@@ -59,6 +59,7 @@ describe('EpgListItemComponent', () => {
let component: EpgListItemComponent;
let fixture: ComponentFixture<EpgListItemComponent>;
let dialog: MatDialog;
let translate: TranslateService;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
@@ -76,6 +77,20 @@ describe('EpgListItemComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(EpgListItemComponent);
dialog = TestBed.inject(MatDialog);
translate = TestBed.inject(TranslateService);
translate.setTranslation(
'en',
{
EPG: {
TIMESHIFT_AVAILABLE: 'Archive playback is available',
},
XTREAM: {
PLAY: 'Play',
},
},
true
);
translate.use('en');
component = fixture.componentInstance;
component.item = EPG_PROGRAM_ITEM;
fixture.detectChanges();
@@ -94,4 +109,21 @@ describe('EpgListItemComponent', () => {
width: '800px',
});
});
it('renders an archive playback chip with replay icon and label', () => {
fixture = TestBed.createComponent(EpgListItemComponent);
component = fixture.componentInstance;
component.item = EPG_PROGRAM_ITEM;
component.showArchiveBadge = true;
fixture.detectChanges();
const chip = fixture.nativeElement.querySelector('.archive-playback');
const icon = fixture.nativeElement.querySelector(
'.archive-playback__icon'
);
expect(chip).not.toBeNull();
expect(chip.textContent).toContain('Play');
expect(icon.textContent.trim()).toBe('replay');
});
});
@@ -19,11 +19,11 @@ export class EpgListItemComponent {
/** EPG Program to render */
@Input() item!: EpgProgram;
/** Actual time */
@Input() timeNow!: string;
/** Whether the program is currently live */
@Input() isLive = false;
/** Aviability of the timeshift function until date */
@Input() timeshiftUntil!: string;
/** Whether archive playback is available for the program */
@Input() showArchiveBadge = false;
/**
* Opens the dialog with details about the selected program
@@ -33,18 +33,18 @@
<div id="program-list" #programList>
@if (items.length > 0) {
@for (program of items; track $index) {
@if (
program.start < timeshiftLimit || program.start >= timeNow()
) {
@if (!canActivateProgram(program)) {
<div
class="program-item"
[class.current-program]="isProgramPlaying(program)"
>
<div class="program-meta">
<app-epg-list-item
[timeshiftUntil]="timeshiftLimit"
[timeNow]="timeNow()"
[item]="program"
[isLive]="isProgramPlaying(program)"
[showArchiveBadge]="
canPlayArchivedProgram(program)
"
/>
</div>
<div class="program-title">
@@ -70,17 +70,16 @@
<div
class="program-item clickable"
[class.current-program]="isProgramPlaying(program)"
[class.active]="
timeNow() >= program.start &&
timeNow() <= program.stop
"
[class.active]="isProgramPlaying(program)"
(click)="activateProgram(program)"
>
<div class="program-meta">
<app-epg-list-item
[timeshiftUntil]="timeshiftLimit"
[timeNow]="timeNow()"
[item]="program"
[isLive]="isProgramPlaying(program)"
[showArchiveBadge]="
canPlayArchivedProgram(program)
"
/>
</div>
<div
@@ -20,8 +20,8 @@ let requestAnimationFrameQueue: FrameRequestCallback[] = [];
})
class StubEpgListItemComponent {
readonly item = input<EpgProgram>();
readonly timeNow = input('');
readonly timeshiftUntil = input('');
readonly isLive = input(false);
readonly showArchiveBadge = input(false);
}
describe('EpgListComponent', () => {
@@ -214,6 +214,30 @@ describe('EpgListComponent', () => {
expect(scrollToSpy).toHaveBeenCalledTimes(1);
});
it('shows archived rows as clickable only when archive playback is supported', () => {
const programs = buildPrograms();
fixture.componentRef.setInput('controlledPrograms', programs);
fixture.componentRef.setInput('archivePlaybackAvailable', false);
fixture.detectChanges();
expect(component.canActivateProgram(programs[0])).toBe(false);
expect(component.canActivateProgram(programs[1])).toBe(true);
expect(
fixture.nativeElement.querySelectorAll('.program-item.clickable')
.length
).toBe(1);
fixture.componentRef.setInput('archivePlaybackAvailable', true);
fixture.detectChanges();
expect(component.canActivateProgram(programs[0])).toBe(true);
expect(
fixture.nativeElement.querySelectorAll('.program-item.clickable')
.length
).toBe(2);
});
});
function buildPrograms(overrides?: {
@@ -48,6 +48,7 @@ export class EpgListComponent {
readonly controlledChannel = input<Channel | null>(null);
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
readonly programActivated = output<EpgProgramActivationEvent>();
private readonly store = inject(Store);
@@ -64,7 +65,7 @@ export class EpgListComponent {
readonly programList = viewChild<ElementRef<HTMLElement>>('programList');
readonly selectedDate = signal(moment().format(DATE_FORMAT));
readonly timeNow = signal(new Date().toISOString());
readonly todayDate = signal(moment().format(DATE_FORMAT));
readonly currentTimeMs = computed(() => Date.parse(this.timeNow()));
readonly isControlled = computed(
() =>
@@ -106,6 +107,9 @@ export class EpgListComponent {
.subtract(this.archiveDays(), 'days')
.toISOString()
);
readonly archivePlaybackEnabled = computed(
() => this.archivePlaybackAvailable() ?? (this.archiveDays() > 0)
);
readonly filteredItems = computed(() =>
[...this.items()]
.filter(
@@ -136,9 +140,7 @@ export class EpgListComponent {
effect(() => {
const programs = this.items();
const channel = this.displayChannel();
this.timeNow.set(new Date().toISOString());
this.todayDate.set(moment().format(DATE_FORMAT));
this.currentTimeMs();
if (!this.isControlled()) {
this.store.dispatch(
@@ -182,7 +184,7 @@ export class EpgListComponent {
activateProgram(program: EpgProgram): void {
const isLive = this.isProgramPlaying(program);
const isTimeshift = this.isProgramArchived(program);
const isTimeshift = this.canPlayArchivedProgram(program);
if (!isLive && !isTimeshift) {
return;
@@ -198,7 +200,7 @@ export class EpgListComponent {
}
if (isLive) {
this.store.dispatch(EpgActions.resetActiveEpgProgram());
this.store.dispatch(EpgActions.returnToLivePlayback());
} else {
this.store.dispatch(EpgActions.setActiveEpgProgram({ program }));
}
@@ -207,11 +209,17 @@ export class EpgListComponent {
}
canActivateProgram(program: EpgProgram): boolean {
return this.isProgramPlaying(program) || this.isProgramArchived(program);
return (
this.isProgramPlaying(program) || this.canPlayArchivedProgram(program)
);
}
canPlayArchivedProgram(program: EpgProgram): boolean {
return this.archivePlaybackEnabled() && this.isProgramArchived(program);
}
calculateProgress(program: EpgProgram): number {
const now = Date.now();
const now = this.currentTimeMs();
const start = getProgramTimeMs(program.start, program.startTimestamp);
const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
const total = stop - start;
@@ -221,7 +229,7 @@ export class EpgListComponent {
}
isProgramPlaying(program: EpgProgram): boolean {
const now = Date.now();
const now = this.currentTimeMs();
const start = getProgramTimeMs(program.start, program.startTimestamp);
const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
return now >= start && now <= stop;
@@ -232,7 +240,7 @@ export class EpgListComponent {
return false;
}
const now = Date.now();
const now = this.currentTimeMs();
const start = getProgramTimeMs(program.start, program.startTimestamp);
const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
const archiveLimit = Date.parse(this.timeshiftUntil());
@@ -241,7 +249,7 @@ export class EpgListComponent {
}
private findCurrentProgram(programs: EpgProgram[]): EpgProgram | undefined {
const now = Date.now();
const now = this.currentTimeMs();
return programs.find((program) => {
const start = getProgramTimeMs(
program.start,