[feat] add DeskHub stream controls and network status

This commit is contained in:
dijunkun
2026-10-13 10:45:48 +08:00
parent 73b4d0f645
commit 3bd0676672
28 changed files with 1266 additions and 259 deletions
@@ -35,6 +35,30 @@ bool GuiRuntime::HasActiveSession() {
});
}
void GuiRuntime::MuteRemoteAudio(const std::shared_ptr<RemoteSession>& props) {
if (!props) return;
std::lock_guard delivery_lock(audio_delivery_mutex_);
props->audio_capture_button_pressed_ = false;
props->audio_allowed_ = false;
devices_.ClearAudio(props->remote_id_);
// Capable peers stop sending to this controller on the next subscription;
// old peers remain muted by the local receive gate above.
props->preview_needs_sync_ = true;
}
void GuiRuntime::UnmuteRemoteAudio(const std::shared_ptr<RemoteSession>& props) {
if (!props || props->closing_) return;
props->audio_capture_button_pressed_ = true;
props->preview_needs_sync_ = true;
// Also resume capture on old peers, or after a legacy controller stopped it.
RemoteAction action{};
action.type = ControlType::audio_capture;
action.a = true;
const auto message = action.to_json();
SendReliableDataFrame(props->peer_, message.data(), message.size(),
props->control_data_label_.c_str());
}
void GuiRuntime::HandleConnectionStatusChange() {
if (!signal_connected_ || !peer_) {
return;
@@ -273,6 +297,7 @@ int GuiRuntime::ForceSignalReconnect() {
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
if (!props || props->closing_.exchange(true)) return;
MuteRemoteAudio(props);
if (props->peer_events_) props->peer_events_->Deactivate();
props->connection_established_ = false;
props->streaming_ = false;
@@ -484,6 +509,7 @@ void GuiRuntime::WaitForSessionCleanup() {
void GuiRuntime::ResetRemoteSessionResources(
std::shared_ptr<RemoteSession> props) {
props->net_traffic_stats_.Reset();
props->received_video_stats_.Reset();
{
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
props->video_latency_->Reset();
@@ -657,6 +683,11 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
int ret = -1;
auto props = FindRemoteSession(remote_id);
if (!props || props->closing_) return -1;
if (background) {
// This also covers reusing a foreground connection that is awaiting auth.
props->wall_managed_ = true;
MuteRemoteAudio(props);
}
if (!props->connection_established_) {
if (!background) show_connection_status_window_ = true;
@@ -53,6 +53,9 @@ class GuiRuntime : protected gui_detail::GuiState {
int ConnectTo(const std::string& remote_id, const char* password,
bool remember_password, bool bypass_presence_check = false,
bool background = false);
// UI thread: mute this receiver without stopping audio for other controllers.
void MuteRemoteAudio(const std::shared_ptr<RemoteSession>& props);
void UnmuteRemoteAudio(const std::shared_ptr<RemoteSession>& props);
int RequestSingleDevicePresence(const std::string& remote_id,
const char* password, bool remember_password);
int ForceSignalReconnect();
@@ -24,6 +24,8 @@ class NetTrafficStatsCache {
"Network statistics require lock-free scalar atomics");
public:
using Clock = std::chrono::steady_clock;
void Store(const MiniRtcNetTrafficStats& stats) {
video_inbound_stats_.Store(stats.video_inbound_stats);
video_outbound_stats_.Store(stats.video_outbound_stats);
@@ -36,6 +38,10 @@ class NetTrafficStatsCache {
srtp_active_.store(stats.srtp_active, std::memory_order_relaxed);
rtt_ms_.store(stats.rtt_ms, std::memory_order_relaxed);
connection_latency_.Update(stats.rtt_ms);
last_report_ms_.store(
std::chrono::duration_cast<std::chrono::milliseconds>(
Clock::now().time_since_epoch()).count(),
std::memory_order_relaxed);
}
MiniRtcNetTrafficStats Load() const {
@@ -65,9 +71,17 @@ class NetTrafficStatsCache {
void ResetLatency() { connection_latency_.Reset(); }
bool HasRecentReport(Clock::time_point now = Clock::now()) const {
const auto last_report = last_report_ms_.load(std::memory_order_relaxed);
const auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
now.time_since_epoch()).count();
return last_report > 0 && now_ms - last_report < 3000;
}
void Reset() {
Store(MiniRtcNetTrafficStats{});
ResetLatency();
last_report_ms_.store(0, std::memory_order_relaxed);
}
private:
@@ -114,6 +128,7 @@ class NetTrafficStatsCache {
OutboundStats total_outbound_stats_;
std::atomic<bool> srtp_active_{false};
std::atomic<double> rtt_ms_{-1};
std::atomic<int64_t> last_report_ms_{0};
ConnectionLatencyStats connection_latency_;
};
@@ -168,20 +168,26 @@ void PeerEventHandler::OnReceiveDataBuffer(
bool accepted = true;
// Apply encoder quality to this controller only; other controllers retain
// their own demand. Frame gating happens before encoding and transmission.
if (subscription->fps > 0 && runtime->preview_subscriptions_.IsNewer(
remote_id, subscription->request_id))
accepted = UpdateVideoSettings(
if (runtime->preview_subscriptions_.IsNewer(remote_id,
subscription->request_id)) {
if (subscription->fps > 0)
accepted = UpdateVideoSettings(
runtime->peer_, remote_id.data(), remote_id.size(),
static_cast<VideoQuality>(subscription->quality),
subscription->frame_rate,
static_cast<VideoDegradationPreference>(
subscription->EncoderPreference())) == 0;
if (accepted && runtime->preview_subscriptions_.Set(remote_id, *subscription) &&
subscription->fps > 0)
RequestAllVideoKeyFrames(runtime->peer_);
if (accepted)
accepted = UpdateVideoResolution(
runtime->peer_, remote_id.data(), remote_id.size(),
subscription->width, subscription->height) == 0;
}
// The resolution API refreshes only this controller's keyframes, including
// resume requests with unchanged dimensions. Do not disturb other viewers.
if (accepted) runtime->preview_subscriptions_.Set(remote_id, *subscription);
const auto reply = nlohmann::json{
{"type", "deskhub_preview_status"},
{"version", 1},
{"version", subscription->version},
{"request_id", subscription->request_id},
{"accepted",
accepted}}.dump();
@@ -196,8 +202,11 @@ void PeerEventHandler::OnReceiveDataBuffer(
if (auto props = runtime->FindRemoteSession(remote_id);
props && source_id == props->control_data_label_) {
try {
if (preview_json.at("version") == 1 &&
if ((preview_json.at("version") == 1 ||
preview_json.at("version") == 2) &&
preview_json.at("request_id").is_number_unsigned() &&
preview_json.at("request_id").get<uint64_t>() > 0 &&
preview_json.at("request_id").get<uint64_t>() <= UINT32_MAX &&
preview_json.at("accepted").is_boolean()) {
props->preview_accepted_ = preview_json.at("accepted").get<bool>();
props->preview_ack_ = preview_json.at("request_id").get<uint32_t>();
@@ -398,6 +407,9 @@ void PeerEventHandler::OnReceiveDataBuffer(
const bool first_advertisement = !props->video_settings_supported_;
props->preview_subscription_supported_ =
remote_action.i.supports_deskhub_preview;
props->preview_resolution_supported_ =
remote_action.i.supports_deskhub_resolution;
props->preview_fps15_supported_ = remote_action.i.supports_deskhub_fps15;
props->video_settings_supported_ =
remote_action.i.supports_video_settings;
if (first_advertisement && props->video_settings_supported_) {
@@ -289,6 +289,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true;
remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = true;
remote_action.i.supports_deskhub_fps15 = true;
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
LOG_INFO("Local display [{}:{}]", i + 1,
remote_action.i.display_list[i]);
@@ -327,8 +329,15 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
props->remote_update_dismissed_ = false;
}
props->net_traffic_stats_.Reset();
props->received_video_stats_.Reset();
props->traversal_mode_ = TraversalMode::UnknownMode;
props->enable_mouse_control_ = false;
props->preview_subscription_supported_ = false;
props->preview_resolution_supported_ = false;
props->preview_fps15_supported_ = false;
props->preview_needs_sync_ = true;
props->preview_ack_ = 0;
props->preview_accepted_ = false;
props->audio_allowed_ = false;
props->clipboard_allowed_ = false;
runtime->ResetRemoteServiceStatus(*props);
@@ -429,6 +438,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true;
remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = true;
remote_action.i.supports_deskhub_fps15 = true;
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
LOG_INFO("Local display [{}:{}]", i + 1,
remote_action.i.display_list[i]);
@@ -545,7 +556,7 @@ void PeerEventHandler::OnNetStatusReport(
if (props->traversal_mode_ != mode) {
props->net_traffic_stats_.ResetLatency();
props->traversal_mode_ = mode;
LOG_INFO("Net mode: [{}]", int(props->traversal_mode_));
LOG_INFO("Net mode: [{}]", int(props->traversal_mode_.load()));
}
if (!net_traffic_stats) {
@@ -50,6 +50,11 @@ void PeerEventHandler::OnReceiveVideoBuffer(
if (props->connection_established_) {
const auto timing_now = VideoLatencyFrame::Clock::now();
const auto* received_native = GetNativeVideoFrame(video_frame);
props->received_video_stats_.Record(
received_native ? received_native->width : video_frame->width,
received_native ? received_native->height : video_frame->height,
timing_now);
// Convert only visible thumbnails, off the UI thread. A session retains
// one latest RGBA image, not an unbounded queue of frames.
const int preview_width = props->gallery_preview_width_.load();
@@ -274,10 +279,10 @@ void PeerEventHandler::OnReceiveAudioBuffer(
std::lock_guard delivery_lock(runtime->audio_delivery_mutex_);
const auto session =
runtime->FindRemoteSession(std::string(user_id, user_id_size));
if (!session || !session->audio_allowed_.load()) return;
if (!session || session->closing_ || !session->audio_allowed_.load()) return;
runtime->audio_buffer_fresh_ = true;
runtime->devices_.PushAudio(data, size);
runtime->devices_.PushAudio(session->remote_id_, data, size);
}
} // namespace crossdesk
@@ -24,13 +24,17 @@ public:
std::lock_guard lock(mutex_);
const auto found = states_.find(id);
return found == states_.end() ||
request_id > found->second.request.request_id;
static_cast<int32_t>(request_id -
found->second.request.request_id) > 0;
}
bool Set(const std::string &id, const PreviewSubscription &request) {
std::lock_guard lock(mutex_);
auto &state = states_[id];
if (request.request_id <= state.request.request_id)
const auto found = states_.find(id);
if (found != states_.end() &&
static_cast<int32_t>(request.request_id -
found->second.request.request_id) <= 0)
return false;
auto &state = states_[id];
state.request = request;
state.next.clear();
return true;
@@ -49,6 +53,15 @@ public:
if (state.request.fps == 60)
return true;
auto &next = state.next[stream];
if (state.request.fps == 15) {
// Keep a stable 15 FPS deadline across capture jitter. Rebasing every
// frame can turn small timing errors into extra dropped capture frames.
const auto interval = std::chrono::nanoseconds(1'000'000'000 / 15);
if (now + std::chrono::milliseconds(1) < next) return false;
next = next == Clock::time_point{} || now - next > interval
? now + interval : next + interval;
return true;
}
if (now < next)
return false;
next = now + std::chrono::milliseconds(1000 / state.request.fps);
@@ -0,0 +1,72 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _RECEIVED_VIDEO_STATS_H_
#define _RECEIVED_VIDEO_STATS_H_
#include <chrono>
#include <cmath>
#include <mutex>
#include <optional>
namespace crossdesk::gui_detail {
// Measured at decode delivery, including wall-only and background sessions.
// Independent of the selected window's presentation cadence and FPS setting.
class ReceivedVideoStats {
public:
using Clock = std::chrono::steady_clock;
struct Snapshot {
int width = 0;
int height = 0;
std::optional<int> fps;
};
void Record(int width, int height, Clock::time_point now = Clock::now()) {
if (width <= 0 || height <= 0) return;
std::lock_guard lock(mutex_);
const bool restart = snapshot_.width == 0 ||
now - last_frame_ >= std::chrono::seconds(3);
snapshot_.width = width;
snapshot_.height = height;
last_frame_ = now;
if (restart) {
started_ = now;
frames_ = 0;
snapshot_.fps.reset();
return;
}
++frames_;
const double elapsed = std::chrono::duration<double>(now - started_).count();
if (elapsed >= 1.0) {
snapshot_.fps = static_cast<int>(std::lround(frames_ / elapsed));
frames_ = 0;
started_ = now;
}
}
Snapshot Get(Clock::time_point now = Clock::now()) const {
std::lock_guard lock(mutex_);
auto result = snapshot_;
if (result.width > 0 && now - last_frame_ >= std::chrono::seconds(1))
result.fps = 0;
return result;
}
void Reset() {
std::lock_guard lock(mutex_);
snapshot_ = {};
frames_ = 0;
started_ = last_frame_ = {};
}
private:
mutable std::mutex mutex_;
Snapshot snapshot_;
unsigned frames_ = 0;
Clock::time_point started_{}, last_frame_{};
};
} // namespace crossdesk::gui_detail
#endif
@@ -23,10 +23,12 @@
#include <preview_subscription.h>
#include <stream_names.h>
#include "display_info.h"
#include "features/gallery/preview_resolution.h"
#include "cursor_presentation.h"
#include "minirtc.h"
#include "rendering/video_latency.h"
#include "runtime/net_traffic_stats_cache.h"
#include "runtime/received_video_stats.h"
#include "runtime/connection_auth_state.h"
namespace crossdesk { class PeerEventHandler; }
@@ -74,10 +76,14 @@ struct RemoteSession {
// Wall membership, stream-window visibility and input ownership are separate.
std::atomic<bool> wall_managed_{false};
std::atomic<bool> preview_subscription_supported_{false};
std::atomic<bool> preview_resolution_supported_{false};
std::atomic<bool> preview_fps15_supported_{false};
std::atomic<bool> preview_needs_sync_{true};
std::atomic<uint32_t> preview_ack_{0};
std::atomic<bool> preview_accepted_{false};
uint32_t preview_request_ = 0;
PreviewSubscription preview_requested_;
gallery::PreviewResolutionDebouncer preview_resolution_;
std::chrono::steady_clock::time_point preview_request_at_{};
std::atomic<bool> clipboard_allowed_{false};
std::atomic<uint64_t> input_generation_{0};
@@ -189,13 +195,14 @@ struct RemoteSession {
CursorPresentation cursor_presentation_;
// Shared by minirtc callbacks, Slint rendering and SDL audio callbacks.
std::atomic<ConnectionStatus> connection_status_ = ConnectionStatus::Closed;
TraversalMode traversal_mode_ = TraversalMode::UnknownMode;
std::atomic<TraversalMode> traversal_mode_{TraversalMode::UnknownMode};
int fps_ = 0;
int frame_count_ = 0;
std::chrono::steady_clock::time_point last_time_;
// UI-thread snapshot refreshed with FPS once per second.
std::optional<VideoLatencyStats::Snapshot> video_latency_snapshot_;
NetTrafficStatsCache net_traffic_stats_;
ReceivedVideoStats received_video_stats_;
using QueuedFile = FileTransferState::QueuedFile;
using FileTransferStatus = FileTransferState::FileTransferStatus;