diff --git a/.changes/playback-mpv-file-chapters.md b/.changes/playback-mpv-file-chapters.md new file mode 100644 index 000000000..e87469ed7 --- /dev/null +++ b/.changes/playback-mpv-file-chapters.md @@ -0,0 +1,6 @@ +--- +type: feature +area: playback +--- + +With the Embedded MPV frame-copy engine, a movie's or episode's own chapters now appear as sections on the timeline, with their titles in the hover label. When an episode has a closing-credits chapter, the "Up next" card appears as the credits start. diff --git a/apps/electron-backend-e2e/src/embedded-mpv-chapters.e2e.ts b/apps/electron-backend-e2e/src/embedded-mpv-chapters.e2e.ts new file mode 100644 index 000000000..4087d46a7 --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-chapters.e2e.ts @@ -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(); + } + } +}); diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts index ac13e51d4..795a3dee6 100644 --- a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts @@ -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: { diff --git a/apps/electron-backend/native/helper/mpv_frame_helper.cpp b/apps/electron-backend/native/helper/mpv_frame_helper.cpp index f2cfbf762..785a64a65 100644 --- a/apps/electron-backend/native/helper/mpv_frame_helper.cpp +++ b/apps/electron-backend/native/helper/mpv_frame_helper.cpp @@ -55,6 +55,12 @@ struct TrackInfo { bool forced = false; }; +/* One entry of mpv's `chapter-list`: start time and optional title. */ +struct ChapterInfo { + double timeSeconds = 0; + std::string title; +}; + struct SnapshotState { std::string status = "idle"; double positionSeconds = 0; @@ -118,6 +124,7 @@ struct SnapshotState { int64_t selectedAudioTrackId = -1; std::vector subtitleTracks; int64_t selectedSubtitleTrackId = -1; + std::vector chapters; double playbackSpeed = 1; std::string aspectOverride = "no"; bool recordingActive = false; @@ -187,6 +194,21 @@ std::string tracksJson(const std::vector& tracks, return out; } +std::string chaptersJson(const std::vector& chapters) { + std::string out = "["; + bool first = true; + for (const ChapterInfo& chapter : chapters) { + JsonWriter writer; + writer.num("timeSeconds", chapter.timeSeconds); + if (!chapter.title.empty()) writer.str("title", chapter.title); + if (!first) out += ','; + first = false; + out += writer.finish(); + } + out += ']'; + return out; +} + /* Compose the `stats` object of the snapshot event. Always emitted, even when * empty: the adapter merges snapshot events field by field, so an omitted key * would leave the previous stream's numbers in place across a channel switch. @@ -262,6 +284,9 @@ std::string composeSnapshotLocked() { } else { writer.nullValue("selectedSubtitleTrackId"); } + /* Always emitted: the adapter merges snapshot events field by field, so + * an omitted key would keep the previous file's chapters. */ + writer.raw("chapters", chaptersJson(s.chapters)); writer.num("playbackSpeed", s.playbackSpeed); writer.str("aspectOverride", s.aspectOverride); writer.raw("stats", composeStatsJsonLocked()); @@ -383,6 +408,63 @@ void updateTracksFromNode(const mpv_node& trackListNode) { g_state.snapshot.subtitleTracks = std::move(subs); } +/* Snapshots repeat the chapter list on every emit, so a file with thousands + * of chapters or a huge title must not inflate every snapshot. Beyond these + * bounds a timeline cannot draw anything useful anyway. */ +constexpr size_t kMaxChapters = 256; +constexpr size_t kMaxChapterTitleBytes = 256; + +/* Cut to at most `maxBytes` without splitting a UTF-8 sequence. */ +std::string truncateUtf8(const char* value, size_t maxBytes) { + std::string text = value ? value : ""; + if (text.size() <= maxBytes) return text; + size_t end = maxBytes; + while (end > 0 && + (static_cast(text[end]) & 0xC0) == 0x80) { + end--; + } + text.resize(end); + return text; +} + +/* mpv `chapter-list`: an array of { time, title? } maps in file order. */ +void updateChaptersFromNode(const mpv_node& chapterListNode) { + std::vector chapters; + if (chapterListNode.format == MPV_FORMAT_NODE_ARRAY && + chapterListNode.u.list) { + for (int i = 0; i < chapterListNode.u.list->num && + chapters.size() < kMaxChapters; + i++) { + const mpv_node& entry = chapterListNode.u.list->values[i]; + if (entry.format != MPV_FORMAT_NODE_MAP || !entry.u.list) continue; + ChapterInfo chapter; + bool hasTime = false; + const mpv_node_list& map = *entry.u.list; + for (int k = 0; k < map.num; k++) { + const char* key = map.keys[k]; + const mpv_node& value = map.values[k]; + if (!key) continue; + if (std::strcmp(key, "time") == 0 && + value.format == MPV_FORMAT_DOUBLE) { + chapter.timeSeconds = value.u.double_; + hasTime = std::isfinite(chapter.timeSeconds); + } else if (std::strcmp(key, "time") == 0 && + value.format == MPV_FORMAT_INT64) { + chapter.timeSeconds = (double)value.u.int64; + hasTime = true; + } else if (std::strcmp(key, "title") == 0 && + value.format == MPV_FORMAT_STRING && + value.u.string) { + chapter.title = + truncateUtf8(value.u.string, kMaxChapterTitleBytes); + } + } + if (hasTime) chapters.push_back(std::move(chapter)); + } + } + g_state.snapshot.chapters = std::move(chapters); +} + bool parseTrackSelection(const char* value, int64_t& out) { if (!value) return false; char* end = nullptr; @@ -436,6 +518,9 @@ void handlePropertyChange(const mpv_event_property& property) { } else if (name == "track-list" && property.format == MPV_FORMAT_NODE && property.data) { updateTracksFromNode(*static_cast(property.data)); + } else if (name == "chapter-list" && + property.format == MPV_FORMAT_NODE && property.data) { + updateChaptersFromNode(*static_cast(property.data)); } else if (name == "aid" && property.format == MPV_FORMAT_STRING && property.data) { int64_t selected = -1; @@ -536,6 +621,7 @@ void runMpvEventLoop() { s.selectedAudioTrackId = -1; s.subtitleTracks.clear(); s.selectedSubtitleTrackId = -1; + s.chapters.clear(); g_state.loadedPath = false; markDirtyLocked(); break; @@ -1115,6 +1201,8 @@ int main(int argc, char** argv) { MPV_FORMAT_STRING); mpv_observe_property(g_state.mpv, 24, "audio-params/samplerate", MPV_FORMAT_INT64); + /* File chapters draw the timeline segments and mark closing credits. */ + mpv_observe_property(g_state.mpv, 25, "chapter-list", MPV_FORMAT_NODE); g_state.pipeline.onGenerationChanged = [](const std::string& name, int width, int height, diff --git a/apps/electron-backend/native/src/embedded_mpv.mm b/apps/electron-backend/native/src/embedded_mpv.mm index b0d3ee4de..7bc62bbad 100644 --- a/apps/electron-backend/native/src/embedded_mpv.mm +++ b/apps/electron-backend/native/src/embedded_mpv.mm @@ -91,6 +91,12 @@ struct AudioTrack { bool forced = false; }; +// One entry of mpv's `chapter-list`: start time and optional title. +struct Chapter { + double timeSeconds = 0.0; + std::string title; +}; + struct SessionSnapshot { SessionStatus status = SessionStatus::Idle; double positionSeconds = 0.0; @@ -108,6 +114,7 @@ struct SessionSnapshot { int64_t selectedAudioTrackId = -1; std::vector subtitleTracks; int64_t selectedSubtitleTrackId = -1; + std::vector chapters; double playbackSpeed = 1.0; std::string aspectOverride = "no"; // Stream diagnostics for the player's info popover. Sentinels mean "mpv @@ -1087,6 +1094,67 @@ void updateAudioTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node) ); } +// Snapshots repeat the chapter list on every emit, so a file with thousands +// of chapters or a huge title must not inflate every snapshot. Beyond these +// bounds a timeline cannot draw anything useful anyway. +constexpr size_t kMaxChapters = 256; +constexpr size_t kMaxChapterTitleBytes = 256; + +// Cut to at most `maxBytes` without splitting a UTF-8 sequence. +std::string truncateUtf8(const std::string& value, size_t maxBytes) +{ + std::string text = value; + if (text.size() <= maxBytes) return text; + size_t end = maxBytes; + while (end > 0 && + (static_cast(text[end]) & 0xC0) == 0x80) { + end--; + } + text.resize(end); + return text; +} + +// mpv `chapter-list`: an array of { time, title? } maps in file order. +void updateChaptersFromNode(SessionSnapshot& snapshot, const mpv_node& node) +{ + snapshot.chapters.clear(); + if (node.format != MPV_FORMAT_NODE_ARRAY || + !node.u.list || + !node.u.list->values) { + return; + } + + for (int index = 0; + index < node.u.list->num && snapshot.chapters.size() < kMaxChapters; + index += 1) { + const mpv_node& chapterNode = node.u.list->values[index]; + if (chapterNode.format != MPV_FORMAT_NODE_MAP) { + continue; + } + + const mpv_node* timeNode = getNodeMapValue(chapterNode, "time"); + if (!timeNode) { + continue; + } + Chapter chapter; + if (timeNode->format == MPV_FORMAT_DOUBLE) { + chapter.timeSeconds = timeNode->u.double_; + } else if (timeNode->format == MPV_FORMAT_INT64) { + chapter.timeSeconds = static_cast(timeNode->u.int64); + } else { + continue; + } + if (!std::isfinite(chapter.timeSeconds)) { + continue; + } + if (const mpv_node* titleNode = getNodeMapValue(chapterNode, "title")) { + chapter.title = + truncateUtf8(readNodeString(*titleNode), kMaxChapterTitleBytes); + } + snapshot.chapters.push_back(chapter); + } +} + void updateSubtitleTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node) { updateTracksFromNode( @@ -1228,6 +1296,7 @@ void runEventLoop(const std::shared_ptr& session) session->snapshot.selectedAudioTrackId = -1; session->snapshot.subtitleTracks.clear(); session->snapshot.selectedSubtitleTrackId = -1; + session->snapshot.chapters.clear(); session->loadedPath = false; break; case MPV_EVENT_FILE_LOADED: @@ -1354,6 +1423,16 @@ void runEventLoop(const std::shared_ptr& session) break; } + if (propertyName == "chapter-list" && + property->format == MPV_FORMAT_NODE && + property->data) { + updateChaptersFromNode( + session->snapshot, + *static_cast(property->data) + ); + break; + } + if (propertyName == "aid" && property->format == MPV_FORMAT_STRING && property->data) { @@ -1993,6 +2072,8 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info) "audio-params/samplerate", MPV_FORMAT_INT64 ); + // File chapters draw the timeline segments and mark closing credits. + mpv_observe_property(session->handle, 23, "chapter-list", MPV_FORMAT_NODE); session->running.store(true); session->eventThread = std::thread(runEventLoop, session); @@ -2818,6 +2899,21 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info) } result.Set("subtitleTracks", subtitleTracks); + auto chapters = Napi::Array::New(env, snapshot.chapters.size()); + for (size_t index = 0; index < snapshot.chapters.size(); index += 1) { + const Chapter& chapter = snapshot.chapters[index]; + auto chapterObject = Napi::Object::New(env); + chapterObject.Set( + "timeSeconds", + Napi::Number::New(env, chapter.timeSeconds) + ); + if (!chapter.title.empty()) { + chapterObject.Set("title", Napi::String::New(env, chapter.title)); + } + chapters.Set(index, chapterObject); + } + result.Set("chapters", chapters); + result.Set( "playbackSpeed", Napi::Number::New(env, snapshot.playbackSpeed) diff --git a/apps/electron-backend/native/src/embedded_mpv_wid_common.h b/apps/electron-backend/native/src/embedded_mpv_wid_common.h index c67c4393c..9997a6790 100644 --- a/apps/electron-backend/native/src/embedded_mpv_wid_common.h +++ b/apps/electron-backend/native/src/embedded_mpv_wid_common.h @@ -112,6 +112,12 @@ struct AudioTrack { bool forced = false; }; +/** One entry of mpv's `chapter-list`: start time and optional title. */ +struct Chapter { + double timeSeconds = 0.0; + std::string title; +}; + struct SessionSnapshot { SessionStatus status = SessionStatus::Idle; double positionSeconds = 0.0; @@ -125,6 +131,8 @@ struct SessionSnapshot { int64_t selectedAudioTrackId = -1; std::vector subtitleTracks; int64_t selectedSubtitleTrackId = -1; + /** libmpv backend only; the Linux `--wid` poller does not read it. */ + std::vector chapters; double playbackSpeed = 1.0; std::string aspectOverride = "no"; /** Stream diagnostics for the player's info popover. Sentinels mean "not @@ -1400,6 +1408,65 @@ void updateAudioTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node) } } +/* Snapshots repeat the chapter list on every emit, so a file with thousands + * of chapters or a huge title must not inflate every snapshot. Beyond these + * bounds a timeline cannot draw anything useful anyway. */ +constexpr size_t kMaxChapters = 256; +constexpr size_t kMaxChapterTitleBytes = 256; + +/* Cut to at most `maxBytes` without splitting a UTF-8 sequence. */ +std::string truncateUtf8(const std::string& value, size_t maxBytes) +{ + std::string text = value; + if (text.size() <= maxBytes) return text; + size_t end = maxBytes; + while (end > 0 && + (static_cast(text[end]) & 0xC0) == 0x80) { + end--; + } + text.resize(end); + return text; +} + +/** mpv `chapter-list`: an array of { time, title? } maps in file order. */ +void updateChaptersFromNode(SessionSnapshot& snapshot, const mpv_node& node) +{ + snapshot.chapters.clear(); + if (node.format != MPV_FORMAT_NODE_ARRAY || !node.u.list || + !node.u.list->values) { + return; + } + + for (int index = 0; + index < node.u.list->num && snapshot.chapters.size() < kMaxChapters; + index += 1) { + const mpv_node& chapterNode = node.u.list->values[index]; + if (chapterNode.format != MPV_FORMAT_NODE_MAP) { + continue; + } + const mpv_node* timeNode = getNodeMapValue(chapterNode, "time"); + if (!timeNode) { + continue; + } + Chapter chapter; + if (timeNode->format == MPV_FORMAT_DOUBLE) { + chapter.timeSeconds = timeNode->u.double_; + } else if (timeNode->format == MPV_FORMAT_INT64) { + chapter.timeSeconds = static_cast(timeNode->u.int64); + } else { + continue; + } + if (!std::isfinite(chapter.timeSeconds)) { + continue; + } + if (const mpv_node* titleNode = getNodeMapValue(chapterNode, "title")) { + chapter.title = + truncateUtf8(readNodeString(*titleNode), kMaxChapterTitleBytes); + } + snapshot.chapters.push_back(chapter); + } +} + void updateSubtitleTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node) { snapshot.subtitleTracks = readTracksOfType(node, "sub"); @@ -1498,6 +1565,7 @@ void runEventLoop(std::shared_ptr session) session->snapshot.status = SessionStatus::Loading; session->snapshot.error.clear(); session->snapshot.clearStreamStats(); + session->snapshot.chapters.clear(); break; case MPV_EVENT_FILE_LOADED: session->snapshot.status = @@ -1580,6 +1648,10 @@ void runEventLoop(std::shared_ptr session) *static_cast(property->data); updateAudioTracksFromNode(session->snapshot, node); updateSubtitleTracksFromNode(session->snapshot, node); + } else if (name == "chapter-list" && property->format == MPV_FORMAT_NODE) { + updateChaptersFromNode( + session->snapshot, + *static_cast(property->data)); } else if (name == "aid") { const char* rawValue = property->format == MPV_FORMAT_STRING ? *static_cast(property->data) @@ -1918,6 +1990,8 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info) session->handle, 21, "audio-params/channels", MPV_FORMAT_STRING); mpv_observe_property( session->handle, 22, "audio-params/samplerate", MPV_FORMAT_INT64); + /* File chapters draw the timeline segments and mark closing credits. */ + mpv_observe_property(session->handle, 23, "chapter-list", MPV_FORMAT_NODE); session->running.store(true); session->eventThread = std::thread(runEventLoop, session); @@ -2624,6 +2698,17 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info) result.Set("selectedSubtitleTrackId", env.Null()); } writeTracks(env, result, "subtitleTracks", snapshot.subtitleTracks); + auto chapters = Napi::Array::New(env, snapshot.chapters.size()); + for (size_t index = 0; index < snapshot.chapters.size(); index += 1) { + const Chapter& chapter = snapshot.chapters[index]; + auto chapterObject = Napi::Object::New(env); + chapterObject.Set("timeSeconds", Napi::Number::New(env, chapter.timeSeconds)); + if (!chapter.title.empty()) { + chapterObject.Set("title", Napi::String::New(env, chapter.title)); + } + chapters.Set(index, chapterObject); + } + result.Set("chapters", chapters); result.Set("playbackSpeed", Napi::Number::New(env, snapshot.playbackSpeed)); result.Set("aspectOverride", Napi::String::New(env, snapshot.aspectOverride)); writeStreamStats(env, result, snapshot); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-chapters.util.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-chapters.util.spec.ts new file mode 100644 index 000000000..9a1848f6f --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-chapters.util.spec.ts @@ -0,0 +1,61 @@ +import { + MAX_EMBEDDED_MPV_CHAPTERS, + MAX_EMBEDDED_MPV_CHAPTER_TITLE_LENGTH, + normalizeEmbeddedMpvChapters, +} from './embedded-mpv-chapters.util'; + +describe('normalizeEmbeddedMpvChapters', () => { + it('keeps valid chapters and trims titles', () => { + expect( + normalizeEmbeddedMpvChapters([ + { timeSeconds: 0, title: ' Intro ' }, + { timeSeconds: 12.5 }, + { timeSeconds: 40, title: '' }, + ]) + ).toEqual([ + { timeSeconds: 0, title: 'Intro' }, + { timeSeconds: 12.5 }, + { timeSeconds: 40 }, + ]); + }); + + it('drops malformed entries', () => { + expect( + normalizeEmbeddedMpvChapters([ + null, + 'chapter', + { title: 'No time' }, + { timeSeconds: -1 }, + { timeSeconds: Number.NaN }, + { timeSeconds: '10' }, + { timeSeconds: 5, title: 7 }, + ]) + ).toEqual([{ timeSeconds: 5 }]); + }); + + it('bounds the chapter count and title length', () => { + const chapters = normalizeEmbeddedMpvChapters( + Array.from({ length: MAX_EMBEDDED_MPV_CHAPTERS + 50 }, (_, i) => ({ + timeSeconds: i, + title: '๐Ÿ˜€'.repeat(MAX_EMBEDDED_MPV_CHAPTER_TITLE_LENGTH + 5), + })) + ); + + expect(chapters).toHaveLength(MAX_EMBEDDED_MPV_CHAPTERS); + expect(chapters.at(-1)?.timeSeconds).toBe( + MAX_EMBEDDED_MPV_CHAPTERS - 1 + ); + // Cut by code point, so no surrogate pair is split. + expect(Array.from(chapters[0].title ?? '')).toHaveLength( + MAX_EMBEDDED_MPV_CHAPTER_TITLE_LENGTH + ); + expect(chapters[0].title).toBe( + '๐Ÿ˜€'.repeat(MAX_EMBEDDED_MPV_CHAPTER_TITLE_LENGTH) + ); + }); + + it('treats a missing field from an older binary as no chapters', () => { + expect(normalizeEmbeddedMpvChapters(undefined)).toEqual([]); + expect(normalizeEmbeddedMpvChapters({})).toEqual([]); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-chapters.util.ts b/apps/electron-backend/src/app/services/embedded-mpv-chapters.util.ts new file mode 100644 index 000000000..7c76fb99a --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-chapters.util.ts @@ -0,0 +1,51 @@ +import type { EmbeddedMpvChapter } from '@iptvnator/shared/interfaces'; + +/** + * Every session update repeats the list, so a chapter-heavy file must not + * inflate each IPC payload; the native parsers apply the same bounds. + */ +export const MAX_EMBEDDED_MPV_CHAPTERS = 256; +export const MAX_EMBEDDED_MPV_CHAPTER_TITLE_LENGTH = 256; + +/** + * Validate the addon's or helper's `chapters` snapshot field before it + * reaches the renderer: an older binary omits it, and a malformed entry must + * not draw a segment; the list and each title are bounded. Order is left to the renderer's timeline mapping. + */ +export function normalizeEmbeddedMpvChapters( + value: unknown +): EmbeddedMpvChapter[] { + if (!Array.isArray(value)) { + return []; + } + const chapters: EmbeddedMpvChapter[] = []; + for (const entry of value) { + if (chapters.length >= MAX_EMBEDDED_MPV_CHAPTERS) { + break; + } + if (!entry || typeof entry !== 'object') { + continue; + } + const { timeSeconds, title } = entry as Record; + if ( + typeof timeSeconds !== 'number' || + !Number.isFinite(timeSeconds) || + timeSeconds < 0 + ) { + continue; + } + const trimmedTitle = + typeof title === 'string' + ? Array.from(title.trim()) + .slice(0, MAX_EMBEDDED_MPV_CHAPTER_TITLE_LENGTH) + .join('') + .trim() + : ''; + chapters.push( + trimmedTitle + ? { timeSeconds, title: trimmedTitle } + : { timeSeconds } + ); + } + return chapters; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.ts index 3f5a56429..0e5eb59f1 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.ts @@ -53,6 +53,7 @@ export function createInitialSnapshot(): NativeEmbeddedMpvSessionSnapshot { selectedAudioTrackId: null, subtitleTracks: [], selectedSubtitleTrackId: null, + chapters: [], playbackSpeed: 1, aspectOverride: 'no', recording: { active: false }, diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index c4b43007d..a6d561231 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -83,6 +83,7 @@ interface MockSnapshot { streamUrl: string; audioTracks?: never[]; selectedAudioTrackId?: number | null; + chapters?: Array<{ timeSeconds: number; title?: string }>; stats?: { fps?: number; videoBitrateBps?: number; @@ -580,6 +581,24 @@ describe('EmbeddedMpvNativeService power blocker', () => { }); }); + it('passes validated file chapters through to the renderer session', () => { + startSession('s1', snapshot('playing')); + addon.getSessionSnapshot.mockReturnValue( + snapshot('playing', { + chapters: [ + { timeSeconds: 0, title: 'Intro' }, + { timeSeconds: Number.NaN, title: 'Broken' }, + { timeSeconds: 1320, title: 'Credits' }, + ], + }) + ); + + expect(service.setVolume('s1', 1)?.chapters).toEqual([ + { timeSeconds: 0, title: 'Intro' }, + { timeSeconds: 1320, title: 'Credits' }, + ]); + }); + it('omits the stats key entirely when the engine reports none', () => { // An empty object would make the renderer render an empty popover // instead of hiding the info button. diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index b53e3be7d..6a06c5c97 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -14,6 +14,7 @@ import App from '../app'; import { EmbeddedMpvAudioTrack, EmbeddedMpvBounds, + EmbeddedMpvChapter, EmbeddedMpvCapabilities, EmbeddedMpvRecordingStartOptions, EmbeddedMpvRecordingState, @@ -56,6 +57,7 @@ import { resolveFrameCopyHelperPath, } from './embedded-mpv-frame-copy-platform.util'; import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime'; +import { normalizeEmbeddedMpvChapters } from './embedded-mpv-chapters.util'; import { EMBEDDED_MPV_EXPERIMENT_ENV, isEmbeddedMpvFeatureEnabled, @@ -71,6 +73,7 @@ export interface NativeEmbeddedMpvSessionSnapshot { selectedAudioTrackId?: number | null; subtitleTracks?: EmbeddedMpvSubtitleTrack[]; selectedSubtitleTrackId?: number | null; + chapters?: EmbeddedMpvChapter[]; playbackSpeed?: number; aspectOverride?: string; videoWidth?: number; @@ -1237,6 +1240,7 @@ export class EmbeddedMpvNativeService { typeof snapshot.selectedSubtitleTrackId === 'number' ? snapshot.selectedSubtitleTrackId : null, + chapters: normalizeEmbeddedMpvChapters(snapshot.chapters), playbackSpeed: typeof snapshot.playbackSpeed === 'number' ? snapshot.playbackSpeed diff --git a/apps/web-e2e/src/fixtures/playback/episode-chapters.webm b/apps/web-e2e/src/fixtures/playback/episode-chapters.webm new file mode 100644 index 000000000..5f5f903b6 Binary files /dev/null and b/apps/web-e2e/src/fixtures/playback/episode-chapters.webm differ diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 69f46a5d3..0fd964f68 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -487,6 +487,8 @@ Audio tracks are discovered from MPV's `track-list` property. The selected track Subtitle tracks mirror the audio-track contract: same `track-list` source, same parsing pipeline, but selected through MPV's `sid` property. A `trackId` of `-1` from the renderer is interpreted as "disable subtitles" and translated to `sid=no` at the addon boundary. Playback speed is observed and set through MPV's `speed` property, clamped at the addon to `[0.25, 4.0]`. Aspect override uses MPV's `video-aspect-override` property as a passthrough string ("no", "16:9", "4:3", "21:9", "2.35:1"). All four properties (`sid`, `speed`, `video-aspect-override`, plus `aid`) are observed at session init so renderer state stays in sync with the native side without needing extra round-trips. +File chapters come from MPV's `chapter-list` property, observed as a node next to `track-list` by the macOS and Windows addons and the frame-copy helper, and cleared on every `START_FILE` so a replacement file never shows the previous one's chapters. Each snapshot carries `chapters: [{ timeSeconds, title? }]` (the helper always emits the key, because the adapter merges snapshot events field by field); `EmbeddedMpvNativeService` validates it with `normalizeEmbeddedMpvChapters` into `EmbeddedMpvSession.chapters`, and an older binary without the field yields `[]`. The Linux `mpv --wid` poller does not read chapters. The renderer maps them to timeline segments โ€” see "Timeline segments" in the [player controls contract](player-controls-contract.md). + The renderer learns which features the loaded addon binary supports through the `EmbeddedMpvSupport.capabilities` field returned from `getEmbeddedMpvSupport()`. The service probes `typeof addon. === 'function'` for each optional native export. Older addon binaries with the original audio-only surface return `capabilities: { subtitles: false, playbackSpeed: false, aspectOverride: false, screenshot: false, recording: false }`, and the renderer hides the corresponding controls instead of throwing at runtime. Linux intentionally does not export libmpv-only optional controls while it uses the process-isolated `mpv --wid` backend. Linux audio-track discovery works differently from macOS/Windows because the hand-rolled JSON IPC reply parser only understands scalar `data` values: the poll loop reads `track-list/count` every tick and walks the scalar `track-list/N/{type,id,title,lang,default,forced}` sub-properties only when the count changes. The selected track is reconciled from the scalar `aid` property on every tick (`aid` reads back non-numeric when audio is disabled, which maps to "no selection"). Track switching still goes through `set_property aid` over the same socket. diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index e90ef224f..04167ef6e 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -366,7 +366,23 @@ always covers `[0, duration]`. Without segments โ€” live playback, VOD and series โ€” the row is one untitled segment, which is the plain bar. `ControlsTimeline` owns the normalized list and the per-segment fill for the current scrub or playback value; the hover label becomes `Chapter 2 ยท 12:40` -over a titled segment. mpv's chapter list is not a producer yet. +over a titled segment. + +**Chapter producer.** Embedded MPV reports the file's own chapters: the +addons and the frame-copy helper observe mpv `chapter-list` and publish it +as `EmbeddedMpvSession.chapters` (`{ timeSeconds, title? }`, validated in +main by `normalizeEmbeddedMpvChapters`). `buildChapterTimelineSegments` +(`chapter-timeline-segments.ts`) runs each chapter from its start to the +next one's, the last to the duration, orders them and drops repeated or +out-of-range starts; an untitled chapter becomes an untitled segment, and +no chapters or no finite duration yields `null`. +`EmbeddedMpvPlayerComponent.controlsTimelineSegments` +(`embeddedMpvTimelineSegments`) passes the host's segments when it +supplies any and the chapter segments otherwise, so catch-up programmes +always win; `sameTimelineSegments` keeps a re-polled, unchanged list from +counting as a change. The Linux native-view `mpv --wid` backend reads no +chapters, and the web engines report none (streamed HLS/DASH carries no +chapter track), so their VOD and series stay one plain bar. **Catch-up producer.** Archive playback of a live channel passes the EPG programmes overlapping its archive window. @@ -413,8 +429,9 @@ episode: `upNextThresholdSeconds` is 4% of the duration clamped to `timelineSegments` carry a closing-credits chapter in the last third (`creditsStartSeconds`: "Credits", "Outro", "Ending", an anime `ED`, and a few localized names), the card appears when it starts instead, at most -5 min before the end. No engine reports file chapters today, so in practice -the adaptive lead applies. The countdown shows whole minutes, and seconds +5 min before the end. Embedded MPV under the frame-copy engine supplies +file chapters (see the chapter producer above); every other engine, and a +file without a credits chapter, uses the adaptive lead. The countdown shows whole minutes, and seconds in the last minute. A still that fails to load falls back to the label tile. A click emits `nextEpisodeRequested` directly โ€” not through the diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts index f452966c5..91f658ddc 100644 --- a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts +++ b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts @@ -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; diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-chapter-segments.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-chapter-segments.spec.ts new file mode 100644 index 000000000..ccda08f0f --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-chapter-segments.spec.ts @@ -0,0 +1,58 @@ +import type { EmbeddedMpvSession } from '@iptvnator/shared/interfaces'; +import { embeddedMpvTimelineSegments } from './embedded-mpv-chapter-segments'; + +function session( + overrides: Partial = {} +): 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(); + }); +}); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-chapter-segments.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-chapter-segments.ts new file mode 100644 index 000000000..7edc323d8 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-chapter-segments.ts @@ -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); +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html index 62f36d072..f7aff44cb 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html @@ -104,7 +104,7 @@ [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" [upNext]="upNext()" - [timelineSegments]="timelineSegments()" + [timelineSegments]="controlsTimelineSegments()" [showControls]="showControls()" [shortcutsEnabled]="sharedShortcutsEnabled()" (previousEpisodeRequested)="requestPreviousEpisode()" diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts index b189b5737..a35ae41b4 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts @@ -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 + ): 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'); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index 17e6b7452..d726cc9bb 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -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(null); readonly mediaTitle = input(null); readonly upNext = input(null); - /** Catch-up programmes drawn as track segments; null draws one. */ + /** Catch-up programmes drawn as track segments; they outrank chapters. */ readonly timelineSegments = input( 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()); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-factory.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-factory.ts index e28be1678..f16e7fac7 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-factory.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-factory.ts @@ -26,6 +26,7 @@ function baseSession( selectedAudioTrackId: null, subtitleTracks: [], selectedSubtitleTrackId: null, + chapters: [], playbackSpeed: 1, aspectOverride: 'no', recording: { active: false }, diff --git a/libs/ui/playback/src/lib/player-controls/chapter-timeline-segments.spec.ts b/libs/ui/playback/src/lib/player-controls/chapter-timeline-segments.spec.ts new file mode 100644 index 000000000..3888c1f96 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/chapter-timeline-segments.spec.ts @@ -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); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/chapter-timeline-segments.ts b/libs/ui/playback/src/lib/player-controls/chapter-timeline-segments.ts new file mode 100644 index 000000000..195d63bd2 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/chapter-timeline-segments.ts @@ -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 + ); +} diff --git a/libs/ui/playback/src/lib/player-controls/index.ts b/libs/ui/playback/src/lib/player-controls/index.ts index 9dcab2561..4b382ea7e 100644 --- a/libs/ui/playback/src/lib/player-controls/index.ts +++ b/libs/ui/playback/src/lib/player-controls/index.ts @@ -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';