From f38c6f86d1eba463a13a1ae089a7b8ff2b865d8b Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:34:38 +0200 Subject: [PATCH] feat(playback): draw catch-up programmes as seek-bar segments (#1750) --- .../playback-catchup-timeline-segments.md | 9 + .changes/playback-timeline-segments-model.md | 8 - .../src/xtream-catchup-timeline.e2e.ts | 76 ++++++ docs/architecture/player-controls-contract.md | 40 ++- .../xtream-portal-compatibility.md | 4 + .../video-player-catchup-timeline.spec.ts | 247 ++++++++++++++++++ .../video-player/video-player.component.html | 1 + .../video-player/video-player.component.ts | 13 + .../video-player/video-player.spec-stubs.ts | 1 + .../unified-live-catchup-timeline.spec.ts | 180 +++++++++++++ .../unified-live-epg-view.ts | 20 ++ .../unified-live-tab.component.html | 1 + .../unified-live-tab.component.ts | 1 + .../unified-live-tab.spec-stubs.ts | 1 + .../live-stream-layout-stubs.spec-data.ts | 1 + ...ive-stream-layout.catchup-timeline.spec.ts | 246 +++++++++++++++++ .../live-stream-layout.component.html | 1 + .../live-stream-layout.component.ts | 8 + .../m3u-utils/src/lib/catchup.utils.spec.ts | 22 ++ .../shared/m3u-utils/src/lib/catchup.utils.ts | 18 ++ .../lib/art-player/art-player.component.html | 1 + .../lib/art-player/art-player.component.ts | 5 + .../embedded-mpv-player.component.html | 1 + .../embedded-mpv-player.component.ts | 5 + .../html-video-player.component.html | 1 + .../html-video-player.component.ts | 5 + .../catchup-timeline-segments.spec.ts | 146 +++++++++++ .../catchup-timeline-segments.ts | 97 +++++++ .../playback/src/lib/player-controls/index.ts | 1 + .../lib/vjs-player/vjs-player.component.html | 1 + .../lib/vjs-player/vjs-player.component.ts | 5 + .../web-player-view.component.html | 4 + ...yer-view.component.shared-controls.spec.ts | 42 +++ .../web-player-view.component.ts | 5 + .../web-player-view.spec-stubs.ts | 4 + 35 files changed, 1206 insertions(+), 15 deletions(-) create mode 100644 .changes/playback-catchup-timeline-segments.md delete mode 100644 .changes/playback-timeline-segments-model.md create mode 100644 apps/electron-backend-e2e/src/xtream-catchup-timeline.e2e.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/video-player/video-player-catchup-timeline.spec.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-catchup-timeline.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.catchup-timeline.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/catchup-timeline-segments.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/catchup-timeline-segments.ts diff --git a/.changes/playback-catchup-timeline-segments.md b/.changes/playback-catchup-timeline-segments.md new file mode 100644 index 000000000..a6acc9841 --- /dev/null +++ b/.changes/playback-catchup-timeline-segments.md @@ -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. diff --git a/.changes/playback-timeline-segments-model.md b/.changes/playback-timeline-segments-model.md deleted file mode 100644 index cbcd8eff1..000000000 --- a/.changes/playback-timeline-segments-model.md +++ /dev/null @@ -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. diff --git a/apps/electron-backend-e2e/src/xtream-catchup-timeline.e2e.ts b/apps/electron-backend-e2e/src/xtream-catchup-timeline.e2e.ts new file mode 100644 index 000000000..57458dc6e --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream-catchup-timeline.e2e.ts @@ -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); + } +}); diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index a4d9c5eb4..b1a770685 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -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 diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index ae2494af6..6545321e0 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -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" diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-catchup-timeline.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-catchup-timeline.spec.ts new file mode 100644 index 000000000..c169b5924 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-catchup-timeline.spec.ts @@ -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; + + 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) + ), + }, + }, + { + 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' }, + ]); + }); +}); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 1580b5338..481352fc8 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -127,6 +127,7 @@ : (playerSettings.player ?? null) " [volume]="volume()" + [timelineSegments]="catchupTimelineSegments()" [recordingMetadata]="recordingMetadata()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 39040c24d..e10c4f119 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -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). diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts index f405f92d5..72a55a66b 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts @@ -113,6 +113,7 @@ export class StubWebPlayerViewComponent { readonly playback = input(null); readonly playerOverride = input(null); readonly volume = input(1); + readonly timelineSegments = input(null); readonly recordingMetadata = input(null); readonly externalFallbackRequested = output(); readonly recordingStopped = output(); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-catchup-timeline.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-catchup-timeline.spec.ts new file mode 100644 index 000000000..a99c15d9d --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-catchup-timeline.spec.ts @@ -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(null); + const activeItem = signal(null); + const isM3uSelection = signal(false); + const currentM3uPrograms = signal([]); + const currentPortalEpgItems = signal([]); + let resolveXtreamCatchupUrl: jest.Mock; + + function setup() { + return TestBed.runInInjectionContext(() => { + const common = { + activeTimeshift, + activeItem, + activeDetail: signal(null), + isM3uSelection, + currentM3uChannel: signal(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' }, + ]); + }); +}); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-view.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-view.ts index c66880399..5e40244bf 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-view.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-view.ts @@ -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; readonly timelineArchiveAvailable: Signal; readonly timelineArchiveDays: Signal; + /** The archive window's programmes as seek-bar segments; null live. */ + readonly catchupTimelineSegments: Signal; readonly panelSummary: Signal; readonly panelSummaryLabelKey: Signal; 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) { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 54a2d27f1..0ff2dbcb2 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -67,6 +67,7 @@ [title]="activeDetail()?.playback?.title ?? ''" [playback]="inlinePlayback()" [playerOverride]="inlinePlayer()" + [timelineSegments]="catchupTimelineSegments()" [recordingMetadata]="recordingMetadata()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index ee6eeefe5..a85adb88a 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -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, diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts index 005fc0ef4..837524da1 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts @@ -115,6 +115,7 @@ export class StubWebPlayerViewComponent { readonly title = input(''); readonly playback = input(null); readonly playerOverride = input(null); + readonly timelineSegments = input(null); readonly recordingMetadata = input(null); readonly externalFallbackRequested = output(); readonly recordingStopped = output(); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts index 70fea25b5..6a6b93ae0 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts @@ -61,6 +61,7 @@ export class StubWebPlayerViewComponent { readonly streamUrl = input(''); readonly title = input(''); readonly playback = input(null); + readonly timelineSegments = input(null); readonly recordingMetadata = input(null); readonly externalFallbackRequested = output(); readonly recordingStopped = output(); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.catchup-timeline.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.catchup-timeline.spec.ts new file mode 100644 index 000000000..12c69cdcf --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.catchup-timeline.spec.ts @@ -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; + let component: LiveStreamLayoutComponent; + const emptyList = signal([]); + const epgItems = signal([]); + 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([[1, 1]])), + getPaginatedContent: emptyList, + hasMoreContent: signal(false), + epgItems, + currentEpgItem: signal(null), + isLoadingEpg: signal(false), + selectedTypeContentLoading: signal(false), + selectedCategoryId: signal(1), + selectedContentType: signal('live'), + selectedItem: signal(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(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 5cb783cab..a8ed17f15 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -154,6 +154,7 @@ [streamUrl]="streamUrl" [title]="activePlayback()?.title ?? ''" [playback]="activePlayback()" + [timelineSegments]="catchupTimelineSegments()" [recordingMetadata]="recordingMetadata()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index b24e9986c..6c45da2fe 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -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(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, diff --git a/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts b/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts index cd9fc21c8..c7b36b711 100644 --- a/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts +++ b/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts @@ -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(); + }); + }); }); diff --git a/libs/shared/m3u-utils/src/lib/catchup.utils.ts b/libs/shared/m3u-utils/src/lib/catchup.utils.ts index 612d14113..8b55783db 100644 --- a/libs/shared/m3u-utils/src/lib/catchup.utils.ts +++ b/libs/shared/m3u-utils/src/lib/catchup.utils.ts @@ -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 { diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.html b/libs/ui/playback/src/lib/art-player/art-player.component.html index 918f3a631..799a85e12 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.html +++ b/libs/ui/playback/src/lib/art-player/art-player.component.html @@ -13,6 +13,7 @@ [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" [upNext]="upNext()" + [timelineSegments]="timelineSegments()" [showControls]="interactionEnabled()" [shortcutsEnabled]="interactionEnabled()" (previousEpisodeRequested)="previousEpisodeRequested.emit()" diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index bc815ac91..fd216f783 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -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(null); readonly upNext = input(null); + /** Catch-up programmes drawn as track segments; null draws one. */ + readonly timelineSegments = input( + null + ); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html index aff35a474..62f36d072 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html @@ -104,6 +104,7 @@ [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" [upNext]="upNext()" + [timelineSegments]="timelineSegments()" [showControls]="showControls()" [shortcutsEnabled]="sharedShortcutsEnabled()" (previousEpisodeRequested)="requestPreviousEpisode()" diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index b35a715c6..17e6b7452 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -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(null); readonly mediaTitle = input(null); readonly upNext = input(null); + /** Catch-up programmes drawn as track segments; null draws one. */ + readonly timelineSegments = input( + null + ); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */ readonly fullscreenTarget = input(null); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html index e554aaa6f..e502bf21e 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html @@ -13,6 +13,7 @@ [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" [upNext]="upNext()" + [timelineSegments]="timelineSegments()" [showControls]="interactionEnabled()" [shortcutsEnabled]="interactionEnabled()" (previousEpisodeRequested)="previousEpisodeRequested.emit()" diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index b4a5ce6a5..a0384a293 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -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(null); readonly upNext = input(null); + /** Catch-up programmes drawn as track segments; null draws one. */ + readonly timelineSegments = input( + null + ); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); @Output() timeUpdate = new EventEmitter(); diff --git a/libs/ui/playback/src/lib/player-controls/catchup-timeline-segments.spec.ts b/libs/ui/playback/src/lib/player-controls/catchup-timeline-segments.spec.ts new file mode 100644 index 000000000..422edc960 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/catchup-timeline-segments.spec.ts @@ -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], + ]); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/catchup-timeline-segments.ts b/libs/ui/playback/src/lib/player-controls/catchup-timeline-segments.ts new file mode 100644 index 000000000..3d5b22278 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/catchup-timeline-segments.ts @@ -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; +} diff --git a/libs/ui/playback/src/lib/player-controls/index.ts b/libs/ui/playback/src/lib/player-controls/index.ts index ee43d7b32..9dcab2561 100644 --- a/libs/ui/playback/src/lib/player-controls/index.ts +++ b/libs/ui/playback/src/lib/player-controls/index.ts @@ -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'; diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html index 331a38777..1a602f26a 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html @@ -15,6 +15,7 @@ [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" [upNext]="upNext()" + [timelineSegments]="timelineSegments()" [showControls]="interactionEnabled()" [shortcutsEnabled]="interactionEnabled()" (previousEpisodeRequested)="previousEpisodeRequested.emit()" diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 33cc796b8..6770f466e 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -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(null); readonly upNext = input(null); + /** Catch-up programmes drawn as track segments; null draws one. */ + readonly timelineSegments = input( + null + ); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index 5b204c4a9..3d5d96b0a 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -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()" diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts index cf3c77639..3adef78ed 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts @@ -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(); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index b5947f51e..a1c55b8f9 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -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(null); /** Next episode for the shared controls' "Up next" card; series hosts only. */ readonly upNext = input(null); + /** Catch-up programmes drawn as track segments; null draws one. */ + readonly timelineSegments = input( + null + ); readonly alternativeSources = input([]); /** Channel/EPG snapshot for the embedded-MPV recording tracker. */ readonly recordingMetadata = input(null); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts index 400585d11..f1c186f4d 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts @@ -22,6 +22,7 @@ export class StubVjsPlayerComponent { readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); readonly upNext = input(null); + readonly timelineSegments = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly interactionEnabled = input(true); @@ -44,6 +45,7 @@ export class StubHtmlVideoPlayerComponent { readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); readonly upNext = input(null); + readonly timelineSegments = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly isLive = input(true); @@ -67,6 +69,7 @@ export class StubArtPlayerComponent { readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); readonly upNext = input(null); + readonly timelineSegments = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly isLive = input(true); @@ -100,6 +103,7 @@ export class StubEmbeddedMpvPlayerComponent { readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); readonly upNext = input(null); + readonly timelineSegments = input(null); readonly recordingFolder = input(''); readonly recordingMetadata = input(null); readonly seriesNavigation = input(null);