feat(playback): stream-info popover in the player overlay (#1578)

This commit is contained in:
Lars Emig authored and GitHub committed 2026-09-10 21:33:41 +02:00
1 parent bad8a0991e
commit e76447975b
72 files changed
+4355 -163

No files matched your search

@@ -61,6 +61,57 @@ struct SnapshotState {
double volume = 1; /* 0..1 */
int64_t videoWidth = 0; /* mpv dwidth/dheight; 0 = unknown */
int64_t videoHeight = 0;
/* Stream diagnostics for the player's info popover. Sentinels mean
* "mpv has not answered yet" so a row is omitted rather than shown as 0. */
double fps = 0; /* estimated-vf-fps; <=0 = unknown */
double videoBitrate = 0; /* bits/s; <=0 = unknown */
double audioBitrate = 0;
std::string videoCodec; /* video-format, e.g. "h264" */
std::string audioCodec; /* audio-codec-name, e.g. "aac" */
std::string audioChannels; /* audio-params/channels, e.g. "5.1" */
int64_t audioSampleRate = 0; /* audio-params/samplerate; 0 unknown */
std::string container; /* file-format, e.g. "mpegts" */
double cacheDuration = -1; /* demuxer-cache-duration; <0 = unknown */
int64_t droppedFrames = -1; /* frame-drop-count; <0 = unknown */
int64_t decoderDroppedFrames = -1;
// MPV_FORMAT_NONE revokes just this observation, not unrelated values.
bool clearUnavailableStreamProperty(const std::string& name) {
if (name == "estimated-vf-fps") { fps = 0; }
else if (name == "video-bitrate") { videoBitrate = 0; }
else if (name == "audio-bitrate") { audioBitrate = 0; }
else if (name == "video-format") { videoCodec.clear(); }
else if (name == "audio-codec-name") { audioCodec.clear(); }
else if (name == "audio-params/channels") { audioChannels.clear(); }
else if (name == "audio-params/samplerate") { audioSampleRate = 0; }
else if (name == "file-format") { container.clear(); }
else if (name == "demuxer-cache-duration") { cacheDuration = -1; }
else if (name == "frame-drop-count") { droppedFrames = -1; }
else if (name == "decoder-frame-drop-count") { decoderDroppedFrames = -1; }
else if (name == "dwidth") { videoWidth = 0; }
else if (name == "dheight") { videoHeight = 0; }
else { return false; }
return true;
}
/* Every new file starts from "nothing reported yet": the popover must
* never show the previous stream's codec or bitrate. Kept next to the
* fields so adding one cannot forget the reset. */
void clearStreamStats() {
videoWidth = 0;
videoHeight = 0;
fps = 0;
videoBitrate = 0;
audioBitrate = 0;
videoCodec.clear();
audioCodec.clear();
audioChannels.clear();
audioSampleRate = 0;
container.clear();
cacheDuration = -1;
droppedFrames = -1;
decoderDroppedFrames = -1;
}
std::string streamUrl;
std::vector<TrackInfo> audioTracks;
int64_t selectedAudioTrackId = -1;
@@ -135,6 +186,50 @@ std::string tracksJson(const std::vector<TrackInfo>& tracks,
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.
* Caller holds g_state.mutex. */
std::string composeStatsJsonLocked() {
const SnapshotState& s = g_state.snapshot;
JsonWriter stats;
if (s.fps > 0) {
stats.num("fps", s.fps);
}
if (s.videoBitrate > 0) {
stats.num("videoBitrateBps", s.videoBitrate);
}
if (s.audioBitrate > 0) {
stats.num("audioBitrateBps", s.audioBitrate);
}
if (!s.videoCodec.empty()) {
stats.str("videoCodec", s.videoCodec);
}
if (!s.audioCodec.empty()) {
stats.str("audioCodec", s.audioCodec);
}
if (!s.audioChannels.empty()) {
stats.str("audioChannels", s.audioChannels);
}
if (s.audioSampleRate > 0) {
stats.num("audioSampleRateHz", (double)s.audioSampleRate);
}
if (!s.container.empty()) {
stats.str("container", s.container);
}
if (s.cacheDuration >= 0) {
stats.num("bufferedAheadSeconds", s.cacheDuration);
}
if (s.droppedFrames >= 0 || s.decoderDroppedFrames >= 0) {
/* mpv counts render-time and decoder drops separately; the popover
* shows the one number a viewer cares about. */
const int64_t dropped = std::max<int64_t>(0, s.droppedFrames) +
std::max<int64_t>(0, s.decoderDroppedFrames);
stats.num("droppedFrames", (double)dropped);
}
return stats.finish();
}
/* Compose the snapshot event. Caller holds g_state.mutex. */
std::string composeSnapshotLocked() {
const SnapshotState& s = g_state.snapshot;
@@ -142,10 +237,9 @@ std::string composeSnapshotLocked() {
writer.str("event", "snapshot");
writer.str("status", s.status);
writer.num("positionSeconds", std::max(0.0, s.positionSeconds));
if (s.videoWidth > 0 && s.videoHeight > 0) {
writer.num("videoWidth", (double)s.videoWidth);
writer.num("videoHeight", (double)s.videoHeight);
}
// Snapshots merge in main; publish zero so an old size cannot survive.
writer.num("videoWidth", (double)s.videoWidth);
writer.num("videoHeight", (double)s.videoHeight);
if (s.durationSeconds >= 0) {
writer.num("durationSeconds", s.durationSeconds);
} else {
@@ -169,6 +263,7 @@ std::string composeSnapshotLocked() {
}
writer.num("playbackSpeed", s.playbackSpeed);
writer.str("aspectOverride", s.aspectOverride);
writer.raw("stats", composeStatsJsonLocked());
JsonWriter recording;
recording.boolean("active", s.recordingActive);
if (!s.recordingTargetPath.empty())
@@ -302,6 +397,16 @@ void handlePropertyChange(const mpv_event_property& property) {
const std::string name = property.name ? property.name : "";
SnapshotState& s = g_state.snapshot;
if (property.format == MPV_FORMAT_NONE) {
if (s.clearUnavailableStreamProperty(name)) {
if (name == "dwidth" || name == "dheight") {
applyRenderSizeLocked();
}
markDirtyLocked();
}
return;
}
if (name == "time-pos" && property.format == MPV_FORMAT_DOUBLE &&
property.data) {
s.positionSeconds = *static_cast<double*>(property.data);
@@ -360,6 +465,41 @@ void handlePropertyChange(const mpv_event_property& property) {
s.videoHeight = value;
}
applyRenderSizeLocked();
} else if (name == "estimated-vf-fps" &&
property.format == MPV_FORMAT_DOUBLE && property.data) {
s.fps = *static_cast<double*>(property.data);
} else if (name == "video-bitrate" &&
property.format == MPV_FORMAT_DOUBLE && property.data) {
s.videoBitrate = *static_cast<double*>(property.data);
} else if (name == "audio-bitrate" &&
property.format == MPV_FORMAT_DOUBLE && property.data) {
s.audioBitrate = *static_cast<double*>(property.data);
} else if (name == "demuxer-cache-duration" &&
property.format == MPV_FORMAT_DOUBLE && property.data) {
s.cacheDuration = *static_cast<double*>(property.data);
} else if (name == "audio-params/samplerate" &&
property.format == MPV_FORMAT_INT64 && property.data) {
s.audioSampleRate = *static_cast<int64_t*>(property.data);
} else if (name == "frame-drop-count" &&
property.format == MPV_FORMAT_INT64 && property.data) {
s.droppedFrames = *static_cast<int64_t*>(property.data);
} else if (name == "decoder-frame-drop-count" &&
property.format == MPV_FORMAT_INT64 && property.data) {
s.decoderDroppedFrames = *static_cast<int64_t*>(property.data);
} else if ((name == "video-format" || name == "audio-codec-name" ||
name == "audio-params/channels" || name == "file-format") &&
property.format == MPV_FORMAT_STRING && property.data) {
const char* value = *static_cast<char**>(property.data);
const std::string text = value ? value : "";
if (name == "video-format") {
s.videoCodec = text;
} else if (name == "audio-codec-name") {
s.audioCodec = text;
} else if (name == "audio-params/channels") {
s.audioChannels = text;
} else if (name == "file-format") {
s.container = text;
}
} else if (name == "video-aspect-override" &&
property.format == MPV_FORMAT_STRING && property.data) {
const char* value = *static_cast<char**>(property.data);
@@ -390,6 +530,7 @@ void runMpvEventLoop() {
s.status = "loading";
s.error.clear();
s.engineError = false;
s.clearStreamStats();
s.audioTracks.clear();
s.selectedAudioTrackId = -1;
s.subtitleTracks.clear();
@@ -921,6 +1062,26 @@ int main(int argc, char** argv) {
mpv_observe_property(g_state.mpv, 11, "eof-reached", MPV_FORMAT_FLAG);
mpv_observe_property(g_state.mpv, 12, "dwidth", MPV_FORMAT_INT64);
mpv_observe_property(g_state.mpv, 13, "dheight", MPV_FORMAT_INT64);
/* Stream diagnostics behind the player's info popover. They change often,
* but the snapshot emit stays throttled, so this adds no event traffic. */
mpv_observe_property(g_state.mpv, 14, "estimated-vf-fps",
MPV_FORMAT_DOUBLE);
mpv_observe_property(g_state.mpv, 15, "video-bitrate", MPV_FORMAT_DOUBLE);
mpv_observe_property(g_state.mpv, 16, "audio-bitrate", MPV_FORMAT_DOUBLE);
mpv_observe_property(g_state.mpv, 17, "video-format", MPV_FORMAT_STRING);
mpv_observe_property(g_state.mpv, 18, "audio-codec-name",
MPV_FORMAT_STRING);
mpv_observe_property(g_state.mpv, 19, "file-format", MPV_FORMAT_STRING);
mpv_observe_property(g_state.mpv, 20, "demuxer-cache-duration",
MPV_FORMAT_DOUBLE);
mpv_observe_property(g_state.mpv, 21, "frame-drop-count",
MPV_FORMAT_INT64);
mpv_observe_property(g_state.mpv, 22, "decoder-frame-drop-count",
MPV_FORMAT_INT64);
mpv_observe_property(g_state.mpv, 23, "audio-params/channels",
MPV_FORMAT_STRING);
mpv_observe_property(g_state.mpv, 24, "audio-params/samplerate",
MPV_FORMAT_INT64);
g_state.pipeline.onGenerationChanged = [](const std::string& name,
int width, int height,
@@ -110,6 +110,55 @@ struct SessionSnapshot {
int64_t selectedSubtitleTrackId = -1;
double playbackSpeed = 1.0;
std::string aspectOverride = "no";
// Stream diagnostics for the player's info popover. Sentinels mean "mpv
// has not answered yet", so an unknown value omits its row instead of
// reporting a zero.
double fps = 0.0; // estimated-vf-fps; <=0 = unknown
double videoBitrate = 0.0; // bits/s; <=0 = unknown
double audioBitrate = 0.0;
std::string videoCodec; // video-format, e.g. "h264"
std::string audioCodec; // audio-codec-name, e.g. "aac"
std::string audioChannels; // audio-params/channels, e.g. "5.1"
int64_t audioSampleRate = 0; // audio-params/samplerate; 0 unknown
std::string container; // file-format, e.g. "mpegts"
double cacheDuration = -1.0; // demuxer-cache-duration; <0 = unknown
int64_t droppedFrames = -1; // frame-drop-count; <0 = unknown
int64_t decoderDroppedFrames = -1;
// MPV_FORMAT_NONE revokes just this observation, not unrelated values.
bool clearUnavailableStreamProperty(const std::string& name) {
if (name == "estimated-vf-fps") { fps = 0; }
else if (name == "video-bitrate") { videoBitrate = 0; }
else if (name == "audio-bitrate") { audioBitrate = 0; }
else if (name == "video-format") { videoCodec.clear(); }
else if (name == "audio-codec-name") { audioCodec.clear(); }
else if (name == "audio-params/channels") { audioChannels.clear(); }
else if (name == "audio-params/samplerate") { audioSampleRate = 0; }
else if (name == "file-format") { container.clear(); }
else if (name == "demuxer-cache-duration") { cacheDuration = -1; }
else if (name == "frame-drop-count") { droppedFrames = -1; }
else if (name == "decoder-frame-drop-count") { decoderDroppedFrames = -1; }
else { return false; }
return true;
}
// Every new file starts from "nothing reported yet": the popover must
// never show the previous stream's codec or bitrate. Kept next to the
// fields so adding one cannot forget the reset.
void clearStreamStats()
{
fps = 0.0;
videoBitrate = 0.0;
audioBitrate = 0.0;
videoCodec.clear();
audioCodec.clear();
audioChannels.clear();
audioSampleRate = 0;
container.clear();
cacheDuration = -1.0;
droppedFrames = -1;
decoderDroppedFrames = -1;
}
bool recordingActive = false;
std::string recordingTargetPath;
std::string recordingStartedAt;
@@ -1174,6 +1223,7 @@ void runEventLoop(const std::shared_ptr<Session>& session)
session->snapshot.status = SessionStatus::Loading;
session->snapshot.error.clear();
session->snapshot.engineError = false;
session->snapshot.clearStreamStats();
session->snapshot.audioTracks.clear();
session->snapshot.selectedAudioTrackId = -1;
session->snapshot.subtitleTracks.clear();
@@ -1222,6 +1272,11 @@ void runEventLoop(const std::shared_ptr<Session>& session)
const std::string propertyName = property->name;
if (property->format == MPV_FORMAT_NONE) {
session->snapshot.clearUnavailableStreamProperty(propertyName);
break;
}
if (propertyName == "time-pos" &&
property->format == MPV_FORMAT_DOUBLE &&
property->data) {
@@ -1347,6 +1402,67 @@ void runEventLoop(const std::shared_ptr<Session>& session)
break;
}
if (property->format == MPV_FORMAT_DOUBLE && property->data) {
const double value = *static_cast<double*>(property->data);
if (propertyName == "estimated-vf-fps") {
session->snapshot.fps = value;
break;
}
if (propertyName == "video-bitrate") {
session->snapshot.videoBitrate = value;
break;
}
if (propertyName == "audio-bitrate") {
session->snapshot.audioBitrate = value;
break;
}
if (propertyName == "demuxer-cache-duration") {
session->snapshot.cacheDuration = value;
break;
}
}
if (property->format == MPV_FORMAT_INT64 && property->data) {
const int64_t value =
*static_cast<int64_t*>(property->data);
if (propertyName == "frame-drop-count") {
session->snapshot.droppedFrames = value;
break;
}
if (propertyName == "decoder-frame-drop-count") {
session->snapshot.decoderDroppedFrames = value;
break;
}
if (propertyName == "audio-params/samplerate") {
session->snapshot.audioSampleRate = value;
break;
}
}
if (property->format == MPV_FORMAT_STRING && property->data) {
// MPV_FORMAT_STRING hands over a char**.
const char* const* textValue =
static_cast<char**>(property->data);
const std::string text =
(textValue && *textValue) ? *textValue : "";
if (propertyName == "video-format") {
session->snapshot.videoCodec = text;
break;
}
if (propertyName == "audio-codec-name") {
session->snapshot.audioCodec = text;
break;
}
if (propertyName == "audio-params/channels") {
session->snapshot.audioChannels = text;
break;
}
if (propertyName == "file-format") {
session->snapshot.container = text;
break;
}
}
break;
}
case MPV_EVENT_LOG_MESSAGE: {
@@ -1809,6 +1925,74 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
MPV_FORMAT_STRING
);
mpv_observe_property(session->handle, 11, "eof-reached", MPV_FORMAT_FLAG);
// Stream diagnostics behind the player's info popover. The renderer pulls
// snapshots on its own cadence, so observing these adds no IPC traffic.
mpv_observe_property(
session->handle,
12,
"estimated-vf-fps",
MPV_FORMAT_DOUBLE
);
mpv_observe_property(
session->handle,
13,
"video-bitrate",
MPV_FORMAT_DOUBLE
);
mpv_observe_property(
session->handle,
14,
"audio-bitrate",
MPV_FORMAT_DOUBLE
);
mpv_observe_property(
session->handle,
15,
"video-format",
MPV_FORMAT_STRING
);
mpv_observe_property(
session->handle,
16,
"audio-codec-name",
MPV_FORMAT_STRING
);
mpv_observe_property(
session->handle,
17,
"file-format",
MPV_FORMAT_STRING
);
mpv_observe_property(
session->handle,
18,
"demuxer-cache-duration",
MPV_FORMAT_DOUBLE
);
mpv_observe_property(
session->handle,
19,
"frame-drop-count",
MPV_FORMAT_INT64
);
mpv_observe_property(
session->handle,
20,
"decoder-frame-drop-count",
MPV_FORMAT_INT64
);
mpv_observe_property(
session->handle,
21,
"audio-params/channels",
MPV_FORMAT_STRING
);
mpv_observe_property(
session->handle,
22,
"audio-params/samplerate",
MPV_FORMAT_INT64
);
session->running.store(true);
session->eventThread = std::thread(runEventLoop, session);
@@ -2453,6 +2637,86 @@ Napi::Value StopRecording(const Napi::CallbackInfo& info)
return env.Undefined();
}
/** Serializes the optional `stats` object; omits it when nothing is known. */
void writeStreamStats(
Napi::Env env,
Napi::Object result,
const SessionSnapshot& snapshot
)
{
auto stats = Napi::Object::New(env);
bool hasStats = false;
if (snapshot.fps > 0.0) {
stats.Set("fps", Napi::Number::New(env, snapshot.fps));
hasStats = true;
}
if (snapshot.videoBitrate > 0.0) {
stats.Set(
"videoBitrateBps",
Napi::Number::New(env, snapshot.videoBitrate)
);
hasStats = true;
}
if (snapshot.audioBitrate > 0.0) {
stats.Set(
"audioBitrateBps",
Napi::Number::New(env, snapshot.audioBitrate)
);
hasStats = true;
}
if (!snapshot.videoCodec.empty()) {
stats.Set("videoCodec", Napi::String::New(env, snapshot.videoCodec));
hasStats = true;
}
if (!snapshot.audioCodec.empty()) {
stats.Set("audioCodec", Napi::String::New(env, snapshot.audioCodec));
hasStats = true;
}
if (!snapshot.audioChannels.empty()) {
stats.Set(
"audioChannels",
Napi::String::New(env, snapshot.audioChannels)
);
hasStats = true;
}
if (snapshot.audioSampleRate > 0) {
stats.Set(
"audioSampleRateHz",
Napi::Number::New(
env,
static_cast<double>(snapshot.audioSampleRate)
)
);
hasStats = true;
}
if (!snapshot.container.empty()) {
stats.Set("container", Napi::String::New(env, snapshot.container));
hasStats = true;
}
if (snapshot.cacheDuration >= 0.0) {
stats.Set(
"bufferedAheadSeconds",
Napi::Number::New(env, snapshot.cacheDuration)
);
hasStats = true;
}
if (snapshot.droppedFrames >= 0 || snapshot.decoderDroppedFrames >= 0) {
// mpv counts render-time and decoder drops separately; the popover
// shows the one number a viewer cares about.
const int64_t dropped =
std::max<int64_t>(0, snapshot.droppedFrames) +
std::max<int64_t>(0, snapshot.decoderDroppedFrames);
stats.Set(
"droppedFrames",
Napi::Number::New(env, static_cast<double>(dropped))
);
hasStats = true;
}
if (hasStats) {
result.Set("stats", stats);
}
}
Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
@@ -2562,6 +2826,8 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
"aspectOverride",
Napi::String::New(env, snapshot.aspectOverride)
);
writeStreamStats(env, result, snapshot);
if (snapshot.recordingActive ||
!snapshot.recordingTargetPath.empty() ||
!snapshot.recordingError.empty()) {
@@ -127,6 +127,54 @@ struct SessionSnapshot {
int64_t selectedSubtitleTrackId = -1;
double playbackSpeed = 1.0;
std::string aspectOverride = "no";
/** Stream diagnostics for the player's info popover. Sentinels mean "not
* reported yet", so an unknown value omits its row instead of showing 0. */
double fps = 0.0; /* estimated-vf-fps; <=0 = unknown */
double videoBitrate = 0.0; /* bits/s; <=0 = unknown */
double audioBitrate = 0.0;
std::string videoCodec; /* video-format, e.g. "h264" */
std::string audioCodec; /* audio-codec-name, e.g. "aac" */
std::string audioChannels; /* audio-params/channels, e.g. "5.1" */
int64_t audioSampleRate = 0; /* audio-params/samplerate; 0 unknown */
std::string container; /* file-format, e.g. "mpegts" */
double cacheDuration = -1.0; /* demuxer-cache-duration; <0 unknown */
int64_t droppedFrames = -1; /* frame-drop-count; <0 = unknown */
int64_t decoderDroppedFrames = -1;
// MPV_FORMAT_NONE revokes just this observation, not unrelated values.
bool clearUnavailableStreamProperty(const std::string& name) {
if (name == "estimated-vf-fps") { fps = 0; }
else if (name == "video-bitrate") { videoBitrate = 0; }
else if (name == "audio-bitrate") { audioBitrate = 0; }
else if (name == "video-format") { videoCodec.clear(); }
else if (name == "audio-codec-name") { audioCodec.clear(); }
else if (name == "audio-params/channels") { audioChannels.clear(); }
else if (name == "audio-params/samplerate") { audioSampleRate = 0; }
else if (name == "file-format") { container.clear(); }
else if (name == "demuxer-cache-duration") { cacheDuration = -1; }
else if (name == "frame-drop-count") { droppedFrames = -1; }
else if (name == "decoder-frame-drop-count") { decoderDroppedFrames = -1; }
else { return false; }
return true;
}
/** Every new file starts from "nothing reported yet": the popover must
* never show the previous stream's codec or bitrate. Kept next to the
* fields so adding one cannot forget the reset. */
void clearStreamStats()
{
fps = 0.0;
videoBitrate = 0.0;
audioBitrate = 0.0;
videoCodec.clear();
audioCodec.clear();
audioChannels.clear();
audioSampleRate = 0;
container.clear();
cacheDuration = -1.0;
droppedFrames = -1;
decoderDroppedFrames = -1;
}
bool recordingActive = false;
std::string recordingTargetPath;
std::string recordingStartedAt;
@@ -1002,6 +1050,13 @@ void refreshLinuxMpvSnapshot(const std::shared_ptr<Session>& session)
queryLinuxMpvInteger(socketPath, "track-list/count");
const auto selectedAudioTrackId = queryLinuxMpvInteger(socketPath, "aid");
// No stream-stats polling here: this backend drives the out-of-process
// native-view engine, whose legacy dock does not mount the shared
// controls that own the stream-info popover, so every property would be
// one socket round trip on the same pass that publishes position, pause
// and EOF for a value nothing can render. The in-process engines get the
// same fields for free from `mpv_observe_property` instead.
int64_t cachedTrackCount = -1;
{
std::lock_guard<std::mutex> lock(session->mutex);
@@ -1442,6 +1497,7 @@ void runEventLoop(std::shared_ptr<Session> session)
case MPV_EVENT_START_FILE:
session->snapshot.status = SessionStatus::Loading;
session->snapshot.error.clear();
session->snapshot.clearStreamStats();
break;
case MPV_EVENT_FILE_LOADED:
session->snapshot.status =
@@ -1477,10 +1533,17 @@ void runEventLoop(std::shared_ptr<Session> session)
case MPV_EVENT_PROPERTY_CHANGE: {
const auto* property =
static_cast<mpv_event_property*>(event->data);
if (!property || !property->name || !property->data) {
if (!property || !property->name) {
break;
}
const std::string name(property->name);
if (property->format == MPV_FORMAT_NONE) {
session->snapshot.clearUnavailableStreamProperty(name);
break;
}
if (!property->data) {
break;
}
if (name == "time-pos" && property->format == MPV_FORMAT_DOUBLE) {
session->snapshot.positionSeconds =
*static_cast<double*>(property->data);
@@ -1546,6 +1609,51 @@ void runEventLoop(std::shared_ptr<Session> session)
*static_cast<char**>(property->data);
session->snapshot.aspectOverride =
value && value[0] ? value : "no";
} else if (name == "estimated-vf-fps" &&
property->format == MPV_FORMAT_DOUBLE) {
session->snapshot.fps =
*static_cast<double*>(property->data);
} else if (name == "video-bitrate" &&
property->format == MPV_FORMAT_DOUBLE) {
session->snapshot.videoBitrate =
*static_cast<double*>(property->data);
} else if (name == "audio-bitrate" &&
property->format == MPV_FORMAT_DOUBLE) {
session->snapshot.audioBitrate =
*static_cast<double*>(property->data);
} else if (name == "demuxer-cache-duration" &&
property->format == MPV_FORMAT_DOUBLE) {
session->snapshot.cacheDuration =
*static_cast<double*>(property->data);
} else if (name == "frame-drop-count" &&
property->format == MPV_FORMAT_INT64) {
session->snapshot.droppedFrames =
*static_cast<int64_t*>(property->data);
} else if (name == "decoder-frame-drop-count" &&
property->format == MPV_FORMAT_INT64) {
session->snapshot.decoderDroppedFrames =
*static_cast<int64_t*>(property->data);
} else if (name == "audio-params/samplerate" &&
property->format == MPV_FORMAT_INT64) {
session->snapshot.audioSampleRate =
*static_cast<int64_t*>(property->data);
} else if ((name == "video-format" ||
name == "audio-codec-name" ||
name == "audio-params/channels" ||
name == "file-format") &&
property->format == MPV_FORMAT_STRING) {
const char* value =
*static_cast<char**>(property->data);
const std::string text = value ? value : "";
if (name == "video-format") {
session->snapshot.videoCodec = text;
} else if (name == "audio-codec-name") {
session->snapshot.audioCodec = text;
} else if (name == "audio-params/channels") {
session->snapshot.audioChannels = text;
} else if (name == "file-format") {
session->snapshot.container = text;
}
}
break;
}
@@ -1786,6 +1894,30 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
MPV_FORMAT_STRING
);
mpv_observe_property(session->handle, 11, "eof-reached", MPV_FORMAT_FLAG);
/* Stream diagnostics behind the player's info popover. The renderer pulls
* snapshots on its own cadence, so observing these adds no IPC traffic. */
mpv_observe_property(
session->handle, 12, "estimated-vf-fps", MPV_FORMAT_DOUBLE);
mpv_observe_property(
session->handle, 13, "video-bitrate", MPV_FORMAT_DOUBLE);
mpv_observe_property(
session->handle, 14, "audio-bitrate", MPV_FORMAT_DOUBLE);
mpv_observe_property(
session->handle, 15, "video-format", MPV_FORMAT_STRING);
mpv_observe_property(
session->handle, 16, "audio-codec-name", MPV_FORMAT_STRING);
mpv_observe_property(
session->handle, 17, "file-format", MPV_FORMAT_STRING);
mpv_observe_property(
session->handle, 18, "demuxer-cache-duration", MPV_FORMAT_DOUBLE);
mpv_observe_property(
session->handle, 19, "frame-drop-count", MPV_FORMAT_INT64);
mpv_observe_property(
session->handle, 20, "decoder-frame-drop-count", MPV_FORMAT_INT64);
mpv_observe_property(
session->handle, 21, "audio-params/channels", MPV_FORMAT_STRING);
mpv_observe_property(
session->handle, 22, "audio-params/samplerate", MPV_FORMAT_INT64);
session->running.store(true);
session->eventThread = std::thread(runEventLoop, session);
@@ -2375,6 +2507,77 @@ void writeTracks(
result.Set(key, output);
}
/** Serializes the optional `stats` object; omits it when nothing is known. */
void writeStreamStats(
Napi::Env env,
Napi::Object result,
const SessionSnapshot& snapshot)
{
auto stats = Napi::Object::New(env);
bool any = false;
if (snapshot.fps > 0.0) {
stats.Set("fps", Napi::Number::New(env, snapshot.fps));
any = true;
}
if (snapshot.videoBitrate > 0.0) {
stats.Set(
"videoBitrateBps",
Napi::Number::New(env, snapshot.videoBitrate));
any = true;
}
if (snapshot.audioBitrate > 0.0) {
stats.Set(
"audioBitrateBps",
Napi::Number::New(env, snapshot.audioBitrate));
any = true;
}
if (!snapshot.videoCodec.empty()) {
stats.Set("videoCodec", Napi::String::New(env, snapshot.videoCodec));
any = true;
}
if (!snapshot.audioCodec.empty()) {
stats.Set("audioCodec", Napi::String::New(env, snapshot.audioCodec));
any = true;
}
if (!snapshot.audioChannels.empty()) {
stats.Set(
"audioChannels",
Napi::String::New(env, snapshot.audioChannels));
any = true;
}
if (snapshot.audioSampleRate > 0) {
stats.Set(
"audioSampleRateHz",
Napi::Number::New(
env, static_cast<double>(snapshot.audioSampleRate)));
any = true;
}
if (!snapshot.container.empty()) {
stats.Set("container", Napi::String::New(env, snapshot.container));
any = true;
}
if (snapshot.cacheDuration >= 0.0) {
stats.Set(
"bufferedAheadSeconds",
Napi::Number::New(env, snapshot.cacheDuration));
any = true;
}
if (snapshot.droppedFrames >= 0 || snapshot.decoderDroppedFrames >= 0) {
/* mpv counts render-time and decoder drops separately; the popover
* shows the one number a viewer cares about. */
const int64_t dropped =
std::max<int64_t>(0, snapshot.droppedFrames) +
std::max<int64_t>(0, snapshot.decoderDroppedFrames);
stats.Set(
"droppedFrames",
Napi::Number::New(env, static_cast<double>(dropped)));
any = true;
}
if (any) {
result.Set("stats", stats);
}
}
Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
@@ -2423,6 +2626,7 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
writeTracks(env, result, "subtitleTracks", snapshot.subtitleTracks);
result.Set("playbackSpeed", Napi::Number::New(env, snapshot.playbackSpeed));
result.Set("aspectOverride", Napi::String::New(env, snapshot.aspectOverride));
writeStreamStats(env, result, snapshot);
if (
snapshot.recordingActive ||
!snapshot.recordingTargetPath.empty() ||
@@ -88,6 +88,58 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
expect(snapshot?.streamUrl).toBe('http://stream');
});
it('carries the helper stats object into the cached snapshot', () => {
const sessionId = createSession();
child.emitStdout({
event: 'snapshot',
status: 'playing',
positionSeconds: 1,
durationSeconds: null,
volume: 1,
streamUrl: 'http://stream',
stats: {
fps: 25,
videoBitrateBps: 3_500_000,
videoCodec: 'h264',
container: 'mpegts',
bufferedAheadSeconds: 4.5,
droppedFrames: 1,
},
});
expect(adapter.getSessionSnapshot(sessionId)?.stats).toEqual({
fps: 25,
videoBitrateBps: 3_500_000,
videoCodec: 'h264',
container: 'mpegts',
bufferedAheadSeconds: 4.5,
droppedFrames: 1,
});
});
it('replaces stats wholesale so a new stream drops the previous numbers', () => {
const sessionId = createSession();
child.emitStdout({
event: 'snapshot',
status: 'playing',
positionSeconds: 1,
streamUrl: 'http://stream-one',
stats: { videoCodec: 'h264', fps: 25 },
});
// The helper emits `stats` on every snapshot, empty included: the
// adapter merges field by field, so an omitted key would keep the
// previous channel's codec on screen after a switch.
child.emitStdout({
event: 'snapshot',
status: 'loading',
positionSeconds: 0,
streamUrl: 'http://stream-two',
stats: {},
});
expect(adapter.getSessionSnapshot(sessionId)?.stats).toEqual({});
});
it('publishes shm generations through onFrameSourceChanged', () => {
const sessionId = createSession();
child.emitStdout({
@@ -16,12 +16,8 @@ describe('Embedded MPV native source recording invariants', () => {
const widCommonSource = readSource(
'../../../native/src/embedded_mpv_wid_common.h'
);
const win32Source = readSource(
'../../../native/src/embedded_mpv_win32.cc'
);
const linuxSource = readSource(
'../../../native/src/embedded_mpv_linux.cc'
);
const win32Source = readSource('../../../native/src/embedded_mpv_win32.cc');
const linuxSource = readSource('../../../native/src/embedded_mpv_linux.cc');
const buildScriptSource = readSource('../../../build-embedded-mpv.js');
const buildAndMakeWorkflowSource = readSource(
'../../../../../.github/workflows/build-and-make.yaml'
@@ -184,6 +180,153 @@ describe('Embedded MPV native source recording invariants', () => {
}
});
it('observes the stream-stats properties on every embedded engine', () => {
// The info popover renders whatever the engine reports, so a backend
// that stops observing one of these silently loses a row. Source-text
// assertions are the only guard the C++ has in CI.
const observedProperties = [
'estimated-vf-fps',
'video-bitrate',
'audio-bitrate',
'video-format',
'audio-codec-name',
'file-format',
'demuxer-cache-duration',
'frame-drop-count',
'decoder-frame-drop-count',
'audio-params/channels',
'audio-params/samplerate',
];
for (const property of observedProperties) {
expect(nativeSource).toContain(`"${property}"`);
expect(widCommonSource).toContain(`"${property}"`);
expect(frameHelperSource).toContain(`"${property}"`);
}
// Observing is push-based and free, which is the only reason the
// native-view backends carry these fields at all: their legacy dock
// cannot render them. The Linux backend has no observe mechanism —
// it would have to poll each property over the JSON IPC socket on
// the same pass that publishes position, pause and EOF — so it must
// not collect them at all.
for (const property of observedProperties) {
expect(widCommonSource).not.toContain(`socketPath, "${property}"`);
}
});
it('serializes stream stats as an optional snapshot object', () => {
// An absent value must omit its key: the renderer treats a missing
// field as "unknown" and a zero as a real measurement.
// Both native-view backends serialize through the same named helper
// so the two can be read side by side while porting.
for (const source of [nativeSource, widCommonSource]) {
expect(source).toContain('void writeStreamStats(');
expect(source).toContain('result.Set("stats", stats);');
expect(source).toContain(
'writeStreamStats(env, result, snapshot);'
);
}
expect(frameHelperSource).toContain('composeStatsJsonLocked');
// Unconditional on the frame-copy path: its snapshots are merged, so
// an omitted key would keep the previous stream's numbers.
expect(frameHelperSource).toContain(
'writer.raw("stats", composeStatsJsonLocked());'
);
});
it('clears stream stats when a new file starts so rows never go stale', () => {
// One reset per backend that collects stats, invoked wherever a new
// file begins. The wid backend has a single call site: its Linux half
// polls nothing, so there is nothing there to go stale.
for (const source of [
nativeSource,
widCommonSource,
frameHelperSource,
]) {
expect(source).toContain('void clearStreamStats()');
}
expect(nativeSource).toContain('session->snapshot.clearStreamStats();');
expect(
widCommonSource.match(/snapshot\.clearStreamStats\(\);/g)
).toHaveLength(1);
expect(frameHelperSource).toContain('s.clearStreamStats();');
});
it('clears frame-copy dimensions and publishes the reset through merged snapshots', () => {
const reset = sourceFunctionBody(
frameHelperSource,
'void clearStreamStats()',
'reset'
);
expect(reset).toContain('videoWidth = 0;');
expect(reset).toContain('videoHeight = 0;');
const snapshot = sourceFunctionBody(
frameHelperSource,
'std::string composeSnapshotLocked()',
'snapshot'
);
expect(snapshot).toContain(
'writer.num("videoWidth", (double)s.videoWidth);'
);
expect(snapshot).toContain(
'writer.num("videoHeight", (double)s.videoHeight);'
);
expect(snapshot).not.toContain('if (s.videoWidth > 0');
});
it('restores unknown sentinels when observed diagnostics become unavailable', () => {
const fields = [
['estimated-vf-fps', 'fps = 0'],
['video-bitrate', 'videoBitrate = 0'],
['audio-bitrate', 'audioBitrate = 0'],
['video-format', 'videoCodec.clear()'],
['audio-codec-name', 'audioCodec.clear()'],
['audio-params/channels', 'audioChannels.clear()'],
['audio-params/samplerate', 'audioSampleRate = 0'],
['file-format', 'container.clear()'],
['demuxer-cache-duration', 'cacheDuration = -1'],
['frame-drop-count', 'droppedFrames = -1'],
['decoder-frame-drop-count', 'decoderDroppedFrames = -1'],
];
for (const source of [
nativeSource,
widCommonSource,
frameHelperSource,
]) {
const reset = sourceFunctionBody(
source,
'bool clearUnavailableStreamProperty(',
'unavailable property'
);
for (const [property, assignment] of fields) {
expect(reset).toContain(
`name == "${property}") { ${assignment}; }`
);
}
const event = source.indexOf('MPV_EVENT_PROPERTY_CHANGE');
const handler =
source === frameHelperSource
? sourceFunctionBody(
source,
'void handlePropertyChange(',
'property handler'
)
: source.slice(
event,
source.indexOf('case MPV_EVENT_', event + 5)
);
expect(handler).toContain('MPV_FORMAT_NONE');
expect(handler).toContain('clearUnavailableStreamProperty(');
const dataGuard = handler.indexOf('!property->data');
if (dataGuard >= 0) {
expect(handler.indexOf('MPV_FORMAT_NONE')).toBeLessThan(
dataGuard
);
}
}
});
it('maps keep-open eof-reached property changes to an ended session status', () => {
expect(nativeSource).toContain(
'mpv_observe_property(session->handle, 11, "eof-reached", MPV_FORMAT_FLAG);'
@@ -83,6 +83,12 @@ interface MockSnapshot {
streamUrl: string;
audioTracks?: never[];
selectedAudioTrackId?: number | null;
stats?: {
fps?: number;
videoBitrateBps?: number;
videoCodec?: string;
droppedFrames?: number;
};
recording?: {
active: boolean;
targetPath?: string;
@@ -553,6 +559,38 @@ describe('EmbeddedMpvNativeService power blocker', () => {
});
});
it('passes engine stream stats through to the renderer session', () => {
startSession('s1', snapshot('playing'));
addon.getSessionSnapshot.mockReturnValue(
snapshot('playing', {
stats: {
fps: 50,
videoBitrateBps: 6_000_000,
videoCodec: 'h264',
droppedFrames: 0,
},
})
);
expect(service.setVolume('s1', 1)?.stats).toEqual({
fps: 50,
videoBitrateBps: 6_000_000,
videoCodec: 'h264',
droppedFrames: 0,
});
});
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.
startSession('s1', snapshot('playing'));
addon.getSessionSnapshot.mockReturnValue(
snapshot('playing', { stats: {} })
);
expect(service.setVolume('s1', 1)).not.toHaveProperty('stats');
});
it('seekBy forwards the delta to the addon as a relative seek and refreshes the snapshot', () => {
startSession('s1', snapshot('playing', { positionSeconds: 10 }));
addon.getSessionSnapshot.mockReturnValue(
@@ -19,6 +19,7 @@ import {
EmbeddedMpvRecordingState,
EmbeddedMpvSession,
EmbeddedMpvSessionStatus,
EmbeddedMpvStreamStats,
EmbeddedMpvSubtitleStyle,
EmbeddedMpvSubtitleTrack,
EmbeddedMpvEngine,
@@ -74,6 +75,7 @@ export interface NativeEmbeddedMpvSessionSnapshot {
aspectOverride?: string;
videoWidth?: number;
videoHeight?: number;
stats?: EmbeddedMpvStreamStats;
recording?: EmbeddedMpvRecordingState;
error?: string;
/**
@@ -1250,6 +1252,9 @@ export class EmbeddedMpvNativeService {
videoHeight: snapshot.videoHeight,
}
: {}),
...(snapshot.stats && Object.keys(snapshot.stats).length > 0
? { stats: snapshot.stats }
: {}),
recording: snapshot.recording ?? { active: false },
startedAt: session.startedAt,
updatedAt: new Date().toISOString(),