mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-20 19:14:39 +08:00
[feat] add DeskHub stream controls and network status
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user