diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index cbcedb1f..c3c46f33 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -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(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(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 preview_ids; std::vector tiles; + std::vector 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(""); diff --git a/apps/desktop/src/gui/runtime/connection_runtime.cpp b/apps/desktop/src/gui/runtime/connection_runtime.cpp index e8de5dd2..946b544c 100644 --- a/apps/desktop/src/gui/runtime/connection_runtime.cpp +++ b/apps/desktop/src/gui/runtime/connection_runtime.cpp @@ -510,6 +510,11 @@ void GuiRuntime::ResetRemoteSessionResources( std::shared_ptr 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 lock(props->video_frame_mutex_); props->video_latency_->Reset(); diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index 5d07224b..bcfe4e42 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -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; diff --git a/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp index 7bcbd46a..6a88e54c 100644 --- a/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp @@ -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()) { diff --git a/apps/desktop/src/gui/runtime/remote_session.h b/apps/desktop/src/gui/runtime/remote_session.h index 34f71181..8ea173e7 100644 --- a/apps/desktop/src/gui/runtime/remote_session.h +++ b/apps/desktop/src/gui/runtime/remote_session.h @@ -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 gallery_video_latency_ = + std::make_shared(); // Decoder-thread scratch memory, never shared with the UI. std::mutex gallery_conversion_mutex_; std::vector gallery_nv12_, gallery_scaled_, gallery_scratch_; diff --git a/apps/desktop/src/gui/ui/gallery_network_dialog.slint b/apps/desktop/src/gui/ui/gallery_network_dialog.slint index c77a6088..42ee97c6 100644 --- a/apps/desktop/src/gui/ui/gallery_network_dialog.slint +++ b/apps/desktop/src/gui/ui/gallery_network_dialog.slint @@ -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 { diff --git a/docs/deskhub.md b/docs/deskhub.md index f11406cb..88036bac 100644 --- a/docs/deskhub.md +++ b/docs/deskhub.md @@ -24,7 +24,7 @@ - 按住单元格内的设备画面或名称拖动,可与另一台设备交换位置;目标单元格高亮,鼠标旁显示设备缩略图和松开提示。跨页时保持按住鼠标,将设备拖到窗口内容区左、右边缘内侧 32 逻辑像素范围,或上一页、下一页按钮,悬停 700 毫秒后翻页;边缘显示方向提示并在触发区域高亮,继续停留可连续翻页,到首尾页自动停止。翻页期间保留拖动源,末页设备不足一整页时也可继续拖动,移到目标设备后松开完成交换;离开翻页区域会重新计算悬停时间,在可翻页的边缘区域或按钮上松开只取消拖动,不会误与边缘下方的设备换位;窗口外不触发翻页。普通、精简及批量选择模式均支持,短距离点击仍执行原来的查看或选择操作。拖到自身、空单元格、分隔线或表格外不会换位;Esc、窗口失焦、切换分组或调整窗口大小会取消拖动。交换后的顺序保存到本地,并应用于所有分组视图,不修改分组、批量选择、连接或静音状态;保存失败时保持原顺序并提示重试。仅活动会话也可换位,排序不会自动将它加入保存的桌面墙成员。 - 顶部“精简模式”切换为仅画面的设备卡片,隐藏名称、在线状态、状态文字和悬停操作条,同时收起设备管理工具栏;画面完整等比显示,不裁切。顶部保持当前语言下宽度稳定的文字按钮高亮显示“退出精简”,不带图标;全屏入口仍可见。底部保留连接计数、网络详情入口和分页,不显示服务器状态栏。点击图片仍可展开查看和接管操控,返回后保持精简模式;模式切换会释放操控、退出批量选择并返回网格,不改变连接、分组和设备顺序。 - 主窗口菜单中的全局“设置”顶部并排提供“常规”和“桌面墙”两个选项卡;“桌面墙”可统一配置桌面墙设备的传输分辨率上限和帧率,桌面墙处于普通、精简、展开或全屏视图时,也可通过主窗口菜单打开设置。切换选项卡保留未保存修改,底部统一保存或取消。分辨率支持自动、320 × 180、640 × 360、1280 × 720、1920 × 1080、2560 × 1440、3840 × 2160和原始分辨率;固定上限适用于缩略图和展开画面,保持比例且不放大源画面。帧率支持跟随设备设置、15、30、60 FPS。保存后立即更新当前桌面墙会话,也用于后续桌面墙连接,不影响普通连接,跨分组和分页生效;取消不应用修改,恢复默认需保存后生效。配置保存在本地,重启后保留;保存失败时保留面板和输入。打开设置会释放远程操控。 -- 服务器连接状态、指示灯和重连入口仅在主窗口显示,桌面墙的普通、精简、展开、批量选择和全屏视图均不显示服务器状态栏。桌面墙底部操作栏左侧以普通文字显示已连接设备数量,旁边是紧凑的“网络状态”按钮;点击按钮可打开当前已建立连接的设备列表,空白区域不响应点击,也不显示整条悬停提示。弹窗采用紧凑宽度,高度随设备数量调整,最多同时显示 6 行,更多设备通过滚动查看。网络列表不受桌面墙分组和分页限制;离线、未连接和连接中的设备不列入,断开后自动移除。列表显示设备名称、ID、直连/中继、实际协商的 SRTP 加密状态、往返延迟(RTT)、控制端接收与发送速率、视频丢包率、实际接收分辨率与帧率,打开期间每秒刷新。加密状态紧随连接方式显示为“已加密/未加密”,尚无统计或报告超过 3 秒未更新时显示“待确认”。分辨率与帧率在解码交付时统计,墙内预览和后台会话均可用;尚未收到画面时显示“—”,画面停止更新超过 1 秒时帧率显示 0 FPS。网络数值尚无统计或报告超过 3 秒未更新时显示“—”。可通过关闭按钮、Esc 或点击列表外关闭;打开列表会释放远程操控。 +- 服务器连接状态、指示灯和重连入口仅在主窗口显示,桌面墙的普通、精简、展开、批量选择和全屏视图均不显示服务器状态栏。桌面墙底部操作栏左侧以普通文字显示已连接设备数量,旁边是紧凑的“网络状态”按钮;点击按钮可打开当前已建立连接的设备列表,空白区域不响应点击,也不显示整条悬停提示。弹窗采用紧凑宽度,高度随设备数量调整,最多同时显示 6 行,更多设备通过滚动查看。网络列表不受桌面墙分组和分页限制;离线、未连接和连接中的设备不列入,断开后自动移除。列表显示设备名称、ID、直连/中继、实际协商的 SRTP 加密状态、控制端接收与发送速率、视频丢包率、视频延时、实际接收分辨率与帧率,打开期间每秒刷新。加密状态紧随连接方式显示为“已加密/未加密”,尚无统计或报告超过 3 秒未更新时显示“待确认”。分辨率与帧率在解码交付时统计,墙内预览和后台会话均可用;尚未收到画面时显示“—”,画面停止更新超过 1 秒时帧率显示 0 FPS。网络数值尚无统计或报告超过 3 秒未更新时显示“—”。每台设备采用两行布局:画面列显示分辨率和帧率,视频延时列显示延时和丢包率,不再显示 RTT。视频延时直接显示毫秒数,取最近 1 秒内新提交预览帧的平均值;从被控端采集时间戳计时,包含编码、传输、解码、预览转换和 UI 等待,按采集到画面提交估算,不包含提交后到屏幕实际显示的等待。当前无墙内预览样本时,可使用该设备独立串流窗口的延时统计。没有新显示帧、时间戳不可用或无有效样本时显示“—”,不把 RTT 当作视频延时。可通过关闭按钮、Esc 或点击列表外关闭;打开列表会释放远程操控。 - 点卡片进入只读放大视图,点“接管操控”才开始发送键鼠。点“释放操控”或按 Ctrl/⌘ + Shift + Esc 可以释放。切换设备、翻页、打开本地操作面板、切换模式及窗口失焦也会释放。 - 桌面墙连接默认静音,包含新建连接、重连、复用已有连接、添加或导入已有会话,以及切入桌面墙时已有的连接。展开、全屏或接管操控不会自动开启声音;需要声音时,可通过批量“取消静音”或在独立串流窗口主动开启。两个入口共用同一静音状态,主动开启的声音在桌面墙可见时播放,翻页、展开及释放操控保留声音设置;每台设备使用独立音频流同时播放,静音一台不会清空其他设备的声音。新版本对端按会话关闭音频订阅;旧版本发送的音频由控制端丢弃,不通过停止对端的全局声音采集影响其他控制端。 - 放大视图使用一行设备操作栏,显示返回、设备名称、状态和接管/释放操控按钮,右侧保留精简模式及全屏入口;选中的桌面在下方放大显示,同页其他桌面的缩略图统一在上方横向排列,超出宽度时可横向滚动。点击上方缩略图可切换下方桌面,当前桌面不在缩略图中重复显示;同页只有一台设备时隐藏缩略图栏。普通模式缩略图下方以一行显示设备名,长名称省略,不显示状态圆点;不在线设备的缩略图和下方放大预览统一使用灰色,精简模式同样生效;悬停可查看设备名称、ID 和在线状态,精简模式也支持悬停查看。缺少画面或画面停滞时保留状态占位;滚动条单独留出空间。普通和精简模式的缩略图预览区域均固定为 16:9,普通模式的设备名称单独显示在画面下方。普通、精简、宽窗口及全屏均保持上下布局,上下区域之间提供带抓手的分隔条:向上拖动扩大下方主画面,向下拖动同步放大上方缩略图;选中分隔条后也可用上下方向键微调。缩略图始终保持 16:9,并保留主画面的最小可用高度;窗口缩小时临时限制尺寸,恢复窗口后沿用原先尺寸。普通和精简模式共用此尺寸,切换设备后保持,松开鼠标后保存并在重启时恢复;与网格的单元格尺寸独立。同页只有一台设备时同时隐藏缩略图栏和分隔条。拖动分隔条不会切换设备或发送远程键鼠,Esc、窗口失焦及窗口大小变化会结束拖动。调整尺寸后仍保留当前设备所在页。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。