mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Reference in new issue
Block a user