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

@@ -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<TrackInfo> subtitleTracks;
int64_t selectedSubtitleTrackId = -1;
std::vector<ChapterInfo> chapters;
double playbackSpeed = 1;
std::string aspectOverride = "no";
bool recordingActive = false;
@@ -187,6 +194,21 @@ std::string tracksJson(const std::vector<TrackInfo>& tracks,
return out;
}
std::string chaptersJson(const std::vector<ChapterInfo>& 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<unsigned char>(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<ChapterInfo> 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<mpv_node*>(property.data));
} else if (name == "chapter-list" &&
property.format == MPV_FORMAT_NODE && property.data) {
updateChaptersFromNode(*static_cast<mpv_node*>(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,
@@ -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<AudioTrack> subtitleTracks;
int64_t selectedSubtitleTrackId = -1;
std::vector<Chapter> 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<unsigned char>(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<double>(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)
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>& session)
break;
}
if (propertyName == "chapter-list" &&
property->format == MPV_FORMAT_NODE &&
property->data) {
updateChaptersFromNode(
session->snapshot,
*static_cast<mpv_node*>(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)
@@ -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<AudioTrack> subtitleTracks;
int64_t selectedSubtitleTrackId = -1;
/** libmpv backend only; the Linux `--wid` poller does not read it. */
std::vector<Chapter> 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<unsigned char>(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<double>(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)
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> session)
*static_cast<mpv_node*>(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<mpv_node*>(property->data));
} else if (name == "aid") {
const char* rawValue = property->format == MPV_FORMAT_STRING
? *static_cast<char**>(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);
@@ -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([]);
});
});
@@ -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<string, unknown>;
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;
}
@@ -53,6 +53,7 @@ export function createInitialSnapshot(): NativeEmbeddedMpvSessionSnapshot {
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
chapters: [],
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
@@ -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.
@@ -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