mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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
|
||||
|
||||
|
||||
+46
-13
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user