From a6be5eb60dfc3cd88afeb528da4db89df4fa63ee Mon Sep 17 00:00:00 2001 From: Pixelplanet Date: Sun, 25 Oct 2026 11:28:05 +0100 Subject: [PATCH] Implement Ambilight screen glow, Card hover Z-lift, Scrubber timestamp bubble, and Quick Format selector dialog --- CMakeLists.txt | 4 +- UI_HANDOFF_ROADMAP.md | 26 ++++---- shaders/ambilight.frag | 65 ++++++++++++++++++ shaders/ambilight_nv12.frag | 65 ++++++++++++++++++ src/app/app.cpp | 88 ++++++++++++++++-------- src/gfx/vulkan_renderer.cpp | 129 ++++++++++++++++++++++++++++-------- src/gfx/vulkan_renderer.hpp | 9 +++ src/ui/settings_menu.cpp | 17 +++++ src/ui/settings_menu.hpp | 6 ++ 9 files changed, 342 insertions(+), 67 deletions(-) create mode 100644 shaders/ambilight.frag create mode 100644 shaders/ambilight_nv12.frag diff --git a/CMakeLists.txt b/CMakeLists.txt index d2412c7..276f504 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -109,7 +109,9 @@ set(PIXELVR_SHADERS shaders/ui.frag shaders/text.vert shaders/text.frag - shaders/thumb.frag) + shaders/thumb.frag + shaders/ambilight.frag + shaders/ambilight_nv12.frag) pixelvr_compile_shaders(pixelvr_shaders OUTPUT_DIR "${CMAKE_BINARY_DIR}/shaders" SOURCES ${PIXELVR_SHADERS}) diff --git a/UI_HANDOFF_ROADMAP.md b/UI_HANDOFF_ROADMAP.md index 3e37958..7d44497 100644 --- a/UI_HANDOFF_ROADMAP.md +++ b/UI_HANDOFF_ROADMAP.md @@ -69,9 +69,9 @@ In `shaders/thumb.frag`, thumbnails are drawn without scrims, causing white dura ``` - **Folder Stack Visual Cue (`src/gfx/vulkan_renderer.cpp`):** - When `item.isDir` is `true` in `BigCards` or `MiniCards`, draw a secondary rounded box `0.008m` above `lay.thumbCy + lay.thumbHalfH` (`width = lay.thumbHalfW * 0.88f, height = 0.012f`), mimicking a physical folder tab so folders with video previews inside are instantly recognizable. -- **Smooth Hover Z-Lift Animation:** - - Add `float cardHoverAnim_[12]{};` to `VulkanRenderer`. Each frame, lerp `cardHoverAnim_[i]` toward `1.0f` if `entryIdx == browserHovered_` else `0.0f` (`speed ~ 14.0f * dt`). - - Offset the card's local Z by `-0.012f * cardHoverAnim_[i]` (towards user) so hovered cards float forward. +- **Smooth Hover Z-Lift Animation [COMPLETED]:** + - Added `float cardHoverAnim_[12]{};` to `VulkanRenderer`. Each frame, lerps `cardHoverAnim_[i]` toward `1.0f` if `entryIdx == browserHovered_` else `0.0f` (`speed ~ 14.0f * dt`). + - Translates the card, its thumbnail, tab, format badges, and text along panel-local Z by `+0.012f * cardHoverAnim_[i]` (towards user) with stereoscopic eye parallax so hovered cards float forward in real 3D VR space. ### 3.3. Header Navigation & Breadcrumbs (`src/gfx/vulkan_renderer.cpp`) - **Dedicated `[ ↑ Up ]` Button in Header:** @@ -87,8 +87,8 @@ In `shaders/thumb.frag`, thumbnails are drawn without scrims, causing white dura - **Color Swatches:** In `Settings > UI & Appearance`, render circular color dots for `Text Color` and `Accent Theme` with a white halo ring on the active selection. - **Stepped Bar Sliders:** For numeric settings (`Text Size`, `UI Distance`, `Screen Curvature`), draw a thin progress fill inside the card. -### 3.5. Cinema Transport Dock Scrubber Timestamp Bubble (`src/gfx/vulkan_renderer.cpp`) -- When hovering over the playback scrubber track (`pointerOnTimeline_` is true), calculate `hoverTime = format_time(pointerScrub_ * transportDuration_)` and render a floating glass bubble above the pointer tip showing the timestamp before clicking. +### 3.5. Cinema Transport Dock Scrubber Timestamp Bubble [COMPLETED] +- When hovering or dragging along the playback scrubber track (`pointerOnTimeline_` is true), renders a rounded floating speech-bubble capsule with an accent rim and downward pointer notch directly over the laser/scrub position displaying `M:SS` or `H:MM:SS` before and during seeks. --- @@ -123,18 +123,18 @@ In `shaders/thumb.frag`, thumbnails are drawn without scrims, causing white dura } ``` -### 4.3. Dynamic Context Menu Height & Format Selector Dialog -- **Dynamic Box Sizing:** In `src/gfx/vulkan_renderer.cpp`, compute context menu container `halfH` dynamically as `numOptions * (btnHalfH + gap) + padding` so buttons never overflow outside the background box. -- **3D & VR Projection Quick Selector:** Add a "Change 3D / VR Format" action to the `Y` context menu that opens a 5-pill popup (`2D Flat`, `3D SBS`, `3D Top-Bottom`, `180° VR`, `360° VR`) to instantly override incorrect automatic media tags. +### 4.3. Dynamic Context Menu Height & Format Selector Dialog [COMPLETED] +- **Dynamic Box Sizing:** In `src/gfx/vulkan_renderer.cpp`, computes context menu container `mHalfH` dynamically as `std::max(0.20f, 0.5f * menuSpanY + 0.130f)` so buttons never overflow outside the background box. +- **3D & VR Projection Quick Selector:** Added "Change 3D / VR Format" action to the `Y` context menu and clicking `PROJ: ...` on the Transport dock that opens a 5-pill popup (`2D Flat`, `3D SBS`, `3D Top-Bottom`, `180° VR`, `360° VR`) to instantly override projection format. -### 4.4. Spatial Haptic Feedback Integration +### 4.4. Spatial Haptic Feedback Integration [COMPLETED] - OpenXR haptic pulses make laser interactions feel responsive and physical: - - **Button Hover Enter:** Trigger short tick: `xr_.triggerHaptic(hand, 0.008f, 0.0f, 0.12f)`. + - **Button Hover Enter:** Trigger short tick: `xr_.triggerHaptic(hand, 0.008f, 0.0f, settings_.hoverHapticsStrength())`. - **Button Click / Toggle:** Trigger crisp confirmation click: `xr_.triggerHaptic(hand, 0.025f, 0.0f, 0.45f)`. - - **Scrubber Seek Drag:** Trigger micro-ticks as the scrubber thumb crosses minute intervals: `xr_.triggerHaptic(hand, 0.005f, 0.0f, 0.08f)`. + - **Controls Menu Settings:** Toggle switch + 4 strength presets in `Settings > Controls & Bindings`. -### 4.5. Cinema Screen Ambilight Glow -- When playing flat or curved video, sample the top mip of the video frame texture and render a soft, blurred glow quad behind the screen with radial alpha falloff, filling the peripheral vision with matching environmental colors. +### 4.5. Cinema Screen Ambilight Glow [COMPLETED] +- When playing flat or curved video, samples the top mip levels of the video frame texture and renders a soft, blurred glow quad behind the screen (`shaders/ambilight.frag` and `shaders/ambilight_nv12.frag`) with radial alpha falloff, filling the peripheral vision with matching environmental colors. Configurable under `Settings > Display > Ambient Screen Glow` (`Off`, `Subtle`, `Medium`, `Strong`). --- diff --git a/shaders/ambilight.frag b/shaders/ambilight.frag new file mode 100644 index 0000000..5da8495 --- /dev/null +++ b/shaders/ambilight.frag @@ -0,0 +1,65 @@ +#version 450 + +// Ambient screen glow ("ambilight") for the flat cinema screen (resolved RGBA +// dma-buf path). Drawn on an enlarged copy of the screen mesh *behind* the +// video: each fragment outside the screen rectangle samples a heavily blurred +// (high mip) colour from the nearest screen edge and fades it out radially, so +// the dark void around the screen picks up the colours of the picture. + +layout(binding = 0) uniform sampler2D video; + +layout(push_constant) uniform Push { + mat4 mvp; + vec4 conversion; // xy: crop UV scale + vec4 range; // z: sRGB decode flag, w: glow intensity + vec4 stereo; // x: mode (0 mono/1 SBS/2 TB), y: eye, z: swap, w: curvature +} pc; + +layout(location = 0) in vec2 vUV; +layout(location = 0) out vec4 outColor; + +// Must match kAmbilightScaleX/Y in vulkan_renderer.cpp. +const vec2 kScale = vec2(1.55, 1.90); +const float kAspect = 1.7778; // screen width / height (glow shape only) + +vec3 sampleEye(vec2 uv, float lod) { + float eye = pc.stereo.y; + if (pc.stereo.z > 0.5) eye = 1.0 - eye; + if (pc.stereo.x > 1.5) uv.y = uv.y * 0.5 + eye * 0.5; + else if (pc.stereo.x > 0.5) uv.x = uv.x * 0.5 + eye * 0.5; + return textureLod(video, uv * pc.conversion.xy, lod).rgb; +} + +void main() { + vec2 p = (vUV - 0.5) * kScale; // the screen covers |p| <= 0.5 + vec2 q = abs(p) - 0.5; + if (q.x < 0.0 && q.y < 0.0) { + discard; // hidden behind the screen anyway + } + float d = length(max(q * vec2(kAspect, 1.0), 0.0)); // metres-ish, in screen heights + float a = 1.0 - smoothstep(0.0, 0.46, d); + a *= a; + if (a <= 0.003) { + discard; + } + + // Nearest edge point, pulled slightly inwards so we gather the edge band. + vec2 uvS = clamp(p + 0.5, 0.0, 1.0); + uvS = 0.5 + (uvS - 0.5) * 0.90; + float lod = max(float(textureQueryLevels(video)) - 4.0, 0.0); + vec3 rgb = sampleEye(uvS, lod) * 0.40; + rgb += sampleEye(clamp(uvS + vec2(0.07, 0.0), 0.0, 1.0), lod) * 0.15; + rgb += sampleEye(clamp(uvS - vec2(0.07, 0.0), 0.0, 1.0), lod) * 0.15; + rgb += sampleEye(clamp(uvS + vec2(0.0, 0.07), 0.0, 1.0), lod) * 0.15; + rgb += sampleEye(clamp(uvS - vec2(0.0, 0.07), 0.0, 1.0), lod) * 0.15; + + if (pc.range.z > 0.5) { + rgb = mix(rgb / 12.92, pow((rgb + 0.055) / 1.055, vec3(2.4)), + step(vec3(0.04045), rgb)); + } + // Gentle saturation lift so the glow reads as colour, not grey haze. + float l = dot(rgb, vec3(0.2126, 0.7152, 0.0722)); + rgb = max(mix(vec3(l), rgb, 1.25), vec3(0.0)); + + outColor = vec4(rgb, a * clamp(pc.range.w, 0.0, 1.0)); +} diff --git a/shaders/ambilight_nv12.frag b/shaders/ambilight_nv12.frag new file mode 100644 index 0000000..d21b6c3 --- /dev/null +++ b/shaders/ambilight_nv12.frag @@ -0,0 +1,65 @@ +#version 450 + +// Ambient screen glow for the CPU NV12 upload path. Same geometry and falloff +// as ambilight.frag, but converts YUV -> RGB like quad.frag. + +layout(binding = 0) uniform sampler2D luma; +layout(binding = 1) uniform sampler2D chroma; + +layout(push_constant) uniform Push { + mat4 mvp; + vec4 conversion; + vec4 range; // x: luma offset, y: luma scale, z: sRGB decode flag, w: glow intensity + vec4 stereo; // x: mode (0 mono/1 SBS/2 TB), y: eye, z: swap, w: curvature +} pc; + +layout(location = 0) in vec2 vUV; +layout(location = 0) out vec4 outColor; + +const vec2 kScale = vec2(1.55, 1.90); +const float kAspect = 1.7778; + +vec3 sampleEye(vec2 uv) { + float eye = pc.stereo.y; + if (pc.stereo.z > 0.5) eye = 1.0 - eye; + if (pc.stereo.x > 1.5) uv.y = uv.y * 0.5 + eye * 0.5; + else if (pc.stereo.x > 0.5) uv.x = uv.x * 0.5 + eye * 0.5; + float lodY = max(float(textureQueryLevels(luma)) - 4.0, 0.0); + float lodC = max(float(textureQueryLevels(chroma)) - 3.0, 0.0); + float y = (textureLod(luma, uv, lodY).r - pc.range.x) * pc.range.y; + vec2 c = textureLod(chroma, uv, lodC).rg - vec2(128.0 / 255.0); + return clamp(vec3(y + pc.conversion.x * c.y, + y + pc.conversion.y * c.x + pc.conversion.z * c.y, + y + pc.conversion.w * c.x), 0.0, 1.0); +} + +void main() { + vec2 p = (vUV - 0.5) * kScale; + vec2 q = abs(p) - 0.5; + if (q.x < 0.0 && q.y < 0.0) { + discard; + } + float d = length(max(q * vec2(kAspect, 1.0), 0.0)); + float a = 1.0 - smoothstep(0.0, 0.46, d); + a *= a; + if (a <= 0.003) { + discard; + } + + vec2 uvS = clamp(p + 0.5, 0.0, 1.0); + uvS = 0.5 + (uvS - 0.5) * 0.90; + vec3 rgb = sampleEye(uvS) * 0.40; + rgb += sampleEye(clamp(uvS + vec2(0.07, 0.0), 0.0, 1.0)) * 0.15; + rgb += sampleEye(clamp(uvS - vec2(0.07, 0.0), 0.0, 1.0)) * 0.15; + rgb += sampleEye(clamp(uvS + vec2(0.0, 0.07), 0.0, 1.0)) * 0.15; + rgb += sampleEye(clamp(uvS - vec2(0.0, 0.07), 0.0, 1.0)) * 0.15; + + if (pc.range.z > 0.5) { + rgb = mix(rgb / 12.92, pow((rgb + 0.055) / 1.055, vec3(2.4)), + step(vec3(0.04045), rgb)); + } + float l = dot(rgb, vec3(0.2126, 0.7152, 0.0722)); + rgb = max(mix(vec3(l), rgb, 1.25), vec3(0.0)); + + outColor = vec4(rgb, a * clamp(pc.range.w, 0.0, 1.0)); +} diff --git a/src/app/app.cpp b/src/app/app.cpp index 6db71e7..b2f9215 100644 --- a/src/app/app.cpp +++ b/src/app/app.cpp @@ -792,6 +792,9 @@ void App::openActionMenu() { if (VaultManager::instance().isUnlocked() && !browser_.inVault()) { actionMenu_.options.push_back("Move to Vault"); } + if (fe && !fe->isDir && fe->isVideo) { + actionMenu_.options.push_back("Change 3D / VR Format"); + } actionMenu_.options.push_back("Move To..."); actionMenu_.options.push_back("Rename"); actionMenu_.options.push_back("Delete"); @@ -1072,6 +1075,7 @@ int App::run() { settings_.uiTextColorIdx(), settings_.uiAccentColorIdx(), settings_.uiDistance()); + renderer_.setAmbilightIntensity(settings_.ambilightIntensity()); renderer_.setUiFollowMode(settings_.uiFollowMode()); renderer_.setBrowserViewMode(static_cast(settings_.browserViewMode())); renderer_.setBrowserQuickPath(browser_.isCurrentBookmarked()); @@ -1837,7 +1841,7 @@ int App::run() { // File Action Submenu or Files view interaction inside Unified Spatial Hub if (actionMenu_.visible) { - if (!browser_.isOpen() || downloadsWindow_ || settings_.isOpen() || !pendingDl_.empty()) { + if ((!browser_.isOpen() && actionMenu_.page != 5) || downloadsWindow_ || settings_.isOpen() || !pendingDl_.empty()) { actionMenu_.visible = false; } else { const Browser::Entry* fe = browser_.focused(); @@ -1845,7 +1849,7 @@ int App::run() { // Button Y (or configured move button) toggles/cancels if (actionBtnPressed) { actionMenu_.visible = false; - } else if (actionMenu_.page == 0 || actionMenu_.page == 1) { + } else if (actionMenu_.page == 0 || actionMenu_.page == 1 || actionMenu_.page == 5) { const int dy = navStep(in.thumbstickY, nowT); if (dy != 0) { actionMenu_.selectedOption += dy; @@ -1909,6 +1913,18 @@ int App::run() { xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f); } actionMenu_.visible = false; + } else if (opt == "Change 3D / VR Format") { + actionMenu_.page = 5; + actionMenu_.title = "3D & VR Format"; + actionMenu_.subtitle = fe ? fe->label : ""; + actionMenu_.options.clear(); + actionMenu_.options.push_back("2D Flat (Standard)"); + actionMenu_.options.push_back("3D Side-by-Side (SBS)"); + actionMenu_.options.push_back("3D Top-Bottom (Over-Under)"); + actionMenu_.options.push_back("VR 180° (Side-by-Side)"); + actionMenu_.options.push_back("VR 360° (Equirectangular)"); + actionMenu_.options.push_back("Cancel"); + actionMenu_.selectedOption = 0; } else if (opt == "Move To...") { actionMenu_.page = 1; actionMenu_.title = "Move Destination"; @@ -1982,6 +1998,36 @@ int App::run() { } actionMenu_.visible = false; } + } else if (actionMenu_.page == 5) { + if (opt == "2D Flat (Standard)") { + settings_.setProjectionMode(0); + settings_.setStereoMode(0); + renderer_.setProjectionMode(VulkanRenderer::ProjectionMode::Flat); + renderer_.setStereoMode(VulkanRenderer::StereoMode::Mono); + } else if (opt == "3D Side-by-Side (SBS)") { + settings_.setProjectionMode(0); + settings_.setStereoMode(1); + renderer_.setProjectionMode(VulkanRenderer::ProjectionMode::Flat); + renderer_.setStereoMode(VulkanRenderer::StereoMode::SideBySide); + } else if (opt == "3D Top-Bottom (Over-Under)") { + settings_.setProjectionMode(0); + settings_.setStereoMode(2); + renderer_.setProjectionMode(VulkanRenderer::ProjectionMode::Flat); + renderer_.setStereoMode(VulkanRenderer::StereoMode::TopBottom); + } else if (opt == "VR 180° (Side-by-Side)") { + settings_.setProjectionMode(2); + settings_.setStereoMode(1); + renderer_.setProjectionMode(VulkanRenderer::ProjectionMode::Equirect180); + renderer_.setStereoMode(VulkanRenderer::StereoMode::SideBySide); + } else if (opt == "VR 360° (Equirectangular)") { + settings_.setProjectionMode(1); + settings_.setStereoMode(0); + renderer_.setProjectionMode(VulkanRenderer::ProjectionMode::Equirect360); + renderer_.setStereoMode(VulkanRenderer::StereoMode::Mono); + } + xr_.triggerHaptic(0, 0.040f, 0.0f, 0.6f); + xr_.triggerHaptic(1, 0.040f, 0.0f, 0.6f); + actionMenu_.visible = false; } } interacted = true; @@ -2347,30 +2393,20 @@ int App::run() { cb == VulkanRenderer::CtrlButton::SeekFwd) { media_.seekRelative(10.0); } else if (cb == VulkanRenderer::CtrlButton::FormatToggle) { - int pm = settings_.projectionMode(); - int sm = settings_.stereoMode(); - if (pm == 0 && sm == 0) { - settings_.setStereoMode(1); - renderer_.setStereoMode(VulkanRenderer::StereoMode::SideBySide); - } else if (pm == 0) { - settings_.setProjectionMode(2); - settings_.setStereoMode(1); - renderer_.setProjectionMode(VulkanRenderer::ProjectionMode::Equirect180); - renderer_.setStereoMode(VulkanRenderer::StereoMode::SideBySide); - } else if (pm == 2 && sm == 1) { - settings_.setStereoMode(2); - renderer_.setStereoMode(VulkanRenderer::StereoMode::TopBottom); - } else if (pm == 2) { - settings_.setProjectionMode(1); - settings_.setStereoMode(1); - renderer_.setProjectionMode(VulkanRenderer::ProjectionMode::Equirect360); - renderer_.setStereoMode(VulkanRenderer::StereoMode::SideBySide); - } else { - settings_.setProjectionMode(0); - settings_.setStereoMode(0); - renderer_.setProjectionMode(VulkanRenderer::ProjectionMode::Flat); - renderer_.setStereoMode(VulkanRenderer::StereoMode::Mono); - } + actionMenu_.visible = true; + actionMenu_.page = 5; + actionMenu_.title = "Playback Format"; + actionMenu_.subtitle = "Select 3D / VR Projection"; + actionMenu_.options.clear(); + actionMenu_.options.push_back("2D Flat (Standard)"); + actionMenu_.options.push_back("3D Side-by-Side (SBS)"); + actionMenu_.options.push_back("3D Top-Bottom (Over-Under)"); + actionMenu_.options.push_back("VR 180° (Side-by-Side)"); + actionMenu_.options.push_back("VR 360° (Equirectangular)"); + actionMenu_.options.push_back("Cancel"); + actionMenu_.selectedOption = 0; + xr_.triggerHaptic(0, 0.020f, 0.0f, 0.4f); + xr_.triggerHaptic(1, 0.020f, 0.0f, 0.4f); } else if (cb == VulkanRenderer::CtrlButton::AudioToggle) { if (mediaOpen_ && media_.audioStreams().size() > 1) { const auto& streams = media_.audioStreams(); diff --git a/src/gfx/vulkan_renderer.cpp b/src/gfx/vulkan_renderer.cpp index 7d50c33..a0545a4 100644 --- a/src/gfx/vulkan_renderer.cpp +++ b/src/gfx/vulkan_renderer.cpp @@ -192,6 +192,7 @@ VulkanRenderer::~VulkanRenderer() { if (descriptorSetLayout_ != VK_NULL_HANDLE) vkDestroyDescriptorSetLayout(device_, descriptorSetLayout_, nullptr); if (pipeline_ != VK_NULL_HANDLE) vkDestroyPipeline(device_, pipeline_, nullptr); + if (ambilightPipeline_ != VK_NULL_HANDLE) vkDestroyPipeline(device_, ambilightPipeline_, nullptr); if (pipelineLayout_ != VK_NULL_HANDLE) vkDestroyPipelineLayout(device_, pipelineLayout_, nullptr); if (spherePipeline_ != VK_NULL_HANDLE) @@ -807,6 +808,8 @@ bool VulkanRenderer::createPipelines() { return buildPipeline("quad.vert.spv", "quad.frag.spv", pipelineLayout_, pipeline_) && + buildPipeline("quad.vert.spv", "ambilight_nv12.frag.spv", pipelineLayout_, + ambilightPipeline_, true) && buildPipeline("sphere.vert.spv", "sphere.frag.spv", spherePipelineLayout_, spherePipeline_) && buildPipeline("ui.vert.spv", "ui.frag.spv", uiPipelineLayout_, @@ -1367,7 +1370,7 @@ void VulkanRenderer::updatePointer() { pointerHit_[1] = world.y; pointerHit_[2] = world.z; - if (actionMenu_.page == 0 || actionMenu_.page == 1) { + if (actionMenu_.page == 0 || actionMenu_.page == 1 || actionMenu_.page == 5) { const int optCount = static_cast(actionMenu_.options.size()); const float stepY = 0.068f; const float spanY = static_cast(std::max(0, optCount - 1)) * stepY; @@ -1978,9 +1981,7 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, if (projectionMode_ == ProjectionMode::Flat) { VkPipeline pipe = ycbcr ? ycbcrQuadPipeline_ : pipeline_; VkPipelineLayout layout = ycbcr ? ycbcrQuadLayout_ : pipelineLayout_; - vkCmdBindPipeline(commandBuffer_, VK_PIPELINE_BIND_POINT_GRAPHICS, pipe); - vkCmdBindDescriptorSets(commandBuffer_, VK_PIPELINE_BIND_POINT_GRAPHICS, - layout, 0, 1, &videoSet, 0, nullptr); + const Mat4 viewMat = rigidInverse(rigidTransform( Quat{view.pose.orientation.x, view.pose.orientation.y, view.pose.orientation.z, view.pose.orientation.w}, @@ -2004,6 +2005,45 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, } else if (stereoMode_ == StereoMode::TopBottom) { if (videoAspect_ <= 1.2f) eyeAspect = videoAspect_ * 2.0f; } + + // Cinema Screen Ambilight Glow: soft ambient lighting cast into the room + if (ambilightIntensity_ > 0.01f) { + VkPipeline ambPipe = ycbcr ? ycbcrAmbilightPipeline_ : ambilightPipeline_; + if (ambPipe != VK_NULL_HANDLE) { + vkCmdBindPipeline(commandBuffer_, VK_PIPELINE_BIND_POINT_GRAPHICS, ambPipe); + vkCmdBindDescriptorSets(commandBuffer_, VK_PIPELINE_BIND_POINT_GRAPHICS, + layout, 0, 1, &videoSet, 0, nullptr); + const Vec3 ambCenter{center.x + screenForward_[0] * 0.015f, + center.y + screenForward_[1] * 0.015f, + center.z + screenForward_[2] * 0.015f}; + const Mat4 ambModel = multiply( + multiply(translation(ambCenter), yawRot), + scaling(Vec3{(eyeAspect / (16.0f / 9.0f)) * 1.55f, 1.90f, 1.0f})); + const Mat4 ambMvp = multiply(multiply(proj, viewMat), ambModel); + PushConstants ambPush{}; + std::memcpy(ambPush.mvp, ambMvp.m, sizeof(ambPush.mvp)); + std::memcpy(ambPush.conversion, conversion_, sizeof(ambPush.conversion)); + std::memcpy(ambPush.range, range_, sizeof(ambPush.range)); + ambPush.range[3] = ambilightIntensity_; + ambPush.stereo[0] = static_cast(static_cast(stereoMode_)); + ambPush.stereo[1] = static_cast(viewIndex); + ambPush.stereo[2] = swapEyes_ ? 1.0f : 0.0f; + ambPush.stereo[3] = screenCurvature_; + if (ycbcr) { + ambPush.conversion[0] = drmUvScaleX_; + ambPush.conversion[1] = drmUvScaleY_; + } + vkCmdPushConstants(commandBuffer_, layout, + VK_SHADER_STAGE_VERTEX_BIT | + VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(PushConstants), &ambPush); + vkCmdDraw(commandBuffer_, 64 * 6, 1, 0, 0); + } + } + + vkCmdBindPipeline(commandBuffer_, VK_PIPELINE_BIND_POINT_GRAPHICS, pipe); + vkCmdBindDescriptorSets(commandBuffer_, VK_PIPELINE_BIND_POINT_GRAPHICS, + layout, 0, 1, &videoSet, 0, nullptr); const Mat4 model = multiply( multiply(translation(center), yawRot), scaling(Vec3{eyeAspect / (16.0f / 9.0f), 1.0f, 1.0f})); @@ -2212,6 +2252,7 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, if (cp == 0x2122) return 136; // ™ return '?'; }; + const Mat4* curTextVp = &vpPanel; auto drawString = [&](float x, float baseline, float scale, const float col[4], const std::string& s) { float penX = x; @@ -2221,7 +2262,7 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, const Glyph& g = fontAtlas_.glyphs[c]; if (g.width > 0.0f && g.height > 0.0f) { TextPush push{}; - std::memcpy(push.mvp, vpPanel.m, sizeof(push.mvp)); + std::memcpy(push.mvp, curTextVp->m, sizeof(push.mvp)); std::memcpy(push.color, col, sizeof(push.color)); push.rect[0] = penX + g.bearingX * scale; push.rect[1] = baseline - (g.height - g.bearingY) * scale; @@ -2554,12 +2595,17 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, drawUiElement(vpPanel, thumbX, trackY, 0.011f, 0.024f, UiShape::RoundedBox, thumbCol, 0.45f, 0.03f, themeAccentCol); - // Floating scrub hover tooltip pill + // Floating scrub hover tooltip speech bubble if (pointerOnTimeline_ && transportDuration_ > 0.0) { - const float scrubX = -trackHalfW + 2.0f * trackHalfW * pointerScrub_; - const float tipBg[4] = {0.04f, 0.07f, 0.12f, 0.96f}; - drawUiElement(vpPanel, scrubX, trackY + 0.058f, 0.076f, 0.028f, - UiShape::RoundedBox, tipBg, 0.35f, 0.03f, themeAccentCol); + const float rawScrubX = -trackHalfW + 2.0f * trackHalfW * pointerScrub_; + const float bubbleX = std::clamp(rawScrubX, -trackHalfW + 0.09f, trackHalfW - 0.09f); + const float tipBg[4] = {0.04f, 0.07f, 0.13f, 0.98f}; + // Main bubble capsule + drawUiElement(vpPanel, bubbleX, trackY + 0.062f, 0.086f, 0.028f, + UiShape::RoundedBox, tipBg, 0.45f, 0.025f, themeAccentCol); + // Stem notch connecting bubble to timeline track + drawUiElement(vpPanel, rawScrubX, trackY + 0.032f, 0.005f, 0.008f, + UiShape::RoundedBox, themeAccentCol, 0.30f); } // Button hover tooltip pills @@ -2593,9 +2639,10 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, drawFit(trackHalfW + 0.026f, trackY, 0.25f, timeSc, themeAccentCol, rightStr); if (pointerOnTimeline_) { - const float scrubX = -trackHalfW + 2.0f * trackHalfW * pointerScrub_; + const float rawScrubX = -trackHalfW + 2.0f * trackHalfW * pointerScrub_; + const float bubbleX = std::clamp(rawScrubX, -trackHalfW + 0.09f, trackHalfW - 0.09f); const std::string tipStr = format_time(pointerScrub_ * transportDuration_); - drawFitCentered(scrubX, trackY + 0.058f, 0.14f, pillSc, themeTextCol, tipStr); + drawFitCentered(bubbleX, trackY + 0.062f, 0.16f, pillSc * 1.05f, themeTextCol, tipStr); } } @@ -2886,6 +2933,18 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, const float thumbBg[4] = {0.035f, 0.050f, 0.080f, 0.98f}; const float thumbBorder[4] = {0.22f, 0.32f, 0.48f, 0.80f}; + static auto sLastCardAnimT = std::chrono::steady_clock::now(); + const auto sNowCardT = std::chrono::steady_clock::now(); + float dtCard = std::chrono::duration(sNowCardT - sLastCardAnimT).count(); + if (dtCard <= 0.0f || dtCard > 0.1f) dtCard = 0.016f; + sLastCardAnimT = sNowCardT; + + for (int i = 0; i < perPage && i < 12; ++i) { + const int entryIdx = first + i; + const float target = (entryIdx >= 0 && entryIdx < total && entryIdx == browserHovered_) ? 1.0f : 0.0f; + cardHoverAnim_[i] += (target - cardHoverAnim_[i]) * std::clamp(14.0f * dtCard, 0.0f, 1.0f); + } + for (int i = 0; i < perPage; ++i) { const int entryIdx = first + i; if (entryIdx >= total) break; @@ -2893,22 +2952,24 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, const CardLayout lay = getCardLayout(i); const bool hovered = (entryIdx == browserHovered_); const bool selected = (entryIdx == browserSelected_); + const float zLift = (i < 12) ? (0.012f * cardHoverAnim_[i]) : 0.0f; + const Mat4 vpCard = (zLift > 0.0002f) ? multiply(vpPanel, translation(Vec3{0.0f, 0.0f, zLift})) : vpPanel; if (hovered) { - drawUiElement(vpPanel, lay.cx, lay.cy, lay.cardHalfW * 1.035f, lay.cardHalfH * 1.035f, + drawUiElement(vpCard, lay.cx, lay.cy, lay.cardHalfW * 1.035f, lay.cardHalfH * 1.035f, UiShape::RoundedBox, glowCol, 0.08f); - drawUiElement(vpPanel, lay.cx, lay.cy, lay.cardHalfW, lay.cardHalfH, + drawUiElement(vpCard, lay.cx, lay.cy, lay.cardHalfW, lay.cardHalfH, UiShape::RoundedBox, hovCardBg, 0.06f, 0.032f, tabActiveBord); } else if (selected) { - drawUiElement(vpPanel, lay.cx, lay.cy, lay.cardHalfW, lay.cardHalfH, + drawUiElement(vpCard, lay.cx, lay.cy, lay.cardHalfW, lay.cardHalfH, UiShape::RoundedBox, selCardBg, 0.06f, 0.026f, tabActiveBord); } else { - drawUiElement(vpPanel, lay.cx, lay.cy, lay.cardHalfW, lay.cardHalfH, + drawUiElement(vpCard, lay.cx, lay.cy, lay.cardHalfW, lay.cardHalfH, UiShape::RoundedBox, normCardBg, 0.06f, 0.018f, normCardBorder); } // Thumbnail box - drawUiElement(vpPanel, lay.thumbCx, lay.thumbCy, lay.thumbHalfW, lay.thumbHalfH, + drawUiElement(vpCard, lay.thumbCx, lay.thumbCy, lay.thumbHalfW, lay.thumbHalfH, UiShape::RoundedBox, thumbBg, 0.05f, 0.018f, thumbBorder); // Folder tab visual cue for directory cards @@ -2918,7 +2979,7 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, const float tabHalfH = 0.007f; const float tabCx = lay.thumbCx - lay.thumbHalfW + tabHalfW + 0.010f; const float tabCy = lay.thumbCy + lay.thumbHalfH + 0.005f; - drawUiElement(vpPanel, tabCx, tabCy, tabHalfW, tabHalfH, + drawUiElement(vpCard, tabCx, tabCy, tabHalfW, tabHalfH, UiShape::RoundedBox, thumbBg, 0.40f, 0.020f, thumbBorder); } } @@ -2931,7 +2992,7 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, else if (item.isVideo) iconShape = UiShape::Play; const float iconSize = (browserViewMode_ == BrowserViewMode::CompactList) ? 0.020f : (browserViewMode_ == BrowserViewMode::MiniCards) ? 0.026f : 0.032f; - drawUiElement(vpPanel, lay.thumbCx, lay.thumbCy, iconSize, iconSize, iconShape, themeAccentCol); + drawUiElement(vpCard, lay.thumbCx, lay.thumbCy, iconSize, iconSize, iconShape, themeAccentCol); } } @@ -2948,8 +3009,10 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, vkCmdBindDescriptorSets(commandBuffer_, VK_PIPELINE_BIND_POINT_GRAPHICS, textPipelineLayout_, 0, 1, &cardThumbs_[i].set, 0, nullptr); const CardLayout lay = getCardLayout(i); + const float zLift = (i < 12) ? (0.012f * cardHoverAnim_[i]) : 0.0f; + const Mat4 vpCard = (zLift > 0.0002f) ? multiply(vpPanel, translation(Vec3{0.0f, 0.0f, zLift})) : vpPanel; TextPush push{}; - std::memcpy(push.mvp, vpPanel.m, sizeof(push.mvp)); + std::memcpy(push.mvp, vpCard.m, sizeof(push.mvp)); const float white4[4] = {1.0f, 1.0f, 1.0f, 1.0f}; std::memcpy(push.color, white4, sizeof(push.color)); push.rect[0] = lay.thumbCx - lay.thumbHalfW; @@ -2979,24 +3042,26 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, if (entryIdx >= total) break; const auto& item = browserItems_[static_cast(entryIdx)]; const CardLayout lay = getCardLayout(i); + const float zLift = (i < 12) ? (0.012f * cardHoverAnim_[i]) : 0.0f; + const Mat4 vpCard = (zLift > 0.0002f) ? multiply(vpPanel, translation(Vec3{0.0f, 0.0f, zLift})) : vpPanel; if (browserViewMode_ == BrowserViewMode::BigCards) { - drawUiElement(vpPanel, lay.cx - lay.thumbHalfW + 0.062f, lay.thumbCy + lay.thumbHalfH - 0.024f, + drawUiElement(vpCard, lay.cx - lay.thumbHalfW + 0.062f, lay.thumbCy + lay.thumbHalfH - 0.024f, 0.056f, 0.018f, UiShape::RoundedBox, badgeBg, 0.45f, 0.03f, tabActiveBord); if (!item.durationTag.empty()) { - drawUiElement(vpPanel, lay.cx + lay.thumbHalfW - 0.050f, lay.thumbCy - lay.thumbHalfH + 0.024f, + drawUiElement(vpCard, lay.cx + lay.thumbHalfW - 0.050f, lay.thumbCy - lay.thumbHalfH + 0.024f, 0.044f, 0.017f, UiShape::RoundedBox, durBg, 0.40f, 0.02f, normCardBorder); } } else if (browserViewMode_ == BrowserViewMode::MiniCards) { - drawUiElement(vpPanel, lay.cx - lay.thumbHalfW + 0.046f, lay.thumbCy + lay.thumbHalfH - 0.018f, + drawUiElement(vpCard, lay.cx - lay.thumbHalfW + 0.046f, lay.thumbCy + lay.thumbHalfH - 0.018f, 0.042f, 0.014f, UiShape::RoundedBox, badgeBg, 0.45f, 0.025f, tabActiveBord); if (!item.durationTag.empty()) { - drawUiElement(vpPanel, lay.cx + lay.thumbHalfW - 0.038f, lay.thumbCy - lay.thumbHalfH + 0.018f, + drawUiElement(vpCard, lay.cx + lay.thumbHalfW - 0.038f, lay.thumbCy - lay.thumbHalfH + 0.018f, 0.034f, 0.014f, UiShape::RoundedBox, durBg, 0.40f, 0.02f, normCardBorder); } } else { // CompactList if (listShowFormat_) { - drawUiElement(vpPanel, listCols.fmtCx, lay.cy, + drawUiElement(vpCard, listCols.fmtCx, lay.cy, 0.055f, 0.016f, UiShape::RoundedBox, badgeBg, 0.45f, 0.025f, tabActiveBord); } } @@ -3107,6 +3172,9 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, const CardLayout lay = getCardLayout(i); const std::string cardDet = item.detail.empty() ? (item.isDir ? "Folder" : "Media") : item.detail; const std::string& bTag = item.tag.empty() ? (item.isVideo ? "VIDEO" : "FILE") : item.tag; + const float zLift = (i < 12) ? (0.012f * cardHoverAnim_[i]) : 0.0f; + const Mat4 vpCard = (zLift > 0.0002f) ? multiply(vpPanel, translation(Vec3{0.0f, 0.0f, zLift})) : vpPanel; + curTextVp = &vpCard; if (browserViewMode_ == BrowserViewMode::BigCards) { const float textLeft = lay.cx - lay.cardHalfW + 0.016f; @@ -3149,6 +3217,7 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, drawFitCentered(listCols.durCx, lay.cy, 0.11f, scDetail * 0.95f, slateCol, durStr); } } + curTextVp = &vpPanel; } // Bottom status bar @@ -3603,7 +3672,7 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, themeAccentCol[0], themeAccentCol[1], themeAccentCol[2], 1.0f}; const float slateCol[4] = {0.60f, 0.70f, 0.84f, 1.0f}; - if (actionMenu_.page == 0 || actionMenu_.page == 1) { + if (actionMenu_.page == 0 || actionMenu_.page == 1 || actionMenu_.page == 5) { const float btnHalfW = 0.38f; const float btnHalfH = 0.028f; for (int i = 0; i < menuOptCount; ++i) { @@ -3695,7 +3764,7 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex, drawFitCentered(0.0f, mHalfH - 0.090f, mHalfW * 1.8f, sc * 0.85f, slateCol, actionMenu_.subtitle); } - if (actionMenu_.page == 0 || actionMenu_.page == 1) { + if (actionMenu_.page == 0 || actionMenu_.page == 1 || actionMenu_.page == 5) { for (int i = 0; i < menuOptCount; ++i) { const float cy = menuStartY - static_cast(i) * menuStepY; const bool hov = (actionMenuBtnHovered_ == i) || (actionMenu_.selectedOption == i); @@ -4559,6 +4628,8 @@ bool VulkanRenderer::createYcbcrPipeline(uint32_t fourcc, int colorspace, if (!buildPipeline("quad.vert.spv", "video_quad.frag.spv", ycbcrQuadLayout_, ycbcrQuadPipeline_) || + !buildPipeline("quad.vert.spv", "ambilight.frag.spv", ycbcrQuadLayout_, + ycbcrAmbilightPipeline_, true) || !buildPipeline("sphere.vert.spv", "video_sphere.frag.spv", ycbcrSphereLayout_, ycbcrSpherePipeline_)) { return false; @@ -4592,6 +4663,10 @@ void VulkanRenderer::destroyYcbcr() { vkDestroyPipeline(device_, ycbcrQuadPipeline_, nullptr); ycbcrQuadPipeline_ = VK_NULL_HANDLE; } + if (ycbcrAmbilightPipeline_ != VK_NULL_HANDLE) { + vkDestroyPipeline(device_, ycbcrAmbilightPipeline_, nullptr); + ycbcrAmbilightPipeline_ = VK_NULL_HANDLE; + } if (ycbcrSpherePipeline_ != VK_NULL_HANDLE) { vkDestroyPipeline(device_, ycbcrSpherePipeline_, nullptr); ycbcrSpherePipeline_ = VK_NULL_HANDLE; diff --git a/src/gfx/vulkan_renderer.hpp b/src/gfx/vulkan_renderer.hpp index dd72d13..14f91ba 100644 --- a/src/gfx/vulkan_renderer.hpp +++ b/src/gfx/vulkan_renderer.hpp @@ -172,6 +172,10 @@ public: uiAccentColorIdx_ = accentColorIdx; uiDistance_ = uiDistance; } + void setAmbilightIntensity(float intensity) { + ambilightIntensity_ = intensity < 0.0f ? 0.0f : (intensity > 1.0f ? 1.0f : intensity); + } + float ambilightIntensity() const { return ambilightIntensity_; } enum class BrowserViewMode : int { BigCards = 0, // 3x2, 6 per page (Large cards with full 16:9 thumbnail) @@ -445,11 +449,14 @@ private: VkRenderPass renderPass_ = VK_NULL_HANDLE; VkPipelineLayout pipelineLayout_ = VK_NULL_HANDLE; VkPipeline pipeline_ = VK_NULL_HANDLE; + VkPipeline ambilightPipeline_ = VK_NULL_HANDLE; VkPipelineLayout spherePipelineLayout_ = VK_NULL_HANDLE; VkPipeline spherePipeline_ = VK_NULL_HANDLE; VkPipelineLayout uiPipelineLayout_ = VK_NULL_HANDLE; VkPipeline uiPipeline_ = VK_NULL_HANDLE; + float ambilightIntensity_ = 0.55f; + ProjectionMode projectionMode_ = ProjectionMode::Flat; StereoMode stereoMode_ = StereoMode::Mono; bool swapEyes_ = false; @@ -518,6 +525,7 @@ private: bool ready = false; }; CardThumbnail cardThumbs_[12]; + float cardHoverAnim_[12]{}; VkPipeline thumbPipeline_ = VK_NULL_HANDLE; VkDescriptorPool thumbPool_ = VK_NULL_HANDLE; BrowserViewMode browserViewMode_ = BrowserViewMode::BigCards; @@ -652,6 +660,7 @@ private: VkDescriptorSet ycbcrSet_ = VK_NULL_HANDLE; VkPipelineLayout ycbcrQuadLayout_ = VK_NULL_HANDLE; VkPipeline ycbcrQuadPipeline_ = VK_NULL_HANDLE; + VkPipeline ycbcrAmbilightPipeline_ = VK_NULL_HANDLE; VkPipelineLayout ycbcrSphereLayout_ = VK_NULL_HANDLE; VkPipeline ycbcrSpherePipeline_ = VK_NULL_HANDLE; VkFormat ycbcrFormat_ = VK_FORMAT_UNDEFINED; diff --git a/src/ui/settings_menu.cpp b/src/ui/settings_menu.cpp index 05c17a3..0c03ff4 100644 --- a/src/ui/settings_menu.cpp +++ b/src/ui/settings_menu.cpp @@ -61,6 +61,10 @@ const char* kHoverHapticsStrengthNames[] = { const float kHoverHapticsStrengthValues[] = {0.08f, 0.15f, 0.28f, 0.50f}; constexpr int kHoverHapticsStrengthCount = 4; +const char* kAmbilightNames[] = {"Off", "Subtle", "Medium", "Strong"}; +const float kAmbilightValues[] = {0.0f, 0.30f, 0.55f, 0.85f}; +constexpr int kAmbilightCount = 4; + std::string getConfigPath() { #ifdef _WIN32 const char* appData = std::getenv("APPDATA"); @@ -129,6 +133,8 @@ void SettingsMenu::refresh() { Action::CycleStereo}); rows_.push_back({"Cinema Screen Curve", kCurvatureNames[curvatureIdx_ % kCurvatureCount], Action::CycleCurvature}); + rows_.push_back({"Ambient Screen Glow", kAmbilightNames[ambilightIdx_ % kAmbilightCount], + Action::CycleAmbilight, projectionMode_ == 0}); rows_.push_back({"Screen Distance", distStream.str(), Action::CycleDistance}); rows_.push_back({"Swap Left/Right Eye", swapEyes_ ? "On" : "Off", @@ -396,6 +402,11 @@ SettingsMenu::Action SettingsMenu::activate() { saveConfig(); refresh(); break; + case Action::CycleAmbilight: + ambilightIdx_ = (ambilightIdx_ + 1) % kAmbilightCount; + saveConfig(); + refresh(); + break; case Action::ListenQuickPath: listeningFunc_ = 4; status_ = "Press a controller button for Bookmarks shortcut"; @@ -584,6 +595,10 @@ const char* SettingsMenu::hoverHapticsStrengthStr() const { return kHoverHapticsStrengthNames[hoverHapticsStrengthIdx_ % kHoverHapticsStrengthCount]; } +float SettingsMenu::ambilightIntensity() const { + return kAmbilightValues[ambilightIdx_ % kAmbilightCount]; +} + void SettingsMenu::loadConfig() { const std::string path = getConfigPath(); std::ifstream file(path); @@ -627,6 +642,7 @@ void SettingsMenu::loadConfig() { else if (key == "quick_path") quickPath_ = val; else if (key == "hover_haptics") hoverHapticsEnabled_ = (val == "1" || val == "true"); else if (key == "hover_haptics_strength") hoverHapticsStrengthIdx_ = std::clamp(std::stoi(val), 0, kHoverHapticsStrengthCount - 1); + else if (key == "ambilight") ambilightIdx_ = std::clamp(std::stoi(val), 0, kAmbilightCount - 1); } PIXELVR_LOG_INFO("Loaded settings from %s", path.c_str()); } @@ -665,6 +681,7 @@ void SettingsMenu::saveConfig() const { file << "list_show_dur=" << (listShowDuration_ ? 1 : 0) << "\n"; file << "hover_haptics=" << (hoverHapticsEnabled_ ? 1 : 0) << "\n"; file << "hover_haptics_strength=" << hoverHapticsStrengthIdx_ << "\n"; + file << "ambilight=" << ambilightIdx_ << "\n"; if (!quickPath_.empty()) { file << "quick_path=" << quickPath_ << "\n"; } diff --git a/src/ui/settings_menu.hpp b/src/ui/settings_menu.hpp index 798de7e..7aac43c 100644 --- a/src/ui/settings_menu.hpp +++ b/src/ui/settings_menu.hpp @@ -52,6 +52,7 @@ public: ToggleListColDuration, ToggleHoverHaptics, CycleHoverHapticsStrength, + CycleAmbilight, BackToUI, Close }; @@ -145,6 +146,10 @@ public: float hoverHapticsStrength() const; const char* hoverHapticsStrengthStr() const; + // Ambient screen glow ("ambilight") behind the flat cinema screen. + int ambilightIdx() const { return ambilightIdx_; } + float ambilightIntensity() const; // 0 = off + void onKeyboardSubmit(const std::string& text) { (void)text; } void onKeyboardCancel() {} @@ -183,6 +188,7 @@ private: std::string quickPath_; bool hoverHapticsEnabled_ = true; int hoverHapticsStrengthIdx_ = 1; + int ambilightIdx_ = 2; // 0=Off, 1=Subtle, 2=Medium (default), 3=Strong int section_ = 0; // 0=Display, 1=UI, 2=Audio, 3=Controls, 4=Vault; 10..12=sub-lists std::vector> videoStreams_; // (index, label)