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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-29 09:12:50 +02:00
1 parent 3f763310e2
commit 301837184b
4 files changed
+94 -43

No files matched your search

@@ -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
@@ -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<VideoPlayerComponentInstance>;
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:
'<ng-template #fullscreenChannelPanel></ng-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' },
]);
});
@@ -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' },
]);
});
@@ -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,