mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06: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
@@ -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,
|
||||
type LaunchedElectronApp,
|
||||
} 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',
|
||||
});
|
||||
// The launch sits inside the cleanup scope: a failed launch must still
|
||||
// close the media server.
|
||||
let app: LaunchedElectronApp | undefined;
|
||||
try {
|
||||
const launched = await launchElectronApp(dataDir, {
|
||||
env: {
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1',
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY: '1',
|
||||
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW: '1',
|
||||
},
|
||||
});
|
||||
app = launched;
|
||||
const support = await launched.mainWindow.evaluate(() =>
|
||||
window.electron.getEmbeddedMpvSupport()
|
||||
);
|
||||
test.skip(
|
||||
!support.supported || support.engine !== 'frame-copy',
|
||||
`Frame-copy runtime unavailable: ${JSON.stringify(support)}`
|
||||
);
|
||||
await openSettings(launched.mainWindow);
|
||||
await openSettingsSection(launched.mainWindow, 'playback');
|
||||
await launched.mainWindow.getByTestId('select-video-player').click();
|
||||
await launched.mainWindow.getByTestId('embedded-mpv').click();
|
||||
await saveSettings(launched.mainWindow);
|
||||
await goToDashboard(launched.mainWindow);
|
||||
|
||||
const playlist = join(dataDir, 'chapters.m3u');
|
||||
writeFileSync(
|
||||
playlist,
|
||||
`#EXTM3U\n#EXTINF:-1,Chaptered fixture\n${media.url}\n`
|
||||
);
|
||||
await installEmbeddedMpvSessionCapture(launched);
|
||||
await importM3uPlaylistFromNativeDialog(launched, playlist);
|
||||
await channelItemByTitle(launched.mainWindow, 'Chaptered fixture')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The local frame-copy runtime can fail a frame-view initialization,
|
||||
// and the failure can surface at any point while the session starts;
|
||||
// a user Retry recovers it (see player-theme). Retry whenever the
|
||||
// stalled state shows until mpv's chapters arrive.
|
||||
const stalled = launched.mainWindow.locator(
|
||||
'.embedded-mpv-player__stalled'
|
||||
);
|
||||
await expect(async () => {
|
||||
if (await stalled.isVisible()) {
|
||||
await stalled.getByRole('button', { name: 'Retry' }).click();
|
||||
}
|
||||
expect(
|
||||
await launched.mainWindow.evaluate(
|
||||
() =>
|
||||
window.__packagedEmbeddedMpvSessions?.at(-1)
|
||||
?.chapters ?? []
|
||||
)
|
||||
).toEqual([
|
||||
{ timeSeconds: 0, title: 'Intro' },
|
||||
{ timeSeconds: 5, title: 'Episode' },
|
||||
{ timeSeconds: 24, title: 'Credits' },
|
||||
]);
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
const segments = launched.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 {
|
||||
try {
|
||||
if (app) {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
} finally {
|
||||
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: {
|
||||
|
||||
Reference in new issue
Block a user