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

Observe mpv `chapter-list` in the macOS and Windows addons and the
frame-copy helper, carry it as `EmbeddedMpvSession.chapters`, and map it
to `PlayerTimelineSegment[]` for `app-player-controls` when the host
passes no catch-up segments. A closing-credits chapter now times the
Up next card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-30 22:02:12 +02:00
1 parent 5f6e8b037f
commit 0b96fe70fa
24 files changed
+820 -5

No files matched your search

@@ -0,0 +1,129 @@
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import {
channelItemByTitle,
closeElectronApp,
expect,
goToDashboard,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
openSettings,
openSettingsSection,
saveSettings,
test,
workspaceRoot,
} from './electron-test-fixtures';
import {
createLocalMediaServer,
installEmbeddedMpvSessionCapture,
} from './embedded-mpv-frame-copy-packaged-fixtures';
/**
* mpv's `chapter-list` must reach the shared controls: the chaptered fixture
* (Intro 0 s, Episode 5 s, Credits 24 s of 30 s) is drawn as three titled
* timeline segments. Frame-copy is the Embedded MPV engine that mounts
* `app-player-controls`; the test skips where that runtime is unavailable.
*/
test('@playback @electron @embedded-mpv frame-copy draws file chapters on the timeline', async ({
dataDir,
}) => {
const media = await createLocalMediaServer({
body: readFileSync(
join(
workspaceRoot,
'apps/web-e2e/src/fixtures/playback/episode-chapters.webm'
)
),
resourcePath: '/episode-chapters.webm',
contentType: 'video/webm',
});
const app = await launchElectronApp(dataDir, {
env: {
IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1',
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY: '1',
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW: '1',
},
});
try {
const support = await app.mainWindow.evaluate(() =>
window.electron.getEmbeddedMpvSupport()
);
test.skip(
!support.supported || support.engine !== 'frame-copy',
`Frame-copy runtime unavailable: ${JSON.stringify(support)}`
);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'playback');
await app.mainWindow.getByTestId('select-video-player').click();
await app.mainWindow.getByTestId('embedded-mpv').click();
await saveSettings(app.mainWindow);
await goToDashboard(app.mainWindow);
const playlist = join(dataDir, 'chapters.m3u');
writeFileSync(
playlist,
`#EXTM3U\n#EXTINF:-1,Chaptered fixture\n${media.url}\n`
);
await installEmbeddedMpvSessionCapture(app);
await importM3uPlaylistFromNativeDialog(app, playlist);
await channelItemByTitle(app.mainWindow, 'Chaptered fixture')
.first()
.click();
// The local frame-copy runtime can fail its first frame-view
// initialization; one user Retry recovers it (see player-theme).
await expect
.poll(() =>
app.mainWindow.evaluate(
() =>
(window.__packagedEmbeddedMpvSessions?.at(-1)?.chapters
?.length ?? 0) > 0 ||
!!document.querySelector('.embedded-mpv-player__stalled')
),
{ timeout: 20000 }
)
.toBe(true);
const stalled = app.mainWindow.locator('.embedded-mpv-player__stalled');
if (await stalled.isVisible()) {
await stalled.getByRole('button', { name: 'Retry' }).click();
}
await expect
.poll(
() =>
app.mainWindow.evaluate(
() =>
window.__packagedEmbeddedMpvSessions?.at(-1)
?.chapters ?? []
),
{ timeout: 20000 }
)
.toEqual([
{ timeSeconds: 0, title: 'Intro' },
{ timeSeconds: 5, title: 'Episode' },
{ timeSeconds: 24, title: 'Credits' },
]);
const segments = app.mainWindow.locator(
'app-player-controls .player-controls__timeline-segment'
);
await expect(segments).toHaveCount(3);
await expect
.poll(() =>
segments.evaluateAll((elements) =>
elements.map((element) => ({
title: element.getAttribute('data-segment-title'),
left: Math.round(parseFloat(element.style.left)),
}))
)
)
.toEqual([
{ title: 'Intro', left: 0 },
{ title: 'Episode', left: 17 },
{ title: 'Credits', left: 80 },
]);
} finally {
await closeElectronApp(app);
await media.close();
}
});
@@ -360,12 +360,23 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
resourcePath: '/stream-stats-switch.webm',
contentType: 'video/webm',
});
const chapteredMedia = await createLocalMediaServer({
body: readFileSync(
join(
__dirname,
'../../web-e2e/src/fixtures/playback/episode-chapters.webm'
)
),
resourcePath: '/chapters.webm',
contentType: 'video/webm',
});
media = {
url: mediaServer.url,
close: async () => {
await Promise.all([
mediaServer.close(),
alternateMedia.close(),
chapteredMedia.close(),
]);
},
};
@@ -415,6 +426,33 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
});
}
// mpv's `chapter-list` reaches the renderer session (it draws the
// timeline segments and times the Up next card), and the next
// file starts without the previous file's chapters.
await launchedFrameCopyApp.mainWindow.evaluate(
async ({ sessionId, streamUrl }) => {
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
streamUrl,
title: 'Chaptered episode',
isLive: false,
});
},
{ sessionId: created.id, streamUrl: chapteredMedia.url }
);
await expect
.poll(
() => getLatestSession(launchedFrameCopyApp, created.id),
{ timeout: 15000 }
)
.toMatchObject({
streamUrl: chapteredMedia.url,
chapters: [
{ timeSeconds: 0, title: 'Intro' },
{ timeSeconds: 5, title: 'Episode' },
{ timeSeconds: 24, title: 'Credits' },
],
});
// Audio-only replacements must not retain the previous video size.
await launchedFrameCopyApp.mainWindow.evaluate(
async ({ sessionId, streamUrl }) => {
@@ -433,6 +471,7 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
)
.toMatchObject({
streamUrl: audioMedia.url,
chapters: [],
videoWidth: 0,
videoHeight: 0,
stats: {