[feat] add remote display resolution selection to video settings

This commit is contained in:
dijunkun
2026-11-11 05:30:25 +08:00
parent 14fa95beeb
commit a3159b198e
15 changed files with 740 additions and 43 deletions
@@ -1071,6 +1071,9 @@ struct GuiApplication::SlintUi {
std::make_shared<slint::VectorModel<ui::StreamTab>>();
std::shared_ptr<slint::VectorModel<slint::SharedString>> display_model =
std::make_shared<slint::VectorModel<slint::SharedString>>();
std::shared_ptr<slint::VectorModel<slint::SharedString>> resolution_model =
std::make_shared<slint::VectorModel<slint::SharedString>>();
DisplayResolutionInfo resolution_choices;
struct TransferNotification {
std::optional<slint::ComponentHandle<ui::FileTransferNotification>> window;
RemoteSession::FileTransferInfo info;
@@ -2280,9 +2283,10 @@ void GuiApplication::BindStreamCallbacks() {
});
stream->on_switch_display([this](int index) {
auto props = SelectedSession();
if (!props || index < 0 ||
index >= static_cast<int>(props->display_info_list_.size())) {
return;
if (!props || !props->peer_ || index < 0) return;
{
std::lock_guard lock(props->remote_version_mutex_);
if (index >= static_cast<int>(props->display_info_list_.size())) return;
}
props->selected_display_ = index;
{
@@ -2394,6 +2398,32 @@ void GuiApplication::BindStreamCallbacks() {
props->video_settings_request_tick_ = SDL_GetTicks();
}
});
stream->on_change_remote_resolution([this](int index) {
auto props = SelectedSession();
if (!props || !props->peer_ || props->closing_ || index < 0 ||
props->connection_status_ != MiniRtcConnectionStatus::MiniRtcConnectionConnected ||
ui_->resolution_choices.display_id != props->selected_display_ ||
index >= int(ui_->resolution_choices.modes.size())) return;
RemoteAction action;
action.type = ControlType::display_resolution;
{
std::lock_guard lock(props->display_resolution_mutex_);
auto& state = props->display_resolution_;
const DisplayResolutionChange selection{props->selected_display_,
ui_->resolution_choices.display_key, ui_->resolution_choices.modes[index]};
if (!state.Begin(selection, SDL_GetTicks())) return;
action.resolution_change = state.requested;
}
const auto message = action.to_json();
if (MiniRtcSendReliableDataFrame(props->peer_, message.data(), message.size(),
props->control_data_label_.c_str()) != 0) {
std::lock_guard lock(props->display_resolution_mutex_);
if (props->display_resolution_.requested.request_id == action.resolution_change.request_id) {
props->display_resolution_.pending = false;
props->display_resolution_.failed = true;
}
}
});
stream->on_toggle_audio([this] {
auto props = SelectedSession();
if (!props || !props->connection_established_) {
@@ -3876,6 +3906,7 @@ void GuiApplication::SyncStreamWindow() {
ui_->localized_language = -1;
(*ui_->stream)->set_tabs(ui_->tab_model);
(*ui_->stream)->set_displays(ui_->display_model);
(*ui_->stream)->set_remote_resolutions(ui_->resolution_model);
(*ui_->stream)->set_stats_rows(ui_->stats_model);
BindStreamCallbacks();
(*ui_->stream)->window().set_size(slint::LogicalSize({
@@ -4148,14 +4179,44 @@ void GuiApplication::SyncStreamWindow() {
(*ui_->stream)->set_stats_visible(props->net_traffic_stats_button_pressed_);
std::vector<slint::SharedString> displays;
displays.reserve(props->display_info_list_.size());
for (size_t index = 0; index < props->display_info_list_.size(); ++index) {
displays.emplace_back(UiText(localization::FormatDisplayLabel(
index, props->display_info_list_[index].name,
localization_language_index_)));
{
std::lock_guard lock(props->remote_version_mutex_);
displays.reserve(props->display_info_list_.size());
for (size_t index = 0; index < props->display_info_list_.size(); ++index) {
displays.emplace_back(UiText(localization::FormatDisplayLabel(
index, props->display_info_list_[index].name,
localization_language_index_)));
}
if (props->selected_display_ >= int(displays.size())) props->selected_display_ = 0;
}
ui::UpdateModelRows(*ui_->display_model, std::move(displays));
(*ui_->stream)->set_selected_display(props->selected_display_);
{
std::lock_guard lock(props->display_resolution_mutex_);
auto& state = props->display_resolution_;
state.Expire(SDL_GetTicks());
ui_->resolution_choices = {};
int selected = -1;
std::vector<slint::SharedString> resolutions;
for (const auto& display : state.displays) {
if (display.display_id != props->selected_display_) continue;
ui_->resolution_choices = display;
for (const auto& mode : display.modes) {
if (mode == display.current) selected = int(resolutions.size());
resolutions.emplace_back(UiText(FormatResolution(mode.width, mode.height)));
}
break;
}
const bool visible = status == MiniRtcConnectionStatus::MiniRtcConnectionConnected &&
!resolutions.empty();
ui::UpdateModelRows(*ui_->resolution_model, std::move(resolutions));
(*ui_->stream)->set_remote_resolution_visible(visible);
(*ui_->stream)->set_remote_resolution_enabled(visible && !state.pending);
(*ui_->stream)->set_remote_resolution_index(selected);
(*ui_->stream)->set_remote_resolution_feedback(UiText(
state.failed ? localization::video_settings_failed[localization_language_index_]
: state.pending ? localization::video_settings_pending[localization_language_index_] : ""));
}
const auto net = props->net_traffic_stats_.Load();
const auto codecs = props->net_traffic_stats_.LoadVideoCodecs();
@@ -799,6 +799,7 @@ void SessionDeviceManager::UpdateInteractions() {
}
owner_.keyboard_.CheckRemoteTimeouts();
owner_.HandleDisplayResolutionChanges();
}
void SessionDeviceManager::DrainCapturedKeyboardInput() {
@@ -0,0 +1,89 @@
#include "runtime/gui_runtime.h"
#include <SDL3/SDL_timer.h>
#ifdef _WIN32
#include "interactive_desktop.h"
#include "platform/windows/screen_capturer/display_resolution_win.h"
#endif
namespace crossdesk {
namespace {
// EnumDisplaySettings caches its enumeration. Serialize queries on transport
// threads with mode changes on the application thread.
std::mutex native_display_resolution_mutex;
} // namespace
std::vector<DisplayResolutionInfo> GuiRuntime::QueryHostDisplayResolutions() {
#ifdef _WIN32
std::lock_guard lock(native_display_resolution_mutex);
return QueryWindowsDisplayResolutions(devices_.display_info_list());
#else
return {};
#endif
}
void GuiRuntime::QueueDisplayResolutionChange(
const std::string &id, PeerEventHandler *source,
const DisplayResolutionChange &change) {
const auto events = ControllerEvents(id);
if (events.get() != source || !IsAuthorizedController(id))
return;
std::lock_guard lock(display_resolution_commands_mutex_);
if (display_resolution_commands_.size() >= 32)
return;
display_resolution_commands_.push_back({id, events, change});
}
void GuiRuntime::HandleDisplayResolutionChanges() {
std::deque<PendingDisplayResolution> commands;
{
std::lock_guard lock(display_resolution_commands_mutex_);
commands.swap(display_resolution_commands_);
}
for (const auto &command : commands) {
const auto source = command.source.lock();
if (!source || !source->IsActive() ||
source != ControllerEvents(command.remote_id) ||
!IsAuthorizedController(command.remote_id))
continue;
RemoteAction reply;
reply.type = ControlType::display_resolution_status;
reply.resolution_change = command.change;
reply.resolution_change.accepted = false;
#ifdef _WIN32
{
std::lock_guard lock(native_display_resolution_mutex);
if (IsCurrentSessionUserDesktopActive())
reply.resolution_change.accepted = ApplyWindowsDisplayResolution(
devices_.display_info_list(), command.change);
}
#endif
reply.display_resolutions = QueryHostDisplayResolutions();
const auto message = reply.to_json();
SendControllerData(command.remote_id, message.data(), message.size(),
control_data_label_.c_str());
next_display_resolution_poll_ = 0;
}
const auto now = SDL_GetTicks();
if (now < next_display_resolution_poll_)
return;
next_display_resolution_poll_ = now + 2000;
const auto controllers = ConnectedControllers();
if (controllers.empty())
return;
auto current = QueryHostDisplayResolutions();
if (current == last_display_resolutions_)
return;
last_display_resolutions_ = std::move(current);
// Re-advertise after local changes/hotplug as well as remote requests. The
// optional host-info extension is harmless to pre-feature controllers.
const auto message = BuildHostInformation();
for (const auto &id : controllers) {
if (IsAuthorizedController(id))
SendControllerData(id, message.data(), message.size(),
control_data_label_.c_str());
}
}
} // namespace crossdesk
@@ -0,0 +1,75 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-11
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _DISPLAY_RESOLUTION_STATE_H_
#define _DISPLAY_RESOLUTION_STATE_H_
#include <display_resolution.h>
namespace crossdesk {
// Guarded by RemoteSession::display_resolution_mutex_. The current selection
// always comes from the host, never from an unacknowledged request.
struct DisplayResolutionState {
std::vector<DisplayResolutionInfo> displays;
DisplayResolutionChange requested;
bool pending = false;
bool failed = false;
uint64_t request_tick = 0;
bool Begin(const DisplayResolutionChange &selection, uint64_t now) {
if (pending)
return false;
for (const auto &display : displays) {
if (!SupportsDisplayResolution(display, selection))
continue;
if (display.current == selection.resolution)
return false;
uint32_t next = requested.request_id + 1;
if (next == 0)
next = 1;
requested = {display.display_id, display.display_key,
selection.resolution, next, false};
pending = true;
failed = false;
request_tick = now;
return true;
}
return false;
}
void Complete(const DisplayResolutionChange &response,
std::vector<DisplayResolutionInfo> actual) {
if (response.request_id != requested.request_id ||
response.display_id != requested.display_id ||
response.display_key != requested.display_key ||
!(response.resolution == requested.resolution))
return;
displays = std::move(actual);
pending = false;
failed = !response.accepted;
if (response.accepted) {
const auto found = std::find_if(
displays.begin(), displays.end(), [&](const auto &display) {
return display.display_id == response.display_id &&
display.display_key == response.display_key &&
display.current == response.resolution;
});
failed = found == displays.end();
}
}
void Expire(uint64_t now) {
if (pending && now - request_tick >= 10000) {
pending = false;
failed = true;
}
}
};
} // namespace crossdesk
#endif
@@ -152,6 +152,11 @@ class GuiRuntime : protected gui_detail::GuiState {
// any connected controller lacks support, or on for the first automatic one.
void ApplyPrivacyAdmission(bool automatic_enable);
void QueuePrivacyCommand(const std::string& remote_id, const PrivacyCommand& command);
std::vector<DisplayResolutionInfo> QueryHostDisplayResolutions();
void QueueDisplayResolutionChange(const std::string& id,
PeerEventHandler* source,
const DisplayResolutionChange& change);
void HandleDisplayResolutionChanges();
void CloseRemoteSession(std::shared_ptr<RemoteSession> props);
void CloseAllRemoteSessions();
@@ -247,6 +252,15 @@ class GuiRuntime : protected gui_detail::GuiState {
DisconnectLockPolicy disconnect_lock_;
std::mutex privacy_commands_mutex_;
std::deque<std::pair<std::string, PrivacyCommand>> privacy_commands_;
struct PendingDisplayResolution {
std::string remote_id;
std::weak_ptr<PeerEventHandler> source;
DisplayResolutionChange change;
};
std::mutex display_resolution_commands_mutex_;
std::deque<PendingDisplayResolution> display_resolution_commands_;
std::vector<DisplayResolutionInfo> last_display_resolutions_;
uint64_t next_display_resolution_poll_ = 0;
bool privacy_settings_changed_ = false;
uint32_t last_privacy_revision_ = 0;
uint64_t last_privacy_status_tick_ = 0;
@@ -280,6 +280,21 @@ void PeerEventHandler::OnReceiveDataBuffer(
return;
}
if (remote_action.type == ControlType::display_resolution_status) {
if (auto props = runtime->FindRemoteSession(remote_id);
props && source_id == props->control_data_label_ && !props->closing_ &&
props->connection_status_ == MiniRtcConnectionStatus::MiniRtcConnectionConnected) {
std::lock_guard lock(props->display_resolution_mutex_);
props->display_resolution_.Complete(remote_action.resolution_change,
std::move(remote_action.display_resolutions));
}
return;
}
if (remote_action.type == ControlType::display_resolution) {
if (source_id == runtime->control_data_label_ || source_id == kWebControlStream)
runtime->QueueDisplayResolutionChange(remote_id, handler, remote_action.resolution_change);
return;
}
if (remote_action.type == ControlType::video_codec_status) {
if (auto props = runtime->FindRemoteSession(remote_id);
props && source_id == props->control_data_label_ &&
@@ -460,6 +475,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
}
if (is_client_mode) {
if (props && source_id == props->control_data_label_) {
std::lock_guard lock(props->display_resolution_mutex_);
props->display_resolution_.displays = std::move(remote_action.display_resolutions);
}
if (props && source_id == props->control_data_label_) {
std::lock_guard lock(props->remote_version_mutex_);
props->remote_host_info_received_ = true;
@@ -502,7 +521,11 @@ void PeerEventHandler::OnReceiveDataBuffer(
props->remote_host_name_ = std::string(remote_action.i.host_name,
remote_action.i.host_name_size);
LOG_DEBUG("Remote hostname: [{}]", props->remote_host_name_);
}
// Preserve legacy hosts' initial display list on their original data
// channel; live topology updates use the reliable control channel.
if (source_id == props->control_data_label_ || props->display_info_list_.empty()) {
props->display_info_list_.clear();
for (int i = 0; i < remote_action.i.display_num; i++) {
props->display_info_list_.push_back(
DisplayInfo(remote_action.i.display_list[i],
@@ -333,6 +333,10 @@ void PeerEventHandler::DispatchConnectionStatus(MiniRtcConnectionStatus status,
if (!runtime->BeginControllerTrust(remote_id, handler)) return;
} else {
runtime->ForgetControllerTrust(remote_id, handler);
std::lock_guard lock(runtime->display_resolution_commands_mutex_);
std::erase_if(runtime->display_resolution_commands_, [&](const auto& request) {
return request.remote_id == remote_id;
});
}
#ifdef _WIN32
if (runtime->unattended_host_) {
@@ -422,6 +426,10 @@ void PeerEventHandler::DispatchConnectionStatus(MiniRtcConnectionStatus status,
props->video_settings_pending_ = false;
props->video_settings_failed_ = false;
}
{
std::lock_guard lock(props->display_resolution_mutex_);
props->display_resolution_ = {};
}
{
std::lock_guard lock(props->remote_cursor_state_mutex_);
props->remote_cursor_state_ = {};
@@ -648,6 +656,7 @@ void PeerEventHandler::OnNetStatusReport(
std::string GuiRuntime::BuildHostInformation(bool advertise_trust) {
auto action = MakeHostInformation(GetHostName(), devices_.host_display_list(),
UserSessionFeaturesAvailable(), CROSSDESK_VERSION, kClientPlatform);
action.display_resolutions = QueryHostDisplayResolutions();
action.i.supports_video_settings = true;
action.i.supports_video_codec_status = true;
action.i.supports_text_input = devices_.SupportsTextInput();
@@ -24,6 +24,7 @@
#include <stream_names.h>
#include "display_info.h"
#include "features/gallery/preview_resolution.h"
#include "runtime/display_resolution_state.h"
#include "cursor_presentation.h"
#include "minirtc.h"
#include "rendering/video_latency.h"
@@ -203,6 +204,8 @@ struct RemoteSession {
bool remote_service_available_ = false;
std::string remote_interactive_stage_;
std::vector<DisplayInfo> display_info_list_;
std::mutex display_resolution_mutex_;
DisplayResolutionState display_resolution_;
// Cursor snapshots arrive on the transport callback thread and are applied
// by the Slint UI thread. Keep the snapshot atomic as a unit so visibility
// and shape cannot briefly come from different protocol messages.
+40 -32
View File
@@ -234,6 +234,7 @@ export component ImageButton inherits Rectangle {
export component CompactComboBox inherits FlatSurface {
in property <[string]> model;
in property <length> maximum-popup-height: root.model.length * 23px + 8px;
in-out property <int> current-index: 0;
in property <bool> enabled: true;
in property <bool> subtle: false;
@@ -331,7 +332,7 @@ export component CompactComboBox inherits FlatSurface {
x: 0px;
y: root.height + 4px;
width: root.width;
height: root.model.length * 23px + 8px;
height: min(root.maximum-popup-height, root.model.length * 23px + 8px);
close-policy: close-on-click-outside;
FlatSurface {
@@ -345,39 +346,46 @@ export component CompactComboBox inherits FlatSurface {
outline-color: root.normal-outline-color;
border-radius: UiRadius.menu;
clip: true;
for choice[index] in root.model: Rectangle {
x: 4px; y: 4px + index * 23px;
Flickable {
x: 4px; y: 4px;
width: parent.width - 8px;
height: 23px;
border-radius: UiRadius.control;
background: index == root.current-index
? (option-touch.pressed ? ButtonStyle.pressed : ButtonStyle.hover)
: option-touch.pressed ? ButtonStyle.pressed : option-touch.has-hover ? ButtonStyle.hover : white;
Text {
x: 6px; width: parent.width - 28px; height: parent.height; text: choice;
color: index == root.current-index ? ControlStyle.selected-text : #202124;
font-weight: index == root.current-index ? 600 : 400;
font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center;
}
if index == root.current-index: Text {
x: parent.width - 20px; width: 14px; height: parent.height;
text: FontAwesomeIcons.check; color: UiSurface.focus;
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 10px;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
option-touch := TouchArea {
enabled: root.enabled;
mouse-cursor: pointer;
clicked => {
popup-open-timer.running = false;
root.popup-animation-enabled = false;
root.popup-progress = 0.0;
if root.enabled && index >= 0 && index < root.model.length {
root.current-index = index;
root.selected(index);
height: max(0px, parent.height - 8px);
viewport-width: self.width;
viewport-height: root.model.length * 23px;
for choice[index] in root.model: Rectangle {
y: index * 23px;
width: parent.width;
height: 23px;
border-radius: UiRadius.control;
background: index == root.current-index
? (option-touch.pressed ? ButtonStyle.pressed : ButtonStyle.hover)
: option-touch.pressed ? ButtonStyle.pressed : option-touch.has-hover ? ButtonStyle.hover : white;
Text {
x: 6px; width: parent.width - 28px; height: parent.height; text: choice;
color: index == root.current-index ? ControlStyle.selected-text : #202124;
font-weight: index == root.current-index ? 600 : 400;
font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center;
}
if index == root.current-index: Text {
x: parent.width - 20px; width: 14px; height: parent.height;
text: FontAwesomeIcons.check; color: UiSurface.focus;
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 10px;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
option-touch := TouchArea {
enabled: root.enabled;
mouse-cursor: pointer;
clicked => {
popup-open-timer.running = false;
root.popup-animation-enabled = false;
root.popup-progress = 0.0;
if root.enabled && index >= 0 && index < root.model.length {
root.current-index = index;
root.selected(index);
}
combo-popup.close();
}
combo-popup.close();
}
}
}
+48 -2
View File
@@ -1,6 +1,7 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
import { ComboBox, ScrollView } from "std-widgets.slint";
import { ScrollView } from "std-widgets.slint";
import { SettingsSelect } from "settings_controls.slint";
import { RemoteCursor } from "remote_cursor.slint";
import { FontAwesomeIcons, IconButton, PrimaryButton, SecondaryButton, DialogSurface, ImGuiFontStyle, ControlStyle, ButtonStyle, ScrollingText } from "common.slint";
@@ -34,6 +35,7 @@ export global StreamStrings {
in-out property <string> remote-update: "Update the remote device to the latest version for improved features and performance.";
in-out property <string> remote-update-dismiss: "Got it";
in-out property <string> video-settings: "Video settings";
in-out property <string> remote-resolution: "Resolution";
in-out property <string> video-settings-unavailable: "The remote device does not support live video settings";
in-out property <string> video-quality: "Video quality";
in-out property <string> frame-rate: "Capture frame rate";
@@ -329,6 +331,26 @@ export component StreamWindow inherits Window {
in-out property <int> frame-rate-index: 1;
in-out property <int> adaptation-policy-index: 1;
callback change-video-settings(int, int, int);
in property <[string]> remote-resolutions;
in property <bool> remote-resolution-visible: false;
in property <int> remote-resolution-index: -1;
private property <int> resolution-selection: -1;
changed remote-resolution-index => { root.resolution-selection = root.remote-resolution-index; }
resolution-confirmation-timer := Timer {
interval: 1ms;
running: false;
triggered => {
self.running = false;
root.resolution-selection = root.remote-resolution-index;
}
}
function restore-confirmed-resolution() {
resolution-confirmation-timer.running = true;
resolution-confirmation-timer.restart();
}
in property <bool> remote-resolution-enabled: false;
in property <string> remote-resolution-feedback: "";
callback change-remote-resolution(int);
in property <bool> fullscreen-enabled: false;
in property <bool> stats-visible: false;
in property <[NetworkStatsRow]> stats-rows;
@@ -410,7 +432,9 @@ export component StreamWindow inherits Window {
private property <length> shortcut-menu-y: root.control-panel-y(root.shortcut-menu-height);
in-out property <bool> video-menu-open: false;
private property <length> video-menu-width: min(320px, video.width - 8px);
private property <length> video-menu-height: root.video-settings-enabled && root.video-settings-feedback == "" ? 182px : 220px;
private property <length> video-menu-height:
(root.video-settings-enabled && root.video-settings-feedback == "" ? 182px : 220px)
+ (root.remote-resolution-visible ? 57px + (root.remote-resolution-feedback == "" ? 0px : 32px) : 0px);
private property <length> video-menu-x: min(max(4px, control.x + video-button.x), video.width - root.video-menu-width - 4px);
private property <length> video-menu-y: root.control-panel-y(root.video-menu-height);
private property <bool> disconnect-menu-open: false;
@@ -427,6 +451,7 @@ export component StreamWindow inherits Window {
changed control-menu-open => {
root.keyboard-focus-changed(!root.control-menu-open && !root.modal-visible && input-focus.has-focus);
}
changed selected-display => { root.video-menu-open = false; }
changed selected-tab => {
root.close-control-menus();
root.cancel-remote-pointer();
@@ -1630,6 +1655,27 @@ export component StreamWindow inherits Window {
}
VerticalLayout {
padding: 10px; spacing: 8px;
if root.remote-resolution-visible: VerticalLayout {
spacing: 4px;
Text { text: StreamStrings.remote-resolution; color: #5c626c; font-size: 11px; height: 18px; }
SettingsSelect {
height: 27px;
width: parent.width;
maximum-popup-height: 240px;
model: root.remote-resolutions;
current-index <=> root.resolution-selection;
enabled: root.remote-resolution-enabled;
selected(index) => {
root.change-remote-resolution(index);
// Restore the host-confirmed value after selection.
root.restore-confirmed-resolution();
}
}
}
if root.remote-resolution-visible && root.remote-resolution-feedback != "": Text {
text: root.remote-resolution-feedback; wrap: word-wrap;
color: #8b8f95; font-size: 11px; height: 24px;
}
VideoSettingRow {
label: StreamStrings.video-quality;
options: [StreamStrings.quality-low, StreamStrings.quality-medium, StreamStrings.quality-high];
@@ -274,6 +274,7 @@ inline int ApplyStreamWindowStrings(
Text(localization::video_settings_unavailable[language]));
strings.set_video_quality(Text(localization::video_quality[language]));
strings.set_frame_rate(Text(localization::video_frame_rate[language]));
strings.set_remote_resolution(Text(localization::resolution[language]));
strings.set_adaptation_policy(
Text(localization::video_adaptation_policy[language]));
strings.set_quality_low(Text(localization::video_quality_low[language]));