mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(playback): stream-info popover in the player overlay (#1578)
This commit is contained in:
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(),
|
||||
|
||||
Reference in new issue
Block a user