diff --git a/docs/configuration.md b/docs/configuration.md index 3b5580715a1..139b75016ca 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -958,6 +958,247 @@ editing the `conf` file in a text editor. Use the examples as reference. +### dd_configuration_option + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Description + Perform mandatory verification and additional configuration for the display device. + @note{Applies to Windows only.} +
Default@code{}verify_only@endcode
Example@code{} + dd_configuration_option = ensure_only_display + @endcode
ChoicesdisabledPerform no additional configuration (disables all `dd_` configuration options).
verify_onlyVerify that display is active only (this is a mandatory step without any extra steps to verify display state).
ensure_activeActivate the display if it's currently inactive.
ensure_primaryActivate the display if it's currently inactive and make it primary.
ensure_only_displayActivate the display if it's currently inactive and disable all others.
+ +### dd_resolution_option + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Description + Perform additional resolution configuration for the display device. + @note{"Optimize game settings" must be enabled in Moonlight for this option to work.} + @note{Applies to Windows only.} +
Default@code{}auto@endcode
Example@code{} + dd_resolution_option = manual + @endcode
ChoicesdisabledPerform no additional configuration.
autoChange resolution to the requested resolution from the client.
manualChange resolution to the user specified one (set via [dd_manual_resolution](#dd_manual_resolution)).
+ +### dd_manual_resolution + + + + + + + + + + + + + + +
Description + Specify manual resolution to be used. + @note{[dd_resolution_option](#dd_resolution_option) must be set to `manual`} + @note{Applies to Windows only.} +
Defaultn/a
Example@code{} + dd_manual_resolution = 1920x1080 + @endcode
+ +### dd_refresh_rate_option + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Description + Perform additional refresh rate configuration for the display device. + @note{Applies to Windows only.} +
Default@code{}auto@endcode
Example@code{} + dd_refresh_rate_option = manual + @endcode
ChoicesdisabledPerform no additional configuration.
autoChange refresh rate to the requested FPS value from the client.
manualChange refresh rate to the user specified one (set via [dd_manual_refresh_rate](#dd_manual_refresh_rate)).
+ +### dd_manual_refresh_rate + + + + + + + + + + + + + + +
Description + Specify manual refresh rate to be used. + @note{[dd_refresh_rate_option](#dd_refresh_rate_option) must be set to `manual`} + @note{Applies to Windows only.} +
Defaultn/a
Example@code{} + dd_manual_resolution = 120 + dd_manual_resolution = 59.95 + @endcode
+ +### dd_hdr_option + + + + + + + + + + + + + + + + + + + + + + + +
Description + Perform additional HDR configuration for the display device. + @note{Applies to Windows only.} +
Default@code{}auto@endcode
Example@code{} + dd_hdr_option = disabled + @endcode
ChoicesdisabledPerform no additional configuration.
autoChange HDR to the requested state from the client if the display supports it.
+ +### dd_wa_hdr_toggle + + + + + + + + + + + + + + +
Description + When using virtual display device as for streaming, it might display incorrect (high-contrast) color. + With this option enabled, Sunshine will try to mitigate this issue. + @note{This option works independently of [dd_hdr_option](#dd_hdr_option)} + @note{Applies to Windows only.} +
Default@code{} + disabled + @endcode
Example@code{} + dd_wa_hdr_toggle = enabled + @endcode
+ +### dd_config_revert_delay + + + + + + + + + + + + + + +
Description + Additional delay in milliseconds to wait before reverting configuration when the app has been closed or the last session terminated. + Main purpose is to provide a smoother transition when quickly switching between apps. + @note{Applies to Windows only.} +
Default@code{}3000@endcode
Example@code{} + dd_config_revert_delay = 1500 + @endcode
+ ### min_fps_factor diff --git a/src/audio.cpp b/src/audio.cpp index b24ae61350f..82b1ec37351 100644 --- a/src/audio.cpp +++ b/src/audio.cpp @@ -20,16 +20,6 @@ namespace audio { using opus_t = util::safe_ptr; using sample_queue_t = std::shared_ptr>>; - struct audio_ctx_t { - // We want to change the sink for the first stream only - std::unique_ptr sink_flag; - - std::unique_ptr control; - - bool restore_sink; - platf::sink_t sink; - }; - static int start_audio_control(audio_ctx_t &ctx); static void @@ -95,8 +85,6 @@ namespace audio { }, }; - auto control_shared = safe::make_shared(start_audio_control, stop_audio_control); - void encodeThread(sample_queue_t samples, config_t config, void *channel_data) { auto packets = mail::man->queue(mail::audio_packets); @@ -149,7 +137,7 @@ namespace audio { apply_surround_params(stream, config.customStreamParams); } - auto ref = control_shared.ref(); + auto ref = get_audio_ctx_ref(); if (!ref) { return; } @@ -255,6 +243,26 @@ namespace audio { } } + audio_ctx_ref_t + get_audio_ctx_ref() { + static auto control_shared { safe::make_shared(start_audio_control, stop_audio_control) }; + return control_shared.ref(); + } + + bool + is_audio_ctx_sink_available(const audio_ctx_t &ctx) { + if (!ctx.control) { + return false; + } + + const std::string &sink = ctx.sink.host.empty() ? config::audio.sink : ctx.sink.host; + if (sink.empty()) { + return false; + } + + return ctx.control->is_sink_available(sink); + } + int map_stream(int channels, bool quality) { int shift = quality ? 1 : 0; diff --git a/src/audio.h b/src/audio.h index 208a5775871..927dfdef20b 100644 --- a/src/audio.h +++ b/src/audio.h @@ -4,6 +4,8 @@ */ #pragma once +// local includes +#include "platform/common.h" #include "thread_safe.h" #include "utility.h" @@ -55,8 +57,50 @@ namespace audio { std::bitset flags; }; + struct audio_ctx_t { + // We want to change the sink for the first stream only + std::unique_ptr sink_flag; + + std::unique_ptr control; + + bool restore_sink; + platf::sink_t sink; + }; + using buffer_t = util::buffer_t; using packet_t = std::pair; + using audio_ctx_ref_t = safe::shared_t::ptr_t; + void capture(safe::mail_t mail, config_t config, void *channel_data); + + /** + * @brief Get the reference to the audio context. + * @returns A shared pointer reference to audio context. + * @note Aside from the configuration purposes, it can be used to extend the + * audio sink lifetime to capture sink earlier and restore it later. + * + * @examples + * audio_ctx_ref_t audio = get_audio_ctx_ref() + * @examples_end + */ + audio_ctx_ref_t + get_audio_ctx_ref(); + + /** + * @brief Check if the audio sink held by audio context is available. + * @returns True if available (and can probably be restored), false otherwise. + * @note Useful for delaying the release of audio context shared pointer (which + * tries to restore original sink). + * + * @examples + * audio_ctx_ref_t audio = get_audio_ctx_ref() + * if (audio.get()) { + * return is_audio_ctx_sink_available(*audio.get()); + * } + * return false; + * @examples_end + */ + bool + is_audio_ctx_sink_available(const audio_ctx_t &ctx); } // namespace audio diff --git a/src/config.cpp b/src/config.cpp index 32b43127d13..43527f45130 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -328,13 +328,65 @@ namespace config { } } // namespace sw + namespace dd { + video_t::dd_t::config_option_e + config_option_from_view(const std::string_view value) { +#define _CONVERT_(x) \ + if (value == #x##sv) return video_t::dd_t::config_option_e::x + _CONVERT_(disabled); + _CONVERT_(verify_only); + _CONVERT_(ensure_active); + _CONVERT_(ensure_primary); + _CONVERT_(ensure_only_display); +#undef _CONVERT_ + return video_t::dd_t::config_option_e::disabled; // Default to this if value is invalid + } + + video_t::dd_t::resolution_option_e + resolution_option_from_view(const std::string_view value) { +#define _CONVERT_2_ARG_(str, val) \ + if (value == #str##sv) return video_t::dd_t::resolution_option_e::val +#define _CONVERT_(x) _CONVERT_2_ARG_(x, x) + _CONVERT_(disabled); + _CONVERT_2_ARG_(auto, automatic); + _CONVERT_(manual); +#undef _CONVERT_ +#undef _CONVERT_2_ARG_ + return video_t::dd_t::resolution_option_e::disabled; // Default to this if value is invalid + } + + video_t::dd_t::refresh_rate_option_e + refresh_rate_option_from_view(const std::string_view value) { +#define _CONVERT_2_ARG_(str, val) \ + if (value == #str##sv) return video_t::dd_t::refresh_rate_option_e::val +#define _CONVERT_(x) _CONVERT_2_ARG_(x, x) + _CONVERT_(disabled); + _CONVERT_2_ARG_(auto, automatic); + _CONVERT_(manual); +#undef _CONVERT_ +#undef _CONVERT_2_ARG_ + return video_t::dd_t::refresh_rate_option_e::disabled; // Default to this if value is invalid + } + + video_t::dd_t::hdr_option_e + hdr_option_from_view(const std::string_view value) { +#define _CONVERT_2_ARG_(str, val) \ + if (value == #str##sv) return video_t::dd_t::hdr_option_e::val +#define _CONVERT_(x) _CONVERT_2_ARG_(x, x) + _CONVERT_(disabled); + _CONVERT_2_ARG_(auto, automatic); +#undef _CONVERT_ +#undef _CONVERT_2_ARG_ + return video_t::dd_t::hdr_option_e::disabled; // Default to this if value is invalid + } + } // namespace dd + video_t video { 28, // qp 0, // hevc_mode 0, // av1_mode - 1, // min_fps_factor 2, // min_threads { "superfast"s, // preset @@ -385,6 +437,19 @@ namespace config { {}, // encoder {}, // adapter_name {}, // output_name + + { + video_t::dd_t::config_option_e::verify_only, // configuration_option + video_t::dd_t::resolution_option_e::automatic, // resolution_option + {}, // manual_resolution + video_t::dd_t::refresh_rate_option_e::automatic, // refresh_rate_option + {}, // manual_refresh_rate + video_t::dd_t::hdr_option_e::automatic, // hdr_option + 3s, // config_revert_delay + {} // wa + }, // display_device + + 1 // min_fps_factor }; audio_t audio { @@ -952,9 +1017,9 @@ namespace config { } int_f(vars, "qp", video.qp); - int_f(vars, "min_threads", video.min_threads); int_between_f(vars, "hevc_mode", video.hevc_mode, { 0, 3 }); int_between_f(vars, "av1_mode", video.av1_mode, { 0, 3 }); + int_f(vars, "min_threads", video.min_threads); string_f(vars, "sw_preset", video.sw.sw_preset); if (!video.sw.sw_preset.empty()) { video.sw.svtav1_preset = sw::svtav1_preset_from_view(video.sw.sw_preset); @@ -1024,6 +1089,22 @@ namespace config { string_f(vars, "encoder", video.encoder); string_f(vars, "adapter_name", video.adapter_name); string_f(vars, "output_name", video.output_name); + + generic_f(vars, "dd_configuration_option", video.dd.configuration_option, dd::config_option_from_view); + generic_f(vars, "dd_resolution_option", video.dd.resolution_option, dd::resolution_option_from_view); + string_f(vars, "dd_manual_resolution", video.dd.manual_resolution); + generic_f(vars, "dd_refresh_rate_option", video.dd.refresh_rate_option, dd::refresh_rate_option_from_view); + string_f(vars, "dd_manual_refresh_rate", video.dd.manual_refresh_rate); + generic_f(vars, "dd_hdr_option", video.dd.hdr_option, dd::hdr_option_from_view); + { + int value = -1; + int_between_f(vars, "dd_config_revert_delay", value, { 0, std::numeric_limits::max() }); + if (value >= 0) { + video.dd.config_revert_delay = std::chrono::milliseconds { value }; + } + } + bool_f(vars, "dd_wa_hdr_toggle", video.dd.wa.hdr_toggle); + int_between_f(vars, "min_fps_factor", video.min_fps_factor, { 1, 3 }); path_f(vars, "pkey", nvhttp.pkey); diff --git a/src/config.h b/src/config.h index 891a4079772..e481a1e74d1 100644 --- a/src/config.h +++ b/src/config.h @@ -21,7 +21,6 @@ namespace config { int hevc_mode; int av1_mode; - int min_fps_factor; // Minimum fps target, determines minimum frame time int min_threads; // Minimum number of threads/slices for CPU encoding struct { std::string sw_preset; @@ -79,6 +78,48 @@ namespace config { std::string encoder; std::string adapter_name; std::string output_name; + + struct dd_t { + struct workarounds_t { + bool hdr_toggle; ///< Specify whether to apply HDR high-contrast color workaround. + }; + + enum class config_option_e { + disabled, ///< Disable the configuration for the device. + verify_only, ///< @seealso{display_device::SingleDisplayConfiguration::DevicePreparation} + ensure_active, ///< @seealso{display_device::SingleDisplayConfiguration::DevicePreparation} + ensure_primary, ///< @seealso{display_device::SingleDisplayConfiguration::DevicePreparation} + ensure_only_display ///< @seealso{display_device::SingleDisplayConfiguration::DevicePreparation} + }; + + enum class resolution_option_e { + disabled, ///< Do not change resolution. + automatic, ///< Change resolution and use the one received from Moonlight. + manual ///< Change resolution and use the manually provided one. + }; + + enum class refresh_rate_option_e { + disabled, ///< Do not change refresh rate. + automatic, ///< Change refresh rate and use the one received from Moonlight. + manual ///< Change refresh rate and use the manually provided one. + }; + + enum class hdr_option_e { + disabled, ///< Do not change HDR settings. + automatic ///< Change HDR settings and use the state requested by Moonlight. + }; + + config_option_e configuration_option; + resolution_option_e resolution_option; + std::string manual_resolution; ///< Manual resolution in case `resolution_option == resolution_option_e::manual`. + refresh_rate_option_e refresh_rate_option; + std::string manual_refresh_rate; ///< Manual refresh rate in case `refresh_rate_option == refresh_rate_option_e::manual`. + hdr_option_e hdr_option; + std::chrono::milliseconds config_revert_delay; ///< Time to wait until settings are reverted (after stream ends/app exists). + workarounds_t wa; + } dd; + + int min_fps_factor; // Minimum fps target, determines minimum frame time }; struct audio_t { diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 756a4688259..9b8570e0817 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -28,6 +28,7 @@ #include "config.h" #include "confighttp.h" #include "crypto.h" +#include "display_device.h" #include "file_handler.h" #include "globals.h" #include "httpcommon.h" @@ -734,6 +735,22 @@ namespace confighttp { * } * @endcode */ + void + resetDisplayDevicePersistence(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) return; + + print_req(request); + + pt::ptree outputTree; + auto g = util::fail_guard([&outputTree, &response]() { + std::ostringstream data; + pt::write_json(data, outputTree); + response->write(data.str()); + }); + + outputTree.put("status", display_device::reset_persistence()); + } + void savePassword(resp_https_t response, req_https_t request) { if (!config::sunshine.username.empty() && !authenticate(response, request)) return; @@ -976,6 +993,7 @@ namespace confighttp { server.resource["^/api/config$"]["POST"] = saveConfig; server.resource["^/api/configLocale$"]["GET"] = getLocale; server.resource["^/api/restart$"]["POST"] = restart; + server.resource["^/api/reset-display-device-persistence$"]["POST"] = resetDisplayDevicePersistence; server.resource["^/api/password$"]["POST"] = savePassword; server.resource["^/api/apps/([0-9]+)$"]["DELETE"] = deleteApp; server.resource["^/api/clients/unpair-all$"]["POST"] = unpairAll; diff --git a/src/display_device.cpp b/src/display_device.cpp index f273104bd34..a9a21b6ad1b 100644 --- a/src/display_device.cpp +++ b/src/display_device.cpp @@ -6,12 +6,19 @@ #include "display_device.h" // lib includes +#include +#include +#include #include #include #include +#include +#include // local includes +#include "audio.h" #include "platform/common.h" +#include "rtsp.h" // platform-specific includes #ifdef _WIN32 @@ -22,52 +29,508 @@ namespace display_device { namespace { + constexpr std::chrono::milliseconds DEFAULT_RETRY_INTERVAL { 5000 }; + + /** + * @brief A global for the settings manager interface and other settings whose lifetime is managed by `display_device::init(...)`. + */ + struct { + std::mutex mutex {}; + std::chrono::milliseconds config_revert_delay { 0 }; + std::unique_ptr> sm_instance { nullptr }; + } DD_DATA; + + /** + * @brief Helper class for capturing audio context when the API demands it. + * + * The capture is needed to be done in case some of the displays are going + * to be deactivated before the stream starts. In this case the audio context + * will be captured for this display and can be restored once it is turned back. + */ + class sunshine_audio_context_t: public AudioContextInterface { + public: + [[nodiscard]] bool + capture() override { + return context_scheduler.execute([](auto &audio_context) { + // Explicitly releasing the context first in case it was not release yet so that it can be potentially cleaned up. + audio_context = boost::none; + audio_context = audio_context_t {}; + + // Always say that we have captured it successfully as otherwise the settings change procedure will be aborted. + return true; + }); + } + + [[nodiscard]] bool + isCaptured() const override { + return context_scheduler.execute([](const auto &audio_context) { + if (audio_context) { + // In case we still have context we need to check whether it was released or not. + // If it was released we can pretend that we no longer have it as it will be immediately cleaned up in `capture` method before we acquire new context. + return !audio_context->released; + } + + return false; + }); + } + + void + release() override { + context_scheduler.schedule([](auto &audio_context, auto &stop_token) { + if (audio_context) { + audio_context->released = true; + + const auto *audio_ctx_ptr = audio_context->audio_ctx_ref.get(); + if (audio_ctx_ptr && !audio::is_audio_ctx_sink_available(*audio_ctx_ptr) && audio_context->retry_counter > 0) { + // It is possible that the audio sink is not immediately available after the display is turned on. + // Therefore, we will hold on to the audio context a little longer, until it is either available + // or we time out. + --audio_context->retry_counter; + return; + } + } + + audio_context = boost::none; + stop_token.requestStop(); + }, + SchedulerOptions { .m_sleep_durations = { 2s } }); + } + + private: + struct audio_context_t { + /** + * @brief A reference to the audio context that will automatically extend the audio session. + * @note It is auto-initialized here for convenience. + */ + decltype(audio::get_audio_ctx_ref()) audio_ctx_ref { audio::get_audio_ctx_ref() }; + + /** + * @brief Will be set to true if the capture was released, but we still have to keep the context around, because the device is not available. + */ + bool released { false }; + + /** + * @brief How many times to check if the audio sink is available before giving up. + */ + int retry_counter { 15 }; + }; + + RetryScheduler> context_scheduler { std::make_unique>(boost::none) }; + }; + + /** + * @breif Convert string to unsigned int. + * @note For random reason there is std::stoi, but not std::stou... + * @param value String to be converted + * @return Parsed unsigned integer. + */ + unsigned int + stou(const std::string &value) { + unsigned long result { std::stoul(value) }; + if (result > std::numeric_limits::max()) { + throw std::out_of_range("stou"); + } + return result; + } + + /** + * @brief Parse resolution value from the string. + * @param input String to be parsed. + * @param output Reference to output variable to fill in. + * @returns True on successful parsing (empty string allowed), false otherwise. + * + * @examples + * std::optional resolution; + * if (parse_resolution_string("1920x1080", resolution)) { + * if (resolution) { + * BOOST_LOG(info) << "Value was specified"; + * } + * else { + * BOOST_LOG(info) << "Value was empty"; + * } + * } + * @examples_end + */ + bool + parse_resolution_string(const std::string &input, std::optional &output) { + const std::string trimmed_input { boost::algorithm::trim_copy(input) }; + const std::regex resolution_regex { R"(^(\d+)x(\d+)$)" }; + + if (std::smatch match; std::regex_match(trimmed_input, match, resolution_regex)) { + try { + output = Resolution { + stou(match[1].str()), + stou(match[2].str()) + }; + return true; + } + catch (const std::out_of_range &) { + BOOST_LOG(error) << "Failed to parse resolution string " << trimmed_input << " (number out of range)."; + } + catch (const std::exception &err) { + BOOST_LOG(error) << "Failed to parse resolution string " << trimmed_input << ":\n" + << err.what(); + } + } + else { + if (trimmed_input.empty()) { + output = std::nullopt; + return true; + } + + BOOST_LOG(error) << "Failed to parse resolution string " << trimmed_input << R"(. It must match a "1920x1080" pattern!)"; + } + + return false; + } + + /** + * @brief Parse refresh rate value from the string. + * @param input String to be parsed. + * @param output Reference to output variable to fill in. + * @returns True on successful parsing (empty string allowed), false otherwise. + * + * @examples + * std::optional refresh_rate; + * if (parse_refresh_rate_string("59.95", refresh_rate)) { + * if (refresh_rate) { + * BOOST_LOG(info) << "Value was specified"; + * } + * else { + * BOOST_LOG(info) << "Value was empty"; + * } + * } + * @examples_end + */ + bool + parse_refresh_rate_string(const std::string &input, std::optional &output) { + static const auto is_zero { [](const auto &character) { return character == '0'; } }; + const std::string trimmed_input { boost::algorithm::trim_copy(input) }; + const std::regex refresh_rate_regex { R"(^(\d+)(?:\.(\d+))?$)" }; + + if (std::smatch match; std::regex_match(trimmed_input, match, refresh_rate_regex)) { + try { + // Here we are trimming zeros from the string to possibly reduce out of bounds case + std::string trimmed_match_1 { boost::algorithm::trim_left_copy_if(match[1].str(), is_zero) }; + if (trimmed_match_1.empty()) { + trimmed_match_1 = "0"s; // Just in case ALL the string is full of zeros, we want to leave one + } + + std::string trimmed_match_2; + if (match[2].matched) { + trimmed_match_2 = boost::algorithm::trim_right_copy_if(match[2].str(), is_zero); + } + + if (!trimmed_match_2.empty()) { + // We have a decimal point and will have to split it into numerator and denominator. + // For example: + // 59.995: + // numerator = 59995 + // denominator = 1000 + + // We are essentially removing the decimal point here: 59.995 -> 59995 + const std::string numerator_str { trimmed_match_1 + trimmed_match_2 }; + const auto numerator { stou(numerator_str) }; + + // Here we are counting decimal places and calculating denominator: 10^decimal_places + const auto denominator { static_cast(std::pow(10, trimmed_match_2.size())) }; + + output = Rational { numerator, denominator }; + } + else { + // We do not have a decimal point, just a valid number. + // For example: + // 60: + // numerator = 60 + // denominator = 1 + output = Rational { stou(trimmed_match_1), 1 }; + } + return true; + } + catch (const std::out_of_range &) { + BOOST_LOG(error) << "Failed to parse refresh rate string " << trimmed_input << " (number out of range)."; + } + catch (const std::exception &err) { + BOOST_LOG(error) << "Failed to parse refresh rate string " << trimmed_input << ":\n" + << err.what(); + } + } + else { + if (trimmed_input.empty()) { + output = std::nullopt; + return true; + } + + BOOST_LOG(error) << "Failed to parse refresh rate string " << trimmed_input << R"(. Must have a pattern of "123" or "123.456"!)"; + } + + return false; + } + + /** + * @brief Parse device preparation option from the user configuration and the session information. + * @param video_config User's video related configuration. + * @returns Parsed device preparation value we need to use. + * Empty optional if no preparation nor configuration shall take place. + * + * @examples + * const config::video_t &video_config { config::video }; + * const auto device_prep_option = parse_device_prep_option(video_config); + * @examples_end + */ + std::optional + parse_device_prep_option(const config::video_t &video_config) { + using enum config::video_t::dd_t::config_option_e; + using enum SingleDisplayConfiguration::DevicePreparation; + + switch (video_config.dd.configuration_option) { + case verify_only: + return VerifyOnly; + case ensure_active: + return EnsureActive; + case ensure_primary: + return EnsurePrimary; + case ensure_only_display: + return EnsureOnlyDisplay; + case disabled: + break; + } + + return std::nullopt; + } + + /** + * @brief Parse resolution option from the user configuration and the session information. + * @param video_config User's video related configuration. + * @param session Session information. + * @param config A reference to a display config object that will be modified on success. + * @returns True on successful parsing, false otherwise. + * + * @examples + * const std::shared_ptr launch_session; + * const config::video_t &video_config { config::video }; + * + * SingleDisplayConfiguration config; + * const bool success = parse_resolution_option(video_config, *launch_session, config); + * @examples_end + */ + bool + parse_resolution_option(const config::video_t &video_config, const rtsp_stream::launch_session_t &session, SingleDisplayConfiguration &config) { + using resolution_option_e = config::video_t::dd_t::resolution_option_e; + + switch (video_config.dd.resolution_option) { + case resolution_option_e::automatic: { + if (!session.enable_sops) { + BOOST_LOG(warning) << R"(Sunshine is configured to change resolution automatically, but the "Optimize game settings" is not set in the client! Resolution will not be changed.)"; + } + else if (session.width >= 0 && session.height >= 0) { + config.m_resolution = Resolution { + static_cast(session.width), + static_cast(session.height) + }; + } + else { + BOOST_LOG(error) << "Resolution provided by client session config is invalid: " << session.width << "x" << session.height; + return false; + } + break; + } + case resolution_option_e::manual: { + if (!session.enable_sops) { + BOOST_LOG(warning) << R"(Sunshine is configured to change resolution manually, but the "Optimize game settings" is not set in the client! Resolution will not be changed.)"; + } + else { + if (!parse_resolution_string(video_config.dd.manual_resolution, config.m_resolution)) { + BOOST_LOG(error) << "Failed to parse manual resolution string!"; + return false; + } + + if (!config.m_resolution) { + BOOST_LOG(error) << "Manual resolution must be specified!"; + return false; + } + } + break; + } + case resolution_option_e::disabled: + break; + } + + return true; + } + /** - * @brief A global for the settings manager interface whose lifetime is managed by `display_device::init()`. + * @brief Parse refresh rate option from the user configuration and the session information. + * @param video_config User's video related configuration. + * @param session Session information. + * @param config A reference to a config object that will be modified on success. + * @returns True on successful parsing, false otherwise. + * + * @examples + * const std::shared_ptr launch_session; + * const config::video_t &video_config { config::video }; + * + * SingleDisplayConfiguration config; + * const bool success = parse_refresh_rate_option(video_config, *launch_session, config); + * @examples_end */ - std::unique_ptr> SM_INSTANCE; + bool + parse_refresh_rate_option(const config::video_t &video_config, const rtsp_stream::launch_session_t &session, SingleDisplayConfiguration &config) { + using refresh_rate_option_e = config::video_t::dd_t::refresh_rate_option_e; + + switch (video_config.dd.refresh_rate_option) { + case refresh_rate_option_e::automatic: { + if (session.fps >= 0) { + config.m_refresh_rate = Rational { static_cast(session.fps), 1 }; + } + else { + BOOST_LOG(error) << "FPS value provided by client session config is invalid: " << session.fps; + return false; + } + break; + } + case refresh_rate_option_e::manual: { + if (!parse_refresh_rate_string(video_config.dd.manual_refresh_rate, config.m_refresh_rate)) { + BOOST_LOG(error) << "Failed to parse manual refresh rate string!"; + return false; + } + + if (!config.m_refresh_rate) { + BOOST_LOG(error) << "Manual refresh rate must be specified!"; + return false; + } + break; + } + case refresh_rate_option_e::disabled: + break; + } + + return true; + } + + /** + * @brief Parse HDR option from the user configuration and the session information. + * @param video_config User's video related configuration. + * @param session Session information. + * @returns Parsed HDR state value we need to switch to. + * Empty optional if no action is required. + * + * @examples + * const std::shared_ptr launch_session; + * const config::video_t &video_config { config::video }; + * const auto hdr_option = parse_hdr_option(video_config, *launch_session); + * @examples_end + */ + std::optional + parse_hdr_option(const config::video_t &video_config, const rtsp_stream::launch_session_t &session) { + using hdr_option_e = config::video_t::dd_t::hdr_option_e; + + switch (video_config.dd.hdr_option) { + case hdr_option_e::automatic: + return session.enable_hdr ? HdrState::Enabled : HdrState::Disabled; + case hdr_option_e::disabled: + break; + } + + return std::nullopt; + } /** * @brief Construct a settings manager interface to manage display device settings. + * @param persistence_filepath File location for saving persistent state. + * @param video_config User's video related configuration. * @return An interface or nullptr if the OS does not support the interface. */ std::unique_ptr - make_settings_manager() { + make_settings_manager([[maybe_unused]] const std::filesystem::path &persistence_filepath, [[maybe_unused]] const config::video_t &video_config) { #ifdef _WIN32 - // TODO: In the upcoming PR, add audio context capture and settings persistence return std::make_unique( std::make_shared(std::make_shared()), - nullptr, - std::make_unique(nullptr), - WinWorkarounds {}); + std::make_shared(), + std::make_unique( + std::make_shared(persistence_filepath)), + WinWorkarounds { + .m_hdr_blank_delay = video_config.dd.wa.hdr_toggle ? std::make_optional(500ms) : std::nullopt }); #else return nullptr; #endif } + + /** + * @brief Defines the "revert config" algorithms. + */ + enum class revert_option_e { + try_once, ///< Try reverting once and then abort. + try_indefinitely, ///< Keep trying to revert indefinitely. + try_indefinitely_with_delay ///< Keep trying to revert indefinitely, but delay the first try by some amount of time. + }; + + /** + * @brief Reverts the configuration based on the provided option. + * @note This is function does not lock mutex. + */ + void + revert_configuration_unlocked(const revert_option_e option) { + if (!DD_DATA.sm_instance) { + // Platform is not supported, nothing to do. + return; + } + + // Note: by default the executor function is immediately executed in the calling thread. With delay, we want to avoid that. + SchedulerOptions scheduler_option { .m_sleep_durations = { DEFAULT_RETRY_INTERVAL } }; + if (option == revert_option_e::try_indefinitely_with_delay && DD_DATA.config_revert_delay > std::chrono::milliseconds::zero()) { + scheduler_option.m_sleep_durations = { DD_DATA.config_revert_delay, DEFAULT_RETRY_INTERVAL }; + scheduler_option.m_execution = SchedulerOptions::Execution::ScheduledOnly; + } + + DD_DATA.sm_instance->schedule([try_once = (option == revert_option_e::try_once)](auto &settings_iface, auto &stop_token) { + // Here we want to keep retrying indefinitely until we succeed. + if (settings_iface.revertSettings() || try_once) { + stop_token.requestStop(); + } + }, + scheduler_option); + } } // namespace std::unique_ptr - init() { - // We can support re-init without any issues, however we should make sure to cleanup first! - SM_INSTANCE = nullptr; + init(const std::filesystem::path &persistence_filepath, const config::video_t &video_config) { + std::lock_guard lock { DD_DATA.mutex }; + // We can support re-init without any issues, however we should make sure to clean up first! + revert_configuration_unlocked(revert_option_e::try_once); + DD_DATA.config_revert_delay = video_config.dd.config_revert_delay; + DD_DATA.sm_instance = nullptr; - // If we fail to create settings manager, this means platform is not supported and - // we will need to provided error-free passtrough in other methods - if (auto settings_manager { make_settings_manager() }) { - SM_INSTANCE = std::make_unique>(std::move(settings_manager)); + // If we fail to create settings manager, this means platform is not supported, and + // we will need to provided error-free pass-trough in other methods + if (auto settings_manager { make_settings_manager(persistence_filepath, video_config) }) { + DD_DATA.sm_instance = std::make_unique>(std::move(settings_manager)); - const auto available_devices { SM_INSTANCE->execute([](auto &settings_iface) { return settings_iface.enumAvailableDevices(); }) }; + const auto available_devices { DD_DATA.sm_instance->execute([](auto &settings_iface) { return settings_iface.enumAvailableDevices(); }) }; BOOST_LOG(info) << "Currently available display devices:\n" << toJson(available_devices); - // TODO: In the upcoming PR, schedule recovery here + // In case we have failed to revert configuration before shutting down, we should + // do it now. + revert_configuration_unlocked(revert_option_e::try_indefinitely); } class deinit_t: public platf::deinit_t { public: ~deinit_t() override { - // TODO: In the upcoming PR, execute recovery once here - SM_INSTANCE = nullptr; + std::lock_guard lock { DD_DATA.mutex }; + try { + // This may throw if used incorrectly. At the moment this will not happen, however + // in case some unforeseen changes are made that could raise an exception, + // we definitely don't want this to happen in destructor. Especially in the + // deinit_t where the outcome does not really matter. + revert_configuration_unlocked(revert_option_e::try_once); + } + catch (std::exception &err) { + BOOST_LOG(fatal) << err.what(); + } + + DD_DATA.sm_instance = nullptr; } }; return std::make_unique(); @@ -75,11 +538,94 @@ namespace display_device { std::string map_output_name(const std::string &output_name) { - if (!SM_INSTANCE) { + std::lock_guard lock { DD_DATA.mutex }; + if (!DD_DATA.sm_instance) { // Fallback to giving back the output name if the platform is not supported. return output_name; } - return SM_INSTANCE->execute([&output_name](auto &settings_iface) { return settings_iface.getDisplayName(output_name); }); + return DD_DATA.sm_instance->execute([&output_name](auto &settings_iface) { return settings_iface.getDisplayName(output_name); }); + } + + void + configure_display(const config::video_t &video_config, const rtsp_stream::launch_session_t &session) { + const auto result { parse_configuration(video_config, session) }; + if (const auto *parsed_config { std::get_if(&result) }; parsed_config) { + configure_display(*parsed_config); + return; + } + + if (const auto *disabled { std::get_if(&result) }; disabled) { + revert_configuration(); + return; + } + + // Error already logged for failed_to_parse_tag_t case, and we also don't + // want to revert active configuration in case we have any + } + + void + configure_display(const SingleDisplayConfiguration &config) { + std::lock_guard lock { DD_DATA.mutex }; + if (!DD_DATA.sm_instance) { + // Platform is not supported, nothing to do. + return; + } + + DD_DATA.sm_instance->schedule([config](auto &settings_iface, auto &stop_token) { + // We only want to keep retrying in case of a transient errors. + // In other cases, when we either fail or succeed we just want to stop... + if (settings_iface.applySettings(config) != SettingsManagerInterface::ApplyResult::ApiTemporarilyUnavailable) { + stop_token.requestStop(); + } + }, + { .m_sleep_durations = { DEFAULT_RETRY_INTERVAL } }); + } + + void + revert_configuration() { + std::lock_guard lock { DD_DATA.mutex }; + revert_configuration_unlocked(revert_option_e::try_indefinitely_with_delay); + } + + bool + reset_persistence() { + std::lock_guard lock { DD_DATA.mutex }; + if (!DD_DATA.sm_instance) { + // Platform is not supported, assume success. + return true; + } + + return DD_DATA.sm_instance->execute([](auto &settings_iface, auto &stop_token) { + // Whatever the outcome is we want to stop interfering with the user, + // so any schedulers need to be stopped. + stop_token.requestStop(); + return settings_iface.resetPersistence(); + }); + } + + std::variant + parse_configuration(const config::video_t &video_config, const rtsp_stream::launch_session_t &session) { + const auto device_prep { parse_device_prep_option(video_config) }; + if (!device_prep) { + return configuration_disabled_tag_t {}; + } + + SingleDisplayConfiguration config; + config.m_device_id = video_config.output_name; + config.m_device_prep = *device_prep; + config.m_hdr_state = parse_hdr_option(video_config, session); + + if (!parse_resolution_option(video_config, session, config)) { + // Error already logged + return failed_to_parse_tag_t {}; + } + + if (!parse_refresh_rate_option(video_config, session, config)) { + // Error already logged + return failed_to_parse_tag_t {}; + } + + return config; } } // namespace display_device diff --git a/src/display_device.h b/src/display_device.h index 6562f5a3dcc..e17c408fedb 100644 --- a/src/display_device.h +++ b/src/display_device.h @@ -5,24 +5,35 @@ #pragma once // lib includes +#include +#include #include // forward declarations namespace platf { class deinit_t; -} // namespace platf +} +namespace config { + struct video_t; +} +namespace rtsp_stream { + struct launch_session_t; +} namespace display_device { /** * @brief Initialize the implementation and perform the initial state recovery (if needed). + * @param persistence_filepath File location for reading/saving persistent state. + * @param video_config User's video related configuration. * @returns A deinit_t instance that performs cleanup when destroyed. * * @examples - * const auto init_guard { display_device::init() }; + * const config::video_t &video_config { config::video }; + * const auto init_guard { init("/my/persitence/file.state", video_config) }; * @examples_end */ - std::unique_ptr - init(); + [[nodiscard]] std::unique_ptr + init(const std::filesystem::path &persistence_filepath, const config::video_t &video_config); /** * @brief Map the output name to a specific display. @@ -34,6 +45,111 @@ namespace display_device { * const auto mapped_name_custom { map_output_name("{some-device-id}") }; * @examples_end */ - std::string + [[nodiscard]] std::string map_output_name(const std::string &output_name); + + /** + * @brief Configure the display device based on the user configuration and the session information. + * @note This is a convenience method for calling similar method of a different signature. + * + * @param video_config User's video related configuration. + * @param session Session information. + * + * @examples + * const std::shared_ptr launch_session; + * const config::video_t &video_config { config::video }; + * + * configure_display(video_config, *launch_session); + * @examples_end + */ + void + configure_display(const config::video_t &video_config, const rtsp_stream::launch_session_t &session); + + /** + * @brief Configure the display device using the provided configuration. + * + * In some cases configuring display can fail due to transient issues and + * we will keep trying every 5 seconds, even if the stream has already started as there was + * no possibility to apply settings before the stream start. + * + * Therefore, there is no return value as we still want to continue with the stream, so that + * the users can do something about it once they are connected. Otherwise, we might + * prevent users from logging in at all if we keep failing to apply configuration. + * + * @param config Configuration for the display. + * + * @examples + * const SingleDisplayConfiguration valid_config { }; + * configure_display(valid_config); + * @examples_end + */ + void + configure_display(const SingleDisplayConfiguration &config); + + /** + * @brief Revert the display configuration and restore the previous state. + * + * In case the state could not be restored, by default it will be retried again in 5 seconds + * (repeating indefinitely until success or until persistence is reset). + * + * @examples + * revert_configuration(); + * @examples_end + */ + void + revert_configuration(); + + /** + * @brief Reset the persistence and currently held initial display state. + * + * This is normally used to get out of the "broken" state where the algorithm wants + * to restore the initial display state, but it is no longer possible. + * + * This could happen if the display is no longer available or the hardware was changed + * and the device ids no longer match. + * + * The user then accepts that Sunshine is not able to restore the state and "agrees" to + * do it manually. + * + * @return + * @note Whether the function succeeds or fails, the any of the scheduled "retries" from + * other methods will be stopped to not interfere with the user actions. + * + * @examples + * const auto result = reset_persistence(); + * @examples_end + */ + [[nodiscard]] bool + reset_persistence(); + + /** + * @brief A tag structure indicating that configuration parsing has failed. + */ + struct failed_to_parse_tag_t {}; + + /** + * @brief A tag structure indicating that configuration is disabled. + */ + struct configuration_disabled_tag_t {}; + + /** + * @brief Parse the user configuration and the session information. + * @param video_config User's video related configuration. + * @param session Session information. + * @return Parsed single display configuration or + * a tag indicating that the parsing has failed or + * a tag indicating that the user does not want to perform any configuration. + * + * @examples + * const std::shared_ptr launch_session; + * const config::video_t &video_config { config::video }; + * + * const auto config { parse_configuration(video_config, *launch_session) }; + * if (const auto *parsed_config { std::get_if(&result) }; parsed_config) { + * configure_display(*config); + * } + * @examples_end + */ + [[nodiscard]] std::variant + parse_configuration(const config::video_t &video_config, const rtsp_stream::launch_session_t &session); } // namespace display_device diff --git a/src/main.cpp b/src/main.cpp index b9ffc049128..04ab7d13231 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -137,7 +137,7 @@ main(int argc, char *argv[]) { // Adding guard here first as it also performs recovery after crash, // otherwise people could theoretically end up without display output. // It also should be destroyed before forced shutdown to expedite the cleanup. - auto display_device_deinit_guard = display_device::init(); + auto display_device_deinit_guard = display_device::init(platf::appdata() / "display_device.state", config::video); if (!display_device_deinit_guard) { BOOST_LOG(error) << "Display device session failed to initialize"sv; } diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index 2441aed92d4..e510d0837e6 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -22,6 +22,7 @@ // local includes #include "config.h" #include "crypto.h" +#include "display_device.h" #include "file_handler.h" #include "globals.h" #include "httpcommon.h" @@ -812,12 +813,17 @@ namespace nvhttp { print_req(request); pt::ptree tree; + bool revert_display_configuration { false }; auto g = util::fail_guard([&]() { std::ostringstream data; pt::write_xml(data, tree); response->write(data.str()); response->close_connection_after_response = true; + + if (revert_display_configuration) { + display_device::revert_configuration(); + } }); auto args = request->parse_query_string(); @@ -844,11 +850,22 @@ namespace nvhttp { return; } - // Probe encoders again before streaming to ensure our chosen - // encoder matches the active GPU (which could have changed - // due to hotplugging, driver crash, primary monitor change, - // or any number of other factors). + host_audio = util::from_view(get_arg(args, "localAudioPlayMode")); + auto launch_session = make_launch_session(host_audio, args); + if (rtsp_stream::session_count() == 0) { + // We want to prepare display only if there are no active sessions at + // the moment. This should be done before probing encoders as it could + // change the active displays. + display_device::configure_display(config::video, *launch_session); + + // The display should be restored in case something fails as there are no other sessions. + revert_display_configuration = true; + + // Probe encoders again before streaming to ensure our chosen + // encoder matches the active GPU (which could have changed + // due to hotplugging, driver crash, primary monitor change, + // or any number of other factors). if (video::probe_encoders()) { tree.put("root..status_code", 503); tree.put("root..status_message", "Failed to initialize video capture/encoding. Is a display connected and turned on?"); @@ -858,9 +875,6 @@ namespace nvhttp { } } - host_audio = util::from_view(get_arg(args, "localAudioPlayMode")); - auto launch_session = make_launch_session(host_audio, args); - auto encryption_mode = net::encryption_mode_for_address(request->remote_endpoint().address()); if (!launch_session->rtsp_cipher && encryption_mode == config::ENCRYPTION_MODE_MANDATORY) { BOOST_LOG(error) << "Rejecting client that cannot comply with mandatory encryption requirement"sv; @@ -890,6 +904,9 @@ namespace nvhttp { tree.put("root.gamesession", 1); rtsp_stream::launch_session_raise(launch_session); + + // Stream was started successfully, we will revert the config when the app or session terminates + revert_display_configuration = false; } void @@ -925,7 +942,21 @@ namespace nvhttp { return; } - if (rtsp_stream::session_count() == 0) { + // Newer Moonlight clients send localAudioPlayMode on /resume too, + // so we should use it if it's present in the args and there are + // no active sessions we could be interfering with. + const bool no_active_sessions { rtsp_stream::session_count() == 0 }; + if (no_active_sessions && args.find("localAudioPlayMode"s) != std::end(args)) { + host_audio = util::from_view(get_arg(args, "localAudioPlayMode")); + } + const auto launch_session = make_launch_session(host_audio, args); + + if (no_active_sessions) { + // We want to prepare display only if there are no active sessions at + // the moment. This should be done before probing encoders as it could + // change the active displays. + display_device::configure_display(config::video, *launch_session); + // Probe encoders again before streaming to ensure our chosen // encoder matches the active GPU (which could have changed // due to hotplugging, driver crash, primary monitor change, @@ -937,17 +968,8 @@ namespace nvhttp { return; } - - // Newer Moonlight clients send localAudioPlayMode on /resume too, - // so we should use it if it's present in the args and there are - // no active sessions we could be interfering with. - if (args.find("localAudioPlayMode"s) != std::end(args)) { - host_audio = util::from_view(get_arg(args, "localAudioPlayMode")); - } } - auto launch_session = make_launch_session(host_audio, args); - auto encryption_mode = net::encryption_mode_for_address(request->remote_endpoint().address()); if (!launch_session->rtsp_cipher && encryption_mode == config::ENCRYPTION_MODE_MANDATORY) { BOOST_LOG(error) << "Rejecting client that cannot comply with mandatory encryption requirement"sv; @@ -989,6 +1011,9 @@ namespace nvhttp { if (proc::proc.running() > 0) { proc::proc.terminate(); } + + // The config needs to be reverted regardless of whether "proc::proc.terminate()" was called or not. + display_device::revert_configuration(); } void diff --git a/src/platform/common.h b/src/platform/common.h index 4b2ca66a06b..abcbefc82d8 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -550,6 +550,14 @@ namespace platf { virtual std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size) = 0; + /** + * @brief Check if the audio sink is available in the system. + * @param sink Sink to be checked. + * @returns True if available, false otherwise. + */ + virtual bool + is_sink_available(const std::string &sink) = 0; + virtual std::optional sink_info() = 0; diff --git a/src/platform/linux/audio.cpp b/src/platform/linux/audio.cpp index ff231707e61..a48ee2f028d 100644 --- a/src/platform/linux/audio.cpp +++ b/src/platform/linux/audio.cpp @@ -473,6 +473,12 @@ namespace platf { return ::platf::microphone(mapping, channels, sample_rate, frame_size, get_monitor_name(sink_name)); } + bool + is_sink_available(const std::string &sink) override { + BOOST_LOG(warning) << "audio_control_t::is_sink_available() unimplemented: "sv << sink; + return true; + } + int set_sink(const std::string &sink) override { auto alarm = safe::make_alarm(); diff --git a/src/platform/macos/microphone.mm b/src/platform/macos/microphone.mm index 1e3a4cd65ed..8d2129f28b3 100644 --- a/src/platform/macos/microphone.mm +++ b/src/platform/macos/microphone.mm @@ -81,6 +81,12 @@ return mic; } + bool + is_sink_available(const std::string &sink) override { + BOOST_LOG(warning) << "audio_control_t::is_sink_available() unimplemented: "sv << sink; + return true; + } + std::optional sink_info() override { sink_t sink; diff --git a/src/platform/windows/audio.cpp b/src/platform/windows/audio.cpp index 3335eeb0b0f..3c401976afc 100644 --- a/src/platform/windows/audio.cpp +++ b/src/platform/windows/audio.cpp @@ -722,6 +722,13 @@ namespace platf::audio { return sink; } + bool + is_sink_available(const std::string &sink) override { + const auto match_list = match_all_fields(from_utf8(sink)); + const auto matched = find_device_id(match_list); + return static_cast(matched); + } + /** * @brief Extract virtual audio sink information possibly encoded in the sink name. * @param sink The sink name diff --git a/src/process.cpp b/src/process.cpp index 1c78ff4d9c8..3ee9d6b9c6f 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -23,6 +23,7 @@ #include "config.h" #include "crypto.h" +#include "display_device.h" #include "logging.h" #include "platform/common.h" #include "system_tray.h" @@ -341,16 +342,19 @@ namespace proc { } _pipe.reset(); -#if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 + bool has_run = _app_id > 0; // Only show the Stopped notification if we actually have an app to stop // Since terminate() is always run when a new app has started if (proc::proc.get_last_run_app_name().length() > 0 && has_run) { +#if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 system_tray::update_tray_stopped(proc::proc.get_last_run_app_name()); - } #endif + display_device::revert_configuration(); + } + _app_id = -1; } diff --git a/src/stream.cpp b/src/stream.cpp index 8fa09bec1a5..040a69e937f 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -20,6 +20,7 @@ extern "C" { } #include "config.h" +#include "display_device.h" #include "globals.h" #include "input.h" #include "logging.h" @@ -1948,11 +1949,15 @@ namespace stream { // If this is the last session, invoke the platform callbacks if (--running_sessions == 0) { -#if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 if (proc::proc.running()) { +#if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 system_tray::update_tray_pausing(proc::proc.get_last_run_app_name()); - } #endif + } + else { + display_device::revert_configuration(); + } + platf::streaming_will_stop(); } diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index a7681fdde02..300e1c36f64 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -170,6 +170,14 @@

{{ $t('config.configuration') }}

"install_steam_audio_drivers": "enabled", "adapter_name": "", "output_name": "", + "dd_configuration_option": "verify_only", + "dd_resolution_option": "auto", + "dd_manual_resolution": "", + "dd_refresh_rate_option": "auto", + "dd_manual_refresh_rate": "", + "dd_hdr_option": "auto", + "dd_config_revert_delay": 3000, + "dd_wa_hdr_toggle": "disabled", "min_fps_factor": 1, }, }, diff --git a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue index 430fe1fac9f..531d09729e9 100644 --- a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue +++ b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue @@ -74,6 +74,11 @@ const config = ref(props.config) :config="config" /> + + import { ref } from 'vue' -import { $tp } from '../../../platform-i18n' import PlatformLayout from '../../../PlatformLayout.vue' +import Checkbox from "../../../Checkbox.vue"; const props = defineProps({ platform: String, - config: Object, - display_mode_remapping: Array + config: Object }) const config = ref(props.config) -const display_mode_remapping = ref(props.display_mode_remapping) - -// TODO: Sample for use in PR #2032 -function getRemappingType() -{ - // Assuming here that at least one setting is set to "automatic" - if (config.value.resolution_change !== 'automatic') { - return "refresh_rate_only"; - } - if (config.value.refresh_rate_change !== 'automatic') { - return "resolution_only"; - } - return ""; -} - -function addRemapping(type) { - let template = { - type: type, - received_resolution: "", - received_fps: "", - final_resolution: "", - final_refresh_rate: "", - }; - - display_mode_remapping.value.push(template); -} diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index d3d9356f423..968bb517c56 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -152,6 +152,30 @@ "controller_desc": "Allows guests to control the host system with a gamepad / controller", "credentials_file": "Credentials File", "credentials_file_desc": "Store Username/Password separately from Sunshine's state file.", + "dd_config_ensure_active": "Activate the display automatically", + "dd_config_ensure_only_display": "Deactivate other displays and activate only the specified display", + "dd_config_ensure_primary": "Activate the display automatically and make it a primary display", + "dd_config_label": "Device configuration", + "dd_config_revert_delay": "Config revert delay", + "dd_config_revert_delay_desc": "Additional delay in milliseconds to wait before reverting configuration when the app has been closed or the last session terminated. Main purpose is to provide a smoother transition when quickly switching between apps.", + "dd_config_verify_only": "Verify that the display is enabled (default)", + "dd_hdr_option": "HDR", + "dd_hdr_option_auto": "Switch on/off the HDR mode as requested by the client (default)", + "dd_hdr_option_disabled": "Do not change HDR settings", + "dd_options_header": "Advanced display device options", + "dd_refresh_rate_option": "Refresh rate", + "dd_refresh_rate_option_auto": "Use FPS value provided by the client (default)", + "dd_refresh_rate_option_disabled": "Do not change refresh rate", + "dd_refresh_rate_option_manual": "Use manually entered refresh rate", + "dd_refresh_rate_option_manual_desc": "Enter the refresh rate to be used", + "dd_resolution_option": "Resolution", + "dd_resolution_option_auto": "Use resolution provided by the client (default)", + "dd_resolution_option_disabled": "Do not change resolution", + "dd_resolution_option_manual": "Use manually entered resolution", + "dd_resolution_option_manual_desc": "Enter the resolution to be used", + "dd_resolution_option_ogs_desc": "\"Optimize game settings\" option must be enabled on the Moonlight client for this to work.", + "dd_wa_hdr_toggle_desc": "When using virtual display device as for streaming, it might display incorrect HDR color. With this option enabled, Sunshine will try to mitigate this issue.", + "dd_wa_hdr_toggle": "Enable high-contrast workaround for HDR", "ds4_back_as_touchpad_click": "Map Back/Select to Touchpad Click", "ds4_back_as_touchpad_click_desc": "When forcing DS4 emulation, map Back/Select to Touchpad Click", "encoder": "Force a Specific Encoder", @@ -379,6 +403,10 @@ "third_party_notice": "Third Party Notice" }, "troubleshooting": { + "dd_reset": "Reset Persistent Display Device Settings", + "dd_reset_desc": "If Sunshine is stuck trying to restore the changed display device settings, you can reset the settings and proceed to restore the display state manually.", + "dd_reset_error": "Error while resetting persistence!", + "dd_reset_success": "Success resetting persistence!", "force_close": "Force Close", "force_close_desc": "If Moonlight complains about an app currently running, force closing the app should fix the issue.", "force_close_error": "Error while closing Application", diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index 5e02b8c8113..9ac45698e23 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -75,6 +75,25 @@

{{ $t('troubleshooting.restart_sunshine') }}

+ +
+
+

{{ $t('troubleshooting.dd_reset') }}

+
+

{{ $t('troubleshooting.dd_reset_desc') }}

+
+ {{ $t('troubleshooting.dd_reset_success') }} +
+
+ {{ $t('troubleshooting.dd_reset_error') }} +
+
+ +
+
+
@@ -141,11 +160,14 @@

{{ $t('troubleshooting.logs') }}

clients: [], closeAppPressed: false, closeAppStatus: null, + ddResetPressed: false, + ddResetStatus: null, logs: 'Loading...', logFilter: null, logInterval: null, restartPressed: false, showApplyMessage: false, + platform: "", unpairAllPressed: false, unpairAllStatus: null, }; @@ -159,6 +181,12 @@

{{ $t('troubleshooting.logs') }}

} }, created() { + fetch("/api/config") + .then((r) => r.json()) + .then((r) => { + this.platform = r.platform; + }); + this.logInterval = setInterval(() => { this.refreshLogs(); }, 5000); @@ -236,6 +264,18 @@

{{ $t('troubleshooting.logs') }}

method: "POST", }); }, + ddResetPersistence() { + this.ddResetPressed = true; + fetch("/api/reset-display-device-persistence", { method: "POST" }) + .then((r) => r.json()) + .then((r) => { + this.ddResetPressed = false; + this.ddResetStatus = r.status.toString() === "true"; + setTimeout(() => { + this.ddResetStatus = null; + }, 5000); + }); + }, }, }); diff --git a/tests/unit/test_display_device.cpp b/tests/unit/test_display_device.cpp new file mode 100644 index 00000000000..f08a5d62056 --- /dev/null +++ b/tests/unit/test_display_device.cpp @@ -0,0 +1,276 @@ +/** + * @file tests/unit/test_display_device.cpp + * @brief Test src/display_device.*. + */ +#include "../tests_common.h" + +#include +#include +#include + +namespace { + using config_option_e = config::video_t::dd_t::config_option_e; + using device_prep_t = display_device::SingleDisplayConfiguration::DevicePreparation; + + using hdr_option_e = config::video_t::dd_t::hdr_option_e; + using hdr_state_e = display_device::HdrState; + + using resolution_option_e = config::video_t::dd_t::resolution_option_e; + using resolution_t = display_device::Resolution; + + using refresh_rate_option_e = config::video_t::dd_t::refresh_rate_option_e; + using rational_t = display_device::Rational; + + struct failed_to_parse_resolution_tag_t {}; + struct failed_to_parse_refresh_rate_tag_t {}; + struct no_refresh_rate_tag_t {}; + struct no_resolution_tag_t {}; + + struct client_resolution_t { + int width; + int height; + }; + + using client_fps_t = int; + using sops_enabled_t = bool; + using client_wants_hdr_t = bool; + + constexpr unsigned int max_uint { std::numeric_limits::max() }; + const std::string max_uint_string { std::to_string(std::numeric_limits::max()) }; + + template + struct DisplayDeviceConfigTest: testing::TestWithParam {}; +} // namespace + +using ParseDeviceId = DisplayDeviceConfigTest>; +INSTANTIATE_TEST_SUITE_P( + DisplayDeviceConfigTest, + ParseDeviceId, + testing::Values( + std::make_pair(""s, ""s), + std::make_pair("SomeId"s, "SomeId"s), + std::make_pair("{daeac860-f4db-5208-b1f5-cf59444fb768}"s, "{daeac860-f4db-5208-b1f5-cf59444fb768}"s))); +TEST_P(ParseDeviceId, IntegrationTest) { + const auto &[input_value, expected_value] = GetParam(); + + config::video_t video_config {}; + video_config.dd.configuration_option = config_option_e::verify_only; + video_config.output_name = input_value; + + const auto result { display_device::parse_configuration(video_config, {}) }; + EXPECT_EQ(std::get(result).m_device_id, expected_value); +} + +using ParseConfigOption = DisplayDeviceConfigTest>>; +INSTANTIATE_TEST_SUITE_P( + DisplayDeviceConfigTest, + ParseConfigOption, + testing::Values( + std::make_pair(config_option_e::disabled, std::nullopt), + std::make_pair(config_option_e::verify_only, device_prep_t::VerifyOnly), + std::make_pair(config_option_e::ensure_active, device_prep_t::EnsureActive), + std::make_pair(config_option_e::ensure_primary, device_prep_t::EnsurePrimary), + std::make_pair(config_option_e::ensure_only_display, device_prep_t::EnsureOnlyDisplay))); +TEST_P(ParseConfigOption, IntegrationTest) { + const auto &[input_value, expected_value] = GetParam(); + + config::video_t video_config {}; + video_config.dd.configuration_option = input_value; + + const auto result { display_device::parse_configuration(video_config, {}) }; + if (const auto *parsed_config { std::get_if(&result) }; parsed_config) { + ASSERT_EQ(parsed_config->m_device_prep, expected_value); + } + else { + ASSERT_EQ(std::get_if(&result) != nullptr, !expected_value); + } +} + +using ParseHdrOption = DisplayDeviceConfigTest, std::optional>>; +INSTANTIATE_TEST_SUITE_P( + DisplayDeviceConfigTest, + ParseHdrOption, + testing::Values( + std::make_pair(std::make_pair(hdr_option_e::disabled, client_wants_hdr_t { true }), std::nullopt), + std::make_pair(std::make_pair(hdr_option_e::disabled, client_wants_hdr_t { false }), std::nullopt), + std::make_pair(std::make_pair(hdr_option_e::automatic, client_wants_hdr_t { true }), hdr_state_e::Enabled), + std::make_pair(std::make_pair(hdr_option_e::automatic, client_wants_hdr_t { false }), hdr_state_e::Disabled))); +TEST_P(ParseHdrOption, IntegrationTest) { + const auto &[input_value, expected_value] = GetParam(); + const auto &[input_hdr_option, input_enable_hdr] = input_value; + + config::video_t video_config {}; + video_config.dd.configuration_option = config_option_e::verify_only; + video_config.dd.hdr_option = input_hdr_option; + + rtsp_stream::launch_session_t session {}; + session.enable_hdr = input_enable_hdr; + + const auto result { display_device::parse_configuration(video_config, session) }; + EXPECT_EQ(std::get(result).m_hdr_state, expected_value); +} + +using ParseResolutionOption = DisplayDeviceConfigTest>, + std::variant>>; +INSTANTIATE_TEST_SUITE_P( + DisplayDeviceConfigTest, + ParseResolutionOption, + testing::Values( + //---- Disabled cases ---- + std::make_pair(std::make_tuple(resolution_option_e::disabled, sops_enabled_t { true }, client_resolution_t { 1920, 1080 }), no_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::disabled, sops_enabled_t { true }, "1920x1080"s), no_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::disabled, sops_enabled_t { true }, client_resolution_t { -1, -1 }), no_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::disabled, sops_enabled_t { true }, "invalid_res"s), no_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::disabled, sops_enabled_t { false }, client_resolution_t { 1920, 1080 }), no_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::disabled, sops_enabled_t { false }, "1920x1080"s), no_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::disabled, sops_enabled_t { false }, client_resolution_t { -1, -1 }), no_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::disabled, sops_enabled_t { false }, "invalid_res"s), no_resolution_tag_t {}), + //---- Automatic cases ---- + std::make_pair(std::make_tuple(resolution_option_e::automatic, sops_enabled_t { true }, client_resolution_t { 1920, 1080 }), resolution_t { 1920, 1080 }), + std::make_pair(std::make_tuple(resolution_option_e::automatic, sops_enabled_t { true }, "1920x1080"s), resolution_t {}), + std::make_pair(std::make_tuple(resolution_option_e::automatic, sops_enabled_t { true }, client_resolution_t { -1, -1 }), failed_to_parse_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::automatic, sops_enabled_t { true }, "invalid_res"s), resolution_t {}), + std::make_pair(std::make_tuple(resolution_option_e::automatic, sops_enabled_t { false }, client_resolution_t { 1920, 1080 }), no_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::automatic, sops_enabled_t { false }, "1920x1080"s), no_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::automatic, sops_enabled_t { false }, client_resolution_t { -1, -1 }), no_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::automatic, sops_enabled_t { false }, "invalid_res"s), no_resolution_tag_t {}), + //---- Manual cases ---- + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { true }, client_resolution_t { 1920, 1080 }), failed_to_parse_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { true }, "1920x1080"s), resolution_t { 1920, 1080 }), + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { true }, client_resolution_t { -1, -1 }), failed_to_parse_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { true }, "invalid_res"s), failed_to_parse_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { false }, client_resolution_t { 1920, 1080 }), no_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { false }, "1920x1080"s), no_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { false }, client_resolution_t { -1, -1 }), no_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { false }, "invalid_res"s), no_resolution_tag_t {}), + //---- Both negative values from client are checked ---- + std::make_pair(std::make_tuple(resolution_option_e::automatic, sops_enabled_t { true }, client_resolution_t { 0, 0 }), resolution_t { 0, 0 }), + std::make_pair(std::make_tuple(resolution_option_e::automatic, sops_enabled_t { true }, client_resolution_t { -1, 0 }), failed_to_parse_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::automatic, sops_enabled_t { true }, client_resolution_t { 0, -1 }), failed_to_parse_resolution_tag_t {}), + //---- Resolution string format validation ---- + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { true }, "0x0"s), resolution_t { 0, 0 }), + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { true }, "0x"s), failed_to_parse_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { true }, "x0"s), failed_to_parse_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { true }, "-1x1"s), failed_to_parse_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { true }, "1x-1"s), failed_to_parse_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { true }, "x0x0"s), failed_to_parse_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { true }, "0x0x"s), failed_to_parse_resolution_tag_t {}), + //---- String number is out of bounds ---- + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { true }, max_uint_string + "x"s + max_uint_string), resolution_t { max_uint, max_uint }), + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { true }, max_uint_string + "0"s + "x"s + max_uint_string), failed_to_parse_resolution_tag_t {}), + std::make_pair(std::make_tuple(resolution_option_e::manual, sops_enabled_t { true }, max_uint_string + "x"s + max_uint_string + "0"s), failed_to_parse_resolution_tag_t {}))); +TEST_P(ParseResolutionOption, IntegrationTest) { + const auto &[input_value, expected_value] = GetParam(); + const auto &[input_resolution_option, input_enable_sops, input_resolution] = input_value; + + config::video_t video_config {}; + video_config.dd.configuration_option = config_option_e::verify_only; + video_config.dd.resolution_option = input_resolution_option; + + rtsp_stream::launch_session_t session {}; + session.enable_sops = input_enable_sops; + + if (const auto *client_res { std::get_if(&input_resolution) }; client_res) { + video_config.dd.manual_resolution = {}; + session.width = client_res->width; + session.height = client_res->height; + } + else { + video_config.dd.manual_resolution = std::get(input_resolution); + session.width = {}; + session.height = {}; + } + + const auto result { display_device::parse_configuration(video_config, session) }; + if (const auto *failed_option { std::get_if(&expected_value) }; failed_option) { + EXPECT_NO_THROW(std::get(result)); + } + else { + std::optional expected_resolution; + if (const auto *valid_resolution_option { std::get_if(&expected_value) }; valid_resolution_option) { + expected_resolution = *valid_resolution_option; + } + + EXPECT_EQ(std::get(result).m_resolution, expected_resolution); + } +} + +using ParseRefreshRateOption = DisplayDeviceConfigTest>, + std::variant>>; +INSTANTIATE_TEST_SUITE_P( + DisplayDeviceConfigTest, + ParseRefreshRateOption, + testing::Values( + //---- Disabled cases ---- + std::make_pair(std::make_tuple(refresh_rate_option_e::disabled, client_fps_t { 60 }), no_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::disabled, "60"s), no_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::disabled, "59.9885"s), no_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::disabled, client_fps_t { -1 }), no_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::disabled, "invalid_refresh_rate"s), no_refresh_rate_tag_t {}), + //---- Automatic cases ---- + std::make_pair(std::make_tuple(refresh_rate_option_e::automatic, client_fps_t { 60 }), rational_t { 60, 1 }), + std::make_pair(std::make_tuple(refresh_rate_option_e::automatic, "60"s), rational_t { 0, 1 }), + std::make_pair(std::make_tuple(refresh_rate_option_e::automatic, "59.9885"s), rational_t { 0, 1 }), + std::make_pair(std::make_tuple(refresh_rate_option_e::automatic, client_fps_t { -1 }), failed_to_parse_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::automatic, "invalid_refresh_rate"s), rational_t { 0, 1 }), + //---- Manual cases ---- + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, client_fps_t { 60 }), failed_to_parse_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "60"s), rational_t { 60, 1 }), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "59.9885"s), rational_t { 599885, 10000 }), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, client_fps_t { -1 }), failed_to_parse_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "invalid_refresh_rate"s), failed_to_parse_refresh_rate_tag_t {}), + //---- Refresh rate string format validation ---- + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "0000000000000"s), rational_t { 0, 1 }), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "0"s), rational_t { 0, 1 }), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "00000000.0000000"s), rational_t { 0, 1 }), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "0.0"s), rational_t { 0, 1 }), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "000000000000010"s), rational_t { 10, 1 }), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "00000010.0000000"s), rational_t { 10, 1 }), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "00000010.1000000"s), rational_t { 101, 10 }), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "00000010.0100000"s), rational_t { 1001, 100 }), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "00000000.1000000"s), rational_t { 1, 10 }), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "60,0"s), failed_to_parse_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "-60.0"s), failed_to_parse_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "60.-0"s), failed_to_parse_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "a60.0"s), failed_to_parse_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "60.0b"s), failed_to_parse_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "a60"s), failed_to_parse_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "60b"s), failed_to_parse_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, "-60"s), failed_to_parse_refresh_rate_tag_t {}), + //---- String number is out of bounds ---- + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, max_uint_string), rational_t { max_uint, 1 }), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, max_uint_string + "0"s), failed_to_parse_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, max_uint_string.substr(0, 1) + "."s + max_uint_string.substr(1)), rational_t { max_uint, static_cast(std::pow(10, max_uint_string.size() - 1)) }), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, max_uint_string.substr(0, 1) + "0"s + "."s + max_uint_string.substr(1)), failed_to_parse_refresh_rate_tag_t {}), + std::make_pair(std::make_tuple(refresh_rate_option_e::manual, max_uint_string.substr(0, 1) + "."s + "0"s + max_uint_string.substr(1)), failed_to_parse_refresh_rate_tag_t {}))); +TEST_P(ParseRefreshRateOption, IntegrationTest) { + const auto &[input_value, expected_value] = GetParam(); + const auto &[input_refresh_rate_option, input_refresh_rate] = input_value; + + config::video_t video_config {}; + video_config.dd.configuration_option = config_option_e::verify_only; + video_config.dd.refresh_rate_option = input_refresh_rate_option; + + rtsp_stream::launch_session_t session {}; + if (const auto *client_refresh_rate { std::get_if(&input_refresh_rate) }; client_refresh_rate) { + video_config.dd.manual_refresh_rate = {}; + session.fps = *client_refresh_rate; + } + else { + video_config.dd.manual_refresh_rate = std::get(input_refresh_rate); + session.fps = {}; + } + + const auto result { display_device::parse_configuration(video_config, session) }; + if (const auto *failed_option { std::get_if(&expected_value) }; failed_option) { + EXPECT_NO_THROW(std::get(result)); + } + else { + std::optional expected_refresh_rate; + if (const auto *valid_refresh_rate_option { std::get_if(&expected_value) }; valid_refresh_rate_option) { + expected_refresh_rate = *valid_refresh_rate_option; + } + + EXPECT_EQ(std::get(result).m_refresh_rate, expected_refresh_rate); + } +}