From 301837184baf42b642544f72a3dd4a27281cd0bb Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 29 Sep 2026 09:12:50 +0200 Subject: [PATCH] fix(playback): keep the picked catch-up programme's title on the seek bar - buildCatchupTimelineSegments gives the activated programme its own span; overlapping or revised guide entries only fill the window after it instead of relabelling the archive being played. - The M3U catch-up spec renders the real template and reads the segments app-web-player-view receives, so a dropped binding fails it. - Contract: segments reach Embedded MPV under frame-copy only, like the Up next card; the native-view legacy dock keeps its plain slider. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/player-controls-contract.md | 10 +++- .../video-player-catchup-timeline.spec.ts | 59 +++++++++++++++---- .../catchup-timeline-segments.spec.ts | 28 +++++++-- .../catchup-timeline-segments.ts | 40 ++++++------- 4 files changed, 94 insertions(+), 43 deletions(-) diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index ac6f9c1ac..b1a770685 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -376,8 +376,10 @@ 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 stands in for itself when the host's list has -nothing at the window start. Live playback returns `null`. Stalker has no +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 @@ -387,7 +389,9 @@ 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. +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/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 index 06f9f1795..c169b5924 100644 --- 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 @@ -1,9 +1,13 @@ +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'; @@ -40,6 +44,17 @@ import { 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(), @@ -67,15 +82,13 @@ function programme(title: string, fromHour: number, toHour: number) { } /** - * Catch-up programmes on the seek bar. Kept apart from - * `video-player.component.spec.ts`, which sits at the spec line budget; the - * template is reduced to the panel's `ng-template`, so this reads the - * signal the template binds to `app-web-player-view`. + * 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; - let component: VideoPlayerComponentInstance; beforeAll(async () => { ({ VideoPlayerComponent } = await import('./video-player.component')); @@ -158,15 +171,27 @@ describe('VideoPlayerComponent — catch-up timeline segments', () => { }) .overrideComponent(VideoPlayerComponent, { set: { - imports: [], - template: - '', + imports: [ + AsyncPipe, + StubAudioPlayerComponent, + StubChannelListLoadingStateComponent, + StubEpgGuideComponent, + StubEpgGuideNowPlayingComponent, + StubEpgTimelineComponent, + StubPortalEmptyStateComponent, + StubResizableDirective, + StubSidebarComponent, + StubWebPlayerViewComponent, + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], }, }) .compileComponents(); fixture = TestBed.createComponent(VideoPlayerComponent); - component = fixture.componentInstance; fixture.detectChanges(); }); @@ -174,11 +199,19 @@ describe('VideoPlayerComponent — catch-up timeline segments', () => { 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(component.catchupTimelineSegments()).toBeNull(); + expect(forwarded()).toBeNull(); }); it('spans the programmes from utc up to the lutc of the archive URL', () => { @@ -193,13 +226,13 @@ describe('VideoPlayerComponent — catch-up timeline segments', () => { `http://localhost/archive.m3u8?utc=${T0}&lutc=${T0 + 1.5 * HOUR}` ); - expect(component.catchupTimelineSegments()).toEqual([ + expect(forwarded()).toEqual([ { startSeconds: 0, endSeconds: HOUR, title: 'Picked' }, { startSeconds: HOUR, endSeconds: 1.5 * HOUR, title: 'Next' }, ]); activePlaybackUrl.set(null); - expect(component.catchupTimelineSegments()).toBeNull(); + expect(forwarded()).toBeNull(); }); it('falls back to the programme when the URL carries no lutc', () => { @@ -207,7 +240,7 @@ describe('VideoPlayerComponent — catch-up timeline segments', () => { activeEpgProgram.set(programme('Picked', 0, 1)); activePlaybackUrl.set('http://localhost/archive.m3u8'); - expect(component.catchupTimelineSegments()).toEqual([ + expect(forwarded()).toEqual([ { startSeconds: 0, endSeconds: HOUR, title: 'Picked' }, ]); }); 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 index dd7b1ea84..422edc960 100644 --- 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 @@ -64,15 +64,33 @@ describe('buildCatchupTimelineSegments', () => { ]); }); - it('clips programmes that straddle the window edges', () => { + it('clips programmes that straddle the window end', () => { const segments = buildCatchupTimelineSegments( - [programme('Early', -10, 20), programme('Mid', 20, 40)], - programme('Picked', 5, 30) + [programme('Picked', 0, 30), programme('Long film', 30, 200)], + programme('Picked', 0, 30), + T0 + 60 * 60 ); expect(segments).toEqual([ - { startSeconds: 0, endSeconds: 900, title: 'Early' }, - { startSeconds: 900, endSeconds: 1500, title: 'Mid' }, + { 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' }, ]); }); 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 index 36bea2254..3d5b22278 100644 --- a/libs/ui/playback/src/lib/player-controls/catchup-timeline-segments.ts +++ b/libs/ui/playback/src/lib/player-controls/catchup-timeline-segments.ts @@ -44,16 +44,23 @@ export function buildCatchupTimelineSegments( return null; } - const candidates = [...(programmes ?? [])]; - // The activated programme may come from a list the host no longer - // holds (another EPG date); without a listed programme at the window - // start it stands in for itself. - if (!candidates.some((programme) => coversStart(programme, windowStart))) { - candidates.push(active); - } - - const segments: PlayerTimelineSegment[] = []; - for (const programme of candidates) { + // 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 @@ -62,7 +69,7 @@ export function buildCatchupTimelineSegments( if (start === null || stop === null) { continue; } - const clippedStart = Math.max(start, windowStart); + const clippedStart = Math.max(start, ownEnd, windowStart); const clippedEnd = Math.min(stop, windowEnd); if (clippedEnd <= clippedStart) { continue; @@ -76,17 +83,6 @@ export function buildCatchupTimelineSegments( return segments.sort((a, b) => a.startSeconds - b.startSeconds); } -function coversStart( - programme: CatchupTimelineProgramme, - seconds: number -): boolean { - const start = programmeSeconds(programme.start, programme.startTimestamp); - const stop = programmeSeconds(programme.stop, programme.stopTimestamp); - return ( - start !== null && stop !== null && start <= seconds && seconds < stop - ); -} - /** Epoch seconds: the positive unix timestamp, else the parsed ISO date. */ function programmeSeconds( isoValue: string | null | undefined,