mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-17 06:36:15 -08:00
feat(playback): report Embedded MPV file chapters on the timeline (#1771)
This commit is contained in:
1 parent
d0e366a368
commit
9bc577a4c9
24 files changed
+931
-5
No files matched your search
@@ -0,0 +1,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.
|
||||||
@@ -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',
|
resourcePath: '/stream-stats-switch.webm',
|
||||||
contentType: 'video/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 = {
|
media = {
|
||||||
url: mediaServer.url,
|
url: mediaServer.url,
|
||||||
close: async () => {
|
close: async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
mediaServer.close(),
|
mediaServer.close(),
|
||||||
alternateMedia.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.
|
// Audio-only replacements must not retain the previous video size.
|
||||||
await launchedFrameCopyApp.mainWindow.evaluate(
|
await launchedFrameCopyApp.mainWindow.evaluate(
|
||||||
async ({ sessionId, streamUrl }) => {
|
async ({ sessionId, streamUrl }) => {
|
||||||
@@ -433,6 +471,7 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
|
|||||||
)
|
)
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
streamUrl: audioMedia.url,
|
streamUrl: audioMedia.url,
|
||||||
|
chapters: [],
|
||||||
videoWidth: 0,
|
videoWidth: 0,
|
||||||
videoHeight: 0,
|
videoHeight: 0,
|
||||||
stats: {
|
stats: {
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ struct TrackInfo {
|
|||||||
bool forced = false;
|
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 {
|
struct SnapshotState {
|
||||||
std::string status = "idle";
|
std::string status = "idle";
|
||||||
double positionSeconds = 0;
|
double positionSeconds = 0;
|
||||||
@@ -118,6 +124,7 @@ struct SnapshotState {
|
|||||||
int64_t selectedAudioTrackId = -1;
|
int64_t selectedAudioTrackId = -1;
|
||||||
std::vector<TrackInfo> subtitleTracks;
|
std::vector<TrackInfo> subtitleTracks;
|
||||||
int64_t selectedSubtitleTrackId = -1;
|
int64_t selectedSubtitleTrackId = -1;
|
||||||
|
std::vector<ChapterInfo> chapters;
|
||||||
double playbackSpeed = 1;
|
double playbackSpeed = 1;
|
||||||
std::string aspectOverride = "no";
|
std::string aspectOverride = "no";
|
||||||
bool recordingActive = false;
|
bool recordingActive = false;
|
||||||
@@ -187,6 +194,21 @@ std::string tracksJson(const std::vector<TrackInfo>& tracks,
|
|||||||
return out;
|
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
|
/* 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
|
* 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.
|
* would leave the previous stream's numbers in place across a channel switch.
|
||||||
@@ -262,6 +284,9 @@ std::string composeSnapshotLocked() {
|
|||||||
} else {
|
} else {
|
||||||
writer.nullValue("selectedSubtitleTrackId");
|
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.num("playbackSpeed", s.playbackSpeed);
|
||||||
writer.str("aspectOverride", s.aspectOverride);
|
writer.str("aspectOverride", s.aspectOverride);
|
||||||
writer.raw("stats", composeStatsJsonLocked());
|
writer.raw("stats", composeStatsJsonLocked());
|
||||||
@@ -383,6 +408,63 @@ void updateTracksFromNode(const mpv_node& trackListNode) {
|
|||||||
g_state.snapshot.subtitleTracks = std::move(subs);
|
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) {
|
bool parseTrackSelection(const char* value, int64_t& out) {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
char* end = nullptr;
|
char* end = nullptr;
|
||||||
@@ -436,6 +518,9 @@ void handlePropertyChange(const mpv_event_property& property) {
|
|||||||
} else if (name == "track-list" && property.format == MPV_FORMAT_NODE &&
|
} else if (name == "track-list" && property.format == MPV_FORMAT_NODE &&
|
||||||
property.data) {
|
property.data) {
|
||||||
updateTracksFromNode(*static_cast<mpv_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 &&
|
} else if (name == "aid" && property.format == MPV_FORMAT_STRING &&
|
||||||
property.data) {
|
property.data) {
|
||||||
int64_t selected = -1;
|
int64_t selected = -1;
|
||||||
@@ -536,6 +621,7 @@ void runMpvEventLoop() {
|
|||||||
s.selectedAudioTrackId = -1;
|
s.selectedAudioTrackId = -1;
|
||||||
s.subtitleTracks.clear();
|
s.subtitleTracks.clear();
|
||||||
s.selectedSubtitleTrackId = -1;
|
s.selectedSubtitleTrackId = -1;
|
||||||
|
s.chapters.clear();
|
||||||
g_state.loadedPath = false;
|
g_state.loadedPath = false;
|
||||||
markDirtyLocked();
|
markDirtyLocked();
|
||||||
break;
|
break;
|
||||||
@@ -1115,6 +1201,8 @@ int main(int argc, char** argv) {
|
|||||||
MPV_FORMAT_STRING);
|
MPV_FORMAT_STRING);
|
||||||
mpv_observe_property(g_state.mpv, 24, "audio-params/samplerate",
|
mpv_observe_property(g_state.mpv, 24, "audio-params/samplerate",
|
||||||
MPV_FORMAT_INT64);
|
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,
|
g_state.pipeline.onGenerationChanged = [](const std::string& name,
|
||||||
int width, int height,
|
int width, int height,
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ struct AudioTrack {
|
|||||||
bool forced = false;
|
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 {
|
struct SessionSnapshot {
|
||||||
SessionStatus status = SessionStatus::Idle;
|
SessionStatus status = SessionStatus::Idle;
|
||||||
double positionSeconds = 0.0;
|
double positionSeconds = 0.0;
|
||||||
@@ -108,6 +114,7 @@ struct SessionSnapshot {
|
|||||||
int64_t selectedAudioTrackId = -1;
|
int64_t selectedAudioTrackId = -1;
|
||||||
std::vector<AudioTrack> subtitleTracks;
|
std::vector<AudioTrack> subtitleTracks;
|
||||||
int64_t selectedSubtitleTrackId = -1;
|
int64_t selectedSubtitleTrackId = -1;
|
||||||
|
std::vector<Chapter> chapters;
|
||||||
double playbackSpeed = 1.0;
|
double playbackSpeed = 1.0;
|
||||||
std::string aspectOverride = "no";
|
std::string aspectOverride = "no";
|
||||||
// Stream diagnostics for the player's info popover. Sentinels mean "mpv
|
// 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)
|
void updateSubtitleTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node)
|
||||||
{
|
{
|
||||||
updateTracksFromNode(
|
updateTracksFromNode(
|
||||||
@@ -1228,6 +1296,7 @@ void runEventLoop(const std::shared_ptr<Session>& session)
|
|||||||
session->snapshot.selectedAudioTrackId = -1;
|
session->snapshot.selectedAudioTrackId = -1;
|
||||||
session->snapshot.subtitleTracks.clear();
|
session->snapshot.subtitleTracks.clear();
|
||||||
session->snapshot.selectedSubtitleTrackId = -1;
|
session->snapshot.selectedSubtitleTrackId = -1;
|
||||||
|
session->snapshot.chapters.clear();
|
||||||
session->loadedPath = false;
|
session->loadedPath = false;
|
||||||
break;
|
break;
|
||||||
case MPV_EVENT_FILE_LOADED:
|
case MPV_EVENT_FILE_LOADED:
|
||||||
@@ -1354,6 +1423,16 @@ void runEventLoop(const std::shared_ptr<Session>& session)
|
|||||||
break;
|
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" &&
|
if (propertyName == "aid" &&
|
||||||
property->format == MPV_FORMAT_STRING &&
|
property->format == MPV_FORMAT_STRING &&
|
||||||
property->data) {
|
property->data) {
|
||||||
@@ -1993,6 +2072,8 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
|
|||||||
"audio-params/samplerate",
|
"audio-params/samplerate",
|
||||||
MPV_FORMAT_INT64
|
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->running.store(true);
|
||||||
session->eventThread = std::thread(runEventLoop, session);
|
session->eventThread = std::thread(runEventLoop, session);
|
||||||
@@ -2818,6 +2899,21 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
|
|||||||
}
|
}
|
||||||
result.Set("subtitleTracks", subtitleTracks);
|
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(
|
result.Set(
|
||||||
"playbackSpeed",
|
"playbackSpeed",
|
||||||
Napi::Number::New(env, snapshot.playbackSpeed)
|
Napi::Number::New(env, snapshot.playbackSpeed)
|
||||||
|
|||||||
@@ -112,6 +112,12 @@ struct AudioTrack {
|
|||||||
bool forced = false;
|
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 {
|
struct SessionSnapshot {
|
||||||
SessionStatus status = SessionStatus::Idle;
|
SessionStatus status = SessionStatus::Idle;
|
||||||
double positionSeconds = 0.0;
|
double positionSeconds = 0.0;
|
||||||
@@ -125,6 +131,8 @@ struct SessionSnapshot {
|
|||||||
int64_t selectedAudioTrackId = -1;
|
int64_t selectedAudioTrackId = -1;
|
||||||
std::vector<AudioTrack> subtitleTracks;
|
std::vector<AudioTrack> subtitleTracks;
|
||||||
int64_t selectedSubtitleTrackId = -1;
|
int64_t selectedSubtitleTrackId = -1;
|
||||||
|
/** libmpv backend only; the Linux `--wid` poller does not read it. */
|
||||||
|
std::vector<Chapter> chapters;
|
||||||
double playbackSpeed = 1.0;
|
double playbackSpeed = 1.0;
|
||||||
std::string aspectOverride = "no";
|
std::string aspectOverride = "no";
|
||||||
/** Stream diagnostics for the player's info popover. Sentinels mean "not
|
/** 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)
|
void updateSubtitleTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node)
|
||||||
{
|
{
|
||||||
snapshot.subtitleTracks = readTracksOfType(node, "sub");
|
snapshot.subtitleTracks = readTracksOfType(node, "sub");
|
||||||
@@ -1498,6 +1565,7 @@ void runEventLoop(std::shared_ptr<Session> session)
|
|||||||
session->snapshot.status = SessionStatus::Loading;
|
session->snapshot.status = SessionStatus::Loading;
|
||||||
session->snapshot.error.clear();
|
session->snapshot.error.clear();
|
||||||
session->snapshot.clearStreamStats();
|
session->snapshot.clearStreamStats();
|
||||||
|
session->snapshot.chapters.clear();
|
||||||
break;
|
break;
|
||||||
case MPV_EVENT_FILE_LOADED:
|
case MPV_EVENT_FILE_LOADED:
|
||||||
session->snapshot.status =
|
session->snapshot.status =
|
||||||
@@ -1580,6 +1648,10 @@ void runEventLoop(std::shared_ptr<Session> session)
|
|||||||
*static_cast<mpv_node*>(property->data);
|
*static_cast<mpv_node*>(property->data);
|
||||||
updateAudioTracksFromNode(session->snapshot, node);
|
updateAudioTracksFromNode(session->snapshot, node);
|
||||||
updateSubtitleTracksFromNode(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") {
|
} else if (name == "aid") {
|
||||||
const char* rawValue = property->format == MPV_FORMAT_STRING
|
const char* rawValue = property->format == MPV_FORMAT_STRING
|
||||||
? *static_cast<char**>(property->data)
|
? *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);
|
session->handle, 21, "audio-params/channels", MPV_FORMAT_STRING);
|
||||||
mpv_observe_property(
|
mpv_observe_property(
|
||||||
session->handle, 22, "audio-params/samplerate", MPV_FORMAT_INT64);
|
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->running.store(true);
|
||||||
session->eventThread = std::thread(runEventLoop, session);
|
session->eventThread = std::thread(runEventLoop, session);
|
||||||
@@ -2624,6 +2698,17 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
|
|||||||
result.Set("selectedSubtitleTrackId", env.Null());
|
result.Set("selectedSubtitleTrackId", env.Null());
|
||||||
}
|
}
|
||||||
writeTracks(env, result, "subtitleTracks", snapshot.subtitleTracks);
|
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("playbackSpeed", Napi::Number::New(env, snapshot.playbackSpeed));
|
||||||
result.Set("aspectOverride", Napi::String::New(env, snapshot.aspectOverride));
|
result.Set("aspectOverride", Napi::String::New(env, snapshot.aspectOverride));
|
||||||
writeStreamStats(env, result, snapshot);
|
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,
|
selectedAudioTrackId: null,
|
||||||
subtitleTracks: [],
|
subtitleTracks: [],
|
||||||
selectedSubtitleTrackId: null,
|
selectedSubtitleTrackId: null,
|
||||||
|
chapters: [],
|
||||||
playbackSpeed: 1,
|
playbackSpeed: 1,
|
||||||
aspectOverride: 'no',
|
aspectOverride: 'no',
|
||||||
recording: { active: false },
|
recording: { active: false },
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ interface MockSnapshot {
|
|||||||
streamUrl: string;
|
streamUrl: string;
|
||||||
audioTracks?: never[];
|
audioTracks?: never[];
|
||||||
selectedAudioTrackId?: number | null;
|
selectedAudioTrackId?: number | null;
|
||||||
|
chapters?: Array<{ timeSeconds: number; title?: string }>;
|
||||||
stats?: {
|
stats?: {
|
||||||
fps?: number;
|
fps?: number;
|
||||||
videoBitrateBps?: 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', () => {
|
it('omits the stats key entirely when the engine reports none', () => {
|
||||||
// An empty object would make the renderer render an empty popover
|
// An empty object would make the renderer render an empty popover
|
||||||
// instead of hiding the info button.
|
// instead of hiding the info button.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import App from '../app';
|
|||||||
import {
|
import {
|
||||||
EmbeddedMpvAudioTrack,
|
EmbeddedMpvAudioTrack,
|
||||||
EmbeddedMpvBounds,
|
EmbeddedMpvBounds,
|
||||||
|
EmbeddedMpvChapter,
|
||||||
EmbeddedMpvCapabilities,
|
EmbeddedMpvCapabilities,
|
||||||
EmbeddedMpvRecordingStartOptions,
|
EmbeddedMpvRecordingStartOptions,
|
||||||
EmbeddedMpvRecordingState,
|
EmbeddedMpvRecordingState,
|
||||||
@@ -56,6 +57,7 @@ import {
|
|||||||
resolveFrameCopyHelperPath,
|
resolveFrameCopyHelperPath,
|
||||||
} from './embedded-mpv-frame-copy-platform.util';
|
} from './embedded-mpv-frame-copy-platform.util';
|
||||||
import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime';
|
import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime';
|
||||||
|
import { normalizeEmbeddedMpvChapters } from './embedded-mpv-chapters.util';
|
||||||
import {
|
import {
|
||||||
EMBEDDED_MPV_EXPERIMENT_ENV,
|
EMBEDDED_MPV_EXPERIMENT_ENV,
|
||||||
isEmbeddedMpvFeatureEnabled,
|
isEmbeddedMpvFeatureEnabled,
|
||||||
@@ -71,6 +73,7 @@ export interface NativeEmbeddedMpvSessionSnapshot {
|
|||||||
selectedAudioTrackId?: number | null;
|
selectedAudioTrackId?: number | null;
|
||||||
subtitleTracks?: EmbeddedMpvSubtitleTrack[];
|
subtitleTracks?: EmbeddedMpvSubtitleTrack[];
|
||||||
selectedSubtitleTrackId?: number | null;
|
selectedSubtitleTrackId?: number | null;
|
||||||
|
chapters?: EmbeddedMpvChapter[];
|
||||||
playbackSpeed?: number;
|
playbackSpeed?: number;
|
||||||
aspectOverride?: string;
|
aspectOverride?: string;
|
||||||
videoWidth?: number;
|
videoWidth?: number;
|
||||||
@@ -1237,6 +1240,7 @@ export class EmbeddedMpvNativeService {
|
|||||||
typeof snapshot.selectedSubtitleTrackId === 'number'
|
typeof snapshot.selectedSubtitleTrackId === 'number'
|
||||||
? snapshot.selectedSubtitleTrackId
|
? snapshot.selectedSubtitleTrackId
|
||||||
: null,
|
: null,
|
||||||
|
chapters: normalizeEmbeddedMpvChapters(snapshot.chapters),
|
||||||
playbackSpeed:
|
playbackSpeed:
|
||||||
typeof snapshot.playbackSpeed === 'number'
|
typeof snapshot.playbackSpeed === 'number'
|
||||||
? snapshot.playbackSpeed
|
? snapshot.playbackSpeed
|
||||||
|
|||||||
Binary file not shown.
@@ -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.
|
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.<method> === '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.
|
The renderer learns which features the loaded addon binary supports through the `EmbeddedMpvSupport.capabilities` field returned from `getEmbeddedMpvSupport()`. The service probes `typeof addon.<method> === '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.
|
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.
|
||||||
|
|||||||
@@ -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.
|
series — the row is one untitled segment, which is the plain bar.
|
||||||
`ControlsTimeline` owns the normalized list and the per-segment fill for the
|
`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`
|
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
|
**Catch-up producer.** Archive playback of a live channel passes the EPG
|
||||||
programmes overlapping its archive window.
|
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
|
`timelineSegments` carry a closing-credits chapter in the last third
|
||||||
(`creditsStartSeconds`: "Credits", "Outro", "Ending", an anime `ED`, and a
|
(`creditsStartSeconds`: "Credits", "Outro", "Ending", an anime `ED`, and a
|
||||||
few localized names), the card appears when it starts instead, at most
|
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
|
5 min before the end. Embedded MPV under the frame-copy engine supplies
|
||||||
the adaptive lead applies. The countdown shows whole minutes, and seconds
|
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
|
in the last minute. A
|
||||||
still that fails to load falls back to the label tile. A
|
still that fails to load falls back to the label tile. A
|
||||||
click emits `nextEpisodeRequested` directly — not through the
|
click emits `nextEpisodeRequested` directly — not through the
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ export interface EmbeddedMpvAudioTrack {
|
|||||||
|
|
||||||
export type EmbeddedMpvSubtitleTrack = 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 {
|
export interface EmbeddedMpvRecordingState {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
targetPath?: string;
|
targetPath?: string;
|
||||||
@@ -142,6 +149,11 @@ export interface EmbeddedMpvSession {
|
|||||||
selectedSubtitleTrackId: number | null;
|
selectedSubtitleTrackId: number | null;
|
||||||
playbackSpeed: number;
|
playbackSpeed: number;
|
||||||
aspectOverride: string;
|
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. */
|
/** Source video size (mpv dwidth/dheight); frame-copy engine only. */
|
||||||
videoWidth?: number;
|
videoWidth?: number;
|
||||||
videoHeight?: number;
|
videoHeight?: number;
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { EmbeddedMpvSession } from '@iptvnator/shared/interfaces';
|
||||||
|
import { embeddedMpvTimelineSegments } from './embedded-mpv-chapter-segments';
|
||||||
|
|
||||||
|
function session(
|
||||||
|
overrides: Partial<EmbeddedMpvSession> = {}
|
||||||
|
): 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
[fullscreenTarget]="fullscreenTarget()"
|
[fullscreenTarget]="fullscreenTarget()"
|
||||||
[mediaTitle]="mediaTitle()"
|
[mediaTitle]="mediaTitle()"
|
||||||
[upNext]="upNext()"
|
[upNext]="upNext()"
|
||||||
[timelineSegments]="timelineSegments()"
|
[timelineSegments]="controlsTimelineSegments()"
|
||||||
[showControls]="showControls()"
|
[showControls]="showControls()"
|
||||||
[shortcutsEnabled]="sharedShortcutsEnabled()"
|
[shortcutsEnabled]="sharedShortcutsEnabled()"
|
||||||
(previousEpisodeRequested)="requestPreviousEpisode()"
|
(previousEpisodeRequested)="requestPreviousEpisode()"
|
||||||
|
|||||||
+60
@@ -220,6 +220,66 @@ describe('EmbeddedMpvPlayerComponent shared controls host', () => {
|
|||||||
expect(next).toHaveBeenCalledTimes(1);
|
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<EmbeddedMpvPlayerComponent>
|
||||||
|
): 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', () => {
|
it('syncs bounds on fullscreen changes without revealing legacy state', () => {
|
||||||
const { fixture, component, controller } = render();
|
const { fixture, component, controller } = render();
|
||||||
const triggerBoundsSync = jest.spyOn(controller, 'triggerBoundsSync');
|
const triggerBoundsSync = jest.spyOn(controller, 'triggerBoundsSync');
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
RecordingStoppedEvent,
|
RecordingStoppedEvent,
|
||||||
ResolvedPortalPlayback,
|
ResolvedPortalPlayback,
|
||||||
} from '@iptvnator/shared/interfaces';
|
} from '@iptvnator/shared/interfaces';
|
||||||
|
import { sameTimelineSegments } from '../player-controls/chapter-timeline-segments';
|
||||||
import { PlayerControlsComponent } from '../player-controls/player-controls.component';
|
import { PlayerControlsComponent } from '../player-controls/player-controls.component';
|
||||||
import type {
|
import type {
|
||||||
PlayerMediaTitle,
|
PlayerMediaTitle,
|
||||||
@@ -32,6 +33,7 @@ import type {
|
|||||||
PlayerTimelineSegment,
|
PlayerTimelineSegment,
|
||||||
} from '../player-controls/player-controls.model';
|
} from '../player-controls/player-controls.model';
|
||||||
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
|
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 { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter';
|
||||||
import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component';
|
import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component';
|
||||||
import { EmbeddedMpvDockPanelState } from './embedded-mpv-dock-panels';
|
import { EmbeddedMpvDockPanelState } from './embedded-mpv-dock-panels';
|
||||||
@@ -88,7 +90,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
|||||||
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
|
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
|
||||||
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
|
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
|
||||||
readonly upNext = input<PlayerUpNextItem | null>(null);
|
readonly upNext = input<PlayerUpNextItem | null>(null);
|
||||||
/** Catch-up programmes drawn as track segments; null draws one. */
|
/** Catch-up programmes drawn as track segments; they outrank chapters. */
|
||||||
readonly timelineSegments = input<readonly PlayerTimelineSegment[] | null>(
|
readonly timelineSegments = input<readonly PlayerTimelineSegment[] | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -143,6 +145,12 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
|||||||
|
|
||||||
readonly support = this.controller.support;
|
readonly support = this.controller.support;
|
||||||
readonly session = this.controller.session;
|
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 stalled = this.controller.stalled;
|
||||||
|
|
||||||
readonly volume = signal(readStoredVolume());
|
readonly volume = signal(readStoredVolume());
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function baseSession(
|
|||||||
selectedAudioTrackId: null,
|
selectedAudioTrackId: null,
|
||||||
subtitleTracks: [],
|
subtitleTracks: [],
|
||||||
selectedSubtitleTrackId: null,
|
selectedSubtitleTrackId: null,
|
||||||
|
chapters: [],
|
||||||
playbackSpeed: 1,
|
playbackSpeed: 1,
|
||||||
aspectOverride: 'no',
|
aspectOverride: 'no',
|
||||||
recording: { active: false },
|
recording: { active: false },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export * from './legacy-player-shortcuts';
|
|||||||
export * from './controls-surface';
|
export * from './controls-surface';
|
||||||
export * from './pointer-focus-release';
|
export * from './pointer-focus-release';
|
||||||
export * from './catchup-timeline-segments';
|
export * from './catchup-timeline-segments';
|
||||||
|
export * from './chapter-timeline-segments';
|
||||||
export * from './controls-timeline';
|
export * from './controls-timeline';
|
||||||
export * from './controls-view-model';
|
export * from './controls-view-model';
|
||||||
export * from './controls-visibility';
|
export * from './controls-visibility';
|
||||||
|
|||||||
Reference in new issue
Block a user