From c6a2f6a35bd35deea29d2b6230febe0860641b8a Mon Sep 17 00:00:00 2001 From: dijunkun Date: Thu, 22 Oct 2026 19:18:37 +0800 Subject: [PATCH] [feat] display video codecs and refine network statistics --- .../src/gui/application/gui_application.cpp | 14 +- .../assets/localization/localization_data.h | 2 + .../src/gui/runtime/connection_runtime.cpp | 1 + .../src/gui/runtime/net_traffic_stats_cache.h | 42 +++++- .../src/gui/runtime/peer_data_callbacks.cpp | 16 +++ .../src/gui/runtime/peer_event_handler.cpp | 27 +++- apps/desktop/src/gui/runtime/runtime_state.h | 2 + .../src/gui/ui/gallery_network_dialog.slint | 24 +++- apps/desktop/src/gui/ui/stream_window.slint | 132 ++++++++++++------ apps/desktop/src/gui/ui/ui_localization.h | 2 + deps/submodules/minirtc | 2 +- docs/USER_GUIDE.md | 4 +- docs/USER_GUIDE_EN.md | 4 +- libs/wire/include/remote_action.h | 7 + libs/wire/src/remote_action.cpp | 16 +++ 15 files changed, 247 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 77c59bf7..84b1895b 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -473,7 +473,7 @@ std::string FormatBitrate(uint32_t bits_per_second) { } else if (bits_per_second < 1000000) { std::snprintf(text, sizeof(text), "%u kbps", bits_per_second / 1000); } else { - std::snprintf(text, sizeof(text), "%.1f mbps", bits_per_second / 1000000.0); + std::snprintf(text, sizeof(text), "%.1f Mbps", bits_per_second / 1000000.0); } return text; } @@ -3636,6 +3636,13 @@ void GuiApplication::SyncStreamWindow() { (*ui_->stream)->set_selected_display(props->selected_display_); const auto net = props->net_traffic_stats_.Load(); + const auto codecs = props->net_traffic_stats_.LoadVideoCodecs(); + (*ui_->stream)->set_stats_encoder(UiText( + status == ConnectionStatus::Connected && !codecs.remote_encoder.empty() + ? codecs.remote_encoder : "—")); + (*ui_->stream)->set_stats_decoder(UiText( + status == ConnectionStatus::Connected && !codecs.local_decoder.empty() + ? codecs.local_decoder : "—")); (*ui_->stream)->set_stats_encryption_enabled( status == ConnectionStatus::Connected && net.srtp_active); std::vector stats_rows; @@ -3659,7 +3666,7 @@ void GuiApplication::SyncStreamWindow() { net.total_inbound_stats, net.total_outbound_stats); ui::UpdateModelRows(*ui_->stats_model, std::move(stats_rows)); (*ui_->stream) - ->set_stats_resolution(UiText(std::to_string(props->video_width_) + "x" + + ->set_stats_resolution(UiText(std::to_string(props->video_width_) + " × " + std::to_string(props->video_height_))); (*ui_->stream) ->set_stats_connection_mode( @@ -5738,6 +5745,9 @@ void GuiApplication::SyncGallery() { ? UiText(std::to_string(video.width) + " × " + std::to_string(video.height)) : UiText("—"); device.fps = video.fps ? UiText(std::to_string(*video.fps) + " FPS") : UiText("—"); + const auto codecs = props->net_traffic_stats_.LoadVideoCodecs(); + device.encoder = UiText(codecs.remote_encoder.empty() ? "—" : codecs.remote_encoder); + device.decoder = UiText(codecs.local_decoder.empty() ? "—" : codecs.local_decoder); const bool has_report = props->net_traffic_stats_.HasRecentReport(now); const auto stats = props->net_traffic_stats_.Load(); // Use negotiated transport state, never the local encryption preference. diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index f1da3cef..f5214ff3 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -372,6 +372,8 @@ struct TranslationRow { X(connection_mode_direct, u8"直连", "Direct", u8"Прямой") \ X(connection_mode_relay, u8"中继", "Relay", u8"Релейный") \ X(transport_encryption, u8"加密传输", "Encryption", u8"Шифрование") \ + X(remote_video_encoder, u8"被控端编码器", "Remote encoder", u8"Кодер удалённой стороны") \ + X(local_video_decoder, u8"控制端解码器", "Local decoder", u8"Локальный декодер") \ X(transport_encryption_enabled, u8"已开启", "Enabled", u8"Включено") \ X(transport_encryption_disabled, u8"未开启", "Disabled", u8"Выключено") \ X(online, u8"在线", "Online", u8"Онлайн") \ diff --git a/apps/desktop/src/gui/runtime/connection_runtime.cpp b/apps/desktop/src/gui/runtime/connection_runtime.cpp index d70220fb..31b719fa 100644 --- a/apps/desktop/src/gui/runtime/connection_runtime.cpp +++ b/apps/desktop/src/gui/runtime/connection_runtime.cpp @@ -199,6 +199,7 @@ void GuiRuntime::HandleServerControllerDisconnected( remote_input_.Disconnect(remote_id); devices_.OnControllerConnectionsChanged(); connection_host_names_.erase(remote_id); + codec_status_controllers_.erase(remote_id); privacy_sessions_.Disconnected(remote_id); for (const auto& [id, status] : connection_status_) { if (status != ConnectionStatus::Connected) { diff --git a/apps/desktop/src/gui/runtime/net_traffic_stats_cache.h b/apps/desktop/src/gui/runtime/net_traffic_stats_cache.h index b9deae82..833c3179 100644 --- a/apps/desktop/src/gui/runtime/net_traffic_stats_cache.h +++ b/apps/desktop/src/gui/runtime/net_traffic_stats_cache.h @@ -8,6 +8,9 @@ #define _NET_TRAFFIC_STATS_CACHE_H_ #include +#include +#include +#include #include "minirtc.h" #include "runtime/connection_latency.h" @@ -15,7 +18,8 @@ namespace crossdesk::gui_detail { // Fields are independent observations and may come from different reports. -// Relaxed accesses keep each field safe without publishing any other state. +// Relaxed accesses keep scalar fields safe without publishing other state; +// codec names are copied under their own mutex. class NetTrafficStatsCache { static_assert(std::atomic::is_always_lock_free && std::atomic::is_always_lock_free && @@ -26,7 +30,31 @@ class NetTrafficStatsCache { public: using Clock = std::chrono::steady_clock; + struct VideoCodecNames { + std::string remote_encoder; + std::string local_decoder; + }; + + void StoreRemoteVideoEncoder(const std::string& encoder) { + std::lock_guard lock(codec_mutex_); + codecs_.remote_encoder = encoder; + } + + VideoCodecNames LoadVideoCodecs() const { + std::lock_guard lock(codec_mutex_); + return codecs_; + } + void Store(const MiniRtcNetTrafficStats& stats) { + { + std::lock_guard lock(codec_mutex_); + local_encoder_.assign( + stats.video_encoder, + strnlen(stats.video_encoder, sizeof(stats.video_encoder))); + codecs_.local_decoder.assign( + stats.video_decoder, + strnlen(stats.video_decoder, sizeof(stats.video_decoder))); + } video_inbound_stats_.Store(stats.video_inbound_stats); video_outbound_stats_.Store(stats.video_outbound_stats); audio_inbound_stats_.Store(stats.audio_inbound_stats); @@ -46,6 +74,11 @@ class NetTrafficStatsCache { MiniRtcNetTrafficStats Load() const { MiniRtcNetTrafficStats stats{}; + { + std::lock_guard lock(codec_mutex_); + local_encoder_.copy(stats.video_encoder, sizeof(stats.video_encoder) - 1); + codecs_.local_decoder.copy(stats.video_decoder, sizeof(stats.video_decoder) - 1); + } stats.video_inbound_stats = video_inbound_stats_.Load(); stats.video_outbound_stats = video_outbound_stats_.Load(); stats.audio_inbound_stats = audio_inbound_stats_.Load(); @@ -80,6 +113,10 @@ class NetTrafficStatsCache { void Reset() { Store(MiniRtcNetTrafficStats{}); + { + std::lock_guard lock(codec_mutex_); + codecs_ = {}; + } ResetLatency(); last_report_ms_.store(0, std::memory_order_relaxed); } @@ -130,6 +167,9 @@ class NetTrafficStatsCache { std::atomic rtt_ms_{-1}; std::atomic last_report_ms_{0}; ConnectionLatencyStats connection_latency_; + mutable std::mutex codec_mutex_; + std::string local_encoder_; + VideoCodecNames codecs_; }; } // namespace crossdesk::gui_detail diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index 6e58f07f..504d3c34 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -209,6 +209,16 @@ void PeerEventHandler::OnReceiveDataBuffer( return; } + if (remote_action.type == ControlType::video_codec_status) { + if (auto props = runtime->FindRemoteSession(remote_id); + props && source_id == props->control_data_label_ && + !props->closing_ && + props->connection_status_ == ConnectionStatus::Connected) { + props->net_traffic_stats_.StoreRemoteVideoEncoder(remote_action.vcs.encoder); + } + return; + } + if (remote_action.type == ControlType::video_settings_status) { if (auto props = runtime->FindRemoteSession(remote_id)) { if (source_id != props->control_data_label_) return; @@ -426,6 +436,12 @@ void PeerEventHandler::OnReceiveDataBuffer( { std::unique_lock lock(runtime->connection_status_mutex_); runtime->connection_host_names_[remote_id] = host_name; + if (source_id == runtime->control_data_label_) { + if (remote_action.i.supports_video_codec_status) + runtime->codec_status_controllers_.insert(remote_id); + else + runtime->codec_status_controllers_.erase(remote_id); + } } LOG_INFO("Remote hostname: [{}]", host_name); if (source_id == runtime->control_data_label_) { diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index e1627e9b..ed1e6011 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -295,6 +296,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, /*supports_privacy_screen=*/!runtime->unattended_host_, CROSSDESK_VERSION, kClientPlatform); remote_action.i.supports_video_settings = true; + remote_action.i.supports_video_codec_status = true; remote_action.i.supports_text_input = runtime->devices_.SupportsTextInput(); remote_action.i.supports_deskhub_preview = true; remote_action.i.supports_deskhub_resolution = true; @@ -460,6 +462,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, /*supports_privacy_screen=*/!runtime->unattended_host_, CROSSDESK_VERSION, kClientPlatform); remote_action.i.supports_video_settings = true; + remote_action.i.supports_video_codec_status = true; remote_action.i.supports_text_input = runtime->devices_.SupportsTextInput(); remote_action.i.supports_deskhub_preview = true; remote_action.i.supports_deskhub_resolution = true; @@ -577,7 +580,29 @@ void PeerEventHandler::OnNetStatusReport( std::string remote_id(user_id, user_id_size); auto props = runtime->FindRemoteSession(remote_id); - if (!props) return; + if (!props) { + bool report_codecs = false; + { + std::shared_lock lock(runtime->connection_status_mutex_); + const auto status = runtime->connection_status_.find(remote_id); + report_codecs = runtime->codec_status_controllers_.count(remote_id) != 0 && + status != runtime->connection_status_.end() && + status->second == ConnectionStatus::Connected; + } + if (net_traffic_stats && report_codecs) { + RemoteAction action{}; + action.type = ControlType::video_codec_status; + std::snprintf(action.vcs.encoder, sizeof(action.vcs.encoder), "%.*s", + static_cast(sizeof(net_traffic_stats->video_encoder)), + net_traffic_stats->video_encoder); + const auto message = action.to_json(); + // Report only to this controller, after releasing the status lock. + SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(), + runtime->control_data_label_.c_str(), + remote_id.data(), remote_id.size()); + } + return; + } if (props->traversal_mode_ != mode) { props->net_traffic_stats_.ResetLatency(); props->traversal_mode_ = mode; diff --git a/apps/desktop/src/gui/runtime/runtime_state.h b/apps/desktop/src/gui/runtime/runtime_state.h index e42cda19..80e41f0b 100644 --- a/apps/desktop/src/gui/runtime/runtime_state.h +++ b/apps/desktop/src/gui/runtime/runtime_state.h @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -206,6 +207,7 @@ struct ConnectionState { std::shared_mutex connection_status_mutex_; std::unordered_map connection_status_; std::unordered_map connection_host_names_; + std::unordered_set codec_status_controllers_; // Cursor position is sampled asynchronously after remote mouse input has // been injected. Remember the input source so the sampled position is not // immediately echoed back to that same controller as stale feedback. diff --git a/apps/desktop/src/gui/ui/gallery_network_dialog.slint b/apps/desktop/src/gui/ui/gallery_network_dialog.slint index fe9e3d7b..16bd52f1 100644 --- a/apps/desktop/src/gui/ui/gallery_network_dialog.slint +++ b/apps/desktop/src/gui/ui/gallery_network_dialog.slint @@ -15,6 +15,8 @@ export struct GalleryNetworkDevice { loss: string, resolution: string, fps: string, + encoder: string, + decoder: string, } component NetworkRow inherits Rectangle { @@ -145,6 +147,26 @@ component NetworkRow inherits Rectangle { vertical-alignment: center; overflow: elide; } } + if !root.heading: HorizontalLayout { + x: 12px; y: 52px; width: parent.width - 24px; height: 20px; + spacing: 12px; + Rectangle { + width: (parent.width - 12px) / 2; + Text { + width: parent.width; height: parent.height; + text: GalleryStrings.tr("被控端编码器 ", "Remote encoder ", "Удалённый кодер ") + root.device.encoder; + font-size: 10px; color: #586f87; vertical-alignment: center; overflow: elide; + } + } + Rectangle { + width: (parent.width - 12px) / 2; + Text { + width: parent.width; height: parent.height; + text: GalleryStrings.tr("控制端解码器 ", "Local decoder ", "Локальный декодер ") + root.device.decoder; + font-size: 10px; color: #586f87; vertical-alignment: center; overflow: elide; + } + } + } } component NetworkEmptyState inherits Rectangle { @@ -163,7 +185,7 @@ component NetworkEmptyState inherits Rectangle { export component GalleryNetworkDialog inherits Rectangle { in property <[GalleryNetworkDevice]> devices; - private property row-height: 56px; + private property row-height: 78px; private property list-height: max(2, min(root.devices.length, 6)) * root.row-height; callback close(); background: #00000042; diff --git a/apps/desktop/src/gui/ui/stream_window.slint b/apps/desktop/src/gui/ui/stream_window.slint index 863117b6..ec8c62cd 100644 --- a/apps/desktop/src/gui/ui/stream_window.slint +++ b/apps/desktop/src/gui/ui/stream_window.slint @@ -64,6 +64,8 @@ export global StreamStrings { in-out property stats-video-latency: "Video delay"; in-out property stats-connection-latency: "Connection RTT"; in-out property stats-connection-mode: "Mode"; + in-out property stats-encoder: "Remote encoder"; + in-out property stats-decoder: "Local decoder"; in-out property stats-encryption: "Encryption"; in-out property stats-encryption-enabled: "Enabled"; in-out property stats-encryption-disabled: "Disabled"; @@ -230,6 +232,8 @@ export component StreamWindow inherits Window { in property stats-video-latency: "—"; in property stats-connection-latency: "—"; in property stats-connection-mode: ""; + in property stats-encoder: "—"; + in property stats-decoder: "—"; in property stats-encryption-enabled: false; in property file-transfer-visible: false; in property <[FileTransferEntry]> file-transfers; @@ -269,7 +273,7 @@ export component StreamWindow inherits Window { private property stats-row-height: 18px; private property stats-header-height: 18px; private property stats-content-height: - root.stats-header-height + root.stats-rows.length * root.stats-row-height + 65px; + root.stats-header-height + root.stats-rows.length * root.stats-row-height + 114px; private property file-panel-x: 18px; private property file-panel-y: video.height - min(210px, video.height * 0.35); private property file-panel-width: min(430px, video.width * 0.6); @@ -929,18 +933,17 @@ export component StreamWindow inherits Window { private property row-height: root.stats-row-height; private property label-column-width: 50px; private property loss-column-width: - max((self.width - self.label-column-width) / 3, - stats-loss-label.preferred-width + 8px); + max(56px, stats-loss-label.preferred-width + 12px); private property value-column-width: (self.width - self.label-column-width - self.loss-column-width) / 2; private property details-right-width: right-label-width-probe.preferred-width + 6px + max(resolution-width-probe.preferred-width, connection-latency-width-probe.preferred-width, - encryption-width-probe.preferred-width + encryption-width-probe.preferred-width + 12px + (root.stats-encryption-enabled ? 14px : 0px)); private property details-right-x: - min(self.width / 2, self.width - self.details-right-width - 6px); + min(self.width / 2 + 4px, self.width - self.details-right-width - 8px); left-label-width-probe := Text { visible: false; @@ -970,7 +973,7 @@ export component StreamWindow inherits Window { visible: false; text: root.stats-encryption-enabled ? StreamStrings.stats-encryption-enabled : StreamStrings.stats-encryption-disabled; - font-size: ImGuiFontStyle.body; + font-size: ImGuiFontStyle.small; font-weight: 600; } @@ -979,7 +982,7 @@ export component StreamWindow inherits Window { y: 0px; width: parent.width; height: root.stats-header-height; - background: #f3f5f8; + background: #f5f7fa; border-radius: UiRadius.control; Text { x: stats-table.label-column-width + 4px; @@ -1016,13 +1019,13 @@ export component StreamWindow inherits Window { y: root.stats-header-height + index * stats-table.row-height; width: parent.width; height: stats-table.row-height; - background: index == root.stats-rows.length - 1 ? #edf3fc : transparent; + background: index == root.stats-rows.length - 1 ? #edf4ff : transparent; border-radius: UiRadius.control; Text { x: 4px; width: stats-table.label-column-width - 8px; text: row.label; - color: #475467; + color: index == root.stats-rows.length - 1 ? #275188 : #667085; font-size: ImGuiFontStyle.body; font-weight: index == root.stats-rows.length - 1 ? 600 : 400; vertical-alignment: center; @@ -1031,7 +1034,7 @@ export component StreamWindow inherits Window { x: stats-table.label-column-width + 4px; width: stats-table.value-column-width - 8px; text: row.inbound; - color: #253247; + color: index == root.stats-rows.length - 1 ? #275188 : #344054; font-size: ImGuiFontStyle.body; font-weight: index == root.stats-rows.length - 1 ? 600 : 400; horizontal-alignment: right; @@ -1042,7 +1045,7 @@ export component StreamWindow inherits Window { x: stats-table.label-column-width + stats-table.value-column-width + 4px; width: stats-table.value-column-width - 8px; text: row.outbound; - color: #253247; + color: index == root.stats-rows.length - 1 ? #275188 : #344054; font-size: ImGuiFontStyle.body; font-weight: index == root.stats-rows.length - 1 ? 600 : 400; horizontal-alignment: right; @@ -1053,7 +1056,7 @@ export component StreamWindow inherits Window { x: stats-table.label-column-width + stats-table.value-column-width * 2 + 4px; width: stats-table.loss-column-width - 8px; text: row.loss-rate; - color: #475467; + color: index == root.stats-rows.length - 1 ? #275188 : #667085; font-size: ImGuiFontStyle.body; font-weight: index == root.stats-rows.length - 1 ? 600 : 400; horizontal-alignment: right; @@ -1069,6 +1072,46 @@ export component StreamWindow inherits Window { } } + codec-label-width-probe := Text { + visible: false; + text: StreamStrings.stats-encoder + "\n" + StreamStrings.stats-decoder; + font-size: ImGuiFontStyle.small; + } + Rectangle { + x: 0px; + y: root.stats-header-height + root.stats-rows.length * stats-table.row-height + + 70px; + width: parent.width; height: 44px; + background: #f7f9fc; border-radius: UiRadius.control; + border-width: 1px; border-color: #e8edf4; + Rectangle { + x: 8px; y: 22px; width: parent.width - 16px; height: 1px; + background: #e8edf4; + } + for codec[index] in [ + { label: StreamStrings.stats-encoder, value: root.stats-encoder }, + { label: StreamStrings.stats-decoder, value: root.stats-decoder }, + ]: Rectangle { + x: 8px; y: 2px + index * 21px; + width: parent.width - 16px; height: 19px; + Text { + x: 0px; y: 0px; + width: codec-label-width-probe.preferred-width; + height: parent.height; text: codec.label; + color: #667085; font-size: ImGuiFontStyle.small; + vertical-alignment: center; + } + Text { + x: codec-label-width-probe.preferred-width + 8px; y: 0px; + width: parent.width - self.x; height: parent.height; + text: codec.value; color: #344054; + font-size: ImGuiFontStyle.body; font-weight: 500; + horizontal-alignment: right; + vertical-alignment: center; overflow: elide; + } + } + } + for metric[index] in [ { label: "FPS", value: root.stats-fps }, { label: StreamStrings.stats-resolution, value: root.stats-resolution }, @@ -1080,12 +1123,12 @@ export component StreamWindow inherits Window { ]: Rectangle { private property encryption-icon-width: index == 5 && root.stats-encryption-enabled ? 14px : 0px; - x: mod(index, 2) == 1 ? stats-table.details-right-x : 6px; + x: mod(index, 2) == 1 ? stats-table.details-right-x : 8px; y: root.stats-header-height + root.stats-rows.length * stats-table.row-height - + 2px + floor(index / 2) * 21px; - width: mod(index, 2) == 1 ? stats-table.details-right-width - : stats-table.details-right-x - 14px; - height: 21px; + + 4px + floor(index / 2) * 20px; + width: mod(index, 2) == 1 ? stats-table.width - stats-table.details-right-x - 8px + : stats-table.details-right-x - 18px; + height: 20px; metric-label := Text { x: 0px; y: 0px; @@ -1097,31 +1140,40 @@ export component StreamWindow inherits Window { font-size: ImGuiFontStyle.small; vertical-alignment: center; } - metric-value := Text { - x: metric-label.width + 6px; - y: 0px; - height: parent.height; - width: parent.width - self.x - parent.encryption-icon-width; - text: metric.value; - color: index == 5 && root.stats-encryption-enabled ? #18794e : #253247; - font-size: ImGuiFontStyle.body; + value-width-probe := Text { + visible: false; text: metric.value; + font-size: index >= 4 ? ImGuiFontStyle.small : ImGuiFontStyle.body; font-weight: 600; - horizontal-alignment: left; - vertical-alignment: center; - overflow: elide; } - if index == 5 && root.stats-encryption-enabled: Text { - x: metric-value.x + min(metric-value.preferred-width, metric-value.width) + 4px; - y: metric-value.y + (metric-value.height - self.height) / 2; - width: 10px; - height: 12px; - text: FontAwesomeIcons.shield-halved; - font-family: "Font Awesome 6 Free"; - font-weight: 900; - font-size: 10px; - color: #238653; - horizontal-alignment: center; - vertical-alignment: center; + metric-value := Rectangle { + private property badge-padding: index >= 4 ? 6px : 0px; + private property icon-width: parent.encryption-icon-width; + width: index >= 4 + ? min(max(0px, parent.width - metric-label.width - 6px), + value-width-probe.preferred-width + self.badge-padding * 2 + self.icon-width) + : max(0px, parent.width - metric-label.width - 6px); + x: parent.width - self.width; y: 2px; height: 16px; + border-radius: 4px; + background: index == 4 ? #edf3fc : index == 5 + ? (root.stats-encryption-enabled ? #eaf6ee : #fff3e3) : transparent; + Text { + x: parent.badge-padding; y: 0px; height: parent.height; + width: max(0px, parent.width - parent.badge-padding * 2 - parent.icon-width); + text: metric.value; + color: index == 4 ? #275188 : index == 5 + ? (root.stats-encryption-enabled ? #18794e : #946015) : #253247; + font-size: index >= 4 ? ImGuiFontStyle.small : ImGuiFontStyle.body; + font-weight: 600; horizontal-alignment: right; + vertical-alignment: center; overflow: elide; + } + if index == 5 && root.stats-encryption-enabled: Text { + x: parent.width - parent.badge-padding - 10px; y: 2px; + width: 10px; height: 12px; + text: FontAwesomeIcons.shield-halved; + font-family: "Font Awesome 6 Free"; font-weight: 900; + font-size: 10px; color: #238653; + horizontal-alignment: center; vertical-alignment: center; + } } } } diff --git a/apps/desktop/src/gui/ui/ui_localization.h b/apps/desktop/src/gui/ui/ui_localization.h index e248f9d8..f5907fb5 100644 --- a/apps/desktop/src/gui/ui/ui_localization.h +++ b/apps/desktop/src/gui/ui/ui_localization.h @@ -241,6 +241,8 @@ inline int ApplyStreamWindowStrings( strings.set_stats_connection_latency(Text(localization::connection_latency[language])); strings.set_stats_connection_mode( Text(localization::connection_mode[language])); + strings.set_stats_encoder(Text(localization::remote_video_encoder[language])); + strings.set_stats_decoder(Text(localization::local_video_decoder[language])); strings.set_stats_encryption( Text(localization::transport_encryption[language])); strings.set_stats_encryption_enabled( diff --git a/deps/submodules/minirtc b/deps/submodules/minirtc index 978a2410..61b363f3 160000 --- a/deps/submodules/minirtc +++ b/deps/submodules/minirtc @@ -1 +1 @@ -Subproject commit 978a2410d980176488a12bc7f9b37ffe52305982 +Subproject commit 61b363f34765d15c8496550207aa3f444c4a44f9 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index f8cd92bc..bc6d01aa 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -95,13 +95,15 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-.deb" | 扬声器 | 播放声音 / 静音 | | 文件夹 | 选择文件发送到远端,并查看传输进度 | | 画面设置 | 在当前会话中调整画面质量、采集帧率与画面偏好 | -| 网络状态 | 查看流量、丢包率、帧率、分辨率、直连 / 中继及加密状态 | +| 网络状态 | 查看流量、丢包率、帧率、分辨率、直连 / 中继、加密状态,以及被控端编码器和控制端解码器 | | 隐私屏 | 开启或关闭被控电脑的隐私屏;悬停查看当前状态与可用操作 | | 全屏 | 进入 / 退出全屏 | | 断开连接 | 结束当前远程会话 | 桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 常规 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。 +普通会话与桌面墙的网络状态均显示实际使用的视频编解码器名称,包括回退后的软件编解码器。名称统一采用“实现名称 (编码格式)”,名称首字母大写、技术缩写保留全大写,例如 `VideoToolbox (H.264)`、`NVENC (H.264)`、`NVDEC (H.264)`、`SVT-AV1 (AV1)`、`Dav1d (AV1)` 和 `LibAOM (AV1)`;Media Foundation 额外标注 `HW`(硬件)或 `SW`(软件)。被控端需要支持编码器状态上报;旧版本或尚未获取到的信息显示“—”。 + ### 在会话中调整画面 连接成功后,点击工具栏中的 **画面设置**(滑杆图标),直接选择低 / 中 / 高画质、**30 / 60 fps**,以及帧率优先 / 画质优先 / 平衡。选项会发送给当前被控端,无需断开重连;实际画面效果取决于网络、设备与编码器能力。旧版被控端不支持时,菜单会提示并禁用选项。 diff --git a/docs/USER_GUIDE_EN.md b/docs/USER_GUIDE_EN.md index 14e7f0d1..cd158669 100644 --- a/docs/USER_GUIDE_EN.md +++ b/docs/USER_GUIDE_EN.md @@ -95,13 +95,15 @@ Switch devices using session tabs. Expand the control bar if it is collapsed, an | Speaker | Play / mute remote audio | | Folder | Select a file to send and view transfer progress | | Video settings | Adjust video quality, capture frame rate, and video preference during the current session | -| Network statistics | Inspect traffic, packet loss, frame rate, resolution, direct / relay mode, and encryption status | +| Network statistics | Inspect traffic, packet loss, frame rate, resolution, direct / relay mode, encryption status, the remote encoder, and the local decoder | | Privacy screen | Enable or disable the host's privacy screen; hover to see its status and available actions | | Fullscreen | Enter / exit fullscreen | | Disconnect | End the current remote session | Desktop clients synchronize text clipboard contents. Configure the receiving directory under **Settings → General → File Save Path**. Closing the main window hides it and keeps the app running; use the system tray or menu-bar exit command to quit completely. +Network statistics in both ordinary sessions and DeskHub show the video codecs actually in use, including software fallback. Names use “Implementation (Format)” with an initial capital and uppercase technical abbreviations, such as `VideoToolbox (H.264)`, `NVENC (H.264)`, `NVDEC (H.264)`, `SVT-AV1 (AV1)`, `Dav1d (AV1)`, and `LibAOM (AV1)`; Media Foundation also includes `HW` (hardware) or `SW` (software). The host must support encoder status reporting; older versions and information not yet available display “—”. + ### Adjust video during a session After connecting, open **Video settings** (the sliders icon) in the toolbar. Choose low / medium / high quality, **30 / 60 fps**, and frame-rate priority / quality priority / balanced adaptation. Changes are sent to the current host without reconnecting; actual results depend on the network, device, and encoder. For older hosts that do not support live video settings, the menu explains the limitation and disables the options. diff --git a/libs/wire/include/remote_action.h b/libs/wire/include/remote_action.h index 72681625..655cb656 100644 --- a/libs/wire/include/remote_action.h +++ b/libs/wire/include/remote_action.h @@ -32,6 +32,7 @@ enum ControlType { video_settings = 11, video_settings_status = 12, text_input = 13, + video_codec_status = 14, }; enum MouseFlag { @@ -138,6 +139,7 @@ struct HostInfo { // Optional capability; absent in legacy desktop and web host information. bool supports_privacy_screen; bool supports_video_settings; + bool supports_video_codec_status; bool supports_text_input; bool supports_deskhub_preview; bool supports_deskhub_resolution; @@ -179,6 +181,10 @@ struct VideoSettings { bool accepted; // Only meaningful in video_settings_status. }; +struct VideoCodecStatus { + char encoder[128]; +}; + inline bool ValidVideoSettings(const VideoSettings& settings) { return settings.quality >= 0 && settings.quality <= 2 && (settings.frame_rate == 30 || settings.frame_rate == 60) && @@ -202,6 +208,7 @@ struct RemoteAction { PrivacyCommand pc; PrivacyStatus ps; VideoSettings vs; + VideoCodecStatus vcs; }; // Committed UTF-8 text. Kept outside the POD union for automatic ownership. diff --git a/libs/wire/src/remote_action.cpp b/libs/wire/src/remote_action.cpp index d1469138..05f4e866 100644 --- a/libs/wire/src/remote_action.cpp +++ b/libs/wire/src/remote_action.cpp @@ -26,6 +26,7 @@ void ResetHostInfo(HostInfo& info) { info.bottom = nullptr; info.supports_privacy_screen = false; info.supports_video_settings = false; + info.supports_video_codec_status = false; info.supports_text_input = false; info.supports_deskhub_preview = false; info.supports_deskhub_resolution = false; @@ -177,6 +178,9 @@ std::string RemoteAction::ToJson(const RemoteAction& action) { {"render_mode", static_cast(action.cs.render_mode)}, {"hidden_reason", static_cast(action.cs.hidden_reason)}}; break; + case ControlType::video_codec_status: + object["video_codec"] = {{"encoder", action.vcs.encoder}}; + break; case ControlType::video_settings: case ControlType::video_settings_status: if (!ValidVideoSettings(action.vs)) return {}; @@ -232,6 +236,7 @@ std::string RemoteAction::ToJson(const RemoteAction& action) { {"displays", displays}, {"supports_privacy_screen", action.i.supports_privacy_screen}, {"supports_video_settings", action.i.supports_video_settings}, + {"supports_video_codec_status", action.i.supports_video_codec_status}, {"supports_text_input", action.i.supports_text_input}, {"supports_deskhub_preview", action.i.supports_deskhub_preview}, {"supports_deskhub_resolution", action.i.supports_deskhub_resolution}, @@ -342,6 +347,15 @@ bool RemoteAction::FromJson(const std::string& json_string, std::clamp(output.cs.visual_offset_y, -1.0f, 1.0f); break; } + case ControlType::video_codec_status: { + const auto encoder = object.at("video_codec").at("encoder").get(); + if (encoder.size() >= sizeof(output.vcs.encoder) || + std::any_of(encoder.begin(), encoder.end(), [](unsigned char c) { + return c < 32 || c > 126; + })) return false; + std::memcpy(output.vcs.encoder, encoder.c_str(), encoder.size() + 1); + break; + } case ControlType::video_settings: case ControlType::video_settings_status: { const auto& settings = object.at("video_settings"); @@ -436,6 +450,8 @@ bool RemoteAction::FromJson(const std::string& json_string, host_info_object.value("supports_text_input", false); output.i.supports_video_settings = host_info_object.value("supports_video_settings", false); + output.i.supports_video_codec_status = + host_info_object.value("supports_video_codec_status", false); output.i.supports_deskhub_preview = host_info_object.value("supports_deskhub_preview", false); output.i.supports_deskhub_resolution =