feat(playback): draw catch-up programmes as seek-bar segments (#1750)

This commit is contained in:
4gray authored and GitHub committed 2026-09-29 19:34:38 +02:00
1 parent 4e17fbed4e
commit f38c6f86d1
35 files changed
+1206 -15

No files matched your search

@@ -0,0 +1,9 @@
---
type: feature
area: playback
---
Catch-up and timeshift playback now draws the programmes of the archive window
on the player's seek bar, one segment per programme. Hovering the bar shows the
programme title with the time, so you can jump to the next show in an M3U
recording that spans several.
@@ -1,8 +0,0 @@
---
type: internal
area: playback
---
The shared player timeline can draw chapter or programme segments with
per-segment progress and a titled hover label; no player supplies segments
yet, so the bar still renders as one segment.
@@ -0,0 +1,76 @@
import {
addXtreamPortal,
channelItemByTitle,
clickCategoryByNameExact,
closeElectronApp,
expect,
launchElectronApp,
openWorkspaceSection,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import { fetchXtreamEpgFixture } from './portal-mock-fixtures';
const CHANNEL = 'Timezone News';
const PAST_PROGRAM = 'Earlier Bulletin';
/**
* Catch-up playback draws the EPG programmes of its archive window on the
* shared controls' seek bar (`timelineSegments`); live playback draws none.
* The `epg` mock user serves a playable MPEG-TS clip for timeshift URLs.
*/
test('@epg @xtream @electron draws the catch-up programme on the seek bar', async ({
dataDir,
request,
}) => {
test.setTimeout(180000);
const credentials = { username: 'epg', password: 'epg' };
await resetMockServers(request, ['xtream']);
const fixture = await fetchXtreamEpgFixture(request, credentials);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await addXtreamPortal(page, {
name: 'Catch-up timeline',
...credentials,
});
await waitForXtreamWorkspaceReady(page);
await openWorkspaceSection(page, 'Live TV');
await clickCategoryByNameExact(page, fixture.categoryName);
const row = channelItemByTitle(page, CHANNEL).first();
await expect(row).toBeVisible({ timeout: 20000 });
await row.click();
const player = page.locator('app-web-player-view');
const titledSegments = player.locator(
'.player-controls__timeline-segment--titled'
);
await expect(player).toBeVisible({ timeout: 20000 });
await expect(titledSegments).toHaveCount(0);
const block = page
.locator('app-epg-timeline .epg-timeline__block')
.filter({ hasText: PAST_PROGRAM })
.first();
await expect(block).toBeVisible({ timeout: 20000 });
await block.click();
const segment = player.locator(
`.player-controls__timeline-segment[data-segment-title="${PAST_PROGRAM}"]`
);
await expect(segment).toHaveCount(1, { timeout: 30000 });
const bar = player.locator('.player-controls__timeline-bar');
await bar.hover();
await expect(
player.locator('[data-test-id="player-controls-timeline-label"]')
).toContainText(`${PAST_PROGRAM} ·`);
await row.click();
await expect(titledSegments).toHaveCount(0, { timeout: 20000 });
} finally {
await closeElectronApp(app);
}
});
+33 -7
View File
@@ -359,13 +359,39 @@ collapses instead of pushing its neighbours. The optional `timelineSegments` inp
them; `normalizeTimelineSegments` (`controls-timeline-segments.ts`) clamps
to the duration, orders, drops empty and reversed entries, cuts overlaps at
the previous end and fills every gap with an untitled segment so the row
always covers `[0, duration]`. Without segments — every host today — the
row is one untitled segment, which is the plain bar. `ControlsTimeline`
owns the normalized list and the per-segment fill for the current scrub or
playback value; the hover label becomes `Chapter 2 · 12:40` over a titled
segment. Producers (EPG programmes for catch-up and timeshift playback,
mpv's chapter list) are separate follow-ups; the rendering, model and rules
are in place for them.
always covers `[0, duration]`. Without segments — live playback, VOD and
series — the row is one untitled segment, which is the plain bar.
`ControlsTimeline` owns the normalized list and the per-segment fill for the
current scrub or playback value; the hover label becomes `Chapter 2 · 12:40`
over a titled segment. mpv's chapter list is not a producer yet.
**Catch-up producer.** Archive playback of a live channel passes the EPG
programmes overlapping its archive window.
`buildCatchupTimelineSegments(programmes, activeProgramme, windowEnd?)`
(`catchup-timeline-segments.ts`) clips every programme to the window and
makes it relative to the window start, which is the activated programme's
start. Xtream timeshift URLs request exactly `[start, stop]` of that
programme, so the window ends at its stop. M3U catch-up URLs run from `utc`
to `lutc`, the moment the URL was resolved, so M3U hosts pass
`getM3uCatchupWindowEndSeconds(url)` (`@iptvnator/shared/m3u-utils`) and every
programme up to then is drawn. Programme and window times follow the EPG view
rule (unix timestamp, else the ISO string), so the display offset cancels.
The activated programme always owns its own span, with its own title: a
list from another date may lack it, and an overlapping or revised guide
entry must not relabel the archive being played, so other programmes only
fill the window after it. Live playback returns `null`. Stalker has no
archive playback, so it has no producer.
Plumbing mirrors `mediaTitle`: each live host derives
`catchupTimelineSegments` → `WebPlayerViewComponent.timelineSegments` → the
four engine hosts → `app-player-controls`. The hosts are the Xtream live
layout (`controlledEpgPrograms` + `activeCatchupProgram`), the unified live
tab for Favorites and Recent (`createUnifiedLiveEpgView`, both Xtream and
M3U), and the M3U playlist player (`epgPrograms` + `activeEpgProgram` while
`activePlaybackUrl` is set). `PortalInlinePlayerComponent` hosts no catch-up
and passes nothing. Like the Up next card, the segments reach Embedded MPV
under the frame-copy engine only, the one that mounts `app-player-controls`;
the native-view legacy dock keeps its plain slider.
### Up next card
@@ -306,6 +306,10 @@ tab can gate the timeline's archive window. `tv_archive_duration` is
interpreted as **days** everywhere, matching
`live-stream-layout.controlledArchiveDays` (issue #1138).
While a programme plays from the archive, both entry points draw it on the
player's seek bar as a titled segment; see "Timeline segments" in the
[player controls contract](player-controls-contract.md#timeline-segments).
Programme details in the Live TV and Favorites/Recent EPG timeline/list also
offer **Copy archive URL**. This uses the same resolver and persisted server
timezone without changing playback. See the M3U module's "Copy archive URL"
@@ -0,0 +1,247 @@
import { AsyncPipe } from '@angular/common';
import { NO_ERRORS_SCHEMA, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { of } from 'rxjs';
import { By } from '@angular/platform-browser';
import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import {
DataService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
TmdbEnrichmentService,
} from '@iptvnator/services';
import {
EpgProgram,
Settings,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
import {
activeEpgProgram,
activePlaybackUrl,
dataServiceMock,
epgPrograms$,
epgServiceMock,
epgUrlSetting,
epgViewMode,
externalSession,
player,
playlistId,
playlistsServiceMock,
routerMock,
sampleChannel,
showCaptions,
storeMock,
stripCountryPrefix,
syncStoreState,
translateServiceProvider,
} from './video-player.spec-harness';
import {
StubAudioPlayerComponent,
StubChannelListLoadingStateComponent,
StubEpgGuideComponent,
StubEpgGuideNowPlayingComponent,
StubEpgTimelineComponent,
StubPortalEmptyStateComponent,
StubResizableDirective,
StubSidebarComponent,
StubWebPlayerViewComponent,
} from './video-player.spec-stubs';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
}));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
const HOUR = 3600;
const T0 = 1_775_386_800; // 2026-04-05T11:00:00Z
function programme(title: string, fromHour: number, toHour: number) {
const start = T0 + fromHour * HOUR;
const stop = T0 + toHour * HOUR;
return {
channel: 'sample-tvg-id',
start: new Date(start * 1000).toISOString(),
stop: new Date(stop * 1000).toISOString(),
title,
desc: null,
category: null,
startTimestamp: start,
stopTimestamp: stop,
} satisfies EpgProgram;
}
/**
* Catch-up programmes on the seek bar, read from the `app-web-player-view`
* the real template binds. Kept apart from `video-player.component.spec.ts`,
* which sits at the spec line budget.
*/
describe('VideoPlayerComponent — catch-up timeline segments', () => {
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
let fixture: ComponentFixture<VideoPlayerComponentInstance>;
beforeAll(async () => {
({ VideoPlayerComponent } = await import('./video-player.component'));
});
beforeEach(async () => {
syncStoreState(sampleChannel);
activePlaybackUrl.set(null);
activeEpgProgram.set(null);
epgPrograms$.next([]);
playlistId.set('playlist-1');
player.set(VideoPlayer.VideoJs);
showCaptions.set(false);
stripCountryPrefix.set(false);
externalSession.set(null);
storeMock.dispatch.mockClear();
await TestBed.configureTestingModule({
imports: [VideoPlayerComponent],
schemas: [NO_ERRORS_SCHEMA],
providers: [
{
provide: ActivatedRoute,
useValue: {
params: of({ id: playlistId(), view: 'all' }),
queryParams: of({}),
snapshot: {
data: { layout: 'workspace' },
queryParams: {},
},
},
},
{ provide: Router, useValue: routerMock },
{ provide: Store, useValue: storeMock },
translateServiceProvider,
{ provide: DataService, useValue: dataServiceMock },
{
provide: RuntimeCapabilitiesService,
useValue: {
supportsEpg: true,
isElectron: false,
supportsRemoteControl: false,
},
},
{ provide: PlaylistsService, useValue: playlistsServiceMock },
{ provide: EpgService, useValue: epgServiceMock },
{
provide: PlaylistContextFacade,
useValue: { resolvedPlaylistId: playlistId },
},
{
provide: TmdbEnrichmentService,
useValue: { isEnabled: () => false },
},
{
provide: SettingsStore,
useValue: {
player,
showCaptions,
stripCountryPrefix,
m3uVodDetails: signal(true),
resolvedEpgViewMode: epgViewMode,
resolvedEpgOffsetMinutes: signal(0),
epgUrl: epgUrlSetting,
},
},
{
provide: StorageMap,
useValue: {
get: jest.fn(() =>
of({ player: player() } as Partial<Settings>)
),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession: externalSession },
},
],
})
.overrideComponent(VideoPlayerComponent, {
set: {
imports: [
AsyncPipe,
StubAudioPlayerComponent,
StubChannelListLoadingStateComponent,
StubEpgGuideComponent,
StubEpgGuideNowPlayingComponent,
StubEpgTimelineComponent,
StubPortalEmptyStateComponent,
StubResizableDirective,
StubSidebarComponent,
StubWebPlayerViewComponent,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(VideoPlayerComponent);
fixture.detectChanges();
});
afterEach(() => {
fixture?.destroy();
});
const forwarded = () => {
fixture.detectChanges();
return (
fixture.debugElement.query(By.directive(StubWebPlayerViewComponent))
.componentInstance as StubWebPlayerViewComponent
).timelineSegments();
};
it('draws nothing during live playback', () => {
epgPrograms$.next([programme('Picked', 0, 1)]);
activeEpgProgram.set(programme('Picked', 0, 1));
expect(forwarded()).toBeNull();
});
it('spans the programmes from utc up to the lutc of the archive URL', () => {
epgPrograms$.next([
programme('Before', -1, 0),
programme('Picked', 0, 1),
programme('Next', 1, 2),
programme('Later', 2, 3),
]);
activeEpgProgram.set(programme('Picked', 0, 1));
activePlaybackUrl.set(
`http://localhost/archive.m3u8?utc=${T0}&lutc=${T0 + 1.5 * HOUR}`
);
expect(forwarded()).toEqual([
{ startSeconds: 0, endSeconds: HOUR, title: 'Picked' },
{ startSeconds: HOUR, endSeconds: 1.5 * HOUR, title: 'Next' },
]);
activePlaybackUrl.set(null);
expect(forwarded()).toBeNull();
});
it('falls back to the programme when the URL carries no lutc', () => {
epgPrograms$.next([programme('Picked', 0, 1), programme('Next', 1, 2)]);
activeEpgProgram.set(programme('Picked', 0, 1));
activePlaybackUrl.set('http://localhost/archive.m3u8');
expect(forwarded()).toEqual([
{ startSeconds: 0, endSeconds: HOUR, title: 'Picked' },
]);
});
});
@@ -127,6 +127,7 @@
: (playerSettings.player ?? null)
"
[volume]="volume()"
[timelineSegments]="catchupTimelineSegments()"
[recordingMetadata]="recordingMetadata()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
@@ -28,6 +28,7 @@ import { ResizableDirective } from '@iptvnator/ui/components';
import {
applyChannelNameStrip,
getM3uArchiveDays,
getM3uCatchupWindowEndSeconds,
extractDrmFromRaw,
isDashChannel,
isDashStreamUrl,
@@ -102,6 +103,7 @@ import {
} from '@iptvnator/portal/shared/ui';
import {
AudioPlayerComponent,
buildCatchupTimelineSegments,
FULLSCREEN_CHANNEL_PANEL,
type FullscreenChannelPanelContext,
type FullscreenChannelPanelHost,
@@ -420,6 +422,17 @@ export class VideoPlayerComponent
readonly epgPrograms = toSignal(this.epgService.currentEpgPrograms$, {
initialValue: [] as EpgProgram[],
});
/** Catch-up programmes from `utc` up to `lutc` as seek-bar segments. */
readonly catchupTimelineSegments = computed(() => {
const playbackUrl = this.activePlaybackUrl();
return playbackUrl
? buildCatchupTimelineSegments(
this.epgPrograms(),
this.activeEpgProgramOrNull(),
getM3uCatchupWindowEndSeconds(playbackUrl) ?? undefined
)
: null;
});
// Shared helper skips blank strings (`tvg-rec=""` is a common default that
// `??` would not fall through), so a channel with only `timeshift`/
// `catchup-days` still gets its real window instead of 0 (unbounded).
@@ -113,6 +113,7 @@ export class StubWebPlayerViewComponent {
readonly playback = input<unknown>(null);
readonly playerOverride = input<VideoPlayer | null>(null);
readonly volume = input(1);
readonly timelineSegments = input<unknown>(null);
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
readonly recordingStopped = output<RecordingStoppedEvent>();
@@ -0,0 +1,180 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateModule } from '@ngx-translate/core';
import {
ResolvedLiveCollectionDetail,
StreamResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
PORTAL_PLAYER,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { SettingsStore } from '@iptvnator/services';
import { Channel, EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
import { EpgArchiveCopyService } from '@iptvnator/ui/epg';
import {
createUnifiedLiveCatchup,
UnifiedLiveTimeshift,
} from './unified-live-catchup';
import { createUnifiedLiveEpgView } from './unified-live-epg-view';
import { createUnifiedLiveSelectionGeneration } from './unified-live-selection-generation';
import { buildLiveItem } from './unified-live-tab.spec-data';
const HOUR = 3600;
const T0 = Date.parse('2026-03-26T11:00:00.000Z') / 1000;
function programme(title: string, fromHour: number, toHour: number) {
return {
start: new Date((T0 + fromHour * HOUR) * 1000).toISOString(),
stop: new Date((T0 + toHour * HOUR) * 1000).toISOString(),
channel: 'test-channel',
title,
desc: null,
category: null,
} satisfies EpgProgram;
}
function epgItem(title: string, fromHour: number, toHour: number): EpgItem {
const { start, stop } = programme(title, fromHour, toHour);
return {
id: title,
epg_id: '',
title,
description: '',
lang: '',
start,
end: stop,
stop,
channel_id: '1',
start_timestamp: String(T0 + fromHour * HOUR),
stop_timestamp: String(T0 + toHour * HOUR),
};
}
const m3uChannel = {
id: 'm3u-channel',
name: 'M3U Live',
url: 'https://example.com/m3u.m3u8',
group: { title: 'News' },
tvg: { id: 'm3u-channel', name: 'M3U Live', url: '', logo: '', rec: '3' },
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
epgParams: '',
} as Channel;
describe('unified live catch-up timeline segments', () => {
const activeTimeshift = signal<UnifiedLiveTimeshift | null>(null);
const activeItem = signal<UnifiedCollectionItem | null>(null);
const isM3uSelection = signal(false);
const currentM3uPrograms = signal<EpgProgram[]>([]);
const currentPortalEpgItems = signal<EpgItem[]>([]);
let resolveXtreamCatchupUrl: jest.Mock;
function setup() {
return TestBed.runInInjectionContext(() => {
const common = {
activeTimeshift,
activeItem,
activeDetail: signal<ResolvedLiveCollectionDetail | null>(null),
isM3uSelection,
currentM3uChannel: signal<Channel | null>(m3uChannel),
};
return {
catchup: createUnifiedLiveCatchup({
...common,
shouldUseInlinePlayer: signal(true),
generation: createUnifiedLiveSelectionGeneration(),
}),
epgView: createUnifiedLiveEpgView({
...common,
currentM3uPrograms,
currentPortalEpgItems,
progressTick: signal(0),
}),
};
});
}
beforeEach(() => {
activeTimeshift.set(null);
resolveXtreamCatchupUrl = jest
.fn()
.mockResolvedValue('https://example.com/timeshift.ts');
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [
{
provide: EpgArchiveCopyService,
useValue: { copy: jest.fn() },
},
{
provide: StreamResolverService,
useValue: { resolveXtreamCatchupUrl },
},
{ provide: PORTAL_PLAYER, useValue: {} },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: SettingsStore,
useValue: {
resolvedEpgViewMode: signal('timeline'),
resolvedEpgOffsetMinutes: signal(0),
stripCountryPrefix: signal(false),
},
},
],
});
});
afterEach(() => jest.useRealTimers());
it('spans M3U programmes from the picked start up to lutc', () => {
jest.useFakeTimers({ now: (T0 + 2.5 * HOUR) * 1000 });
isM3uSelection.set(true);
activeItem.set(buildLiveItem('m3u'));
currentM3uPrograms.set([
programme('Before', -1, 0),
programme('Picked', 0, 1),
programme('Next', 1, 2),
programme('Now on', 2, 3),
]);
const { catchup, epgView } = setup();
expect(epgView.catchupTimelineSegments()).toBeNull();
catchup.handleProgramActivation({
type: 'timeshift',
program: programme('Picked', 0, 1),
});
expect(activeTimeshift()?.url).toContain(`lutc=${T0 + 2.5 * HOUR}`);
expect(epgView.catchupTimelineSegments()).toEqual([
{ startSeconds: 0, endSeconds: HOUR, title: 'Picked' },
{ startSeconds: HOUR, endSeconds: 2 * HOUR, title: 'Next' },
{ startSeconds: 2 * HOUR, endSeconds: 2.5 * HOUR, title: 'Now on' },
]);
catchup.returnToLive();
expect(epgView.catchupTimelineSegments()).toBeNull();
});
it('bounds Xtream catch-up to the requested programme', async () => {
isM3uSelection.set(false);
activeItem.set(buildLiveItem('xtream'));
currentPortalEpgItems.set([
epgItem('Picked', 0, 1),
epgItem('Next', 1, 2),
]);
const { catchup, epgView } = setup();
catchup.handleProgramActivation({
type: 'timeshift',
program: programme('Picked', 0, 1),
});
await Promise.resolve();
expect(resolveXtreamCatchupUrl).toHaveBeenCalled();
expect(epgView.catchupTimelineSegments()).toEqual([
{ startSeconds: 0, endSeconds: HOUR, title: 'Picked' },
]);
});
});
@@ -2,6 +2,7 @@ import { computed, inject, Signal, signal } from '@angular/core';
import {
applyChannelNameStrip,
getM3uArchiveDays,
getM3uCatchupWindowEndSeconds,
isM3uCatchupPlaybackSupported,
} from '@iptvnator/shared/m3u-utils';
import { ResolvedLiveCollectionDetail } from '@iptvnator/portal/shared/data-access';
@@ -23,6 +24,10 @@ import {
EpgProgram,
EpgViewMode,
} from '@iptvnator/shared/interfaces';
import {
buildCatchupTimelineSegments,
type PlayerTimelineSegment,
} from '@iptvnator/ui/playback';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import {
getLiveEpgPanelSummary,
@@ -48,6 +53,8 @@ export interface UnifiedLiveEpgView {
readonly timelineChannelLogo: Signal<string>;
readonly timelineArchiveAvailable: Signal<boolean>;
readonly timelineArchiveDays: Signal<number>;
/** The archive window's programmes as seek-bar segments; null live. */
readonly catchupTimelineSegments: Signal<PlayerTimelineSegment[] | null>;
readonly panelSummary: Signal<LiveEpgPanelSummary | null>;
readonly panelSummaryLabelKey: Signal<string>;
setPanelCollapsed(collapsed: boolean): void;
@@ -143,6 +150,19 @@ export function createUnifiedLiveEpgView(options: {
Number(options.activeItem()?.tvArchiveDuration ?? 0) || 0
);
}),
catchupTimelineSegments: computed(() => {
const timeshift = options.activeTimeshift();
// M3U catch-up runs up to `lutc`; Xtream's is the programme.
const windowEnd =
timeshift && options.isM3uSelection()
? getM3uCatchupWindowEndSeconds(timeshift.url)
: null;
return buildCatchupTimelineSegments(
timelinePrograms(),
timeshift?.program,
windowEnd ?? undefined
);
}),
panelSummary: computed(() => {
const timeshift = options.activeTimeshift();
if (timeshift) {
@@ -67,6 +67,7 @@
[title]="activeDetail()?.playback?.title ?? ''"
[playback]="inlinePlayback()"
[playerOverride]="inlinePlayer()"
[timelineSegments]="catchupTimelineSegments()"
[recordingMetadata]="recordingMetadata()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
@@ -198,6 +198,7 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
readonly timelineChannelLogo = this.epgView.timelineChannelLogo;
readonly timelineArchiveAvailable = this.epgView.timelineArchiveAvailable;
readonly timelineArchiveDays = this.epgView.timelineArchiveDays;
readonly catchupTimelineSegments = this.epgView.catchupTimelineSegments;
private readonly recording = createUnifiedLiveRecording({
activeItem: this.activeItem,
@@ -115,6 +115,7 @@ export class StubWebPlayerViewComponent {
readonly title = input('');
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly playerOverride = input<VideoPlayer | null>(null);
readonly timelineSegments = input<unknown>(null);
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
readonly recordingStopped = output<RecordingStoppedEvent>();
@@ -61,6 +61,7 @@ export class StubWebPlayerViewComponent {
readonly streamUrl = input('');
readonly title = input('');
readonly playback = input<unknown>(null);
readonly timelineSegments = input<unknown>(null);
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
readonly recordingStopped = output<RecordingStoppedEvent>();
@@ -0,0 +1,246 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { MockPipe } from 'ng-mocks';
import { TranslatePipe } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { Subject, of } from 'rxjs';
import {
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import {
FavoritesService,
XtreamStore,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import {
EpgArchiveCopyService,
EpgArchiveDownloadService,
EpgListViewComponent,
EpgTimelineComponent,
} from '@iptvnator/ui/epg';
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
import { GridListComponent } from '@iptvnator/portal/shared/ui';
import {
DatabaseService,
ParentalLockService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
import {
playlist,
sampleChannel,
StubEpgTimelineComponent,
StubGridListComponent,
StubPortalChannelsListComponent,
StubWebPlayerViewComponent,
} from './live-stream-layout-stubs.spec-data';
const HOUR = 3600;
const T0 = Date.parse('2026-04-04T10:00:00.000Z') / 1000;
function epgItem(title: string, fromHour: number, toHour: number): EpgItem {
const start = new Date((T0 + fromHour * HOUR) * 1000).toISOString();
const stop = new Date((T0 + toHour * HOUR) * 1000).toISOString();
return {
id: title,
epg_id: `epg-${title}`,
title,
description: '',
lang: 'en',
start,
stop,
end: stop,
channel_id: 'channel-101',
start_timestamp: String(T0 + fromHour * HOUR),
stop_timestamp: String(T0 + toHour * HOUR),
};
}
/**
* Catch-up programmes on the player's seek bar. Split from
* `live-stream-layout.component.spec.ts`, which sits at the spec line cap.
*/
describe('LiveStreamLayoutComponent catch-up timeline segments', () => {
let fixture: ComponentFixture<LiveStreamLayoutComponent>;
let component: LiveStreamLayoutComponent;
const emptyList = signal<unknown[]>([]);
const epgItems = signal<EpgItem[]>([]);
const parentalLock = {
version: signal(0),
active: signal(false),
isXtreamCategoryLocked: jest.fn(() => false),
};
const databaseService = {
getAllXtreamCategories: jest.fn(async () => [] as unknown[]),
};
const runtime = { supportsXtreamSqliteDataSource: false };
const xtreamStore = {
getCategoriesBySelectedType: signal([
{ category_id: 1, category_name: 'News' },
]),
liveCategories: signal([{ id: 1, xtream_id: 1, name: 'News' }]),
getCategoryItemCounts: signal(new Map<number, number>([[1, 1]])),
getPaginatedContent: emptyList,
hasMoreContent: signal(false),
epgItems,
currentEpgItem: signal(null),
isLoadingEpg: signal(false),
selectedTypeContentLoading: signal(false),
selectedCategoryId: signal<number | null>(1),
selectedContentType: signal('live'),
selectedItem: signal<unknown>(sampleChannel),
currentPlaylist: signal(playlist),
liveStreams: emptyList,
isContentInitialized: signal(true),
selectItemsFromSelectedCategory: jest.fn(() => []),
constructStreamUrl: jest.fn(() => 'https://example.com/live.ts'),
openPlayer: jest.fn(),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
loadMoreContent: jest.fn(),
};
beforeEach(async () => {
epgItems.set([]);
await TestBed.configureTestingModule({
imports: [LiveStreamLayoutComponent, NoopAnimationsModule],
providers: [
{
provide: EpgArchiveDownloadService,
useValue: { start: jest.fn() },
},
{
provide: EpgArchiveCopyService,
useValue: { copy: jest.fn() },
},
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: {},
queryParamMap: convertToParamMap({}),
},
queryParamMap: of(convertToParamMap({})),
pathFromRoot: [
{ snapshot: { data: { layout: 'workspace' } } },
],
},
},
{
provide: Router,
useValue: {
events: new Subject().asObservable(),
navigate: jest.fn(),
},
},
{ provide: XtreamStore, useValue: xtreamStore },
{
provide: FavoritesService,
useValue: { getFavorites: jest.fn(() => of([])) },
},
{
provide: XtreamUrlService,
useValue: {
constructAutoLiveTsUrl: jest.fn(() => undefined),
resolveCatchupUrl: jest.fn(
async () => 'https://example.com/timeshift.ts'
),
},
},
{ provide: RuntimeCapabilitiesService, useValue: runtime },
{ provide: DatabaseService, useValue: databaseService },
{
provide: SettingsStore,
useValue: {
openStreamOnDoubleClick: signal(false),
resolvedEpgViewMode: signal('timeline'),
resolvedEpgOffsetMinutes: signal(0),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: () => true,
openExternalPlayback: jest.fn(),
},
},
{ provide: ParentalLockService, useValue: parentalLock },
],
})
.overrideComponent(LiveStreamLayoutComponent, {
remove: {
imports: [
EpgListViewComponent,
EpgTimelineComponent,
GridListComponent,
PortalChannelsListComponent,
TranslatePipe,
WebPlayerViewComponent,
],
},
add: {
imports: [
StubEpgTimelineComponent,
StubGridListComponent,
StubPortalChannelsListComponent,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
StubWebPlayerViewComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
component = fixture.componentInstance;
TestBed.inject(LiveLayoutSidebarStateService).setState(
'portal',
'expanded'
);
fixture.detectChanges();
});
afterEach(() => fixture.destroy());
const webPlayer = () =>
fixture.debugElement.query(By.directive(StubWebPlayerViewComponent))
.componentInstance as StubWebPlayerViewComponent;
it('draws the archive window programmes until live playback resumes', async () => {
epgItems.set([
epgItem('Morning News', -1, 0),
epgItem('Archived Show', 0, 1),
epgItem('Afternoon Film', 1, 3),
]);
component.playLive(sampleChannel);
fixture.detectChanges();
expect(webPlayer().timelineSegments()).toBeNull();
const archived: EpgProgram = {
...component.controlledEpgPrograms()[1],
};
await component.onProgramActivated({
type: 'timeshift',
program: archived,
});
fixture.detectChanges();
// Xtream's timeshift URL requests exactly the programme.
expect(webPlayer().timelineSegments()).toEqual([
{ startSeconds: 0, endSeconds: HOUR, title: 'Archived Show' },
]);
component.playLive(sampleChannel);
fixture.detectChanges();
expect(webPlayer().timelineSegments()).toBeNull();
});
});
@@ -154,6 +154,7 @@
[streamUrl]="streamUrl"
[title]="activePlayback()?.title ?? ''"
[playback]="activePlayback()"
[timelineSegments]="catchupTimelineSegments()"
[recordingMetadata]="recordingMetadata()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
@@ -70,6 +70,7 @@ import {
} from '@iptvnator/ui/epg';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import {
buildCatchupTimelineSegments,
FULLSCREEN_CHANNEL_PANEL,
type FullscreenChannelPanelContext,
type FullscreenChannelPanelHost,
@@ -303,6 +304,13 @@ export class LiveStreamLayoutComponent
}
private readonly currentTimeMs = signal(Date.now());
readonly activeCatchupProgram = signal<EpgProgram | null>(null);
/** The archive window's programmes as seek-bar segments; null live. */
readonly catchupTimelineSegments = computed(() =>
buildCatchupTimelineSegments(
this.controlledEpgPrograms(),
this.activeCatchupProgram()
)
);
readonly controlledArchiveDays = computed(() =>
Math.max(
0,
@@ -1,6 +1,7 @@
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import {
getM3uArchiveDays,
getM3uCatchupWindowEndSeconds,
isM3uCatchupPlaybackSupported,
resolveM3uCatchupUrl,
} from './catchup.utils';
@@ -333,4 +334,25 @@ describe('catchup.utils', () => {
})
);
});
describe('getM3uCatchupWindowEndSeconds', () => {
it('reads the lutc the resolver stamped on the URL', () => {
const url = resolveM3uCatchupUrl(
baseChannel,
{ start: '2026-03-26T11:00:00.000Z' },
1_774_530_000
);
expect(getM3uCatchupWindowEndSeconds(url)).toBe(1_774_530_000);
});
it.each([
['no URL', null],
['an unparsable URL', 'not a url'],
['a URL without lutc', 'https://example.com/live.m3u8?utc=1'],
['a zero lutc', 'https://example.com/live.m3u8?lutc=0'],
])('is null for %s', (_label, url) => {
expect(getM3uCatchupWindowEndSeconds(url)).toBeNull();
});
});
});
@@ -59,6 +59,24 @@ export function resolveM3uCatchupUrl(
});
}
/**
* End of an M3U catch-up URL's archive window in epoch seconds: its `lutc`
* parameter, the moment `resolveM3uCatchupUrl` built it. The stream runs
* from the programme's start (`utc`) up to then. Null without the param.
*/
export function getM3uCatchupWindowEndSeconds(
playbackUrl: string | null | undefined
): number | null {
try {
const lutc = Number(
new URL(playbackUrl ?? '').searchParams.get('lutc') ?? ''
);
return Number.isFinite(lutc) && lutc > 0 ? lutc : null;
} catch {
return null;
}
}
function getM3uCatchupSupportMode(
channel: Channel | null | undefined
): CatchupSupportMode {
@@ -13,6 +13,7 @@
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[timelineSegments]="timelineSegments()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -21,6 +21,7 @@ import {
PlayerControlsComponent,
type PlayerMediaTitle,
PlayerUpNextItem,
type PlayerTimelineSegment,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -62,6 +63,10 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
readonly interactionEnabled = input(true);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** Catch-up programmes drawn as track segments; null draws one. */
readonly timelineSegments = input<readonly PlayerTimelineSegment[] | null>(
null
);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@@ -104,6 +104,7 @@
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[timelineSegments]="timelineSegments()"
[showControls]="showControls()"
[shortcutsEnabled]="sharedShortcutsEnabled()"
(previousEpisodeRequested)="requestPreviousEpisode()"
@@ -29,6 +29,7 @@ import { PlayerControlsComponent } from '../player-controls/player-controls.comp
import type {
PlayerMediaTitle,
PlayerUpNextItem,
PlayerTimelineSegment,
} from '../player-controls/player-controls.model';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter';
@@ -87,6 +88,10 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** Catch-up programmes drawn as track segments; null draws one. */
readonly timelineSegments = input<readonly PlayerTimelineSegment[] | null>(
null
);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@@ -13,6 +13,7 @@
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[timelineSegments]="timelineSegments()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -30,6 +30,7 @@ import {
PlayerControlsComponent,
type PlayerMediaTitle,
PlayerUpNextItem,
type PlayerTimelineSegment,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -84,6 +85,10 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** Catch-up programmes drawn as track segments; null draws one. */
readonly timelineSegments = input<readonly PlayerTimelineSegment[] | null>(
null
);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@Output() timeUpdate = new EventEmitter<PlayerTimeUpdate>();
@@ -0,0 +1,146 @@
import {
buildCatchupTimelineSegments,
type CatchupTimelineProgramme,
} from './catchup-timeline-segments';
import { normalizeTimelineSegments } from './controls-timeline-segments';
const T0 = 1_750_000_000; // epoch seconds
function programme(
title: string,
startOffsetMin: number,
stopOffsetMin: number,
withTimestamps = true
): CatchupTimelineProgramme {
const start = T0 + startOffsetMin * 60;
const stop = T0 + stopOffsetMin * 60;
return {
title,
start: new Date(start * 1000).toISOString(),
stop: new Date(stop * 1000).toISOString(),
...(withTimestamps
? { startTimestamp: start, stopTimestamp: stop }
: {}),
};
}
describe('buildCatchupTimelineSegments', () => {
const news = programme('News', -30, 0);
const film = programme('Film', 0, 90);
const late = programme('Late show', 90, 150);
const guide = [late, news, film];
it('returns null for live playback or an unusable window', () => {
expect(buildCatchupTimelineSegments(guide, null)).toBeNull();
expect(
buildCatchupTimelineSegments(guide, {
title: 'Broken',
start: 'not a date',
stop: 'not a date',
})
).toBeNull();
expect(
buildCatchupTimelineSegments(guide, programme('Reversed', 10, 5))
).toBeNull();
expect(buildCatchupTimelineSegments(guide, film, T0)).toBeNull();
});
it('bounds the window to the programme for Xtream timeshift', () => {
expect(buildCatchupTimelineSegments(guide, film)).toEqual([
{ startSeconds: 0, endSeconds: 5400, title: 'Film' },
]);
});
it('lists every overlapping programme of an open window, in order', () => {
const segments = buildCatchupTimelineSegments(
guide,
film,
T0 + 120 * 60
);
expect(segments).toEqual([
{ startSeconds: 0, endSeconds: 5400, title: 'Film' },
{ startSeconds: 5400, endSeconds: 7200, title: 'Late show' },
]);
});
it('clips programmes that straddle the window end', () => {
const segments = buildCatchupTimelineSegments(
[programme('Picked', 0, 30), programme('Long film', 30, 200)],
programme('Picked', 0, 30),
T0 + 60 * 60
);
expect(segments).toEqual([
{ startSeconds: 0, endSeconds: 1800, title: 'Picked' },
{ startSeconds: 1800, endSeconds: 3600, title: 'Long film' },
]);
});
it('keeps the picked title over overlapping or revised guide entries', () => {
const segments = buildCatchupTimelineSegments(
[
programme('Early', -10, 20),
programme('Revised Picked', 5, 30),
programme('Mid', 20, 40),
],
programme('Picked', 5, 30),
T0 + 40 * 60
);
expect(segments).toEqual([
{ startSeconds: 0, endSeconds: 1500, title: 'Picked' },
{ startSeconds: 1500, endSeconds: 2100, title: 'Mid' },
]);
});
it('falls back to the ISO strings when timestamps are missing', () => {
const isoFilm = programme('Film', 0, 90, false);
expect(
buildCatchupTimelineSegments(
[isoFilm, programme('Late show', 90, 150, false)],
isoFilm,
T0 + 100 * 60
)
).toEqual([
{ startSeconds: 0, endSeconds: 5400, title: 'Film' },
{ startSeconds: 5400, endSeconds: 6000, title: 'Late show' },
]);
});
it('keeps the activated programme when the list does not hold it', () => {
expect(buildCatchupTimelineSegments([], film)).toEqual([
{ startSeconds: 0, endSeconds: 5400, title: 'Film' },
]);
expect(buildCatchupTimelineSegments(null, film)).toEqual([
{ startSeconds: 0, endSeconds: 5400, title: 'Film' },
]);
});
it('turns blank titles into untitled segments', () => {
const blank = programme(' ', 0, 30);
expect(buildCatchupTimelineSegments([blank], blank)).toEqual([
{ startSeconds: 0, endSeconds: 1800, title: null },
]);
});
it('feeds the controls a cover of the real stream duration', () => {
const segments = buildCatchupTimelineSegments(
guide,
film,
T0 + 120 * 60
);
// The provider served 100 minutes: the tail programme is clamped.
expect(
normalizeTimelineSegments(segments, 6000).map((segment) => [
segment.title,
segment.startSeconds,
segment.endSeconds,
])
).toEqual([
['Film', 0, 5400],
['Late show', 5400, 6000],
]);
});
});
@@ -0,0 +1,97 @@
import type { EpgProgram } from '@iptvnator/shared/interfaces';
import type { PlayerTimelineSegment } from './player-controls.model';
/** The EPG fields a catch-up timeline reads from a programme. */
export type CatchupTimelineProgramme = Pick<
EpgProgram,
'title' | 'start' | 'stop' | 'startTimestamp' | 'stopTimestamp'
>;
/**
* Seek-bar segments for catch-up / timeshift playback of a live channel:
* every EPG programme overlapping the archive window, with start and end
* relative to the playback start (the window start) and the programme title.
*
* The window starts at the activated programme's start. It ends at that
* programme's stop — Xtream's timeshift URL requests exactly that range —
* unless the host passes `windowEndSeconds`, as M3U catch-up does with the
* moment it resolved `lutc`, since that stream runs from the programme's
* start up to then. Times use the same rule as the EPG views: the unix
* timestamp when present, else the parsed ISO string; programme and window
* share that clock, so the EPG display offset cancels out.
*
* Returns `null` without a usable window (live playback), so the controls
* draw one plain segment. The controls clamp the result to the stream's
* real duration and fill any gap (`normalizeTimelineSegments`).
*/
export function buildCatchupTimelineSegments(
programmes: readonly CatchupTimelineProgramme[] | null | undefined,
active: CatchupTimelineProgramme | null | undefined,
windowEndSeconds?: number
): PlayerTimelineSegment[] | null {
if (!active) {
return null;
}
const windowStart = programmeSeconds(active.start, active.startTimestamp);
const windowEnd =
windowEndSeconds ?? programmeSeconds(active.stop, active.stopTimestamp);
if (
windowStart === null ||
windowEnd === null ||
!Number.isFinite(windowEnd) ||
windowEnd <= windowStart
) {
return null;
}
// The activated programme owns its own span, whatever the guide holds
// there: another date's list may lack it, and an overlapping or revised
// entry must not relabel the archive being played. Other programmes
// only fill the window after it.
const activeStop = programmeSeconds(active.stop, active.stopTimestamp);
const ownEnd = Math.min(activeStop ?? windowStart, windowEnd);
const segments: PlayerTimelineSegment[] =
ownEnd > windowStart
? [
{
startSeconds: 0,
endSeconds: ownEnd - windowStart,
title: active.title?.trim() || null,
},
]
: [];
for (const programme of programmes ?? []) {
const start = programmeSeconds(
programme.start,
programme.startTimestamp
);
const stop = programmeSeconds(programme.stop, programme.stopTimestamp);
if (start === null || stop === null) {
continue;
}
const clippedStart = Math.max(start, ownEnd, windowStart);
const clippedEnd = Math.min(stop, windowEnd);
if (clippedEnd <= clippedStart) {
continue;
}
segments.push({
startSeconds: clippedStart - windowStart,
endSeconds: clippedEnd - windowStart,
title: programme.title?.trim() || null,
});
}
return segments.sort((a, b) => a.startSeconds - b.startSeconds);
}
/** Epoch seconds: the positive unix timestamp, else the parsed ISO date. */
function programmeSeconds(
isoValue: string | null | undefined,
timestamp: number | null | undefined
): number | null {
const unix = Number(timestamp);
if (timestamp != null && Number.isFinite(unix) && unix > 0) {
return unix;
}
const ms = Date.parse(isoValue ?? '');
return Number.isFinite(ms) ? Math.floor(ms / 1000) : null;
}
@@ -13,6 +13,7 @@ export * from './controls-shortcuts';
export * from './legacy-player-shortcuts';
export * from './controls-surface';
export * from './pointer-focus-release';
export * from './catchup-timeline-segments';
export * from './controls-timeline';
export * from './controls-view-model';
export * from './controls-visibility';
@@ -15,6 +15,7 @@
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[timelineSegments]="timelineSegments()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -25,6 +25,7 @@ import {
PlayerControlsComponent,
type PlayerMediaTitle,
PlayerUpNextItem,
type PlayerTimelineSegment,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -80,6 +81,10 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** Catch-up programmes drawn as track segments; null draws one. */
readonly timelineSegments = input<readonly PlayerTimelineSegment[] | null>(
null
);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@@ -8,6 +8,7 @@
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[timelineSegments]="timelineSegments()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
@@ -37,6 +38,7 @@
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[timelineSegments]="timelineSegments()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
@@ -67,6 +69,7 @@
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[timelineSegments]="timelineSegments()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
@@ -97,6 +100,7 @@
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[timelineSegments]="timelineSegments()"
[recordingFolder]="recordingFolder()"
[recordingMetadata]="recordingMetadata()"
[seriesNavigation]="seriesNavigation()"
@@ -304,6 +304,48 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
expect(artPlayer.interactionEnabled()).toBe(true);
});
describe('catch-up timeline segments', () => {
const segments = [
{ startSeconds: 0, endSeconds: 1800, title: 'News' },
{ startSeconds: 1800, endSeconds: 5400, title: 'Film' },
];
beforeEach(() => {
fixture.componentRef.setInput('timelineSegments', segments);
});
it.each([
['Video.js', () => renderVjsPlayer({ isLive: false })],
['HTML5', () => renderHtmlPlayer({ isLive: false })],
['ArtPlayer', () => renderArtPlayer({ isLive: false })],
] as const)('hands them to %s', async (_label, render) => {
const player = await render();
expect(player.timelineSegments()).toBe(segments);
});
it('hands them to Embedded MPV', () => {
fixture.componentRef.setInput(
'playerOverride',
VideoPlayer.EmbeddedMpv
);
fixture.detectChanges();
const player = fixture.debugElement.query(
By.directive(StubEmbeddedMpvPlayerComponent)
).componentInstance as StubEmbeddedMpvPlayerComponent;
expect(player.timelineSegments()).toBe(segments);
});
it('hands the engines nothing when the host passes nothing', async () => {
fixture.componentRef.setInput('timelineSegments', null);
const player = await renderVjsPlayer({ isLive: true });
expect(player.timelineSegments()).toBeNull();
});
});
it('disables HTML5 surface interaction while a diagnostic is visible', async () => {
const htmlPlayer = await renderHtmlPlayer();
@@ -42,6 +42,7 @@ import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
import {
type PlayerMediaTitle,
PlayerUpNextItem,
type PlayerTimelineSegment,
WEB_PLAYER_SHARED_CONTROLS,
} from '../player-controls';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
@@ -148,6 +149,10 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
/** Next episode for the shared controls' "Up next" card; series hosts only. */
readonly upNext = input<PlayerUpNextItem | null>(null);
/** Catch-up programmes drawn as track segments; null draws one. */
readonly timelineSegments = input<readonly PlayerTimelineSegment[] | null>(
null
);
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
/** Channel/EPG snapshot for the embedded-MPV recording tracker. */
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
@@ -22,6 +22,7 @@ export class StubVjsPlayerComponent {
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly timelineSegments = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly interactionEnabled = input(true);
@@ -44,6 +45,7 @@ export class StubHtmlVideoPlayerComponent {
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly timelineSegments = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -67,6 +69,7 @@ export class StubArtPlayerComponent {
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly timelineSegments = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -100,6 +103,7 @@ export class StubEmbeddedMpvPlayerComponent {
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly timelineSegments = input<unknown>(null);
readonly recordingFolder = input('');
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<unknown>(null);