[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
@@ -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