[feat] display video codecs and refine network statistics

This commit is contained in:
dijunkun
2026-10-22 19:18:37 +08:00
parent 193dcc4be0
commit c6a2f6a35b
15 changed files with 247 additions and 48 deletions
@@ -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<ui::NetworkStatsRow> 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.
@@ -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"Онлайн") \
@@ -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) {
@@ -8,6 +8,9 @@
#define _NET_TRAFFIC_STATS_CACHE_H_
#include <atomic>
#include <cstring>
#include <mutex>
#include <string>
#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<uint32_t>::is_always_lock_free &&
std::atomic<float>::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<double> rtt_ms_{-1};
std::atomic<int64_t> last_report_ms_{0};
ConnectionLatencyStats connection_latency_;
mutable std::mutex codec_mutex_;
std::string local_encoder_;
VideoCodecNames codecs_;
};
} // namespace crossdesk::gui_detail
@@ -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_) {
@@ -5,6 +5,7 @@
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <memory>
@@ -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<int>(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;
@@ -18,6 +18,7 @@
#include <string>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
@@ -206,6 +207,7 @@ struct ConnectionState {
std::shared_mutex connection_status_mutex_;
std::unordered_map<std::string, ConnectionStatus> connection_status_;
std::unordered_map<std::string, std::string> connection_host_names_;
std::unordered_set<std::string> 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.
@@ -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 <length> row-height: 56px;
private property <length> row-height: 78px;
private property <length> list-height: max(2, min(root.devices.length, 6)) * root.row-height;
callback close();
background: #00000042;
+92 -40
View File
@@ -64,6 +64,8 @@ export global StreamStrings {
in-out property <string> stats-video-latency: "Video delay";
in-out property <string> stats-connection-latency: "Connection RTT";
in-out property <string> stats-connection-mode: "Mode";
in-out property <string> stats-encoder: "Remote encoder";
in-out property <string> stats-decoder: "Local decoder";
in-out property <string> stats-encryption: "Encryption";
in-out property <string> stats-encryption-enabled: "Enabled";
in-out property <string> stats-encryption-disabled: "Disabled";
@@ -230,6 +232,8 @@ export component StreamWindow inherits Window {
in property <string> stats-video-latency: "—";
in property <string> stats-connection-latency: "—";
in property <string> stats-connection-mode: "";
in property <string> stats-encoder: "—";
in property <string> stats-decoder: "—";
in property <bool> stats-encryption-enabled: false;
in property <bool> file-transfer-visible: false;
in property <[FileTransferEntry]> file-transfers;
@@ -269,7 +273,7 @@ export component StreamWindow inherits Window {
private property <length> stats-row-height: 18px;
private property <length> stats-header-height: 18px;
private property <length> 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 <length> file-panel-x: 18px;
private property <length> file-panel-y: video.height - min(210px, video.height * 0.35);
private property <length> file-panel-width: min(430px, video.width * 0.6);
@@ -929,18 +933,17 @@ export component StreamWindow inherits Window {
private property <length> row-height: root.stats-row-height;
private property <length> label-column-width: 50px;
private property <length> 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 <length> value-column-width:
(self.width - self.label-column-width - self.loss-column-width) / 2;
private property <length> 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 <length> 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 <length> 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 <length> badge-padding: index >= 4 ? 6px : 0px;
private property <length> 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;
}
}
}
}
@@ -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(