feat(playback): report Embedded MPV file chapters on the timeline (#1771)

This commit is contained in:
4gray authored and GitHub committed 2026-09-30 22:50:27 +02:00
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()"
@@ -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';