mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
refactor: improve archive playback support and enhance EPG item display
This commit is contained in:
1 parent
c2768ef687
commit
4005687f18
24 files changed
+775
-132
No files matched your search
@@ -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';
|
||||
@@ -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 }>(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,11 @@ export const selectActive = createSelector(
|
||||
fromPlaylistState.selectActiveReducer
|
||||
);
|
||||
|
||||
export const selectActivePlaybackUrl = createSelector(
|
||||
selectPlaylistState,
|
||||
fromPlaylistState.selectActivePlaybackUrlReducer
|
||||
);
|
||||
|
||||
export const selectCurrentEpgProgram = createSelector(
|
||||
selectPlaylistState,
|
||||
fromPlaylistState.selectCurrentEpgProgramReducer
|
||||
|
||||
@@ -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 +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,
|
||||
|
||||
Reference in new issue
Block a user