[feat] add DeskHub stream controls and network status

This commit is contained in:
dijunkun
2026-10-13 10:45:48 +08:00
parent 73b4d0f645
commit 3bd0676672
28 changed files with 1266 additions and 259 deletions
@@ -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<slint::VectorModel<ui::GalleryNetworkDevice>> gallery_network_model =
std::make_shared<slint::VectorModel<ui::GalleryNetworkDevice>>();
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<std::shared_ptr<RemoteSession>> 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<std::string> 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<std::string> 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<ui::GalleryNetworkDevice> 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<int>(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<std::string> preview_ids;
std::vector<ui::GalleryTile> 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<ui::GalleryActionDevice> 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(),
@@ -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();
@@ -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<const Uint8 *>(data),
static_cast<int>(size));
if (pushed < 0) {
if (!SDL_PutAudioStreamData(found->second, data, static_cast<int>(size))) {
LOG_ERROR("Failed to push audio data: {}", SDL_GetError());
}
}
@@ -13,6 +13,7 @@
#include <cstdint>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
#include <remote_action.h>
@@ -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<std::string, SDL_AudioStream*> output_streams_;
ScreenCapturerFactory *screen_capturer_factory_ = nullptr;
ScreenCapturer *screen_capturer_ = nullptr;
std::atomic<bool> cursor_capture_dirty_{true};
@@ -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 <algorithm>
#include <array>
#include <cstdint>
#include <nlohmann/json.hpp>
#include <optional>
#include <preview_subscription.h>
#include "preview_resolution.h"
namespace crossdesk::gallery {
struct StreamSettings {
// Persisted preset order: automatic, fixed bounds, then original size.
static constexpr std::array<PreviewResolution, 8> kResolutions{{
{}, {320, 180}, {640, 360}, {1280, 720}, {1920, 1080},
{2560, 1440}, {3840, 2160}, {}}};
static constexpr std::array<int, 4> 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<StreamSettings> 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<int64_t>();
const auto fps = json.at("frame_rate").get<int64_t>();
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
@@ -17,6 +17,7 @@
#include <vector>
#include "gallery_storage.h"
#include "gallery_stream_settings.h"
namespace crossdesk::gallery {
struct Member {
@@ -33,6 +34,7 @@ struct Workspace {
std::set<std::string> 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<std::string>();
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));
@@ -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 <algorithm>
#include <chrono>
#include <cmath>
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
@@ -35,6 +35,30 @@ bool GuiRuntime::HasActiveSession() {
});
}
void GuiRuntime::MuteRemoteAudio(const std::shared_ptr<RemoteSession>& props) {
if (!props) return;
std::lock_guard delivery_lock(audio_delivery_mutex_);
props->audio_capture_button_pressed_ = false;
props->audio_allowed_ = false;
devices_.ClearAudio(props->remote_id_);
// Capable peers stop sending to this controller on the next subscription;
// old peers remain muted by the local receive gate above.
props->preview_needs_sync_ = true;
}
void GuiRuntime::UnmuteRemoteAudio(const std::shared_ptr<RemoteSession>& props) {
if (!props || props->closing_) return;
props->audio_capture_button_pressed_ = true;
props->preview_needs_sync_ = true;
// Also resume capture on old peers, or after a legacy controller stopped it.
RemoteAction action{};
action.type = ControlType::audio_capture;
action.a = true;
const auto message = action.to_json();
SendReliableDataFrame(props->peer_, message.data(), message.size(),
props->control_data_label_.c_str());
}
void GuiRuntime::HandleConnectionStatusChange() {
if (!signal_connected_ || !peer_) {
return;
@@ -273,6 +297,7 @@ int GuiRuntime::ForceSignalReconnect() {
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
if (!props || props->closing_.exchange(true)) return;
MuteRemoteAudio(props);
if (props->peer_events_) props->peer_events_->Deactivate();
props->connection_established_ = false;
props->streaming_ = false;
@@ -484,6 +509,7 @@ void GuiRuntime::WaitForSessionCleanup() {
void GuiRuntime::ResetRemoteSessionResources(
std::shared_ptr<RemoteSession> props) {
props->net_traffic_stats_.Reset();
props->received_video_stats_.Reset();
{
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
props->video_latency_->Reset();
@@ -657,6 +683,11 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
int ret = -1;
auto props = FindRemoteSession(remote_id);
if (!props || props->closing_) return -1;
if (background) {
// This also covers reusing a foreground connection that is awaiting auth.
props->wall_managed_ = true;
MuteRemoteAudio(props);
}
if (!props->connection_established_) {
if (!background) show_connection_status_window_ = true;
@@ -53,6 +53,9 @@ class GuiRuntime : protected gui_detail::GuiState {
int ConnectTo(const std::string& remote_id, const char* password,
bool remember_password, bool bypass_presence_check = false,
bool background = false);
// UI thread: mute this receiver without stopping audio for other controllers.
void MuteRemoteAudio(const std::shared_ptr<RemoteSession>& props);
void UnmuteRemoteAudio(const std::shared_ptr<RemoteSession>& props);
int RequestSingleDevicePresence(const std::string& remote_id,
const char* password, bool remember_password);
int ForceSignalReconnect();
@@ -24,6 +24,8 @@ class NetTrafficStatsCache {
"Network statistics require lock-free scalar atomics");
public:
using Clock = std::chrono::steady_clock;
void Store(const MiniRtcNetTrafficStats& stats) {
video_inbound_stats_.Store(stats.video_inbound_stats);
video_outbound_stats_.Store(stats.video_outbound_stats);
@@ -36,6 +38,10 @@ class NetTrafficStatsCache {
srtp_active_.store(stats.srtp_active, std::memory_order_relaxed);
rtt_ms_.store(stats.rtt_ms, std::memory_order_relaxed);
connection_latency_.Update(stats.rtt_ms);
last_report_ms_.store(
std::chrono::duration_cast<std::chrono::milliseconds>(
Clock::now().time_since_epoch()).count(),
std::memory_order_relaxed);
}
MiniRtcNetTrafficStats Load() const {
@@ -65,9 +71,17 @@ class NetTrafficStatsCache {
void ResetLatency() { connection_latency_.Reset(); }
bool HasRecentReport(Clock::time_point now = Clock::now()) const {
const auto last_report = last_report_ms_.load(std::memory_order_relaxed);
const auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
now.time_since_epoch()).count();
return last_report > 0 && now_ms - last_report < 3000;
}
void Reset() {
Store(MiniRtcNetTrafficStats{});
ResetLatency();
last_report_ms_.store(0, std::memory_order_relaxed);
}
private:
@@ -114,6 +128,7 @@ class NetTrafficStatsCache {
OutboundStats total_outbound_stats_;
std::atomic<bool> srtp_active_{false};
std::atomic<double> rtt_ms_{-1};
std::atomic<int64_t> last_report_ms_{0};
ConnectionLatencyStats connection_latency_;
};
@@ -168,20 +168,26 @@ void PeerEventHandler::OnReceiveDataBuffer(
bool accepted = true;
// Apply encoder quality to this controller only; other controllers retain
// their own demand. Frame gating happens before encoding and transmission.
if (subscription->fps > 0 && runtime->preview_subscriptions_.IsNewer(
remote_id, subscription->request_id))
accepted = UpdateVideoSettings(
if (runtime->preview_subscriptions_.IsNewer(remote_id,
subscription->request_id)) {
if (subscription->fps > 0)
accepted = UpdateVideoSettings(
runtime->peer_, remote_id.data(), remote_id.size(),
static_cast<VideoQuality>(subscription->quality),
subscription->frame_rate,
static_cast<VideoDegradationPreference>(
subscription->EncoderPreference())) == 0;
if (accepted && runtime->preview_subscriptions_.Set(remote_id, *subscription) &&
subscription->fps > 0)
RequestAllVideoKeyFrames(runtime->peer_);
if (accepted)
accepted = UpdateVideoResolution(
runtime->peer_, remote_id.data(), remote_id.size(),
subscription->width, subscription->height) == 0;
}
// The resolution API refreshes only this controller's keyframes, including
// resume requests with unchanged dimensions. Do not disturb other viewers.
if (accepted) runtime->preview_subscriptions_.Set(remote_id, *subscription);
const auto reply = nlohmann::json{
{"type", "deskhub_preview_status"},
{"version", 1},
{"version", subscription->version},
{"request_id", subscription->request_id},
{"accepted",
accepted}}.dump();
@@ -196,8 +202,11 @@ void PeerEventHandler::OnReceiveDataBuffer(
if (auto props = runtime->FindRemoteSession(remote_id);
props && source_id == props->control_data_label_) {
try {
if (preview_json.at("version") == 1 &&
if ((preview_json.at("version") == 1 ||
preview_json.at("version") == 2) &&
preview_json.at("request_id").is_number_unsigned() &&
preview_json.at("request_id").get<uint64_t>() > 0 &&
preview_json.at("request_id").get<uint64_t>() <= UINT32_MAX &&
preview_json.at("accepted").is_boolean()) {
props->preview_accepted_ = preview_json.at("accepted").get<bool>();
props->preview_ack_ = preview_json.at("request_id").get<uint32_t>();
@@ -398,6 +407,9 @@ void PeerEventHandler::OnReceiveDataBuffer(
const bool first_advertisement = !props->video_settings_supported_;
props->preview_subscription_supported_ =
remote_action.i.supports_deskhub_preview;
props->preview_resolution_supported_ =
remote_action.i.supports_deskhub_resolution;
props->preview_fps15_supported_ = remote_action.i.supports_deskhub_fps15;
props->video_settings_supported_ =
remote_action.i.supports_video_settings;
if (first_advertisement && props->video_settings_supported_) {
@@ -289,6 +289,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true;
remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = true;
remote_action.i.supports_deskhub_fps15 = true;
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
LOG_INFO("Local display [{}:{}]", i + 1,
remote_action.i.display_list[i]);
@@ -327,8 +329,15 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
props->remote_update_dismissed_ = false;
}
props->net_traffic_stats_.Reset();
props->received_video_stats_.Reset();
props->traversal_mode_ = TraversalMode::UnknownMode;
props->enable_mouse_control_ = false;
props->preview_subscription_supported_ = false;
props->preview_resolution_supported_ = false;
props->preview_fps15_supported_ = false;
props->preview_needs_sync_ = true;
props->preview_ack_ = 0;
props->preview_accepted_ = false;
props->audio_allowed_ = false;
props->clipboard_allowed_ = false;
runtime->ResetRemoteServiceStatus(*props);
@@ -429,6 +438,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true;
remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = true;
remote_action.i.supports_deskhub_fps15 = true;
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
LOG_INFO("Local display [{}:{}]", i + 1,
remote_action.i.display_list[i]);
@@ -545,7 +556,7 @@ void PeerEventHandler::OnNetStatusReport(
if (props->traversal_mode_ != mode) {
props->net_traffic_stats_.ResetLatency();
props->traversal_mode_ = mode;
LOG_INFO("Net mode: [{}]", int(props->traversal_mode_));
LOG_INFO("Net mode: [{}]", int(props->traversal_mode_.load()));
}
if (!net_traffic_stats) {
@@ -50,6 +50,11 @@ void PeerEventHandler::OnReceiveVideoBuffer(
if (props->connection_established_) {
const auto timing_now = VideoLatencyFrame::Clock::now();
const auto* received_native = GetNativeVideoFrame(video_frame);
props->received_video_stats_.Record(
received_native ? received_native->width : video_frame->width,
received_native ? received_native->height : video_frame->height,
timing_now);
// Convert only visible thumbnails, off the UI thread. A session retains
// one latest RGBA image, not an unbounded queue of frames.
const int preview_width = props->gallery_preview_width_.load();
@@ -274,10 +279,10 @@ void PeerEventHandler::OnReceiveAudioBuffer(
std::lock_guard delivery_lock(runtime->audio_delivery_mutex_);
const auto session =
runtime->FindRemoteSession(std::string(user_id, user_id_size));
if (!session || !session->audio_allowed_.load()) return;
if (!session || session->closing_ || !session->audio_allowed_.load()) return;
runtime->audio_buffer_fresh_ = true;
runtime->devices_.PushAudio(data, size);
runtime->devices_.PushAudio(session->remote_id_, data, size);
}
} // namespace crossdesk
@@ -24,13 +24,17 @@ public:
std::lock_guard lock(mutex_);
const auto found = states_.find(id);
return found == states_.end() ||
request_id > found->second.request.request_id;
static_cast<int32_t>(request_id -
found->second.request.request_id) > 0;
}
bool Set(const std::string &id, const PreviewSubscription &request) {
std::lock_guard lock(mutex_);
auto &state = states_[id];
if (request.request_id <= state.request.request_id)
const auto found = states_.find(id);
if (found != states_.end() &&
static_cast<int32_t>(request.request_id -
found->second.request.request_id) <= 0)
return false;
auto &state = states_[id];
state.request = request;
state.next.clear();
return true;
@@ -49,6 +53,15 @@ public:
if (state.request.fps == 60)
return true;
auto &next = state.next[stream];
if (state.request.fps == 15) {
// Keep a stable 15 FPS deadline across capture jitter. Rebasing every
// frame can turn small timing errors into extra dropped capture frames.
const auto interval = std::chrono::nanoseconds(1'000'000'000 / 15);
if (now + std::chrono::milliseconds(1) < next) return false;
next = next == Clock::time_point{} || now - next > interval
? now + interval : next + interval;
return true;
}
if (now < next)
return false;
next = now + std::chrono::milliseconds(1000 / state.request.fps);
@@ -0,0 +1,72 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _RECEIVED_VIDEO_STATS_H_
#define _RECEIVED_VIDEO_STATS_H_
#include <chrono>
#include <cmath>
#include <mutex>
#include <optional>
namespace crossdesk::gui_detail {
// Measured at decode delivery, including wall-only and background sessions.
// Independent of the selected window's presentation cadence and FPS setting.
class ReceivedVideoStats {
public:
using Clock = std::chrono::steady_clock;
struct Snapshot {
int width = 0;
int height = 0;
std::optional<int> fps;
};
void Record(int width, int height, Clock::time_point now = Clock::now()) {
if (width <= 0 || height <= 0) return;
std::lock_guard lock(mutex_);
const bool restart = snapshot_.width == 0 ||
now - last_frame_ >= std::chrono::seconds(3);
snapshot_.width = width;
snapshot_.height = height;
last_frame_ = now;
if (restart) {
started_ = now;
frames_ = 0;
snapshot_.fps.reset();
return;
}
++frames_;
const double elapsed = std::chrono::duration<double>(now - started_).count();
if (elapsed >= 1.0) {
snapshot_.fps = static_cast<int>(std::lround(frames_ / elapsed));
frames_ = 0;
started_ = now;
}
}
Snapshot Get(Clock::time_point now = Clock::now()) const {
std::lock_guard lock(mutex_);
auto result = snapshot_;
if (result.width > 0 && now - last_frame_ >= std::chrono::seconds(1))
result.fps = 0;
return result;
}
void Reset() {
std::lock_guard lock(mutex_);
snapshot_ = {};
frames_ = 0;
started_ = last_frame_ = {};
}
private:
mutable std::mutex mutex_;
Snapshot snapshot_;
unsigned frames_ = 0;
Clock::time_point started_{}, last_frame_{};
};
} // namespace crossdesk::gui_detail
#endif
@@ -23,10 +23,12 @@
#include <preview_subscription.h>
#include <stream_names.h>
#include "display_info.h"
#include "features/gallery/preview_resolution.h"
#include "cursor_presentation.h"
#include "minirtc.h"
#include "rendering/video_latency.h"
#include "runtime/net_traffic_stats_cache.h"
#include "runtime/received_video_stats.h"
#include "runtime/connection_auth_state.h"
namespace crossdesk { class PeerEventHandler; }
@@ -74,10 +76,14 @@ struct RemoteSession {
// Wall membership, stream-window visibility and input ownership are separate.
std::atomic<bool> wall_managed_{false};
std::atomic<bool> preview_subscription_supported_{false};
std::atomic<bool> preview_resolution_supported_{false};
std::atomic<bool> preview_fps15_supported_{false};
std::atomic<bool> preview_needs_sync_{true};
std::atomic<uint32_t> preview_ack_{0};
std::atomic<bool> preview_accepted_{false};
uint32_t preview_request_ = 0;
PreviewSubscription preview_requested_;
gallery::PreviewResolutionDebouncer preview_resolution_;
std::chrono::steady_clock::time_point preview_request_at_{};
std::atomic<bool> clipboard_allowed_{false};
std::atomic<uint64_t> input_generation_{0};
@@ -189,13 +195,14 @@ struct RemoteSession {
CursorPresentation cursor_presentation_;
// Shared by minirtc callbacks, Slint rendering and SDL audio callbacks.
std::atomic<ConnectionStatus> connection_status_ = ConnectionStatus::Closed;
TraversalMode traversal_mode_ = TraversalMode::UnknownMode;
std::atomic<TraversalMode> traversal_mode_{TraversalMode::UnknownMode};
int fps_ = 0;
int frame_count_ = 0;
std::chrono::steady_clock::time_point last_time_;
// UI-thread snapshot refreshed with FPS once per second.
std::optional<VideoLatencyStats::Snapshot> video_latency_snapshot_;
NetTrafficStatsCache net_traffic_stats_;
ReceivedVideoStats received_video_stats_;
using QueuedFile = FileTransferState::QueuedFile;
using FileTransferStatus = FileTransferState::FileTransferStatus;
+18 -16
View File
@@ -173,6 +173,8 @@ export component CompactComboBox inherits Rectangle {
in property <[string]> model;
in-out property <int> current-index: 0;
in property <bool> enabled: true;
in property <bool> subtle: false;
in property <bool> show-arrow: true;
property <bool> popup-animation-enabled: false;
property <float> 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;
}
}
@@ -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";
@@ -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 <GalleryNetworkDevice> device;
in property <bool> 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;
}
}
}
@@ -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 <int> resolution-index: 0;
in-out property <int> frame-rate-index: 0;
in property <string> 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;
}
}
+49 -28
View File
@@ -29,6 +29,8 @@ export struct GalleryActionDevice {
}
export component GalleryView inherits Rectangle {
in property <bool> controlling: false;
in property <bool> simplified: false;
private property <length> grid-gap: root.simplified ? 6px : 12px;
// Pointer mapping must use the exact image viewport, including its inset.
out property <float> video-width: video.width / 1px;
out property <float> video-height: video.height / 1px;
@@ -63,6 +65,8 @@ export component GalleryView inherits Rectangle {
in property <int> count: 0;
in property <int> connected-count: 0;
in property <int> selected-count: 0;
in property <bool> selected-audio-available: false;
in property <bool> selected-audio-muted: false;
in property <int> hidden-selected: 0;
in property <string> notice;
in property <GalleryTile> 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 {
+203 -140
View File
@@ -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 <bool> gallery-mode: false;
in-out property <bool> gallery-simplified: false;
changed gallery-simplified => {
root.reset-gallery-transient();
root.gallery-action("view-mode", "");
focus-sink.focus();
}
in-out property <bool> gallery-network-open: false;
in property <int> gallery-resolution-index: 0;
in property <int> gallery-frame-rate-index: 0;
in-out property <int> gallery-resolution-draft: 0;
in-out property <int> gallery-frame-rate-draft: 0;
in property <string> 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 <bool> gallery-fullscreen: false;
in property <bool> gallery-transitioning: false;
in property <bool> gallery-content-visible: true;
@@ -330,6 +349,8 @@ export component MainWindow inherits Window {
in property <int> gallery-count: 0;
in property <int> gallery-connected: 0;
in property <int> gallery-selected: 0;
in property <bool> gallery-selected-audio-available: false;
in property <bool> gallery-selected-audio-muted: false;
in property <int> gallery-hidden-selected: 0;
in property <string> 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 <bool> custom-titlebar: false;
in property <bool> wayland-titlebar: false;
in property <bool> window-active: true;
@@ -503,6 +524,7 @@ export component MainWindow inherits Window {
private property <bool> menu-open: false;
in-out property <bool> settings-open: false;
in-out property <int> settings-tab: 0;
private property <length> settings-scroll-y: 0px;
in-out property <bool> 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;
@@ -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 <string> text;
in property <bool> primary: false;
in property <bool> quiet: false;
in property <bool> align-left: false;
in property <bool> 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 <string> title;
in property <int> 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 <string> label;
in property <bool> enabled: true;
in property <bool> divider: true;
in property <length> 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 <bool> checked;
label-reserve: 64px;
ToggleSwitch {
x: parent.width - self.width - 12px; y: (parent.height - self.height) / 2;
checked <=> root.checked; enabled: root.enabled;
}
}