diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index bb34e63d..5fb66c07 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -859,6 +859,9 @@ struct GuiApplication::SlintUi { std::string gallery_focus, gallery_auth_id, gallery_requested_auth, gallery_control_id; std::chrono::steady_clock::time_point gallery_synced{}; + std::chrono::steady_clock::time_point gallery_network_synced{}; + std::shared_ptr> gallery_network_model = + std::make_shared>(); gallery::WindowState gallery_window; gallery::FullscreenTransition gallery_transition; int main_restore_ticks = 0; @@ -1162,6 +1165,7 @@ void GuiApplication::InitializeUi() { LOG_WARN("Could not load DeskHub workspace; original file retained"); ui_->main->set_gallery_tiles(ui_->gallery_model); ui_->main->set_gallery_selected_devices(ui_->gallery_selected_model); + ui_->main->set_gallery_network_devices(ui_->gallery_network_model); // Always start on Connection; saved wall geometry is restored only when // the user switches to DeskHub, regardless of the previous launch's mode. ui_->main->set_gallery_mode(false); @@ -1313,6 +1317,11 @@ void GuiApplication::ResetSettingsUi() { ui_->main->set_server_host(signal_server_ip_self_); ui_->main->set_server_port(signal_server_port_self_); ui_->main->set_self_host_settings_error(""); + ui_->main->set_gallery_resolution_index(ui_->gallery.stream_settings.resolution); + ui_->main->set_gallery_frame_rate_index(ui_->gallery.stream_settings.FrameRateIndex()); + ui_->main->set_gallery_resolution_draft(ui_->gallery.stream_settings.resolution); + ui_->main->set_gallery_frame_rate_draft(ui_->gallery.stream_settings.FrameRateIndex()); + ui_->main->set_gallery_settings_error(""); } void GuiApplication::BindMainCallbacks() { @@ -1792,14 +1801,11 @@ void GuiApplication::BindStreamCallbacks() { if (!props || !props->connection_established_) { return; } - props->audio_capture_button_pressed_ = - !props->audio_capture_button_pressed_; - RemoteAction action{}; - action.type = ControlType::audio_capture; - action.a = props->audio_capture_button_pressed_; - const std::string message = action.to_json(); - SendReliableDataFrame(props->peer_, message.c_str(), message.size(), - props->control_data_label_.c_str()); + if (props->audio_capture_button_pressed_) + MuteRemoteAudio(props); + else + UnmuteRemoteAudio(props); + SyncAudioPlayback(); }); stream->on_select_file([this] { auto props = SelectedSession(); @@ -3710,14 +3716,35 @@ bool GuiApplication::SaveSettingsFromUi() { !std::string(main->get_server_host()).empty() || !std::string(main->get_server_port()).empty(); if (has_endpoint && !ValidateSelfHostedSettingsFromUi()) { + main->set_settings_tab(0); main->set_self_host_settings_open(true); return false; } + const auto wall_settings = gallery::StreamSettings::FromIndices( + main->get_gallery_resolution_draft(), main->get_gallery_frame_rate_draft()); + const bool wall_changed = wall_settings && + (wall_settings->resolution != ui_->gallery.stream_settings.resolution || + wall_settings->frame_rate != ui_->gallery.stream_settings.frame_rate); + auto updated_wall = ui_->gallery; + if (wall_settings) updated_wall.stream_settings = *wall_settings; + // Persist the draft first, but leave live sessions unchanged until the + // common save succeeds. Restore the previous wall file if it fails. + if (!wall_settings || (wall_changed && !updated_wall.Save(ui_->gallery_path))) { + main->set_gallery_settings_error(UiText( + localization_language_index_ == 0 ? "保存失败,请重试" + : localization_language_index_ == 2 ? "Не удалось сохранить" + : "Could not save; try again")); + main->set_settings_tab(1); + return false; + } if ((has_endpoint && config_center_->SetServerEndpoint( std::string(main->get_server_host()), *ParsePort(main->get_server_port())) != 0) || config_center_->SetSelfHosted(main->get_self_hosted_enabled()) != 0) { + if (wall_changed && !ui_->gallery.Save(ui_->gallery_path)) + LOG_ERROR("Failed to restore DeskHub settings after common save failure"); main->set_self_host_settings_error(UiText( localization::self_hosted_save_failed[localization_language_index_])); + main->set_settings_tab(0); main->set_self_host_settings_open(true); return false; } @@ -3786,6 +3813,14 @@ bool GuiApplication::SaveSettingsFromUi() { sizeof(file_transfer_save_path_buf_) - 1); config_center_->SetFileTransferSavePath(path); + if (wall_changed) { + ui_->gallery.stream_settings = *wall_settings; + std::shared_lock lock(remote_sessions_mutex_); + for (const auto& [_, props] : remote_sessions_) + if (props && !props->closing_ && props->wall_managed_) + props->preview_needs_sync_ = true; + ui_->gallery_synced = {}; + } ApplySettingsFromConfig(previous_server != GetSignalServerSettings(*config_center_)); return true; @@ -3912,9 +3947,6 @@ void GuiApplication::SelectStreamTab(int index) { props->clipboard_allowed_ = props->tab_selected_ && props->control_mouse_ && focus_on_stream_window_; - props->audio_allowed_ = - (props->tab_selected_ && props->audio_capture_button_pressed_ && - focus_on_stream_window_); } } const auto selected = remote_sessions_.find(selected_remote_id); @@ -3926,6 +3958,7 @@ void GuiApplication::SelectStreamTab(int index) { selected_session->connection_status_.load() == ConnectionStatus::Connected; } + SyncAudioPlayback(); } void GuiApplication::ReorderStreamTab(int from, float drop_x, float tab_width) { @@ -4188,6 +4221,26 @@ bool GuiApplication::OpenUrl(const std::string& url) { return SDL_OpenURL(url.c_str()); } +void GuiApplication::SyncAudioPlayback() { + const bool wall_visible = ui_ && ui_->main->get_gallery_mode() && + ui_->main->window().is_visible() && !ui_->main->window().is_minimized(); + const bool stream_active = ui_ && ui_->stream && focus_on_stream_window_ && + (*ui_->stream)->window().is_visible() && + !(*ui_->stream)->window().is_minimized(); + std::lock_guard delivery_lock(audio_delivery_mutex_); + std::shared_lock lock(remote_sessions_mutex_); + for (const auto& [id, props] : remote_sessions_) { + const bool allowed = !props->closing_ && + props->connection_status_ == ConnectionStatus::Connected && + props->audio_capture_button_pressed_ && + ((wall_visible && props->wall_managed_) || + (stream_active && props->tab_opened_ && props->tab_selected_)); + if (props->audio_allowed_.exchange(allowed) != allowed) + props->preview_needs_sync_ = true; + if (!allowed) devices_.ClearAudio(id); + } +} + void GuiApplication::ReleaseControl() { const auto gallery_target = ui_->gallery_control_id; devices_.StopKeyboardCapturer(); @@ -4212,17 +4265,14 @@ void GuiApplication::ReleaseControl() { ui_->main->set_gallery_controlling(false); } ui_->pressed_mouse_buttons.clear(); - std::lock_guard delivery_lock(audio_delivery_mutex_); - devices_.ClearAudio(); + SyncAudioPlayback(); std::shared_lock lock(remote_sessions_mutex_); for (const auto& [id, props] : remote_sessions_) { ++props->input_generation_; props->clipboard_allowed_ = false; - props->audio_allowed_ = false; if (id == gallery_target) { props->control_mouse_ = false; props->enable_mouse_control_ = false; - props->audio_capture_button_pressed_ = false; } } controlled_remote_id_.clear(); @@ -4235,6 +4285,17 @@ void GuiApplication::ChangeMainMode(bool wall) { ui_->main->set_gallery_transitioning(false); ui_->main->set_gallery_content_visible(true); ReleaseControl(); + if (wall) { + std::vector> sessions; + { + std::shared_lock lock(remote_sessions_mutex_); + for (const auto& [_, props] : remote_sessions_) + if (props && !props->closing_) sessions.push_back(props); + } + // Apply the wall's default to existing connections as well as new ones. + // Do this once on entry, so an explicit unmute can still take effect. + for (const auto& props : sessions) MuteRemoteAudio(props); + } auto& window = ui_->main->window(); #if defined(__APPLE__) const bool fullscreen = IsMainWindowFullscreen(); @@ -4361,7 +4422,10 @@ bool GuiApplication::AddGalleryDevice(const std::string& input, "Не удалось сохранить"); ui_->gallery = std::move(updated); ui_->gallery_hidden_active_ids.erase(id); - if (auto props = FindRemoteSession(id)) props->wall_managed_ = true; + if (auto props = FindRemoteSession(id)) { + props->wall_managed_ = true; + MuteRemoteAudio(props); + } for (auto& operation : ui_->batch_operations) if (operation.id == id && operation.action == "connect" && operation.state == State::AwaitingPassword) @@ -4405,7 +4469,10 @@ bool GuiApplication::ImportGalleryRecent(const std::string& input_group) { // store and are looked up by the normal connect action when requested. for (const auto& id : ids) { ui_->gallery_hidden_active_ids.erase(id); - if (auto props = FindRemoteSession(id)) props->wall_managed_ = true; + if (auto props = FindRemoteSession(id)) { + props->wall_managed_ = true; + MuteRemoteAudio(props); + } } ui_->gallery_focus.clear(); ui_->gallery.selected.clear(); @@ -4497,6 +4564,16 @@ void GuiApplication::GalleryAction(const std::string& action, using OperationState = SlintUi::BatchOperation::State; auto& wall = ui_->gallery; ui_->gallery_synced = {}; + if (action == "settings-open") { + ReleaseControl(); + ui_->main->set_gallery_settings_error(""); + return; + } + if (action == "network-open") { + ReleaseControl(); + ui_->gallery_network_synced = {}; + return; + } const auto tr = [this](const char* zh, const char* en, const char* ru) { return UiText(localization_language_index_ == 0 ? zh : localization_language_index_ == 2 ? ru @@ -4556,7 +4633,7 @@ void GuiApplication::GalleryAction(const std::string& action, wall.page = 0; wall.selected.clear(); ui_->gallery_focus.clear(); - } else if (action == "selection") { + } else if (action == "selection" || action == "view-mode") { ReleaseControl(); wall.selected.clear(); ui_->gallery_focus.clear(); @@ -4616,7 +4693,8 @@ void GuiApplication::GalleryAction(const std::string& action, } } else if (action == "release") { ReleaseControl(); - } else if (action == "connect" || action == "disconnect" || action == "file") { + } else if (action == "connect" || action == "disconnect" || action == "mute" || + action == "unmute" || action == "file") { std::vector ids; if (!value.empty()) ids.push_back(value); @@ -4624,6 +4702,21 @@ void GuiApplication::GalleryAction(const std::string& action, for (const auto& id : ui_->gallery_ids) if (wall.selected.count(id)) ids.push_back(id); if (ids.empty()) return; + if (action == "mute" || action == "unmute") { + for (const auto& id : ids) { + if (auto props = FindRemoteSession(id); + props && !props->closing_ && + props->connection_status_ == ConnectionStatus::Connected) { + if (action == "mute") { + MuteRemoteAudio(props); + } else { + UnmuteRemoteAudio(props); + } + } + } + SyncGallery(); + return; + } if (action == "connect" && !value.empty()) ui_->gallery_requested_auth = value; ReleaseControl(); @@ -4776,6 +4869,7 @@ void GuiApplication::RestoreMainWindowGeometry(bool before_show) { void GuiApplication::SyncGallery() { using OperationState = SlintUi::BatchOperation::State; if (!ui_) return; + SyncAudioPlayback(); auto& window = ui_->main->window(); const auto current_size = window.size(); if (ui_->observed_main_size && @@ -4901,6 +4995,71 @@ void GuiApplication::SyncGallery() { } return settings_.RecentConnectionDisplayName(device); }; + if (visible && ui_->main->get_gallery_network_open() && + now - ui_->gallery_network_synced >= 1s) { + ui_->gallery_network_synced = now; + // This list follows live connections, independently of the wall's current + // group and page. Never show saved, queued or still-connecting devices. + std::vector connected_ids; + { + std::shared_lock lock(remote_sessions_mutex_); + for (const auto& [id, props] : remote_sessions_) + if (props && !props->closing_ && + props->connection_status_ == ConnectionStatus::Connected) + connected_ids.push_back(id); + } + std::sort(connected_ids.begin(), connected_ids.end()); + std::vector devices; + for (const auto& id : connected_ids) { + const auto props = FindRemoteSession(id); + if (!props || props->closing_ || + props->connection_status_ != ConnectionStatus::Connected) + continue; + ui::GalleryNetworkDevice device{}; + device.name = UiText(name(id)); + device.id = UiText(FormatPeerId(id.c_str())); + const auto mode = props->traversal_mode_.load(); + device.mode = UiText(mode == TraversalMode::P2P + ? tr("直连", "Direct", "Напрямую") + : mode == TraversalMode::UnknownMode + ? tr("等待统计", "Awaiting stats", "Ждём данные") + : tr("中继", "Relay", "Ретрансляция")); + device.latency = device.inbound = device.outbound = device.loss = "—"; + const auto video = props->received_video_stats_.Get(now); + device.resolution = video.width > 0 && video.height > 0 + ? 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 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. + // Missing/stale reports cannot confirm either encrypted or plaintext. + device.encryption_known = has_report; + device.encryption_enabled = has_report && stats.srtp_active; + if (mode != TraversalMode::UnknownMode && has_report) { + device.inbound = UiText(FormatBitrate(stats.total_inbound_stats.bitrate)); + device.outbound = UiText(FormatBitrate(stats.total_outbound_stats.bitrate)); + if (const auto rtt = props->net_traffic_stats_.SmoothedRttMs(now)) + device.latency = UiText(*rtt < 1 ? "<1 ms" + : std::to_string(static_cast(std::lround(*rtt))) + " ms"); + // MiniRTC's total loss is a sum of media fractions, not an aggregate. + // Report video loss explicitly, and only after receiving video packets. + if (stats.video_inbound_stats.rtp_packet_count > 0 && + std::isfinite(stats.video_inbound_stats.loss_rate)) { + char loss[24]; + std::snprintf(loss, sizeof(loss), "%.1f%%", + std::clamp(stats.video_inbound_stats.loss_rate, 0.0f, 1.0f) * 100.0f); + device.loss = UiText(loss); + } + } + devices.push_back(std::move(device)); + } + if (ui_->gallery_network_model->row_count() != devices.size()) + ui_->gallery_network_model->set_vector(std::move(devices)); + else + for (size_t i = 0; i < devices.size(); ++i) + ui_->gallery_network_model->set_row_data(i, devices[i]); + } // Limit simultaneous operations without keeping a user-facing operation list // or operation history. Targets stay fixed when the selection changes. int running = std::count_if( @@ -4916,6 +5075,11 @@ void GuiApplication::SyncGallery() { ++running; auto props = FindRemoteSession(operation.id); if (operation.action == "connect") { + if (props && !props->closing_) { + // A wall connect can reuse an already connected/connecting peer. + props->wall_managed_ = true; + MuteRemoteAudio(props); + } const auto status = props ? props->connection_status_.load() : ConnectionStatus::Closed; const bool usable = props && props->peer_ && !props->closing_; @@ -5069,6 +5233,11 @@ void GuiApplication::SyncGallery() { std::max(1, (int(ids.size()) + wall.capacity - 1) / wall.capacity)); ui_->main->set_gallery_count(ids.size()); int connected = 0, selected_on_page = 0; + const auto gallery_viewport = visible && !ui_->gallery_focus.empty() + ? gallery::PreviewResolution::Viewport( + ui_->main->get_gallery_video_width(), + ui_->main->get_gallery_video_height(), window.scale_factor()) + : gallery::PreviewResolution{}; std::set preview_ids; std::vector tiles; const auto make_tile = [&](const std::string& id, bool preview) { @@ -5102,7 +5271,9 @@ void GuiApplication::SyncGallery() { props->connection_status_.load(), localization_language_index_)); if (preview && visible && tile.connected) { preview_ids.insert(id); - props->gallery_preview_width_ = ui_->gallery_focus == id ? 960 : 320; + props->gallery_preview_width_ = ui_->gallery_focus == id + ? std::max({320, gallery_viewport.width, gallery_viewport.height}) + : 320; std::lock_guard lock(props->gallery_frame_mutex_); if (props->gallery_rgba_) { auto& cached = ui_->gallery_previews[id]; @@ -5153,6 +5324,7 @@ void GuiApplication::SyncGallery() { ui_->main->set_gallery_hidden_selected(wall.selected.size() - selected_on_page); std::vector selected_devices; + bool selected_audio_available = false, selected_audio_muted = true; for (const auto& id : ids) if (wall.selected.count(id)) { ui::GalleryActionDevice device{}; @@ -5161,8 +5333,15 @@ void GuiApplication::SyncGallery() { const auto props = FindRemoteSession(id); device.connected = props && !props->closing_ && props->connection_status_ == ConnectionStatus::Connected; + if (device.connected) { + selected_audio_available = true; + selected_audio_muted &= !props->audio_capture_button_pressed_; + } selected_devices.push_back(std::move(device)); } + ui_->main->set_gallery_selected_audio_available(selected_audio_available); + ui_->main->set_gallery_selected_audio_muted(selected_audio_available && + selected_audio_muted); if (ui_->gallery_selected_model->row_count() != selected_devices.size()) ui_->gallery_selected_model->set_vector(std::move(selected_devices)); else @@ -5190,8 +5369,10 @@ void GuiApplication::SyncGallery() { } if (props->preview_subscription_supported_ && props->connection_status_ == ConnectionStatus::Connected) { - // Every connected device retains its interactive video settings, - // including devices on other pages or behind a hidden wall window. + if (wall_mode && !ui_->gallery_hidden_active_ids.count(id)) + props->wall_managed_ = true; + // Keep session quality and audio; wall settings override only the + // requested resolution/cadence and never overwrite session preferences. PreviewSubscription demand; { std::lock_guard settings_lock(props->video_settings_mutex_); @@ -5199,12 +5380,35 @@ void GuiApplication::SyncGallery() { props->video_settings_.quality, props->video_settings_.frame_rate, props->video_settings_.preference, props->audio_allowed_.load()); } + if (props->wall_managed_) + wall.stream_settings.ApplyFrameRate(demand, props->preview_fps15_supported_); + const bool needs_sync = props->preview_needs_sync_.exchange(false); + if (props->preview_resolution_supported_) { + gallery::PreviewResolution stream_viewport; + if (props->tab_opened_ && id == focused_remote_id_ && ui_->stream && + (*ui_->stream)->window().is_visible() && + !(*ui_->stream)->window().is_minimized()) { + stream_viewport = gallery::PreviewResolution::Viewport( + (*ui_->stream)->get_video_area_width(), + (*ui_->stream)->get_video_area_height(), + (*ui_->stream)->window().scale_factor()); + } + const auto resolution = props->preview_resolution_.Update( + wall.stream_settings.Resolution( + props->wall_managed_, ui_->gallery_focus == id + ? gallery_viewport : gallery::PreviewResolution{}, + stream_viewport), now, needs_sync); + demand.version = 2; + demand.width = resolution.width; + demand.height = resolution.height; + } const bool acknowledged = props->preview_ack_.load() == props->preview_request_ && props->preview_accepted_.load(); - if (!demand.SameSettings(props->preview_requested_) || + if (needs_sync || !demand.SameSettings(props->preview_requested_) || (!acknowledged && now - props->preview_request_at_ > 5s)) { demand.request_id = ++props->preview_request_; + if (!demand.request_id) demand.request_id = ++props->preview_request_; const auto message = demand.ToJson().dump(); if (SendReliableDataFrame(props->peer_, message.data(), message.size(), diff --git a/apps/desktop/src/gui/application/gui_application.h b/apps/desktop/src/gui/application/gui_application.h index fe4ff2c2..f6bc9e40 100644 --- a/apps/desktop/src/gui/application/gui_application.h +++ b/apps/desktop/src/gui/application/gui_application.h @@ -71,6 +71,7 @@ private: void DeviceGroupAction(const std::string& action, const std::string& value); bool SaveDeviceGroups(const std::string& new_group); void ReleaseControl(); + void SyncAudioPlayback(); void SaveGalleryWindowState(); void SyncAnnouncements(); void SyncConnectionDialog(); 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 564548ca..98b8e1b4 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.cpp +++ b/apps/desktop/src/gui/features/devices/session_device_manager.cpp @@ -540,13 +540,13 @@ int SessionDeviceManager::InitializeAudioOutput() { desired_out.format = SDL_AUDIO_S16; desired_out.channels = 1; - auto open_stream = [&]() { - output_stream_ = SDL_OpenAudioDeviceStream( - SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &desired_out, nullptr, nullptr); - return output_stream_ != nullptr; + auto open_device = [&]() { + output_device_ = SDL_OpenAudioDevice( + SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &desired_out); + return output_device_ != 0; }; - if (!open_stream()) { + if (!open_device()) { #if defined(__linux__) && !defined(__APPLE__) LOG_WARN("Failed to open output stream with driver [{}]: {}", getenv("SDL_AUDIODRIVER") ? getenv("SDL_AUDIODRIVER") @@ -560,7 +560,7 @@ int SessionDeviceManager::InitializeAudioOutput() { SDL_GetError()); return -1; } - if (!open_stream()) { + if (!open_device()) { LOG_ERROR("Failed to open output stream with dummy driver: {}", SDL_GetError()); return -1; @@ -572,31 +572,50 @@ int SessionDeviceManager::InitializeAudioOutput() { #endif } - SDL_ResumeAudioDevice(SDL_GetAudioStreamDevice(output_stream_)); + SDL_ResumeAudioDevice(output_device_); return 0; } int SessionDeviceManager::DestroyAudioOutput() { - if (output_stream_) { - SDL_CloseAudioDevice(SDL_GetAudioStreamDevice(output_stream_)); - SDL_DestroyAudioStream(output_stream_); - output_stream_ = nullptr; + for (const auto& [_, stream] : output_streams_) + SDL_DestroyAudioStream(stream); + output_streams_.clear(); + if (output_device_) { + SDL_CloseAudioDevice(output_device_); + output_device_ = 0; } return 0; } -void SessionDeviceManager::ClearAudio() { - if (output_stream_) SDL_ClearAudioStream(output_stream_); +void SessionDeviceManager::ClearAudio(const std::string& remote_id) { + const auto found = output_streams_.find(remote_id); + if (found == output_streams_.end()) return; + SDL_DestroyAudioStream(found->second); + output_streams_.erase(found); } -void SessionDeviceManager::PushAudio(const char *data, size_t size) { - if (!output_stream_) { - return; +void SessionDeviceManager::PushAudio(const std::string& remote_id, + const char *data, size_t size) { + if (!output_device_) return; + auto found = output_streams_.find(remote_id); + if (found == output_streams_.end()) { + SDL_AudioSpec spec{}; + spec.freq = 48000; + spec.format = SDL_AUDIO_S16; + spec.channels = 1; + auto* stream = SDL_CreateAudioStream(&spec, &spec); + if (!stream) { + LOG_ERROR("Failed to create output stream: {}", SDL_GetError()); + return; + } + if (!SDL_BindAudioStream(output_device_, stream)) { + LOG_ERROR("Failed to bind output stream: {}", SDL_GetError()); + SDL_DestroyAudioStream(stream); + return; + } + found = output_streams_.emplace(remote_id, stream).first; } - const int pushed = SDL_PutAudioStreamData( - output_stream_, reinterpret_cast(data), - static_cast(size)); - if (pushed < 0) { + if (!SDL_PutAudioStreamData(found->second, data, static_cast(size))) { LOG_ERROR("Failed to push audio data: {}", SDL_GetError()); } } diff --git a/apps/desktop/src/gui/features/devices/session_device_manager.h b/apps/desktop/src/gui/features/devices/session_device_manager.h index 34d1e4ad..da7caf76 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.h +++ b/apps/desktop/src/gui/features/devices/session_device_manager.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -67,8 +68,10 @@ public: int InitializeAudioOutput(); int DestroyAudioOutput(); - void ClearAudio(); - void PushAudio(const char *data, size_t size); + // Serialized by GuiRuntime::audio_delivery_mutex_. Each peer has its own + // stream so SDL mixes simultaneous playback and muting clears only that peer. + void ClearAudio(const std::string& remote_id); + void PushAudio(const std::string& remote_id, const char *data, size_t size); bool SendKeyboardCommand(int key_code, bool is_down, uint32_t scan_code, bool extended); @@ -94,7 +97,8 @@ private: bool from_secure_desktop); GuiRuntime &owner_; - SDL_AudioStream *output_stream_ = nullptr; + uint32_t output_device_ = 0; + std::unordered_map output_streams_; ScreenCapturerFactory *screen_capturer_factory_ = nullptr; ScreenCapturer *screen_capturer_ = nullptr; std::atomic cursor_capture_dirty_{true}; diff --git a/apps/desktop/src/gui/features/gallery/gallery_stream_settings.h b/apps/desktop/src/gui/features/gallery/gallery_stream_settings.h new file mode 100644 index 00000000..562786e1 --- /dev/null +++ b/apps/desktop/src/gui/features/gallery/gallery_stream_settings.h @@ -0,0 +1,78 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-12 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _GALLERY_STREAM_SETTINGS_H_ +#define _GALLERY_STREAM_SETTINGS_H_ + +#include +#include +#include +#include +#include +#include + +#include "preview_resolution.h" + +namespace crossdesk::gallery { +struct StreamSettings { + // Persisted preset order: automatic, fixed bounds, then original size. + static constexpr std::array kResolutions{{ + {}, {320, 180}, {640, 360}, {1280, 720}, {1920, 1080}, + {2560, 1440}, {3840, 2160}, {}}}; + static constexpr std::array kFrameRates{0, 15, 30, 60}; + int resolution = 0; + int frame_rate = 0; // 0 follows the session's existing 30/60 FPS preference. + + static std::optional FromIndices(int resolution, int fps) { + if (resolution < 0 || resolution >= int(kResolutions.size()) || + fps < 0 || fps >= int(kFrameRates.size())) + return {}; + return StreamSettings{resolution, kFrameRates[fps]}; + } + int FrameRateIndex() const { + return int(std::find(kFrameRates.begin(), kFrameRates.end(), frame_rate) - + kFrameRates.begin()); + } + bool Valid() const { + return resolution >= 0 && resolution < int(kResolutions.size()) && + FrameRateIndex() < int(kFrameRates.size()); + } + static StreamSettings FromJson(const nlohmann::json& json) { + try { + if (!json.at("resolution").is_number_integer() || + !json.at("frame_rate").is_number_integer()) return {}; + const auto resolution = json.at("resolution").get(); + const auto fps = json.at("frame_rate").get(); + if (resolution < 0 || resolution >= int(kResolutions.size()) || + fps < 0 || fps > 60) return {}; + // Retired sparse presets migrate to the lowest current setting. + StreamSettings result{int(resolution), fps == 1 || fps == 8 ? 15 : int(fps)}; + return result.Valid() ? result : StreamSettings{}; + } catch (...) { + return {}; + } + } + nlohmann::json ToJson() const { + return {{"resolution", resolution}, {"frame_rate", frame_rate}}; + } + PreviewResolution Resolution(bool wall_managed, PreviewResolution wall_view, + PreviewResolution stream_view) const { + if (!wall_managed) return {}; + if (resolution != 0 && Valid()) return kResolutions[resolution]; + // A square bound limits the longest edge of an unexpanded wall tile. + if (!wall_view.width && !stream_view.width) return {320, 320}; + return {std::max(wall_view.width, stream_view.width), + std::max(wall_view.height, stream_view.height)}; + } + void ApplyFrameRate(PreviewSubscription& demand, bool supports_fps15) const { + if (!Valid() || !frame_rate) return; + // Older peers reject fps=15. Keep their existing interactive wire format. + demand.fps = frame_rate == 15 && supports_fps15 ? 15 : 60; + demand.frame_rate = frame_rate <= 30 ? 30 : 60; + } +}; +} // namespace crossdesk::gallery +#endif diff --git a/apps/desktop/src/gui/features/gallery/gallery_workspace.h b/apps/desktop/src/gui/features/gallery/gallery_workspace.h index 6ace61ce..81e8e0af 100644 --- a/apps/desktop/src/gui/features/gallery/gallery_workspace.h +++ b/apps/desktop/src/gui/features/gallery/gallery_workspace.h @@ -17,6 +17,7 @@ #include #include "gallery_storage.h" +#include "gallery_stream_settings.h" namespace crossdesk::gallery { struct Member { @@ -33,6 +34,7 @@ struct Workspace { std::set selected; int page = 0; int capacity = 6; + StreamSettings stream_settings; static std::string NormalizeId(const std::string &input) { std::string result; @@ -181,6 +183,8 @@ struct Workspace { if (version == 2 && !doc.at("wall_ids").is_array()) return false; Workspace loaded; + if (doc.contains("stream_settings")) + loaded.stream_settings = StreamSettings::FromJson(doc["stream_settings"]); for (const auto &member : doc.at("members")) { const auto id = member.at("id").get(); loaded.AddAssignment(id, member.value("group", "")); @@ -197,6 +201,7 @@ struct Workspace { members = std::move(loaded.members); groups = std::move(loaded.groups); wall_ids_ = std::move(loaded.wall_ids_); + stream_settings = loaded.stream_settings; return true; } catch (...) { return false; @@ -206,7 +211,8 @@ struct Workspace { try { nlohmann::json doc{{"version", 2}, {"members", nlohmann::json::array()}, - {"wall_ids", wall_ids_}}; + {"wall_ids", wall_ids_}, + {"stream_settings", stream_settings.ToJson()}}; for (const auto &m : members) doc["members"].push_back({{"id", m.id}, {"group", m.group}}); return WriteAtomically(path, doc.dump(2)); diff --git a/apps/desktop/src/gui/features/gallery/preview_resolution.h b/apps/desktop/src/gui/features/gallery/preview_resolution.h new file mode 100644 index 00000000..b83378ac --- /dev/null +++ b/apps/desktop/src/gui/features/gallery/preview_resolution.h @@ -0,0 +1,55 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-12 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PREVIEW_RESOLUTION_H_ +#define _PREVIEW_RESOLUTION_H_ + +#include +#include +#include + +namespace crossdesk::gallery { +struct PreviewResolution { + int width = 0; + int height = 0; + + bool operator==(const PreviewResolution& other) const { + return width == other.width && height == other.height; + } + static PreviewResolution Viewport(float width, float height, float scale) { + if (!std::isfinite(width) || !std::isfinite(height) || + !std::isfinite(scale) || width <= 0 || height <= 0 || scale <= 0) + return {}; + return {int(std::clamp(std::ceil(double(width) * scale), 2.0, 16384.0)), + int(std::clamp(std::ceil(double(height) * scale), 2.0, 16384.0))}; + } +}; + +class PreviewResolutionDebouncer { + public: + using Clock = std::chrono::steady_clock; + PreviewResolution Update(PreviewResolution desired, Clock::time_point now, + bool reset = false) { + if (!initialized_ || reset) { + initialized_ = true; + applied_ = candidate_ = desired; + changed_at_ = now; + } else if (!(candidate_ == desired)) { + candidate_ = desired; + changed_at_ = now; + } else if (now - changed_at_ >= std::chrono::milliseconds(200)) { + applied_ = candidate_; + } + return applied_; + } + + private: + bool initialized_ = false; + PreviewResolution applied_, candidate_; + Clock::time_point changed_at_{}; +}; +} // namespace crossdesk::gallery +#endif diff --git a/apps/desktop/src/gui/runtime/connection_runtime.cpp b/apps/desktop/src/gui/runtime/connection_runtime.cpp index e747e312..e8de5dd2 100644 --- a/apps/desktop/src/gui/runtime/connection_runtime.cpp +++ b/apps/desktop/src/gui/runtime/connection_runtime.cpp @@ -35,6 +35,30 @@ bool GuiRuntime::HasActiveSession() { }); } +void GuiRuntime::MuteRemoteAudio(const std::shared_ptr& 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& 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 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 props) { props->net_traffic_stats_.Reset(); + props->received_video_stats_.Reset(); { std::lock_guard 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; diff --git a/apps/desktop/src/gui/runtime/gui_runtime.h b/apps/desktop/src/gui/runtime/gui_runtime.h index 7f753632..adc416aa 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.h +++ b/apps/desktop/src/gui/runtime/gui_runtime.h @@ -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& props); + void UnmuteRemoteAudio(const std::shared_ptr& props); int RequestSingleDevicePresence(const std::string& remote_id, const char* password, bool remember_password); int ForceSignalReconnect(); diff --git a/apps/desktop/src/gui/runtime/net_traffic_stats_cache.h b/apps/desktop/src/gui/runtime/net_traffic_stats_cache.h index 3028c105..b9deae82 100644 --- a/apps/desktop/src/gui/runtime/net_traffic_stats_cache.h +++ b/apps/desktop/src/gui/runtime/net_traffic_stats_cache.h @@ -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( + 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( + 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 srtp_active_{false}; std::atomic rtt_ms_{-1}; + std::atomic last_report_ms_{0}; ConnectionLatencyStats connection_latency_; }; diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index d251705a..9ac27f7e 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -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(subscription->quality), subscription->frame_rate, static_cast( 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() > 0 && + preview_json.at("request_id").get() <= UINT32_MAX && preview_json.at("accepted").is_boolean()) { props->preview_accepted_ = preview_json.at("accepted").get(); props->preview_ack_ = preview_json.at("request_id").get(); @@ -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_) { diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index 52267e02..4746444b 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -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) { diff --git a/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp index 8e50cacc..7bcbd46a 100644 --- a/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp @@ -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 diff --git a/apps/desktop/src/gui/runtime/preview_subscriptions.h b/apps/desktop/src/gui/runtime/preview_subscriptions.h index 8cc015ea..4a34f5df 100644 --- a/apps/desktop/src/gui/runtime/preview_subscriptions.h +++ b/apps/desktop/src/gui/runtime/preview_subscriptions.h @@ -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(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(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); diff --git a/apps/desktop/src/gui/runtime/received_video_stats.h b/apps/desktop/src/gui/runtime/received_video_stats.h new file mode 100644 index 00000000..057bd1e1 --- /dev/null +++ b/apps/desktop/src/gui/runtime/received_video_stats.h @@ -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 +#include +#include +#include + +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 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(now - started_).count(); + if (elapsed >= 1.0) { + snapshot_.fps = static_cast(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 diff --git a/apps/desktop/src/gui/runtime/remote_session.h b/apps/desktop/src/gui/runtime/remote_session.h index 2fff633e..3689d89c 100644 --- a/apps/desktop/src/gui/runtime/remote_session.h +++ b/apps/desktop/src/gui/runtime/remote_session.h @@ -23,10 +23,12 @@ #include #include #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 wall_managed_{false}; std::atomic preview_subscription_supported_{false}; + std::atomic preview_resolution_supported_{false}; + std::atomic preview_fps15_supported_{false}; + std::atomic preview_needs_sync_{true}; std::atomic preview_ack_{0}; std::atomic 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 clipboard_allowed_{false}; std::atomic input_generation_{0}; @@ -189,13 +195,14 @@ struct RemoteSession { CursorPresentation cursor_presentation_; // Shared by minirtc callbacks, Slint rendering and SDL audio callbacks. std::atomic connection_status_ = ConnectionStatus::Closed; - TraversalMode traversal_mode_ = TraversalMode::UnknownMode; + std::atomic 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 video_latency_snapshot_; NetTrafficStatsCache net_traffic_stats_; + ReceivedVideoStats received_video_stats_; using QueuedFile = FileTransferState::QueuedFile; using FileTransferStatus = FileTransferState::FileTransferStatus; diff --git a/apps/desktop/src/gui/ui/common.slint b/apps/desktop/src/gui/ui/common.slint index 2b677fd9..c1cfb567 100644 --- a/apps/desktop/src/gui/ui/common.slint +++ b/apps/desktop/src/gui/ui/common.slint @@ -173,6 +173,8 @@ export component CompactComboBox inherits Rectangle { in property <[string]> model; in-out property current-index: 0; in property enabled: true; + in property subtle: false; + in property show-arrow: true; property popup-animation-enabled: false; property popup-progress: 0.0; callback selected(int); @@ -185,29 +187,29 @@ export component CompactComboBox inherits Rectangle { width: 73px; height: 24px; - background: root.enabled ? white : #f1f1f1; + background: root.enabled ? (root.subtle ? #f8fafc : white) : root.subtle ? #f1f3f6 : #f1f1f1; border-width: 1px; - border-color: #bfc2c7; - border-radius: 3px; + border-color: root.subtle ? #dfe6ef : #bfc2c7; + border-radius: root.subtle ? 6px : 3px; Text { - x: 5px; - width: parent.width - 24px; + x: root.subtle ? 10px : 5px; + width: parent.width - (root.show-arrow ? (root.subtle ? 36px : 24px) : (root.subtle ? 20px : 10px)); text: root.model[root.current-index]; color: root.enabled ? #202124 : #8b8f95; - font-size: ImGuiFontStyle.body; + font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } - Rectangle { - x: parent.width - 21px; + if root.show-arrow: Rectangle { + x: parent.width - (root.subtle ? 27px : 21px); width: 20px; height: parent.height - 2px; y: 1px; - background: root.enabled ? #9bc9f8 : #d7d9dc; + background: root.subtle ? transparent : root.enabled ? #9bc9f8 : #d7d9dc; Text { text: FontAwesomeIcons.angle-down; - color: #202124; + color: root.subtle ? #79879a : #202124; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 10px; @@ -222,7 +224,7 @@ export component CompactComboBox inherits Rectangle { } } TouchArea { - enabled: root.enabled; + enabled: root.enabled; mouse-cursor: pointer; clicked => { root.popup-animation-enabled = false; root.popup-progress = 0.0; @@ -261,15 +263,15 @@ export component CompactComboBox inherits Rectangle { opacity: root.popup-progress; background: white; border-width: 1px; - border-color: #bfc2c7; - border-radius: 3px; + border-color: root.subtle ? #dfe6ef : #bfc2c7; + border-radius: root.subtle ? 6px : 3px; clip: true; for choice[index] in root.model: Rectangle { y: index * 23px; width: parent.width; height: 23px; background: option-touch.has-hover ? #dbeafb : index == root.current-index ? #eef5fd : white; - Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } option-touch := TouchArea { clicked => { popup-open-timer.running = false; @@ -286,8 +288,8 @@ export component CompactComboBox inherits Rectangle { height: parent.height; background: transparent; border-width: 1px; - border-color: #bfc2c7; - border-radius: 3px; + border-color: root.subtle ? #dfe6ef : #bfc2c7; + border-radius: root.subtle ? 6px : 3px; z: 2; } } diff --git a/apps/desktop/src/gui/ui/crossdesk_ui.slint b/apps/desktop/src/gui/ui/crossdesk_ui.slint index 7d0817bc..16b30d89 100644 --- a/apps/desktop/src/gui/ui/crossdesk_ui.slint +++ b/apps/desktop/src/gui/ui/crossdesk_ui.slint @@ -7,4 +7,5 @@ export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "s export { GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint"; export { GalleryStrings } from "device_strings.slint"; +export { GalleryNetworkDevice } from "gallery_network_dialog.slint"; export { DeviceGroupOption } from "device_group_dialog.slint"; diff --git a/apps/desktop/src/gui/ui/gallery_network_dialog.slint b/apps/desktop/src/gui/ui/gallery_network_dialog.slint new file mode 100644 index 00000000..c30f9294 --- /dev/null +++ b/apps/desktop/src/gui/ui/gallery_network_dialog.slint @@ -0,0 +1,225 @@ +import { GalleryStrings } from "device_strings.slint"; +import { CompactButton } from "common.slint"; +import { ScrollView } from "std-widgets.slint"; + +export struct GalleryNetworkDevice { + name: string, + id: string, + mode: string, + encryption-known: bool, + encryption-enabled: bool, + latency: string, + inbound: string, + outbound: string, + loss: string, + resolution: string, + fps: string, +} + +component NetworkRow inherits Rectangle { + in property device; + in property heading: false; + // Keep the same column geometry in the header and every row. Grouping + // related values leaves room for device names even in the 640px window. + HorizontalLayout { + x: 0px; y: 0px; width: parent.width; height: parent.height; + padding-left: 12px; padding-right: 12px; spacing: 10px; + identity := Rectangle { + horizontal-stretch: 1; + device-name := Text { + y: root.heading ? 0px : 11px; height: root.heading ? parent.height : 20px; + width: parent.width; text: root.device.name; + font-size: root.heading ? 11px : 12px; font-weight: 600; + color: root.heading ? #6b7785 : #293746; + vertical-alignment: center; overflow: elide; + } + name-hover := TouchArea { + y: 11px; height: 20px; width: parent.width; + enabled: !root.heading; + } + if !root.heading: HorizontalLayout { + x: 0px; y: 34px; width: parent.width; height: 17px; spacing: 4px; + id-text := Text { + text: root.device.id; font-size: 10px; color: #7a8490; + vertical-alignment: center; overflow: elide; + } + Rectangle { + width: max(0px, min(mode-text.preferred-width + 12px, + parent.width - id-text.preferred-width - encryption-badge.width - 12px)); + background: #edf2f7; border-radius: 3px; + mode-text := Text { + x: 6px; width: parent.width - 12px; height: parent.height; + text: root.device.mode; font-size: 10px; color: #586f87; + horizontal-alignment: center; vertical-alignment: center; overflow: elide; + } + } + encryption-badge := Rectangle { + width: encryption-text.preferred-width + 12px; + background: !root.device.encryption-known ? #f0f2f5 + : root.device.encryption-enabled ? #e8f5ec : #fff3df; + border-radius: 3px; + encryption-text := Text { + x: 6px; width: parent.width - 12px; height: parent.height; + text: !root.device.encryption-known + ? GalleryStrings.tr("待确认", "Unknown", "Неизвестно") + : root.device.encryption-enabled + ? GalleryStrings.tr("已加密", "Encrypted", "Защищено") + : GalleryStrings.tr("未加密", "Unencrypted", "Без шифр."); + color: !root.device.encryption-known ? #7a8490 + : root.device.encryption-enabled ? #18794e : #8a6428; + font-size: 10px; horizontal-alignment: center; + vertical-alignment: center; overflow: elide; + } + } + Rectangle { horizontal-stretch: 1; } + } + } + Rectangle { + width: max(108px, root.width * 0.19); + Text { + y: root.heading ? 0px : 11px; height: root.heading ? parent.height : 20px; + width: parent.width; + text: root.heading ? GalleryStrings.tr("画面", "Video", "Видео") : root.device.resolution; + font-size: root.heading ? 11px : 12px; font-weight: root.heading ? 600 : 400; + color: root.heading ? #6b7785 : #293746; + vertical-alignment: center; overflow: elide; + } + if !root.heading: Text { + y: 34px; height: 17px; width: parent.width; + text: root.device.fps; font-size: 11px; color: #7a8490; + vertical-alignment: center; overflow: elide; + } + } + Rectangle { + width: max(100px, root.width * 0.18); + if root.heading: Text { + x: 0px; width: parent.width; + text: GalleryStrings.tr("收发速率", "Transfer rate", "Скорость"); + font-size: 11px; font-weight: 600; color: #6b7785; + vertical-alignment: center; overflow: elide; + } + if !root.heading: Text { + x: 0px; y: 11px; height: 20px; width: 14px; + text: "↓"; color: #2676b9; font-size: 12px; vertical-alignment: center; + } + if !root.heading: Text { + x: 17px; y: 11px; height: 20px; width: parent.width - 17px; + text: root.device.inbound; color: #293746; font-size: 12px; + vertical-alignment: center; overflow: elide; + } + if !root.heading: Text { + x: 0px; y: 34px; height: 17px; width: 14px; + text: "↑"; color: #7a8490; font-size: 11px; vertical-alignment: center; + } + if !root.heading: Text { + x: 17px; y: 34px; height: 17px; width: parent.width - 17px; + text: root.device.outbound; color: #7a8490; font-size: 11px; + vertical-alignment: center; overflow: elide; + } + } + Rectangle { + width: max(94px, root.width * 0.16); + Text { + y: root.heading ? 0px : 11px; height: root.heading ? parent.height : 20px; + width: parent.width; + text: root.heading ? GalleryStrings.tr("网络质量", "Network quality", "Качество сети") : "RTT " + root.device.latency; + font-size: 11px; font-weight: root.heading ? 600 : 400; + color: root.heading ? #6b7785 : #293746; + vertical-alignment: center; overflow: elide; + } + if !root.heading: Text { + y: 34px; height: 17px; width: parent.width; + text: GalleryStrings.tr("丢包 ", "Loss ", "Потери ") + root.device.loss; + font-size: 11px; color: #7a8490; + vertical-alignment: center; overflow: elide; + } + } + } + if !root.heading && name-hover.has-hover && device-name.preferred-width > identity.width: Rectangle { + x: 12px; y: 32px; width: min(name-tip.preferred-width + 20px, root.width - 24px); + height: 26px; background: #293746; border-radius: 4px; z: 10; + name-tip := Text { + x: 10px; width: parent.width - 20px; height: parent.height; + text: root.device.name; font-size: 11px; color: white; + vertical-alignment: center; overflow: elide; + } + } +} + +component NetworkEmptyState inherits Rectangle { + VerticalLayout { + alignment: center; spacing: 8px; + Text { + text: GalleryStrings.tr("暂无已连接设备", "No connected devices", "Нет подключённых устройств"); + font-size: 13px; color: #596878; horizontal-alignment: center; + } + Text { + text: GalleryStrings.tr("建立连接后,网络和画面信息将在此显示", "Network and video details appear here once connected", "После подключения здесь появятся данные сети и видео"); + font-size: 11px; color: #8a95a1; horizontal-alignment: center; + } + } +} + +export component GalleryNetworkDialog inherits Rectangle { + in property <[GalleryNetworkDevice]> devices; + callback close(); + background: #00000042; + TouchArea { clicked => { root.close(); } } + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: min(960px, parent.width - 32px); + height: min(560px, parent.height - 24px); + background: white; border-radius: 8px; + border-width: 1px; border-color: #c6cdd5; + TouchArea {} + FocusScope { + init => { self.focus(); } + key-pressed(event) => { + if event.text == Key.Escape { root.close(); } + accept + } + } + Text { + x: 20px; y: 17px; width: parent.width - 120px; height: 24px; + text: GalleryStrings.tr("设备网络状态", "Device network status", "Сеть устройств"); + font-size: 17px; font-weight: 600; color: #293746; + } + CompactButton { + x: parent.width - 80px; y: 17px; width: 60px; height: 26px; + text: GalleryStrings.tr("关闭", "Close", "Закрыть"); clicked => { root.close(); } + } + Rectangle { + x: 20px; y: 54px; width: 6px; height: 6px; border-radius: 3px; + background: root.devices.length > 0 ? #27a168 : #a7b0ba; + } + Text { + x: 32px; y: 48px; width: parent.width - 52px; height: 18px; + text: GalleryStrings.tr("已连接设备 · ", "Connected devices · ", "Подключённые устройства · ") + root.devices.length + + GalleryStrings.tr(" 台 · 每秒更新", " · Updates every second", " · Обновление раз в секунду"); + font-size: 11px; color: #7a8490; overflow: elide; + } + NetworkRow { + x: 12px; y: 78px; width: parent.width - 38px; height: 30px; + heading: true; background: #f3f5f8; border-radius: 4px; + device: { + name: GalleryStrings.tr("设备 / ID", "Device / ID", "Устройство / ID"), + }; + } + list := ScrollView { + x: 12px; y: 108px; width: parent.width - 24px; + height: parent.height - self.y - 12px; + viewport-width: self.width - 14px; + viewport-height: max(self.height, root.devices.length * 62px); + horizontal-scrollbar-policy: always-off; + for device[index] in root.devices: NetworkRow { + y: index * 62px; width: list.viewport-width; height: 62px; + device: device; background: mod(index, 2) == 0 ? white : #f8fafc; + Rectangle { y: parent.height - 1px; height: 1px; background: #edf0f4; } + } + } + if root.devices.length == 0: NetworkEmptyState { + x: list.x; y: list.y; width: list.width; height: list.height; + } + } +} diff --git a/apps/desktop/src/gui/ui/gallery_settings.slint b/apps/desktop/src/gui/ui/gallery_settings.slint new file mode 100644 index 00000000..8eb21d2a --- /dev/null +++ b/apps/desktop/src/gui/ui/gallery_settings.slint @@ -0,0 +1,36 @@ +import { GalleryStrings } from "device_strings.slint"; +import { SettingsGroup, SettingsRow, SettingsSelect } from "settings_controls.slint"; + +export component GallerySettings inherits Rectangle { + in-out property resolution-index: 0; + in-out property frame-rate-index: 0; + in property error; + SettingsGroup { + y: 0px; width: parent.width; + title: GalleryStrings.tr("画面传输", "Video transmission", "Передача видео"); rows: 2; + SettingsRow { + label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Разрешение"); + label-reserve: self.width - 112px; + SettingsSelect { + x: 136px; y: 4px; width: parent.width - 150px; + current-index <=> root.resolution-index; + model: [GalleryStrings.tr("自动", "Auto", "Авто"), + "320 × 180", "640 × 360", "1280 × 720", "1920 × 1080", "2560 × 1440", "3840 × 2160", + GalleryStrings.tr("原始分辨率", "Original resolution", "Исходное разрешение")]; + } + } + SettingsRow { + label: GalleryStrings.tr("帧率", "Frame rate", "Частота кадров"); + label-reserve: self.width - 112px; divider: false; + SettingsSelect { + x: 136px; y: 4px; width: parent.width - 150px; + current-index <=> root.frame-rate-index; + model: [GalleryStrings.tr("跟随设备设置", "Follow device settings", "Настройки устройства"), "15 FPS", "30 FPS", "60 FPS"]; + } + } + } + if root.error != "": Text { + x: 2px; y: 108px; width: parent.width - 4px; height: 32px; + text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap; + } +} diff --git a/apps/desktop/src/gui/ui/gallery_view.slint b/apps/desktop/src/gui/ui/gallery_view.slint index b1a622dc..5f4a7f64 100644 --- a/apps/desktop/src/gui/ui/gallery_view.slint +++ b/apps/desktop/src/gui/ui/gallery_view.slint @@ -29,6 +29,8 @@ export struct GalleryActionDevice { } export component GalleryView inherits Rectangle { in property controlling: false; + in property simplified: false; + private property grid-gap: root.simplified ? 6px : 12px; // Pointer mapping must use the exact image viewport, including its inset. out property video-width: video.width / 1px; out property video-height: video.height / 1px; @@ -63,6 +65,8 @@ export component GalleryView inherits Rectangle { in property count: 0; in property connected-count: 0; in property selected-count: 0; + in property selected-audio-available: false; + in property selected-audio-muted: false; in property hidden-selected: 0; in property notice; in property focused; @@ -91,7 +95,7 @@ export component GalleryView inherits Rectangle { } toolbar := HorizontalLayout { x: 29px; y: 12px; width: parent.width - 58px; height: 24px; - visible: root.focused.id == ""; + visible: root.focused.id == "" && !root.simplified; spacing: 8px; GroupFilter { width: 155px; height: 24px; system-count: 1; model: root.groups; current-index <=> root.group-index; selected(index) => { root.action("group", index); } @@ -102,30 +106,35 @@ export component GalleryView inherits Rectangle { CompactButton { width: 90px; primary: true; text: GalleryStrings.tr("添加设备", "Add device", "Добавить"); clicked => { root.action("add-open", ""); root.adding = true; } } } grid := Rectangle { - x: toolbar.x; y: toolbar.y + toolbar.height + 12px; width: toolbar.width; - height: footer.y - 12px - self.y; + x: root.simplified ? 12px : toolbar.x; + y: root.simplified ? 12px : toolbar.y + toolbar.height + 12px; + width: parent.width - self.x * 2; + height: (root.simplified && root.pages <= 1 ? parent.height - 12px : footer.y - 12px) - self.y; visible: root.focused.id == ""; for tile[index] in root.tiles: Rectangle { - x: mod(index, root.columns) * (grid.width + 12px) / root.columns; - y: floor(index / root.columns) * (grid.height + 12px) / root.rows; - width: (grid.width - (root.columns - 1) * 12px) / root.columns; - height: (grid.height - (root.rows - 1) * 12px) / root.rows; - border-radius: 6px; background: tile.selected ? #eaf3ff : #eff0f2; - border-width: tile.selected ? 2px : 1px; + x: mod(index, root.columns) * (grid.width + root.grid-gap) / root.columns; + y: floor(index / root.columns) * (grid.height + root.grid-gap) / root.rows; + width: (grid.width - (root.columns - 1) * root.grid-gap) / root.columns; + height: (grid.height - (root.rows - 1) * root.grid-gap) / root.rows; + border-radius: root.simplified ? 0px : 6px; + background: root.simplified ? #24272d : tile.selected ? #eaf3ff : #eff0f2; + border-width: root.simplified ? 0px : tile.selected ? 2px : 1px; border-color: tile.selected ? #76aeea : root.batch && tile-touch.has-hover ? #94bdeb : #c9cdd2; Rectangle { - x: 6px; y: 6px; width: parent.width - 12px; height: parent.height - 36px; + x: root.simplified ? 0px : 6px; y: self.x; + width: parent.width - self.x * 2; + height: root.simplified ? parent.height : parent.height - 36px; background: #24272d; if tile.has-frame: Image { x: 0px; y: 0px; width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale ? 0.45 : 1; } - if !tile.has-frame || !tile.connected || tile.stale: Text { + if !root.simplified && (!tile.has-frame || !tile.connected || tile.stale): Text { text: tile.status; color: #ffffff; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; wrap: word-wrap; } } - Text { x: 9px; y: parent.height - 28px; width: (tile-touch.has-hover && !root.batch ? parent.width - DeviceCardStyle.toolbar-width - 6px : presence-badge.x) - 17px; height: 28px; text: tile.name; color: #24272d; font-size: 12px; overflow: elide; vertical-alignment: center; } + Text { visible: !root.simplified; x: 9px; y: parent.height - 28px; width: (tile-touch.has-hover && !root.batch ? parent.width - DeviceCardStyle.toolbar-width - 6px : presence-badge.x) - 17px; height: 28px; text: tile.name; color: #24272d; font-size: 12px; overflow: elide; vertical-alignment: center; } presence-badge := Rectangle { - visible: root.batch || !tile-touch.has-hover; + visible: !root.simplified && (root.batch || !tile-touch.has-hover); x: parent.width - (root.batch ? 35px : 9px) - self.width; y: parent.height - 23px; width: presence-label.preferred-width + 12px; height: 18px; border-radius: 9px; background: tile.online ? #eaf6ee : #e2e5e9; @@ -135,7 +144,7 @@ export component GalleryView inherits Rectangle { color: tile.online ? #17854c : #62666d; font-size: 10px; vertical-alignment: center; } } - if root.batch: Rectangle { + if root.batch && !root.simplified: Rectangle { x: parent.width - 27px; y: parent.height - 23px; width: 18px; height: 18px; border-radius: 5px; border-width: 1px; background: tile.selected ? (tile-touch.pressed ? #5c9fdf : tile-touch.has-hover ? #659fe0 : #6dabea) : tile-touch.has-hover ? #f3f8fe : white; @@ -148,8 +157,8 @@ export component GalleryView inherits Rectangle { } } tile-touch := TouchArea { - clicked => { root.action(root.batch ? "select" : "focus", tile.id); } - if tile-touch.has-hover && !root.batch: DeviceCardActions { + clicked => { root.action(root.batch && !root.simplified ? "select" : "focus", tile.id); } + if tile-touch.has-hover && !root.batch && !root.simplified: DeviceCardActions { x: parent.width - self.width - 6px; y: parent.height - 26px; remove-label: GalleryStrings.tr("从桌面墙移除", "Remove from DeskHub", "Убрать из DeskHub"); connected: tile.connected; @@ -162,14 +171,16 @@ export component GalleryView inherits Rectangle { } } if root.count == 0: Text { - text: GalleryStrings.tr("添加设备,建立你的桌面墙", "Add devices to build your DeskHub", "Добавьте устройства в DeskHub"); + text: root.simplified + ? GalleryStrings.tr("暂无设备,退出精简模式后可添加", "No devices. Exit compact mode to add devices", "Нет устройств. Выйдите из компактного режима, чтобы добавить их") + : GalleryStrings.tr("添加设备,建立你的桌面墙", "Add devices to build your DeskHub", "Добавьте устройства в DeskHub"); color: #808080; horizontal-alignment: center; vertical-alignment: center; } } focus-view := Rectangle { visible: root.focused.id != ""; x: 29px; y: 7px; width: parent.width - 58px; - height: parent.height - 51px; + height: parent.height - (root.simplified && root.pages <= 1 ? 19px : 51px); HorizontalLayout { x: 0px; y: 0px; width: parent.width; height: 28px; spacing: 8px; CompactButton { width: 52px; text: GalleryStrings.tr("返回", "Back", "Назад"); clicked => { root.action("focus", ""); } } @@ -256,20 +267,21 @@ export component GalleryView inherits Rectangle { border-width: tile.id == root.focused.id ? 2px : 1px; border-color: tile.id == root.focused.id ? #76aeea : #d5dbe3; Rectangle { - x: 5px; y: 5px; width: 126px; height: 52px; background: #24272d; + x: 5px; y: 5px; width: 126px; height: root.simplified ? 82px : 52px; background: #24272d; if tile.has-frame: Image { width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale || !tile.connected ? 0.4 : 1; } - if !tile.has-frame || tile.stale || !tile.connected: Text { + if !root.simplified && (!tile.has-frame || tile.stale || !tile.connected): Text { text: tile.status; color: #e2e6ed; font-size: 10px; overflow: elide; horizontal-alignment: center; vertical-alignment: center; } } - Text { x: 7px; y: 61px; width: 122px; height: 16px; text: tile.name; color: #303943; font-size: 11px; overflow: elide; } + Text { visible: !root.simplified; x: 7px; y: 61px; width: 122px; height: 16px; text: tile.name; color: #303943; font-size: 11px; overflow: elide; } Text { + visible: !root.simplified; x: 7px; y: 78px; width: 86px; height: 12px; font-size: 9px; text: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно"); color: tile.online ? #17854c : #7a828d; overflow: elide; } - if tile.id == root.focused.id: Text { + if !root.simplified && tile.id == root.focused.id: Text { x: 99px; y: 78px; width: 30px; height: 12px; text: GalleryStrings.tr("当前", "Current", "Выбрано"); color: #4d83bd; font-size: 9px; horizontal-alignment: right; overflow: elide; } @@ -280,8 +292,9 @@ export component GalleryView inherits Rectangle { // Reuse the footer for selection actions so entering batch mode never // changes card geometry or moves a device beneath the pointer. footer := HorizontalLayout { - x: toolbar.x; y: parent.height - 36px; width: toolbar.width; height: 24px; spacing: 12px; - if root.batch: CompactButton { + x: toolbar.x; y: parent.height - 36px; width: toolbar.width; height: 24px; spacing: root.batch ? 8px : 12px; + visible: !root.simplified || root.pages > 1; + if root.batch && !root.simplified: CompactButton { width: 90px; enabled: root.count > 0; text: root.count > 0 && root.selected-count == root.count ? GalleryStrings.tr("取消全选", "Deselect all", "Снять выбор") @@ -291,20 +304,28 @@ export component GalleryView inherits Rectangle { Rectangle { horizontal-stretch: 1; min-width: 0px; Text { + visible: !root.simplified; y: 0px; width: parent.width; height: root.batch && root.hidden-selected > 0 ? 13px : parent.height; text: root.batch ? GalleryStrings.tr("已选 ", "Selected ", "Выбрано ") + root.selected-count : root.connected-count + " / " + root.count + GalleryStrings.tr(" 台已连接", " connected", " подключено"); color: #62666d; vertical-alignment: center; font-size: 12px; overflow: elide; } - if root.batch && root.hidden-selected > 0: Text { + if root.batch && !root.simplified && root.hidden-selected > 0: Text { y: 14px; width: parent.width; height: 10px; - text: GalleryStrings.tr("跨页 ", "Other pages ", "Другие страницы ") + root.hidden-selected; + text: GalleryStrings.tr("跨页 ", "Other pages ", "Вне страницы ") + root.hidden-selected; color: #80858d; font-size: 10px; overflow: elide; } } - if root.batch: HorizontalLayout { - width: 214px; spacing: 8px; + if root.batch && !root.simplified: HorizontalLayout { + width: 280px; spacing: 8px; CompactButton { width: 58px; enabled: root.selected-count > 0; text: GalleryStrings.tr("连接", "Connect", "Связь"); clicked => { root.action("connect", ""); } } CompactButton { width: 62px; enabled: root.selected-count > 0; text: GalleryStrings.tr("断开", "Close", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } } + CompactButton { + width: 58px; enabled: root.selected-audio-available; + text: root.selected-audio-muted + ? GalleryStrings.tr("取消静音", "Unmute", "Со звуком") + : GalleryStrings.tr("静音", "Mute", "Без звука"); + clicked => { root.action(root.selected-audio-muted ? "unmute" : "mute", ""); } + } CompactButton { width: 78px; enabled: root.selected-count > 0; text: GalleryStrings.tr("分发文件", "Send file", "Файл"); clicked => { root.confirm-action("file", self.text); } } } HorizontalLayout { diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index b251ba4d..f1289ecf 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -2,6 +2,9 @@ import { GalleryStrings } from "device_strings.slint"; import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint"; import { DeviceGroupDialog, DeviceGroupOption } from "device_group_dialog.slint"; import { GalleryView, GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint"; +import { GalleryNetworkDialog, GalleryNetworkDevice } from "gallery_network_dialog.slint"; +import { SettingsButton, SettingsSelect, SettingsGroup, SettingsRow, SettingsToggleRow } from "settings_controls.slint"; +import { GallerySettings } from "gallery_settings.slint"; import { GroupFilter } from "group_filter.slint"; import { LineEdit, ScrollView } from "std-widgets.slint"; import { AnnouncementItem, MessagePanel, MessageStrings } from "message_panel.slint"; @@ -303,6 +306,22 @@ export component MainWindow inherits Window { default-font-size: ImGuiFontStyle.base; in-out property gallery-mode: false; + in-out property gallery-simplified: false; + changed gallery-simplified => { + root.reset-gallery-transient(); + root.gallery-action("view-mode", ""); + focus-sink.focus(); + } + in-out property gallery-network-open: false; + in property gallery-resolution-index: 0; + in property gallery-frame-rate-index: 0; + in-out property gallery-resolution-draft: 0; + in-out property gallery-frame-rate-draft: 0; + in property gallery-settings-error; + in property <[GalleryNetworkDevice]> gallery-network-devices; + changed gallery-network-open => { + if root.gallery-network-open { root.menu-open = false; root.gallery-action("network-open", ""); } + } in property gallery-fullscreen: false; in property gallery-transitioning: false; in property gallery-content-visible: true; @@ -330,6 +349,8 @@ export component MainWindow inherits Window { in property gallery-count: 0; in property gallery-connected: 0; in property gallery-selected: 0; + in property gallery-selected-audio-available: false; + in property gallery-selected-audio-muted: false; in property gallery-hidden-selected: 0; in property gallery-notice; in property <[GalleryActionDevice]> gallery-selected-devices; @@ -366,7 +387,7 @@ export component MainWindow inherits Window { root.device-group-open = true; } - public function reset-gallery-transient() { gallery.reset-transient(); root.menu-open = false; } + public function reset-gallery-transient() { gallery.reset-transient(); root.menu-open = false; root.gallery-network-open = false; } in property custom-titlebar: false; in property wayland-titlebar: false; in property window-active: true; @@ -503,6 +524,7 @@ export component MainWindow inherits Window { private property menu-open: false; in-out property settings-open: false; + in-out property settings-tab: 0; private property settings-scroll-y: 0px; in-out property self-host-settings-open: false; changed self-host-settings-open => { @@ -544,7 +566,13 @@ export component MainWindow inherits Window { changed permission-dialog-open => { if root.permission-dialog-open { root.gallery-action("release", ""); } } changed settings-open => { if root.settings-open { + root.settings-tab = 0; root.settings-scroll-y = 0px; + root.menu-open = false; + root.gallery-network-open = false; + root.gallery-resolution-draft = root.gallery-resolution-index; + root.gallery-frame-rate-draft = root.gallery-frame-rate-index; + root.gallery-action("settings-open", ""); focus-sink.focus(); } } @@ -780,6 +808,10 @@ export component MainWindow inherits Window { if event.text == Key.Escape { root.messages-open = false; } return accept; } + if root.settings-open && !root.self-host-settings-open { + if event.text == Key.Escape { root.cancel-settings(); root.settings-open = false; } + return accept; + } return reject; } } @@ -915,6 +947,20 @@ export component MainWindow inherits Window { } } } + if root.gallery-mode: CompactButton { + x: menu-button.x - 87px - 16px - 94px - 8px - self.width; + y: mode-switch.y; width: 94px; height: mode-switch.height; + primary: root.gallery-simplified; + text: root.gallery-simplified ? GalleryStrings.tr("退出精简", "Exit compact", "Обычный вид") + : GalleryStrings.tr("精简模式", "Compact", "Компактно"); + clicked => { root.gallery-simplified = !root.gallery-simplified; } + } + if root.gallery-mode: CompactButton { + x: menu-button.x - 87px - 16px - self.width; + y: mode-switch.y; width: 94px; height: mode-switch.height; + text: GalleryStrings.tr("网络状态", "Network", "Сеть"); + clicked => { root.gallery-network-open = true; } + } if root.gallery-mode: CompactButton { x: menu-button.x - self.width - 8px; y: mode-switch.y; @@ -1327,8 +1373,10 @@ export component MainWindow inherits Window { } gallery := GalleryView { - y: root.content-header-height; width: parent.width; height: parent.height - self.y - 24px; + y: root.content-header-height; width: parent.width; + height: parent.height - self.y - (root.gallery-simplified ? 0px : 24px); visible: root.gallery-mode; + simplified: root.gallery-simplified; controlling: root.gallery-controlling; pointer-input(button, kind, x, y) => { root.gallery-pointer(button, kind, x, y); } scroll-input(dx, dy, x, y) => { root.gallery-scroll(dx, dy, x, y); } @@ -1338,6 +1386,8 @@ export component MainWindow inherits Window { columns: root.gallery-columns; rows: root.gallery-rows; page: root.gallery-page; pages: root.gallery-pages; count: root.gallery-count; connected-count: root.gallery-connected; selected-count: root.gallery-selected; + selected-audio-available: root.gallery-selected-audio-available; + selected-audio-muted: root.gallery-selected-audio-muted; hidden-selected: root.gallery-hidden-selected; notice: root.gallery-notice; selected-devices: root.gallery-selected-devices; focused: root.gallery-focused; batch <=> root.gallery-batch; adding <=> root.gallery-adding; @@ -1356,6 +1406,7 @@ export component MainWindow inherits Window { } // Status bar. Rectangle { + visible: !root.gallery-mode || !root.gallery-simplified; y: parent.height - 24px; height: 24px; background: #fafbfc; @@ -1930,172 +1981,176 @@ export component MainWindow inherits Window { } } - // Settings modal. + // Both tabs share one draft and the same save/cancel actions. if root.settings-open: Rectangle { - background: transparent; + background: #15223850; z: 45; TouchArea { clicked => { focus-sink.focus(); } } DialogSurface { - x: root.compact-language ? 219px : 190px; - y: 60px; - width: root.compact-language ? 202px : 269px; - height: 330px; - border-radius: 7px; - - Rectangle { - x: 0px; - y: 0px; - width: parent.width; - height: 21px; - background: #d3d3d3; - border-radius: 7px; - z: 5; - Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; } - Text { x: 6px; text: UiStrings.settings; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; } + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: min(400px, parent.width - 40px); height: 446px; + background: #f8fafc; border-radius: 14px; border-color: #e1e7ef; + drop-shadow-color: #14294424; drop-shadow-blur: 24px; drop-shadow-offset-y: 8px; + TouchArea { clicked => { focus-sink.focus(); } } + Text { + x: 18px; y: 18px; width: parent.width - 80px; height: 27px; + text: UiStrings.settings; font-size: 20px; font-weight: 600; color: #22324a; } - - settings-scroll := ScrollView { - x: 0px; - y: 21px; - width: parent.width; - height: 265px; - viewport-y <=> root.settings-scroll-y; - - settings-content := VerticalLayout { - // Reserve space for the overlaid vertical scrollbar. - width: settings-scroll.width; - padding-top: 8px; - padding-bottom: 8px; - spacing: 0px; - - Rectangle { - height: 30px; - Text { x: 8px; y: 3px; width: root.compact-language ? 94px : 162px; height: 24px; text: UiStrings.language; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } - CompactComboBox { x: root.compact-language ? 106px : 174px; y: 2px; model: ["中文", "English", "Русский"]; current-index <=> root.language-index; } + SettingsButton { + x: parent.width - 90px; y: 18px; width: 76px; height: 28px; quiet: true; + text: GalleryStrings.tr("关闭", "Close", "Закрыть"); + clicked => { root.cancel-settings(); root.settings-open = false; root.self-host-settings-open = false; } + } + Rectangle { + x: 18px; y: 56px; width: parent.width - 36px; height: 40px; + background: #eaf0f6; border-radius: 9px; + Rectangle { + x: 4px + root.settings-tab * (parent.width - 8px) / 2; + y: 4px; width: (parent.width - 8px) / 2; height: parent.height - 8px; + background: white; border-radius: 6px; + drop-shadow-color: #1e3e6520; drop-shadow-blur: 3px; drop-shadow-offset-y: 1px; + animate x { duration: 140ms; easing: ease-out; } + } + for label[index] in [GalleryStrings.tr("常规", "General", "Общие"), GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub")]: Rectangle { + x: 4px + index * (parent.width - 8px) / 2; + y: 4px; width: (parent.width - 8px) / 2; height: parent.height - 8px; + border-radius: 6px; + background: tab-touch.has-hover && root.settings-tab != index ? #e1e8f1 : transparent; + accessible-role: button; accessible-label: label; + accessible-action-default => { tab-touch.clicked(); } + Text { + text: label; font-size: 12px; font-weight: root.settings-tab == index ? 600 : 400; + color: root.settings-tab == index ? #2e70cf : #718096; + horizontal-alignment: center; vertical-alignment: center; } - if root.capture-method-visible: Rectangle { - height: 30px; - Text { x: 8px; y: 3px; width: root.compact-language ? 94px : 162px; height: 24px; text: UiStrings.capture-method; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } - CompactComboBox { x: root.compact-language ? 106px : 174px; y: 2px; enabled: !root.settings-session-active; model: [UiStrings.capture-method-auto, "DXGI", "WGC", "GDI"]; current-index <=> root.capture-method-index; } - } - Rectangle { - height: 30px; - Text { x: 8px; y: 3px; width: root.compact-language ? 94px : 162px; height: 24px; text: UiStrings.codec; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } - CompactComboBox { x: root.compact-language ? 106px : 174px; y: 2px; enabled: !root.settings-session-active; model: [UiStrings.codec-h264, UiStrings.codec-av1]; current-index <=> root.codec-index; } - } - Rectangle { - height: 30px; - Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.hardware-codec; color: root.hardware-codec-available ? #202124 : #8b8f95; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } - ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.hardware-codec-enabled; enabled: root.hardware-codec-available && !root.settings-session-active; } - } - Rectangle { - height: 30px; - Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.turn-relay; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } - ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.turn-enabled; enabled: !root.settings-session-active; } - } - Rectangle { - height: 30px; - Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.force-relay; color: root.turn-enabled ? #202124 : #8b8f95; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } - ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.force-relay-enabled; enabled: root.turn-enabled && !root.settings-session-active; } - } - Rectangle { - height: 30px; - Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.save-remote-previews; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } - ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.save-remote-previews; } - } - Rectangle { - height: 30px; - Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.privacy-on-connect; color: root.privacy-setting-available ? #202124 : #8b8f95; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } - ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.privacy-on-connect-enabled; enabled: root.privacy-setting-available; } - } - Rectangle { - height: 30px; - CompactButton { x: 8px; y: 3px; width: root.compact-language ? 70px : 206px; text: UiStrings.self-hosted; enabled: !root.settings-session-active; clicked => { root.self-host-settings-open = true; } } - ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.self-hosted-enabled; enabled: !root.settings-session-active; } - } - Rectangle { - height: 30px; - Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.autostart; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } - ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.autostart-enabled; enabled: !root.settings-session-active; } - } - Rectangle { - height: 30px; - Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.daemon; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } - ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.daemon-enabled; enabled: !root.settings-session-active; } - } - Rectangle { - height: 30px; - Text { x: 8px; y: 3px; width: root.compact-language ? 94px : 154px; height: 24px; text: UiStrings.file-save-path; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } - CompactButton { x: root.compact-language ? 106px : 166px; y: 3px; width: root.compact-language ? 73px : 80px; text: root.file-save-path == "" ? UiStrings.default-desktop : root.file-save-path; enabled: !root.settings-session-active; clicked => { root.browse-save-path(); } } + tab-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.settings-tab = index; focus-sink.focus(); } } } } - - Rectangle { x: 7px; y: 286px; width: parent.width - 14px; height: 1px; background: ImGuiLineStyle.separator; } - CompactButton { x: root.compact-language ? 63px : 89px; y: 297px; primary: true; text: UiStrings.ok; clicked => { if root.save-settings() { root.settings-open = false; root.self-host-settings-open = false; } } } - CompactButton { x: root.compact-language ? 104px : 130px; y: 297px; width: root.compact-language ? 34px : 56px; text: UiStrings.cancel; clicked => { root.cancel-settings(); root.settings-open = false; root.self-host-settings-open = false; } } + if root.settings-tab == 0: settings-scroll := ScrollView { + x: 18px; y: 108px; width: parent.width - 28px; height: 272px; + viewport-y <=> root.settings-scroll-y; + VerticalLayout { + width: settings-scroll.width - 12px; + spacing: 14px; padding-bottom: 4px; + SettingsGroup { + title: GalleryStrings.tr("界面", "Interface", "Интерфейс"); rows: 1; + SettingsRow { + label: UiStrings.language; divider: false; + SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; model: ["中文", "English", "Русский"]; current-index <=> root.language-index; } + } + } + SettingsGroup { + title: GalleryStrings.tr("视频", "Video", "Видео"); + rows: root.capture-method-visible ? 3 : 2; + if root.capture-method-visible: SettingsRow { + label: UiStrings.capture-method; enabled: !root.settings-session-active; + SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.capture-method-auto, "DXGI", "WGC", "GDI"]; current-index <=> root.capture-method-index; } + } + SettingsRow { + label: UiStrings.codec; enabled: !root.settings-session-active; + SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.codec-h264, UiStrings.codec-av1]; current-index <=> root.codec-index; } + } + SettingsToggleRow { label: UiStrings.hardware-codec; checked <=> root.hardware-codec-enabled; enabled: root.hardware-codec-available && !root.settings-session-active; divider: false; } + } + SettingsGroup { + title: GalleryStrings.tr("网络连接", "Network", "Сеть"); rows: 3; + SettingsToggleRow { label: UiStrings.turn-relay; checked <=> root.turn-enabled; enabled: !root.settings-session-active; } + SettingsToggleRow { label: UiStrings.force-relay; checked <=> root.force-relay-enabled; enabled: root.turn-enabled && !root.settings-session-active; } + SettingsToggleRow { + checked <=> root.self-hosted-enabled; enabled: !root.settings-session-active; divider: false; + SettingsButton { x: 0px; y: 2px; width: parent.width - 60px; height: 32px; quiet: true; align-left: true; text: UiStrings.self-hosted; enabled: !root.settings-session-active; clicked => { root.self-host-settings-open = true; } } + } + } + SettingsGroup { + title: GalleryStrings.tr("隐私保护", "Privacy", "Приватность"); rows: 1; + SettingsToggleRow { label: UiStrings.privacy-on-connect; checked <=> root.privacy-on-connect-enabled; enabled: root.privacy-setting-available; divider: false; } + } + SettingsGroup { + title: GalleryStrings.tr("本地存储", "Local storage", "Локальное хранение"); rows: 2; + SettingsToggleRow { label: UiStrings.save-remote-previews; checked <=> root.save-remote-previews; } + SettingsRow { + label: UiStrings.file-save-path; enabled: !root.settings-session-active; divider: false; + SettingsButton { x: parent.width - 116px; y: 4px; width: 104px; height: 28px; text: root.file-save-path == "" ? UiStrings.default-desktop : root.file-save-path; enabled: !root.settings-session-active; clicked => { root.browse-save-path(); } } + } + } + SettingsGroup { + title: GalleryStrings.tr("启动与后台", "Startup & background", "Запуск и фоновая работа"); rows: 2; + SettingsToggleRow { label: UiStrings.autostart; checked <=> root.autostart-enabled; enabled: !root.settings-session-active; } + SettingsToggleRow { label: UiStrings.daemon; checked <=> root.daemon-enabled; enabled: !root.settings-session-active; divider: false; } + } + } + } + if root.settings-tab == 1: GallerySettings { + x: 18px; y: 108px; width: parent.width - 36px; height: 272px; + resolution-index <=> root.gallery-resolution-draft; + frame-rate-index <=> root.gallery-frame-rate-draft; + error: root.gallery-settings-error; + } + Rectangle { x: 18px; y: 382px; width: parent.width - 36px; height: 1px; background: #e6ebf2; } + HorizontalLayout { + x: 18px; y: 396px; width: parent.width - 36px; height: 32px; spacing: 10px; + if root.settings-tab == 1: SettingsButton { + width: 110px; quiet: true; + text: GalleryStrings.tr("恢复默认", "Reset defaults", "По умолчанию"); + clicked => { root.gallery-resolution-draft = 0; root.gallery-frame-rate-draft = 0; } + } + Rectangle { horizontal-stretch: 1; } + SettingsButton { + width: 80px; text: UiStrings.cancel; + clicked => { root.cancel-settings(); root.settings-open = false; root.self-host-settings-open = false; } + } + SettingsButton { + width: 92px; primary: true; + text: GalleryStrings.tr("保存", "Save", "Сохранить"); + clicked => { if root.save-settings() { root.settings-open = false; root.self-host-settings-open = false; } } + } + } } } if root.self-host-settings-open: Rectangle { - background: transparent; + background: #15223835; z: 49; TouchArea { clicked => { focus-sink.focus(); } } DialogSurface { - x: root.compact-language ? 190px : 140px; - y: 105px; - width: root.compact-language ? 260px : 360px; - height: root.self-host-settings-error == "" ? 138px : 202px; - border-radius: 7px; - - Rectangle { - x: 0px; - y: 0px; - width: parent.width; - height: 21px; - background: #d3d3d3; - border-radius: 7px; - z: 5; - Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; } - Text { x: 6px; text: UiStrings.self-hosted-settings; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; } + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 360px; height: root.self-host-settings-error == "" ? 266px : 322px; + border-radius: 12px; border-color: #e1e7ef; background: #f8fafc; + drop-shadow-color: #14294424; drop-shadow-blur: 18px; drop-shadow-offset-y: 6px; + TouchArea { clicked => { focus-sink.focus(); } } + Text { + x: 18px; y: 18px; width: parent.width - 36px; height: 24px; + text: UiStrings.self-hosted-settings; font-size: 16px; font-weight: 600; color: #22324a; } - Text { x: 8px; y: 31px; width: root.compact-language ? 88px : 165px; height: 24px; text: UiStrings.server-host; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + Text { x: 18px; y: 56px; width: parent.width - 36px; height: 20px; text: UiStrings.server-host; color: #34445a; font-size: 12px; overflow: elide; vertical-alignment: center; } Rectangle { - x: root.compact-language ? 100px : 173px; - y: 30px; - width: root.compact-language ? 152px : 179px; - height: 24px; - background: white; - border-width: 1px; - border-color: ImGuiLineStyle.border; - border-radius: 4px; - LineEdit { x: 1px; y: 1.5px; width: parent.width - 2px; height: parent.height - 2px; text <=> root.server-host; input-type: InputType.text; font-size: ImGuiFontStyle.body; } + x: 18px; y: 80px; width: parent.width - 36px; height: 30px; + background: white; border-width: 1px; border-color: #dfe6ef; border-radius: 6px; + LineEdit { x: 1px; y: 2px; width: parent.width - 2px; height: parent.height - 4px; text <=> root.server-host; input-type: InputType.text; font-size: 12px; } LineEditFocusUnderlineMask { x: 5px; y: parent.height - 3px; width: parent.width - 10px; } } - Text { x: 8px; y: 61px; width: root.compact-language ? 88px : 165px; height: 24px; text: UiStrings.server-port; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + Text { x: 18px; y: 120px; width: parent.width - 36px; height: 20px; text: UiStrings.server-port; color: #34445a; font-size: 12px; overflow: elide; vertical-alignment: center; } Rectangle { - x: root.compact-language ? 100px : 173px; - y: 60px; - width: root.compact-language ? 152px : 179px; - height: 24px; - background: white; - border-width: 1px; - border-color: ImGuiLineStyle.border; - border-radius: 4px; - LineEdit { x: 1px; y: 1.5px; width: parent.width - 2px; height: parent.height - 2px; text <=> root.server-port; input-type: InputType.number; font-size: ImGuiFontStyle.body; } + x: 18px; y: 144px; width: parent.width - 36px; height: 30px; + background: white; border-width: 1px; border-color: #dfe6ef; border-radius: 6px; + LineEdit { x: 1px; y: 2px; width: parent.width - 2px; height: parent.height - 4px; text <=> root.server-port; input-type: InputType.number; font-size: 12px; } LineEditFocusUnderlineMask { x: 5px; y: parent.height - 3px; width: parent.width - 10px; } } if root.self-host-settings-error != "": Text { - x: 8px; y: 89px; width: parent.width - 16px; height: 64px; - text: root.self-host-settings-error; color: #b3261e; - font-size: ImGuiFontStyle.body; wrap: word-wrap; + x: 18px; y: 186px; width: parent.width - 36px; height: 48px; + text: root.self-host-settings-error; color: #c53e3e; font-size: 11px; wrap: word-wrap; } - CompactButton { x: root.compact-language ? 93px : 135px; y: parent.height - 38px; primary: true; text: UiStrings.ok; clicked => { if root.save-self-hosted-settings() { root.self-host-settings-open = false; } } } - CompactButton { x: root.compact-language ? 134px : 176px; y: parent.height - 38px; width: root.compact-language ? 34px : 56px; text: UiStrings.cancel; clicked => { root.cancel-self-hosted-settings(); root.self-host-settings-open = false; } } + Rectangle { x: 18px; y: parent.height - 66px; width: parent.width - 36px; height: 1px; background: #e6ebf2; } + SettingsButton { x: parent.width - 114px; y: parent.height - 50px; width: 92px; primary: true; text: UiStrings.ok; clicked => { if root.save-self-hosted-settings() { root.self-host-settings-open = false; } } } + SettingsButton { x: parent.width - 204px; y: parent.height - 50px; width: 80px; text: UiStrings.cancel; clicked => { root.cancel-self-hosted-settings(); root.self-host-settings-open = false; } } } } - // About modal. if root.about-open: Rectangle { background: transparent; z: 46; @@ -2455,6 +2510,14 @@ export component MainWindow inherits Window { } } + if root.gallery-mode && root.gallery-network-open: GalleryNetworkDialog { + x: 0px; y: root.titlebar-height; + width: root.width; height: root.height - self.y; + z: 100; + devices: root.gallery-network-devices; + close => { root.gallery-network-open = false; focus-sink.focus(); } + } + // Ignore clicks against moving cards and duplicate fullscreen requests. if root.gallery-transitioning: TouchArea { x: 0px; y: root.titlebar-height; diff --git a/apps/desktop/src/gui/ui/settings_controls.slint b/apps/desktop/src/gui/ui/settings_controls.slint new file mode 100644 index 00000000..f8a5249d --- /dev/null +++ b/apps/desktop/src/gui/ui/settings_controls.slint @@ -0,0 +1,73 @@ +import { CompactComboBox, ToggleSwitch } from "common.slint"; + +// Shared visual treatment for the global settings pages. +export component SettingsButton inherits Rectangle { + in property text; + in property primary: false; + in property quiet: false; + in property align-left: false; + in property enabled: true; + callback clicked(); + width: 88px; height: 32px; border-radius: 7px; + background: !root.enabled ? #f0f3f7 + : root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc) + : touch.pressed ? #e9eef5 : touch.has-hover ? #f0f4fa : root.quiet ? transparent : white; + border-width: root.quiet || root.primary ? 0px : 1px; + border-color: #dce3ec; + accessible-role: button; accessible-label: root.text; + accessible-action-default => { if root.enabled { root.clicked(); } } + Text { + x: 12px; width: parent.width - 24px; + text: root.text; font-size: 12px; font-weight: root.primary ? 600 : 400; + color: !root.enabled ? #98a2b3 : root.primary ? white : root.quiet ? #65758b : #3c4d63; + horizontal-alignment: root.align-left ? left : center; vertical-alignment: center; overflow: elide; + } + touch := TouchArea { enabled: root.enabled; mouse-cursor: pointer; clicked => { root.clicked(); } } +} + +export component SettingsSelect inherits CompactComboBox { + subtle: true; + show-arrow: false; + height: 28px; +} + +export component SettingsGroup inherits Rectangle { + in property title; + in property rows: 1; + height: 24px + root.rows * 36px; + Text { + x: 2px; y: 0px; width: parent.width - 4px; height: 18px; text: root.title; + font-size: 11px; font-weight: 600; color: #718096; vertical-alignment: center; + } + Rectangle { + y: 24px; width: parent.width; height: parent.height - self.y; + background: white; border-radius: 9px; border-color: #e5eaf1; border-width: 1px; + VerticalLayout { padding: 0px; spacing: 0px; @children } + } +} + +export component SettingsRow inherits Rectangle { + in property label; + in property enabled: true; + in property divider: true; + in property label-reserve: 128px; + height: 36px; + Text { + x: 12px; width: parent.width - root.label-reserve; height: parent.height; + text: root.label; color: root.enabled ? #34445a : #9ba5b3; + font-size: 12px; overflow: elide; vertical-alignment: center; + } + if root.divider: Rectangle { + x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #eff2f6; + } + @children +} + +export component SettingsToggleRow inherits SettingsRow { + in-out property checked; + label-reserve: 64px; + ToggleSwitch { + x: parent.width - self.width - 12px; y: (parent.height - self.height) / 2; + checked <=> root.checked; enabled: root.enabled; + } +} diff --git a/deps/submodules/minirtc b/deps/submodules/minirtc index f597bbe7..851eb2ed 160000 --- a/deps/submodules/minirtc +++ b/deps/submodules/minirtc @@ -1 +1 @@ -Subproject commit f597bbe7997bb2f058f74ff9c844d8a93cf9249f +Subproject commit 851eb2ed1f121c46981e7b22d79f59d1261a682e diff --git a/docs/deskhub.md b/docs/deskhub.md index 2d814bec..040dd602 100644 --- a/docs/deskhub.md +++ b/docs/deskhub.md @@ -18,20 +18,32 @@ - 近期连接的设备提示浮窗显示所属分组;多个分组并列显示并自动换行,没有分组时显示“未分组”。分组变更后自动刷新。 - “近期连接”标题行右侧可按“全部设备”、未分组或已有分组筛选;一个设备可出现在它所属的多个分组中。筛选与桌面墙当前分组独立,不改变历史记录、在线状态订阅或桌面墙成员。分组修改后自动更新结果;当前分组被清空删除时回到全部设备。 - 网格随窗口大小增加行列,紧凑窗口为 3 × 2;翻页不关闭连接。分组内顺序不会按在线状态自动重排。 +- 顶部“精简模式”切换为仅画面的设备卡片,隐藏名称、在线状态、状态文字和悬停操作条,同时收起设备管理工具栏、连接计数与底部服务器状态栏;画面完整等比显示,不裁切。顶部保留“退出精简”、网络状态、全屏及菜单入口,多页时保留分页。点击图片仍可展开查看和接管操控,返回后保持精简模式;模式切换会释放操控、退出批量选择并返回网格,不改变连接、分组和设备顺序。 +- 菜单中的全局“设置”顶部并排提供“常规”和“桌面墙”两个选项卡;“桌面墙”可统一配置桌面墙设备的传输分辨率上限和帧率,在连接页面以及桌面墙普通、精简、展开和全屏视图中均可打开。切换选项卡保留未保存修改,底部统一保存或取消。分辨率支持自动、320 × 180、640 × 360、1280 × 720、1920 × 1080、2560 × 1440、3840 × 2160和原始分辨率;固定上限适用于缩略图和展开画面,保持比例且不放大源画面。帧率支持跟随设备设置、15、30、60 FPS。保存后立即更新当前桌面墙会话,也用于后续桌面墙连接,不影响普通连接,跨分组和分页生效;取消不应用修改,恢复默认需保存后生效。配置保存在本地,重启后保留;保存失败时保留面板和输入。打开设置会释放远程操控。 +- 顶部“网络状态”按钮打开当前已建立连接的设备列表,不受桌面墙分组和分页限制;离线、未连接和连接中的设备不列入,断开后自动移除。列表显示设备名称、ID、直连/中继、实际协商的 SRTP 加密状态、往返延迟(RTT)、控制端接收与发送速率、视频丢包率、实际接收分辨率与帧率,打开期间每秒刷新。加密状态紧随连接方式显示为“已加密/未加密”,尚无统计或报告超过 3 秒未更新时显示“待确认”。分辨率与帧率在解码交付时统计,墙内预览和后台会话均可用;尚未收到画面时显示“—”,画面停止更新超过 1 秒时帧率显示 0 FPS。网络数值尚无统计或报告超过 3 秒未更新时显示“—”。可通过关闭按钮、Esc 或点击列表外关闭;打开列表会释放远程操控。 - 点卡片进入只读放大视图,点“接管操控”才开始发送键鼠。点“释放操控”或按 Ctrl/⌘ + Shift + Esc 可以释放。切换设备、翻页、打开本地操作面板、切换模式及窗口失焦也会释放。 +- 桌面墙连接默认静音,包含新建连接、重连、复用已有连接、添加或导入已有会话,以及切入桌面墙时已有的连接。展开、全屏或接管操控不会自动开启声音;需要声音时,可通过批量“取消静音”或在独立串流窗口主动开启。两个入口共用同一静音状态,主动开启的声音在桌面墙可见时播放,翻页、展开及释放操控保留声音设置;每台设备使用独立音频流同时播放,静音一台不会清空其他设备的声音。新版本对端按会话关闭音频订阅;旧版本发送的音频由控制端丢弃,不通过停止对端的全局声音采集影响其他控制端。 - 放大视图使用一行设备操作栏,只保留返回、设备名称、状态和接管/释放操控按钮;紧凑窗口的设备缩略图位于下方,宽窗口移到右侧。缩略图显示设备名、在线状态和当前设备标记,缺少画面或画面停滞时有状态占位;滚动条单独留出空间。调整尺寸后当前设备仍在列表中,选中时自动滚入可见区域。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。 - 在“近期连接”中点击已连接设备的卡片画面,可打开原有独立串流窗口,使用声音、显示器切换、隐私屏、文件传输等完整单会话工具。关闭墙内设备的独立窗口不会断开墙中的会话。 -- 批量选择复用固定底栏,设备卡片的位置和尺寸不变。底栏提供“全选”:选中当前筛选下的所有设备,跨页生效;全部选中后显示“取消全选”。翻页保留选择,切换分组清空选择;已选数量与跨页数量、连接/断开/分发文件及分页控件在同一底栏显示。断开和文件分发会先列出完整目标;提交后的操作目标不会跟随之后的选择变化。 +- 批量选择复用固定底栏,设备卡片的位置和尺寸不变。底栏提供“全选”:选中当前筛选下的所有设备,跨页生效;全部选中后显示“取消全选”。翻页保留选择,切换分组清空选择;已选数量与跨页数量、连接/断开/静音/分发文件及分页控件在同一底栏显示。声音按钮根据已选且已连接设备的状态切换:全部静音时显示“取消静音”,有设备开启声音时显示“静音”,无已连接设备时禁用。点击后统一设置所有已选的已连接设备,包含跨页选择,跳过未连接设备,保留当前选择。断开和文件分发会先列出完整目标;提交后的操作目标不会跟随之后的选择变化。 ## 预览与兼容性 -桌面墙的所有已连接设备统一使用各自的交互画质、30/60 帧设置和降级策略。缩略图、放大查看、操控、翻页和隐藏窗口均不改变发送档位。新版两端通过 `supports_deskhub_preview` 同步订阅,视频按编码器协商的帧率发送,不再额外限帧;声音仍需明确开启。 +自动分辨率模式下,桌面墙发起或接管的会话在能力协商后默认请求最长边不超过 320 像素的画面。点卡片展开后,根据视频显示区域的实际物理像素(包含 HiDPI 缩放)请求分辨率;全屏、退出全屏和调整窗口大小后会重新计算,尺寸稳定 200 毫秒后更新订阅。返回网格、隐藏桌面墙或切换到其他设备后恢复缩略图尺寸。同一会话同时在独立串流窗口显示时,按两个可见视频区域中较大的尺寸请求,避免小预览降低大窗口的清晰度。选择固定或原始分辨率后使用对应上限;恢复自动后重新跟随视图。普通连接页发起且未加入桌面墙管理的会话保留原有分辨率行为。 -为兼容旧版控制端,被控端仍接受暂停和 1/8 帧订阅;这些低帧率请求使用保持分辨率策略,避免套用交互视频的帧率与关键帧时延预算而反复缩小画面。当前桌面墙不再发出这些请求。 +被控端按连接分别在编码前缩放,完整保留桌面比例,不放大超过源画面的尺寸,也不修改被控端屏幕的系统分辨率。请求的宽高是上限,画质和网络自适应仍可降低实际发送尺寸。默认跟随每个会话原有的 30/60 帧设置;指定帧率仅覆盖桌面墙订阅,不修改会话的画质或原有帧率偏好。声音仍需明确开启。 + +15 帧订阅使用编码前发送节流和稳定的逐显示流时间预算,并采用保持分辨率策略,避免低帧率触发交互视频的画质降级。30/60 帧选项使用编码器自身的交互帧率控制。仅向声明 `supports_deskhub_fps15` 的对端发送 `fps=15`;缺少该能力的 v1/v2 对端使用原有 `fps=60, frame_rate=30` 格式回退到 30 FPS,未声明预览能力的更旧版本保持原行为。协议仍接受旧控制端的 1/8 帧请求,但设置界面不再提供这两个档位。 预览在解码回调中随收到的画面生成有界缩略图,可见桌面墙随 UI 刷新显示最新帧。UI 不处理全分辨率转换,只保留当前可见设备的最新预览,超过 5 秒未更新会标记停帧。画面保持比例,以完整矩形显示;外层圆角卡片与画面之间保留内边距,不裁切桌面内容或画面边角。实际显示帧率受设备性能与网络状况影响。 -旧版被控端仍可连接,按其支持的交互视频策略发送。多设备同时发送交互视频会增加带宽和编解码负载。 +连接旧版被控端时,若仅声明 `supports_deskhub_preview`,控制端发送原有 v1 订阅;若未声明预览能力,则不发送该订阅,沿用其视频策略。旧控制端连接新版被控端时继续使用原分辨率;v1 订阅也会清除此前的尺寸限制。只有同时声明 `supports_deskhub_preview` 与 `supports_deskhub_resolution` 的被控端才会收到 v2 尺寸请求,旧版本不承诺发送端节省资源。 + +### 协议与接口 + +可靠控制通道的 `deskhub_preview` v2 保留 v1 的 `request_id`、`fps`、`quality`、`frame_rate`、`preference`、`audio` 字段,新增必填 `width`、`height` 整数,单位为物理像素。两个值均为 0 表示恢复源尺寸上限,否则两个值均须在 2~16384 之间;非法或缺失的值不应用。`deskhub_preview_status` 回传请求的协议版本、请求 ID 与 `accepted`。请求 ID 跳过 0,支持 32 位回绕,重复或过期请求不会覆盖新设置。断开会话清除订阅和 15 FPS 协商能力,再连接会重新发送当前需求。15 FPS 扩展通过可选能力 `supports_deskhub_fps15` 协商,沿用 v1/v2 的字段结构,使用 `fps=15, frame_rate=30`,在编码前将实际输入限制为 15 FPS。 + +MiniRTC 新增 `UpdateVideoResolution(peer, remote_id, remote_id_size, width, height)` 接口,返回 0 表示已接受,-1 表示参数、连接或传输类型不支持。它为指定控制端的所有显示流设置独立尺寸上限,切换时丢弃旧设置尚未发出的编码结果,并请求新关键帧;底层自适应也以这个上限作为有效源尺寸。 ## 批量操作 @@ -39,7 +51,7 @@ 批量操作仅在本次运行中执行,不提供任务列表、操作历史或重启后的恢复。成员资料与窗口状态保存在应用缓存目录的 `deskhub.json` 和 `deskhub.json.window` 中,不含设备密码和文件内容。 -`deskhub.json` 第 2 版分别保存分组记录和桌面墙设备 ID。第 1 版配置仍可读取,原有桌面墙成员、分组和顺序会保留;旧版数据未记录加入来源,因此不会自动移除已有成员。 +`deskhub.json` 第 2 版分别保存分组记录和桌面墙设备 ID,并通过可选的 `stream_settings` 保存分辨率预设及帧率。缺少或无效的设置使用自动分辨率和跟随设备帧率,不影响设备成员。已保存的 1/8 FPS 自动迁移为 15 FPS,保留原分辨率预设;原有 30/60 FPS 设置保持不变。第 1 版配置仍可读取,原有桌面墙成员、分组和顺序会保留;旧版数据未记录加入来源,因此不会自动移除已有成员。 批量远程命令、重启尚未提供。实际大规模并发容量需要用真实设备测量。 diff --git a/libs/wire/include/preview_subscription.h b/libs/wire/include/preview_subscription.h index aec4c88e..91b91270 100644 --- a/libs/wire/include/preview_subscription.h +++ b/libs/wire/include/preview_subscription.h @@ -16,11 +16,20 @@ namespace crossdesk { struct PreviewSubscription { uint32_t request_id = 0; int fps = - 0; // 0 pauses encoding/delivery, 1 overview, 8 focus, 60 interactive. + 0; // 0 pauses; 1/8 legacy previews; 15 negotiated preview; 60 interactive. int quality = 0; int frame_rate = 30; int preference = 0; bool audio = false; + // v2 bounds are physical pixels. 0/0 restores the legacy source limit. + int width = 0; + int height = 0; + int version = 1; + + static bool ValidResolution(int width, int height) { + return (width == 0 && height == 0) || + (width >= 2 && height >= 2 && width <= 16384 && height <= 16384); + } static PreviewSubscription Interactive(int quality, int frame_rate, int preference, bool audio = false) { @@ -31,24 +40,32 @@ struct PreviewSubscription { return fps == other.fps && quality == other.quality && frame_rate == other.frame_rate && EncoderPreference() == other.EncoderPreference() && - audio == other.audio; + audio == other.audio && width == other.width && + height == other.height && version == other.version; } int EncoderPreference() const { // Sparse preview input is intentional. Preserve spatial detail instead of // applying the interactive frame-rate/keyframe latency budget to it. - return fps == 1 || fps == 8 ? 1 : preference; + return fps == 1 || fps == 8 || fps == 15 ? 1 : preference; } nlohmann::json ToJson() const { - return {{"type", "deskhub_preview"}, {"version", 1}, + nlohmann::json json = {{"type", "deskhub_preview"}, {"version", version}, {"request_id", request_id}, {"fps", fps}, {"quality", quality}, {"frame_rate", frame_rate}, {"preference", EncoderPreference()}, {"audio", audio}}; + if (version == 2) { + json["width"] = width; + json["height"] = height; + } + return json; } static std::optional Parse(const nlohmann::json &json) { try { - if (json.at("type") != "deskhub_preview" || json.at("version") != 1) + if (json.at("type") != "deskhub_preview" || + !json.at("version").is_number_integer() || + (json.at("version") != 1 && json.at("version") != 2)) return {}; for (const char *key : {"request_id", "fps", "quality", "frame_rate", "preference"}) @@ -66,7 +83,7 @@ struct PreviewSubscription { json.at("quality"), json.at("frame_rate"), json.at("preference")}; if ((result.fps != 0 && result.fps != 1 && result.fps != 8 && - result.fps != 60) || + result.fps != 15 && result.fps != 60) || result.quality < 0 || result.quality > 2 || (result.frame_rate != 30 && result.frame_rate != 60) || result.preference < 0 || result.preference > 2) @@ -76,6 +93,17 @@ struct PreviewSubscription { return {}; result.audio = json.at("audio").get(); } + result.version = json.at("version").get(); + if (result.version == 2) { + for (const char *key : {"width", "height"}) { + if (!json.at(key).is_number_integer()) return {}; + const auto value = json.at(key).get(); + if (value < 0 || value > 16384) return {}; + } + result.width = json.at("width").get(); + result.height = json.at("height").get(); + if (!ValidResolution(result.width, result.height)) return {}; + } return result; } catch (...) { return {}; diff --git a/libs/wire/include/remote_action.h b/libs/wire/include/remote_action.h index 8608c206..e30fbcde 100644 --- a/libs/wire/include/remote_action.h +++ b/libs/wire/include/remote_action.h @@ -138,6 +138,8 @@ struct HostInfo { bool supports_privacy_screen; bool supports_video_settings; bool supports_deskhub_preview; + bool supports_deskhub_resolution; + bool supports_deskhub_fps15; }; struct ServiceStatus { diff --git a/libs/wire/src/remote_action.cpp b/libs/wire/src/remote_action.cpp index 3ac5d061..54b1350b 100644 --- a/libs/wire/src/remote_action.cpp +++ b/libs/wire/src/remote_action.cpp @@ -27,6 +27,8 @@ void ResetHostInfo(HostInfo& info) { info.supports_privacy_screen = false; info.supports_video_settings = false; info.supports_deskhub_preview = false; + info.supports_deskhub_resolution = false; + info.supports_deskhub_fps15 = false; } bool AllocateHostDisplays(HostInfo& info, std::size_t count) { @@ -221,7 +223,9 @@ 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_deskhub_preview", action.i.supports_deskhub_preview}}; + {"supports_deskhub_preview", action.i.supports_deskhub_preview}, + {"supports_deskhub_resolution", action.i.supports_deskhub_resolution}, + {"supports_deskhub_fps15", action.i.supports_deskhub_fps15}}; break; } case ControlType::invalid: @@ -416,6 +420,10 @@ bool RemoteAction::FromJson(const std::string& json_string, host_info_object.value("supports_video_settings", false); output.i.supports_deskhub_preview = host_info_object.value("supports_deskhub_preview", false); + output.i.supports_deskhub_resolution = + host_info_object.value("supports_deskhub_resolution", false); + output.i.supports_deskhub_fps15 = + host_info_object.value("supports_deskhub_fps15", false); const std::string host_name = host_info_object.at("host_name").get(); std::strncpy(output.i.host_name, host_name.c_str(),