diff --git a/apps/desktop/src/gui/features/devices/session_device_manager.cpp b/apps/desktop/src/gui/features/devices/session_device_manager.cpp index 98b8e1b4..ceb9497d 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.cpp +++ b/apps/desktop/src/gui/features/devices/session_device_manager.cpp @@ -51,8 +51,7 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner) frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_); std::shared_lock lock(owner_.connection_status_mutex_); for (const auto& [id, status] : owner_.connection_status_) { - if (status == ConnectionStatus::Connected && - owner_.preview_subscriptions_.ShouldSendAudio(id)) + if (status == ConnectionStatus::Connected) SendAudioFrameToPeer(owner_.peer_, &frame, owner_.audio_label_.c_str(), id.data(), id.size()); diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index 9ac27f7e..3eb0fef9 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -165,11 +165,11 @@ void PeerEventHandler::OnReceiveDataBuffer( return; const auto subscription = PreviewSubscription::Parse(preview_json); if (!subscription) return; + const auto previous = runtime->preview_subscriptions_.Get(remote_id); bool accepted = true; // Apply encoder quality to this controller only; other controllers retain - // their own demand. Frame gating happens before encoding and transmission. - if (runtime->preview_subscriptions_.IsNewer(remote_id, - subscription->request_id)) { + // their own demand. MiniRTC owns per-peer video/audio pause state. + if (!previous || subscription->IsNewerThan(*previous)) { if (subscription->fps > 0) accepted = UpdateVideoSettings( runtime->peer_, remote_id.data(), remote_id.size(), @@ -177,13 +177,22 @@ void PeerEventHandler::OnReceiveDataBuffer( subscription->frame_rate, static_cast( subscription->EncoderPreference())) == 0; - if (accepted) + // Audio-only updates must not force a keyframe. The pause API requests + // its own keyframes on resume. + if (accepted && (!previous || previous->width != subscription->width || + previous->height != subscription->height)) accepted = UpdateVideoResolution( runtime->peer_, remote_id.data(), remote_id.size(), subscription->width, subscription->height) == 0; + if (accepted) + accepted = SetVideoSendPaused(runtime->peer_, remote_id.data(), + remote_id.size(), subscription->fps == 0) == 0; + if (accepted) + accepted = SetAudioSendPaused(runtime->peer_, remote_id.data(), + remote_id.size(), !subscription->audio) == 0; + if (accepted && subscription->audio) + runtime->devices_.StartSpeakerCapturer(); } - // 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"}, @@ -527,9 +536,16 @@ void PeerEventHandler::OnReceiveDataBuffer( runtime->devices_.SendMouseCommand(remote_action, runtime->selected_display_); } else if (remote_action.type == ControlType::audio_capture) { + // A negotiated subscription remains authoritative if a legacy capture + // command arrives after it (e.g. when unmuting an inactive window). + const auto subscription = runtime->preview_subscriptions_.Get(remote_id); + const bool paused = subscription ? !subscription->audio : !remote_action.a; + const int ret = SetAudioSendPaused(runtime->peer_, remote_id.data(), + remote_id.size(), paused); if (remote_action.a) runtime->devices_.StartSpeakerCapturer(); - else + else if (ret != 0) + // Legacy/web transports without the per-peer API retain their fallback. runtime->devices_.StopSpeakerCapturer(); } else if (remote_action.type == ControlType::keyboard) { runtime->keyboard_.ApplyRemoteEvent(remote_id, remote_action); diff --git a/apps/desktop/src/gui/runtime/preview_subscriptions.h b/apps/desktop/src/gui/runtime/preview_subscriptions.h index 4a34f5df..381a1014 100644 --- a/apps/desktop/src/gui/runtime/preview_subscriptions.h +++ b/apps/desktop/src/gui/runtime/preview_subscriptions.h @@ -20,23 +20,21 @@ namespace crossdesk { class PreviewSubscriptions { public: using Clock = std::chrono::steady_clock; - bool IsNewer(const std::string &id, uint32_t request_id) { + std::optional Get(const std::string &id) { std::lock_guard lock(mutex_); const auto found = states_.find(id); - return found == states_.end() || - static_cast(request_id - - found->second.request.request_id) > 0; + return found == states_.end() ? std::nullopt + : std::optional(found->second.request); } bool Set(const std::string &id, const PreviewSubscription &request) { std::lock_guard lock(mutex_); const auto found = states_.find(id); if (found != states_.end() && - static_cast(request.request_id - - found->second.request.request_id) <= 0) + !request.IsNewerThan(found->second.request)) return false; auto &state = states_[id]; + if (state.request.fps != request.fps) state.next.clear(); state.request = request; - state.next.clear(); return true; } bool ShouldSend(const std::string &id, Clock::time_point now = Clock::now(), @@ -46,8 +44,10 @@ public: if (found == states_.end()) return true; auto &state = found->second; + // MiniRTC owns pause/resume. This gate only limits preview cadence, so + // paused input reaches its cheap send gate without dividing by zero here. if (!state.request.fps) - return false; + return true; // Interactive streams use the encoder's own 30/60 FPS cadence. A second // time gate here can drop valid frames because capture timing varies. if (state.request.fps == 60) @@ -67,11 +67,6 @@ public: next = now + std::chrono::milliseconds(1000 / state.request.fps); return true; } - bool ShouldSendAudio(const std::string &id) { - std::lock_guard lock(mutex_); - const auto found = states_.find(id); - return found == states_.end() || found->second.request.audio; - } void Remove(const std::string &id) { std::lock_guard lock(mutex_); states_.erase(id); diff --git a/deps/submodules/minirtc b/deps/submodules/minirtc index 851eb2ed..ac48aa4b 160000 --- a/deps/submodules/minirtc +++ b/deps/submodules/minirtc @@ -1 +1 @@ -Subproject commit 851eb2ed1f121c46981e7b22d79f59d1261a682e +Subproject commit ac48aa4b70770428ed59457595bcae528dd1da4e diff --git a/docs/deskhub.md b/docs/deskhub.md index 040dd602..27702ae4 100644 --- a/docs/deskhub.md +++ b/docs/deskhub.md @@ -45,6 +45,10 @@ MiniRTC 新增 `UpdateVideoResolution(peer, remote_id, remote_id_size, width, height)` 接口,返回 0 表示已接受,-1 表示参数、连接或传输类型不支持。它为指定控制端的所有显示流设置独立尺寸上限,切换时丢弃旧设置尚未发出的编码结果,并请求新关键帧;底层自适应也以这个上限作为有效源尺寸。 +`SetVideoSendPaused(peer, remote_id, remote_id_size, paused)` 与 `SetAudioSendPaused(peer, remote_id, remote_id_size, paused)` 分别控制指定连接的所有视频/音频发送通道,`true` 暂停、`false` 恢复。重复设置不产生额外操作;返回 0 表示接受,-1 表示参数、连接或传输类型不支持。视频暂停阻止新帧进入编码,废弃旧编码结果,并在发送队列上清除待发视频/重传/纠错包,停止探测和填充;恢复请求新关键帧。音频暂停阻止 PCM 编码并清理待发音频,恢复时仅发送新音频。已交给网络的包无法撤回。API 保留连接、采集源、码率与画质设置以及其他媒体/数据通道,也不改变其他控制端的发送状态。 + +桌面墙将 `fps=0` 和 `audio=false` 分别映射到上述 API;应用层仅保留低帧率节流,静音在 MiniRTC 内部执行。接收端仍保留静音门控及播放队列清理,以立即停止播放网络中已在途的音频。旧版 `audio_capture` 命令在支持新 API 的传输中也按连接静音,已有订阅的音频状态优先;不支持 API 的旧/Web 传输沿用采集开关回退。 + ## 批量操作 连接和文件分发最多并行启动 4 个操作。尚未开始连接的设备卡片显示“排队中”,轮到后自动切换为实际连接状态。断开立即执行,同时取消该设备排队中的操作及重连请求,不等待文件传输完成。单台等待密码不会阻塞其他设备,可以从对应设备的放大视图填写密码。连接状态显示在设备卡片上,文件传输详情可在该设备的独立串流窗口中查看。 diff --git a/libs/wire/include/preview_subscription.h b/libs/wire/include/preview_subscription.h index 91b91270..e7d424af 100644 --- a/libs/wire/include/preview_subscription.h +++ b/libs/wire/include/preview_subscription.h @@ -36,6 +36,10 @@ struct PreviewSubscription { return {0, 60, quality, frame_rate, preference, audio}; } + bool IsNewerThan(const PreviewSubscription &other) const { + return static_cast(request_id - other.request_id) > 0; + } + bool SameSettings(const PreviewSubscription &other) const { return fps == other.fps && quality == other.quality && frame_rate == other.frame_rate &&