mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(playback): draw catch-up programmes as seek-bar segments (#1750)
This commit is contained in:
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);
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
+247
@@ -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>();
|
||||
|
||||
+180
@@ -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) {
|
||||
|
||||
+1
@@ -67,6 +67,7 @@
|
||||
[title]="activeDetail()?.playback?.title ?? ''"
|
||||
[playback]="inlinePlayback()"
|
||||
[playerOverride]="inlinePlayer()"
|
||||
[timelineSegments]="catchupTimelineSegments()"
|
||||
[recordingMetadata]="recordingMetadata()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+1
@@ -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>();
|
||||
|
||||
+1
@@ -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>();
|
||||
|
||||
+246
@@ -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();
|
||||
});
|
||||
});
|
||||
+1
@@ -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()"
|
||||
|
||||
+42
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user