mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-23 04:53:06 +08:00
[feat] display video codecs and refine network statistics
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Vendored
+1
-1
Submodule deps/submodules/minirtc updated: 978a2410d9...61b363f347
+3
-1
@@ -95,13 +95,15 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
|
||||
| 扬声器 | 播放声音 / 静音 |
|
||||
| 文件夹 | 选择文件发送到远端,并查看传输进度 |
|
||||
| 画面设置 | 在当前会话中调整画面质量、采集帧率与画面偏好 |
|
||||
| 网络状态 | 查看流量、丢包率、帧率、分辨率、直连 / 中继及加密状态 |
|
||||
| 网络状态 | 查看流量、丢包率、帧率、分辨率、直连 / 中继、加密状态,以及被控端编码器和控制端解码器 |
|
||||
| 隐私屏 | 开启或关闭被控电脑的隐私屏;悬停查看当前状态与可用操作 |
|
||||
| 全屏 | 进入 / 退出全屏 |
|
||||
| 断开连接 | 结束当前远程会话 |
|
||||
|
||||
桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 常规 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。
|
||||
|
||||
普通会话与桌面墙的网络状态均显示实际使用的视频编解码器名称,包括回退后的软件编解码器。名称统一采用“实现名称 (编码格式)”,名称首字母大写、技术缩写保留全大写,例如 `VideoToolbox (H.264)`、`NVENC (H.264)`、`NVDEC (H.264)`、`SVT-AV1 (AV1)`、`Dav1d (AV1)` 和 `LibAOM (AV1)`;Media Foundation 额外标注 `HW`(硬件)或 `SW`(软件)。被控端需要支持编码器状态上报;旧版本或尚未获取到的信息显示“—”。
|
||||
|
||||
### 在会话中调整画面
|
||||
|
||||
连接成功后,点击工具栏中的 **画面设置**(滑杆图标),直接选择低 / 中 / 高画质、**30 / 60 fps**,以及帧率优先 / 画质优先 / 平衡。选项会发送给当前被控端,无需断开重连;实际画面效果取决于网络、设备与编码器能力。旧版被控端不支持时,菜单会提示并禁用选项。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<int>(action.cs.render_mode)},
|
||||
{"hidden_reason", static_cast<int>(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<std::string>();
|
||||
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 =
|
||||
|
||||
Reference in New Issue
Block a user