diff --git a/src/config.cpp b/src/config.cpp index 47475a04b4d..9f5f0a6ede7 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -504,6 +504,8 @@ namespace config { {} // wa }, // display_device + 0, // manual_rotation + 0, // max_bitrate 0 // minimum_fps_target (0 = framerate) }; @@ -1163,6 +1165,18 @@ namespace config { video.dd.wa.hdr_toggle_delay = std::chrono::milliseconds {value}; } + { + int rotation = 0; + int_f(vars, "manual_rotation", rotation); + // Normalize to valid rotation values + if (rotation == 90 || rotation == 180 || rotation == 270) { + video.manual_rotation = rotation; + } + else { + video.manual_rotation = 0; + } + } + int_f(vars, "max_bitrate", video.max_bitrate); double_between_f(vars, "minimum_fps_target", video.minimum_fps_target, {0.0, 1000.0}); diff --git a/src/config.h b/src/config.h index f683647f571..de9e9b8eae6 100644 --- a/src/config.h +++ b/src/config.h @@ -140,6 +140,8 @@ namespace config { workarounds_t wa; } dd; + int manual_rotation; ///< Manual display rotation in degrees (0, 90, 180, 270). Useful for portrait panels used in landscape orientation. + int max_bitrate; // Maximum bitrate, sets ceiling in kbps for bitrate requested from client double minimum_fps_target; ///< Lowest framerate that will be used when streaming. Range 0-1000, 0 = half of client's requested framerate. }; diff --git a/src/platform/linux/graphics.cpp b/src/platform/linux/graphics.cpp index 38eb00717f6..b0d6b692393 100644 --- a/src/platform/linux/graphics.cpp +++ b/src/platform/linux/graphics.cpp @@ -7,6 +7,7 @@ // local includes #include "graphics.h" +#include "src/config.h" #include "src/file_handler.h" #include "src/logging.h" #include "src/video.h" @@ -739,10 +740,19 @@ namespace egl { sws.serial = std::numeric_limits::max(); + // When rotation is 90 or 270 degrees, swap input dimensions for aspect ratio calculation + // because the shader will rotate the texture, effectively swapping width and height + int effective_in_width = in_width; + int effective_in_height = in_height; + if (config::video.manual_rotation == 90 || config::video.manual_rotation == 270) { + effective_in_width = in_height; + effective_in_height = in_width; + } + // Ensure aspect ratio is maintained - auto scalar = std::fminf(out_width / (float) in_width, out_height / (float) in_height); - auto out_width_f = in_width * scalar; - auto out_height_f = in_height * scalar; + auto scalar = std::fminf(out_width / (float) effective_in_width, out_height / (float) effective_in_height); + auto out_width_f = effective_in_width * scalar; + auto out_height_f = effective_in_height * scalar; // result is always positive auto offsetX_f = (out_width - out_width_f) / 2; @@ -831,6 +841,15 @@ namespace egl { gl::ctx.UseProgram(sws.program[1].handle()); gl::ctx.Uniform1fv(loc_width_i, 1, &width_i); + // Set rotation uniform on UV shader (program[1]) + { + int rotation = config::video.manual_rotation; + auto loc_rotation = gl::ctx.GetUniformLocation(sws.program[1].handle(), "rotation"); + if (loc_rotation >= 0) { + gl::ctx.Uniform1i(loc_rotation, rotation); + } + } + auto color_p = video::color_vectors_from_colorspace({video::colorspace_e::rec601, false, 8}, true); std::pair members[] { std::make_pair("color_vec_y", util::view(color_p->color_vec_y)), @@ -855,6 +874,23 @@ namespace egl { sws.program[0].bind(sws.color_matrix); sws.program[1].bind(sws.color_matrix); + // Set rotation uniform on Y shader (program[0]) and Scene/Cursor shader (program[2]) + { + int rotation = config::video.manual_rotation; + + gl::ctx.UseProgram(sws.program[0].handle()); + auto loc_rot_y = gl::ctx.GetUniformLocation(sws.program[0].handle(), "rotation"); + if (loc_rot_y >= 0) { + gl::ctx.Uniform1i(loc_rot_y, rotation); + } + + gl::ctx.UseProgram(sws.program[2].handle()); + auto loc_rot_scene = gl::ctx.GetUniformLocation(sws.program[2].handle(), "rotation"); + if (loc_rot_scene >= 0) { + gl::ctx.Uniform1i(loc_rot_scene, rotation); + } + } + gl::ctx.BlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); gl_drain_errors; diff --git a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue index cc6a88110b3..975339f856b 100644 --- a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue +++ b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue @@ -85,6 +85,18 @@ const config = ref(props.config) :config="config" /> + +
+ + +
{{ $t('config.manual_rotation_desc') }}
+
+ > 1); @@ -18,5 +32,5 @@ void main() float v = idLow * 2.0; gl_Position = vec4(x, y, 0.0, 1.0); - tex = vec2(u, v); -} \ No newline at end of file + tex = rotate_uv(vec2(u, v), rotation); +} diff --git a/tests/unit/test_config.cpp b/tests/unit/test_config.cpp new file mode 100644 index 00000000000..46b525895c5 --- /dev/null +++ b/tests/unit/test_config.cpp @@ -0,0 +1,73 @@ +/** + * @file tests/unit/test_config.cpp + * @brief Test src/config.cpp + */ +#include "../tests_common.h" + +// standard includes +#include +#include + +// local includes +#include + +// Forward-declare the internal apply_config function for testing +namespace config { + void apply_config(std::unordered_map &&vars); +} + +class ManualRotationTest: public ::testing::TestWithParam> { +protected: + void SetUp() override { + // Reset to default before each test + config::video.manual_rotation = 0; + } +}; + +TEST_P(ManualRotationTest, ParsesRotationValues) { + auto [input, expected] = GetParam(); + + std::unordered_map vars; + vars["manual_rotation"] = input; + config::apply_config(std::move(vars)); + + EXPECT_EQ(config::video.manual_rotation, expected); +} + +INSTANTIATE_TEST_SUITE_P( + ConfigTests, + ManualRotationTest, + testing::Values( + // Valid rotation values + std::make_pair("0", 0), + std::make_pair("90", 90), + std::make_pair("180", 180), + std::make_pair("270", 270), + // Invalid values should normalize to 0 + std::make_pair("45", 0), + std::make_pair("360", 0), + std::make_pair("-90", 0), + std::make_pair("1", 0), + std::make_pair("abc", 0) + ), + [](const testing::TestParamInfo &info) { + auto input = info.param.first; + // Replace non-alphanumeric chars for valid test name + std::replace_if( + input.begin(), input.end(), + [](char c) { return !std::isalnum(c); }, + '_' + ); + return "rotation_" + input; + } +); + +TEST(ManualRotationDefaultTest, DefaultIsZero) { + // Reset config and apply empty vars to verify default + config::video.manual_rotation = 999; + std::unordered_map vars; + vars["manual_rotation"] = "0"; + config::apply_config(std::move(vars)); + + EXPECT_EQ(config::video.manual_rotation, 0); +}