mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
feat(playback): report Embedded MPV file chapters on the timeline (#1771)
This commit is contained in:
1 parent
d0e366a368
commit
9bc577a4c9
24 files changed
+931
-5
No files matched your search
@@ -84,6 +84,13 @@ export interface EmbeddedMpvAudioTrack {
|
||||
|
||||
export type EmbeddedMpvSubtitleTrack = EmbeddedMpvAudioTrack;
|
||||
|
||||
/** One entry of mpv's `chapter-list`, in file order. */
|
||||
export interface EmbeddedMpvChapter {
|
||||
/** Chapter start, in media seconds. */
|
||||
timeSeconds: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface EmbeddedMpvRecordingState {
|
||||
active: boolean;
|
||||
targetPath?: string;
|
||||
@@ -142,6 +149,11 @@ export interface EmbeddedMpvSession {
|
||||
selectedSubtitleTrackId: number | null;
|
||||
playbackSpeed: number;
|
||||
aspectOverride: string;
|
||||
/**
|
||||
* The file's chapters (mpv `chapter-list`). Absent from an addon built
|
||||
* before chapters were observed and from the Linux `--wid` backend.
|
||||
*/
|
||||
chapters?: EmbeddedMpvChapter[];
|
||||
/** Source video size (mpv dwidth/dheight); frame-copy engine only. */
|
||||
videoWidth?: number;
|
||||
videoHeight?: number;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { EmbeddedMpvSession } from '@iptvnator/shared/interfaces';
|
||||
import { embeddedMpvTimelineSegments } from './embedded-mpv-chapter-segments';
|
||||
|
||||
function session(
|
||||
overrides: Partial<EmbeddedMpvSession> = {}
|
||||
): EmbeddedMpvSession {
|
||||
return {
|
||||
id: 'session-1',
|
||||
title: 'Episode',
|
||||
streamUrl: 'https://example.test/episode.mkv',
|
||||
status: 'playing',
|
||||
positionSeconds: 0,
|
||||
durationSeconds: 1500,
|
||||
volume: 1,
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
subtitleTracks: [],
|
||||
selectedSubtitleTrackId: null,
|
||||
playbackSpeed: 1,
|
||||
aspectOverride: 'no',
|
||||
startedAt: '2026-09-30T10:00:00Z',
|
||||
updatedAt: '2026-09-30T10:00:00Z',
|
||||
chapters: [
|
||||
{ timeSeconds: 0, title: 'Opening' },
|
||||
{ timeSeconds: 1410, title: 'Ending' },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('embeddedMpvTimelineSegments', () => {
|
||||
it('maps mpv chapters when the host passes no segments', () => {
|
||||
const expected = [
|
||||
{ startSeconds: 0, endSeconds: 1410, title: 'Opening' },
|
||||
{ startSeconds: 1410, endSeconds: 1500, title: 'Ending' },
|
||||
];
|
||||
expect(embeddedMpvTimelineSegments(null, session())).toEqual(expected);
|
||||
expect(embeddedMpvTimelineSegments([], session())).toEqual(expected);
|
||||
});
|
||||
|
||||
it('keeps catch-up programmes ahead of file chapters', () => {
|
||||
const catchup = [{ startSeconds: 0, endSeconds: 1800, title: 'News' }];
|
||||
expect(embeddedMpvTimelineSegments(catchup, session())).toBe(catchup);
|
||||
});
|
||||
|
||||
it('draws nothing without chapters or a known duration', () => {
|
||||
expect(embeddedMpvTimelineSegments(null, null)).toBeNull();
|
||||
expect(
|
||||
embeddedMpvTimelineSegments(null, session({ chapters: undefined }))
|
||||
).toBeNull();
|
||||
expect(
|
||||
embeddedMpvTimelineSegments(
|
||||
null,
|
||||
session({ durationSeconds: null })
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { EmbeddedMpvSession } from '@iptvnator/shared/interfaces';
|
||||
import { buildChapterTimelineSegments } from '../player-controls/chapter-timeline-segments';
|
||||
import type { PlayerTimelineSegment } from '../player-controls/player-controls.model';
|
||||
|
||||
/**
|
||||
* The segments `app-player-controls` draws for an Embedded MPV session: the
|
||||
* host's catch-up programmes when it passes any, otherwise the file's own
|
||||
* chapters from mpv's `chapter-list`, which also lets a closing-credits
|
||||
* chapter time the "Up next" card.
|
||||
*/
|
||||
export function embeddedMpvTimelineSegments(
|
||||
hostSegments: readonly PlayerTimelineSegment[] | null,
|
||||
session: EmbeddedMpvSession | null
|
||||
): readonly PlayerTimelineSegment[] | null {
|
||||
if (hostSegments?.length) {
|
||||
return hostSegments;
|
||||
}
|
||||
const chapters = session?.chapters?.map((chapter) => ({
|
||||
startSeconds: chapter.timeSeconds,
|
||||
title: chapter.title ?? null,
|
||||
}));
|
||||
return buildChapterTimelineSegments(chapters, session?.durationSeconds);
|
||||
}
|
||||
@@ -104,7 +104,7 @@
|
||||
[fullscreenTarget]="fullscreenTarget()"
|
||||
[mediaTitle]="mediaTitle()"
|
||||
[upNext]="upNext()"
|
||||
[timelineSegments]="timelineSegments()"
|
||||
[timelineSegments]="controlsTimelineSegments()"
|
||||
[showControls]="showControls()"
|
||||
[shortcutsEnabled]="sharedShortcutsEnabled()"
|
||||
(previousEpisodeRequested)="requestPreviousEpisode()"
|
||||
|
||||
+60
@@ -220,6 +220,66 @@ describe('EmbeddedMpvPlayerComponent shared controls host', () => {
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('file chapters', () => {
|
||||
const EPISODE: EmbeddedMpvSession = {
|
||||
...READY_SESSION,
|
||||
durationSeconds: 3000,
|
||||
positionSeconds: 2750,
|
||||
chapters: [
|
||||
{ timeSeconds: 0, title: 'Episode' },
|
||||
{ timeSeconds: 2700, title: 'Credits' },
|
||||
],
|
||||
};
|
||||
const UP_NEXT = { label: 'S01E02', title: 'Next', thumbnailUrl: null };
|
||||
|
||||
function upNextCard(
|
||||
fixture: ComponentFixture<EmbeddedMpvPlayerComponent>
|
||||
): Element | null {
|
||||
return fixture.nativeElement.querySelector(
|
||||
'[data-test-id="player-controls-up-next"]'
|
||||
);
|
||||
}
|
||||
|
||||
function play(session: EmbeddedMpvSession) {
|
||||
const rendered = render();
|
||||
rendered.fixture.componentRef.setInput('upNext', UP_NEXT);
|
||||
rendered.controller.session.set(session);
|
||||
rendered.fixture.detectChanges();
|
||||
return rendered;
|
||||
}
|
||||
|
||||
it('draws mpv chapters on the shared timeline', () => {
|
||||
const { fixture } = play(EPISODE);
|
||||
|
||||
expect(sharedControls(fixture)?.timelineSegments()).toEqual([
|
||||
{ startSeconds: 0, endSeconds: 2700, title: 'Episode' },
|
||||
{ startSeconds: 2700, endSeconds: 3000, title: 'Credits' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps host catch-up segments ahead of file chapters', () => {
|
||||
const { fixture } = play(EPISODE);
|
||||
const catchup = [
|
||||
{ startSeconds: 0, endSeconds: 3000, title: 'Programme' },
|
||||
];
|
||||
fixture.componentRef.setInput('timelineSegments', catchup);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(sharedControls(fixture)?.timelineSegments()).toEqual(
|
||||
catchup
|
||||
);
|
||||
});
|
||||
|
||||
it('shows Up next when the closing-credits chapter starts', () => {
|
||||
// 250 s left: past the credits start, but before the adaptive
|
||||
// lead (4% of 50 min = 2 min) would bring the card up.
|
||||
expect(upNextCard(play(EPISODE).fixture)).not.toBeNull();
|
||||
expect(
|
||||
upNextCard(play({ ...EPISODE, chapters: [] }).fixture)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('syncs bounds on fullscreen changes without revealing legacy state', () => {
|
||||
const { fixture, component, controller } = render();
|
||||
const triggerBoundsSync = jest.spyOn(controller, 'triggerBoundsSync');
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
RecordingStoppedEvent,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { sameTimelineSegments } from '../player-controls/chapter-timeline-segments';
|
||||
import { PlayerControlsComponent } from '../player-controls/player-controls.component';
|
||||
import type {
|
||||
PlayerMediaTitle,
|
||||
@@ -32,6 +33,7 @@ import type {
|
||||
PlayerTimelineSegment,
|
||||
} from '../player-controls/player-controls.model';
|
||||
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
|
||||
import { embeddedMpvTimelineSegments } from './embedded-mpv-chapter-segments';
|
||||
import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter';
|
||||
import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component';
|
||||
import { EmbeddedMpvDockPanelState } from './embedded-mpv-dock-panels';
|
||||
@@ -88,7 +90,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
|
||||
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
|
||||
readonly upNext = input<PlayerUpNextItem | null>(null);
|
||||
/** Catch-up programmes drawn as track segments; null draws one. */
|
||||
/** Catch-up programmes drawn as track segments; they outrank chapters. */
|
||||
readonly timelineSegments = input<readonly PlayerTimelineSegment[] | null>(
|
||||
null
|
||||
);
|
||||
@@ -143,6 +145,12 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
|
||||
readonly support = this.controller.support;
|
||||
readonly session = this.controller.session;
|
||||
/** Catch-up programmes first, else the file's chapters; see the helper. */
|
||||
readonly controlsTimelineSegments = computed(
|
||||
() =>
|
||||
embeddedMpvTimelineSegments(this.timelineSegments(), this.session()),
|
||||
{ equal: sameTimelineSegments }
|
||||
);
|
||||
readonly stalled = this.controller.stalled;
|
||||
|
||||
readonly volume = signal(readStoredVolume());
|
||||
|
||||
@@ -26,6 +26,7 @@ function baseSession(
|
||||
selectedAudioTrackId: null,
|
||||
subtitleTracks: [],
|
||||
selectedSubtitleTrackId: null,
|
||||
chapters: [],
|
||||
playbackSpeed: 1,
|
||||
aspectOverride: 'no',
|
||||
recording: { active: false },
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
buildChapterTimelineSegments,
|
||||
sameTimelineSegments,
|
||||
} from './chapter-timeline-segments';
|
||||
|
||||
describe('buildChapterTimelineSegments', () => {
|
||||
it('runs each chapter to the next start and the last one to the end', () => {
|
||||
expect(
|
||||
buildChapterTimelineSegments(
|
||||
[
|
||||
{ startSeconds: 0, title: 'Intro' },
|
||||
{ startSeconds: 90.5, title: 'Episode' },
|
||||
{ startSeconds: 2640, title: 'Credits' },
|
||||
],
|
||||
2700
|
||||
)
|
||||
).toEqual([
|
||||
{ startSeconds: 0, endSeconds: 90.5, title: 'Intro' },
|
||||
{ startSeconds: 90.5, endSeconds: 2640, title: 'Episode' },
|
||||
{ startSeconds: 2640, endSeconds: 2700, title: 'Credits' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('orders chapters and drops repeated or out-of-range starts', () => {
|
||||
expect(
|
||||
buildChapterTimelineSegments(
|
||||
[
|
||||
{ startSeconds: 600, title: 'B' },
|
||||
{ startSeconds: 0, title: 'A' },
|
||||
{ startSeconds: 600, title: 'B again' },
|
||||
{ startSeconds: -1, title: 'Before' },
|
||||
{ startSeconds: 1200, title: 'At the end' },
|
||||
{ startSeconds: Number.NaN, title: 'Broken' },
|
||||
],
|
||||
1200
|
||||
)
|
||||
).toEqual([
|
||||
{ startSeconds: 0, endSeconds: 600, title: 'A' },
|
||||
{ startSeconds: 600, endSeconds: 1200, title: 'B' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps untitled and blank-titled chapters as untitled segments', () => {
|
||||
expect(
|
||||
buildChapterTimelineSegments(
|
||||
[
|
||||
{ startSeconds: 0 },
|
||||
{ startSeconds: 10, title: ' ' },
|
||||
{ startSeconds: 20, title: ' Outro ' },
|
||||
],
|
||||
30
|
||||
)
|
||||
).toEqual([
|
||||
{ startSeconds: 0, endSeconds: 10, title: null },
|
||||
{ startSeconds: 10, endSeconds: 20, title: null },
|
||||
{ startSeconds: 20, endSeconds: 30, title: 'Outro' },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no chapters', [], 100],
|
||||
['null chapters', null, 100],
|
||||
['an unknown duration', [{ startSeconds: 0 }], null],
|
||||
['a zero duration', [{ startSeconds: 0 }], 0],
|
||||
['an infinite duration', [{ startSeconds: 0 }], Infinity],
|
||||
['only unusable chapters', [{ startSeconds: 500 }], 100],
|
||||
])('returns null for %s', (_label, chapters, duration) => {
|
||||
expect(buildChapterTimelineSegments(chapters, duration)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sameTimelineSegments', () => {
|
||||
const segments = [
|
||||
{ startSeconds: 0, endSeconds: 10, title: 'A' },
|
||||
{ startSeconds: 10, endSeconds: 20, title: null },
|
||||
];
|
||||
|
||||
it('compares by value', () => {
|
||||
expect(
|
||||
sameTimelineSegments(
|
||||
segments,
|
||||
segments.map((segment) => ({ ...segment }))
|
||||
)
|
||||
).toBe(true);
|
||||
expect(sameTimelineSegments(null, null)).toBe(true);
|
||||
expect(sameTimelineSegments(segments, null)).toBe(false);
|
||||
expect(sameTimelineSegments(segments, segments.slice(0, 1))).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
sameTimelineSegments(segments, [
|
||||
segments[0],
|
||||
{ ...segments[1], title: 'B' },
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { PlayerTimelineSegment } from './player-controls.model';
|
||||
|
||||
/** A file chapter as an engine reports it: its start and optional title. */
|
||||
export interface PlayerChapter {
|
||||
startSeconds: number;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns an engine's chapter list into timeline segments: each chapter runs
|
||||
* from its start to the next chapter's start, the last one to the end of the
|
||||
* media. Chapters outside `[0, duration)` and repeated start times are
|
||||
* dropped. Without a finite duration or any usable chapter there is nothing
|
||||
* to draw, so the result is null and the track stays one plain bar.
|
||||
*/
|
||||
export function buildChapterTimelineSegments(
|
||||
chapters: readonly PlayerChapter[] | null | undefined,
|
||||
durationSeconds: number | null | undefined
|
||||
): PlayerTimelineSegment[] | null {
|
||||
if (
|
||||
!chapters?.length ||
|
||||
typeof durationSeconds !== 'number' ||
|
||||
!Number.isFinite(durationSeconds) ||
|
||||
durationSeconds <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const starts = chapters
|
||||
.filter(
|
||||
(chapter) =>
|
||||
Number.isFinite(chapter.startSeconds) &&
|
||||
chapter.startSeconds >= 0 &&
|
||||
chapter.startSeconds < durationSeconds
|
||||
)
|
||||
.sort((a, b) => a.startSeconds - b.startSeconds)
|
||||
.filter(
|
||||
(chapter, index, sorted) =>
|
||||
index === 0 ||
|
||||
chapter.startSeconds !== sorted[index - 1].startSeconds
|
||||
);
|
||||
if (starts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return starts.map((chapter, index) => ({
|
||||
startSeconds: chapter.startSeconds,
|
||||
endSeconds: starts[index + 1]?.startSeconds ?? durationSeconds,
|
||||
title: chapter.title?.trim() || null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Value equality, so a re-polled but unchanged chapter list is no update. */
|
||||
export function sameTimelineSegments(
|
||||
a: readonly PlayerTimelineSegment[] | null,
|
||||
b: readonly PlayerTimelineSegment[] | null
|
||||
): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (!a || !b || a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
return a.every(
|
||||
(segment, index) =>
|
||||
segment.startSeconds === b[index].startSeconds &&
|
||||
segment.endSeconds === b[index].endSeconds &&
|
||||
segment.title === b[index].title
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export * from './legacy-player-shortcuts';
|
||||
export * from './controls-surface';
|
||||
export * from './pointer-focus-release';
|
||||
export * from './catchup-timeline-segments';
|
||||
export * from './chapter-timeline-segments';
|
||||
export * from './controls-timeline';
|
||||
export * from './controls-view-model';
|
||||
export * from './controls-visibility';
|
||||
|
||||
Reference in new issue
Block a user