[feat] show video latency in DeskHub network status

This commit is contained in:
dijunkun
2026-10-14 04:17:38 +08:00
parent 61ff9b2787
commit 0a6356085d
7 changed files with 43 additions and 17 deletions
@@ -5222,7 +5222,17 @@ void GuiApplication::SyncGallery() {
: mode == TraversalMode::UnknownMode
? tr("等待统计", "Awaiting stats", "Ждём данные")
: tr("中继", "Relay", "Ретрансляция"));
device.latency = device.inbound = device.outbound = device.loss = "—";
device.inbound = device.outbound = device.loss = "—";
device.video_latency = "—";
// Prefer the wall's own frame path. An independently displayed stream
// may still supply a measurement for a device outside the current page.
auto video_latency = props->gallery_video_latency_->Get(now);
if (!video_latency) video_latency = props->video_latency_->Get(now);
if (video_latency) {
const auto ms = video_latency->average_ms;
device.video_latency = UiText(ms < 1 ? "<1 ms"
: std::to_string(static_cast<int>(std::lround(ms))) + " ms");
}
const auto video = props->received_video_stats_.Get(now);
device.resolution = video.width > 0 && video.height > 0
? UiText(std::to_string(video.width) + " × " + std::to_string(video.height))
@@ -5237,9 +5247,6 @@ void GuiApplication::SyncGallery() {
if (mode != TraversalMode::UnknownMode && has_report) {
device.inbound = UiText(FormatBitrate(stats.total_inbound_stats.bitrate));
device.outbound = UiText(FormatBitrate(stats.total_outbound_stats.bitrate));
if (const auto rtt = props->net_traffic_stats_.SmoothedRttMs(now))
device.latency = UiText(*rtt < 1 ? "<1 ms"
: std::to_string(static_cast<int>(std::lround(*rtt))) + " ms");
// MiniRTC's total loss is a sum of media fractions, not an aggregate.
// Report video loss explicitly, and only after receiving video packets.
if (stats.video_inbound_stats.rtp_packet_count > 0 &&
@@ -5447,6 +5454,7 @@ void GuiApplication::SyncGallery() {
: gallery::PreviewResolution{};
std::set<std::string> preview_ids;
std::vector<ui::GalleryTile> tiles;
std::vector<VideoLatencyFrame> submitted_previews;
const auto make_tile = [&](const std::string& id, bool preview) {
ui::GalleryTile tile{};
tile.id = UiText(id);
@@ -5504,6 +5512,7 @@ void GuiApplication::SyncGallery() {
cached.image = slint::Image(std::move(pixels));
cached.sequence = props->gallery_sequence_;
cached.session = props;
submitted_previews.push_back(props->gallery_frame_timing_);
}
tile.frame = cached.image;
tile.has_frame = true;
@@ -5535,6 +5544,9 @@ void GuiApplication::SyncGallery() {
ui_->wall->set_gallery_focused(ui_->gallery_focus.empty()
? ui::GalleryTile{}
: make_tile(ui_->gallery_focus, true));
// Count each newly submitted image once, including conversion and UI queue
// time. Slint image submission does not confirm physical screen presentation.
for (const auto& timing : submitted_previews) timing.MarkSubmitted();
ui_->wall->set_gallery_connected(connected);
if (!ui_->wall->get_gallery_adding() && !ui_->wall->get_gallery_importing())
ui_->wall->set_gallery_notice("");
@@ -510,6 +510,11 @@ void GuiRuntime::ResetRemoteSessionResources(
std::shared_ptr<RemoteSession> props) {
props->net_traffic_stats_.Reset();
props->received_video_stats_.Reset();
{
std::lock_guard lock(props->gallery_frame_mutex_);
props->gallery_video_latency_->Reset();
props->gallery_frame_timing_ = {};
}
{
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
props->video_latency_->Reset();
@@ -331,6 +331,11 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
}
props->net_traffic_stats_.Reset();
props->received_video_stats_.Reset();
{
std::lock_guard lock(props->gallery_frame_mutex_);
props->gallery_video_latency_->Reset();
props->gallery_frame_timing_ = {};
}
props->traversal_mode_ = TraversalMode::UnknownMode;
props->enable_mouse_control_ = false;
props->preview_subscription_supported_ = false;
@@ -50,6 +50,8 @@ void PeerEventHandler::OnReceiveVideoBuffer(
if (props->connection_established_) {
const auto timing_now = VideoLatencyFrame::Clock::now();
// Pair the clocks before preview conversion so its cost is included.
const auto local_now_us = GetSystemTimeMicros(props->peer_);
const auto* received_native = GetNativeVideoFrame(video_frame);
props->received_video_stats_.Record(
received_native ? received_native->width : video_frame->width,
@@ -65,6 +67,8 @@ void PeerEventHandler::OnReceiveVideoBuffer(
std::defer_lock);
if (preview_width > 0) conversion_lock.lock();
if (conversion_lock.owns_lock()) {
const auto gallery_timing = props->gallery_video_latency_->Frame(
video_frame->captured_timestamp, local_now_us, timing_now);
const auto* native = GetNativeVideoFrame(video_frame);
const int w = native ? native->width : video_frame->width;
const int h = native ? native->height : video_frame->height;
@@ -100,6 +104,7 @@ void PeerEventHandler::OnReceiveVideoBuffer(
props->gallery_width_ = dw;
props->gallery_height_ = dh;
props->gallery_frame_at_ = timing_now;
props->gallery_frame_timing_ = gallery_timing;
++props->gallery_sequence_;
}
}
@@ -118,8 +123,7 @@ void PeerEventHandler::OnReceiveVideoBuffer(
return;
}
const auto timing = props->video_latency_->Frame(
video_frame->captured_timestamp, GetSystemTimeMicros(props->peer_),
timing_now);
video_frame->captured_timestamp, local_now_us, timing_now);
bool background_snapshot_only = false;
auto* native_renderer = runtime->video_renderer_.get();
if (native_renderer && native_renderer->IsActive()) {
@@ -95,6 +95,10 @@ struct RemoteSession {
int gallery_width_ = 0, gallery_height_ = 0;
uint64_t gallery_sequence_ = 0;
std::chrono::steady_clock::time_point gallery_frame_at_{};
VideoLatencyFrame gallery_frame_timing_;
// Keep preview submission estimates separate from stream presentation times.
std::shared_ptr<VideoLatencyStats> gallery_video_latency_ =
std::make_shared<VideoLatencyStats>();
// Decoder-thread scratch memory, never shared with the UI.
std::mutex gallery_conversion_mutex_;
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
@@ -8,7 +8,7 @@ export struct GalleryNetworkDevice {
mode: string,
encryption-known: bool,
encryption-enabled: bool,
latency: string,
video-latency: string,
inbound: string,
outbound: string,
loss: string,
@@ -122,8 +122,8 @@ component NetworkRow inherits Rectangle {
Text {
y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px;
width: parent.width;
text: root.heading ? GalleryStrings.tr("网络质量", "Network quality", "Качество сети") : "RTT " + root.device.latency;
font-size: 11px; font-weight: root.heading ? 600 : 400;
text: root.heading ? GalleryStrings.tr("视频延时", "Video latency", "Задержка видео") : root.device.video-latency;
font-size: root.heading ? 11px : 12px; font-weight: root.heading ? 600 : 400;
color: root.heading ? #6b7785 : #293746;
vertical-alignment: center; overflow: elide;
}
@@ -191,14 +191,10 @@ export component GalleryNetworkDialog inherits Rectangle {
x: parent.width - 80px; y: 17px; width: 60px; height: 26px;
text: GalleryStrings.tr("关闭", "Close", "Закрыть"); clicked => { root.close(); }
}
Rectangle {
x: 20px; y: 54px; width: 6px; height: 6px; border-radius: 3px;
background: root.devices.length > 0 ? #27a168 : #a7b0ba;
}
Text {
x: 32px; y: 48px; width: parent.width - 52px; height: 18px;
text: GalleryStrings.tr("已连接设备 · ", "Connected devices · ", "Подключённые устройства · ") + root.devices.length
+ GalleryStrings.tr(" 台 · 每秒更新", " · Updates every second", " · Обновление раз в секунду");
x: 20px; y: 48px; width: parent.width - 40px; height: 18px;
text: GalleryStrings.tr("已连接设备 ", "Connected devices ", "Подключённые устройства ") + root.devices.length
+ GalleryStrings.tr(" 台", "", "");
font-size: 11px; color: #7a8490; overflow: elide;
}
NetworkRow {