Skip to content

feat(game-menu): add dual-screen streaming controls - #451

Open
lhf1a wants to merge 10 commits into
qiin2333:masterfrom
lhf1a:codex/android-dual-screen
Open

feat(game-menu): add dual-screen streaming controls#451
lhf1a wants to merge 10 commits into
qiin2333:masterfrom
lhf1a:codex/android-dual-screen

Conversation

@lhf1a

@lhf1a lhf1a commented Aug 4, 2026

Copy link
Copy Markdown

Summary

  • add an opt-in upper/lower dual-screen mode and let users select the primary streaming display by display name and resolution
  • show an adaptive control dashboard on the secondary display with connection metrics, session information, and common streaming controls
  • open Game Menu and the full-screen virtual keyboard on the control display, with support for closing the menu using the control display’s back gesture
  • add controls for Esc, Win, Alt+Tab, virtual controller, microphone, disconnecting, closing the control display, and restoring dual-screen mode
  • keep physical gamepad and joystick input routed to the active stream even after interacting with the control display or opening its menu
  • use the configured background image on idle secondary displays with aspect-preserving center-crop rendering

Compatibility

This implementation contains no Thor-specific identifiers. It uses Android’s standard DisplayManager, Presentation, multi-display window tokens, and input event APIs. Dual-screen mode is opt-in, and users explicitly select the primary streaming display from the displays reported by Android.

Devices that do not expose both panels as independent Android displays, or whose firmware restricts secondary-display presentations, navigation gestures, or input dispatch, may require separate handling.

Testing

sh gradlew -PaudioHapticsSdkDir=.deps/moonlight-audio-haptics \
  :app:assembleNonRootDebug \
  :app:testNonRootDebugUnitTest \
  :app:lintNonRootDebug

sh gradlew -PaudioHapticsSdkDir=.deps/moonlight-audio-haptics \
  :app:assembleNonRootRelease
  • 101 unit tests passed
  • tested on a physical Thor upper/lower dual-screen Android device with real Sunshine streaming servers
  • verified primary-display selection, streaming on the upper display, and the adaptive control dashboard on the lower display
  • verified control-display Game Menu, back gesture dismissal, and full-screen virtual keyboard
  • verified closing and restoring the control display
  • verified Xbox controller input remains routed to the stream before, during, and after control-display interaction

Lint

:app:lintNonRootDebug reports 104 warnings and 1 hint. The repository baseline filters 133 existing errors, 1579 warnings, and 1 hint. These changes introduce no new lint errors.

provided by chat-gpt 5.6sol-maxhigh

Summary by CodeRabbit

  • New Features

    • Added dual-screen streaming with selectable streaming and control displays.
    • Added a dashboard with connection status, display details, performance metrics, battery information, and quick actions.
    • Added virtual keyboard and game-menu controls on the secondary display.
    • Added configurable idle backgrounds and improved external-display settings.
  • Bug Fixes

    • Improved display detection, selection, fallback behavior, and connection-status reporting.
  • Tests

    • Added coverage for display selection and metric formatting.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d93386d6-eb74-4e42-87fd-b67ededdac1e

📥 Commits

Reviewing files that changed from the base of the PR and between 9ba15ca and ac31dd5.

📒 Files selected for processing (5)
  • app/src/main/java/com/limelight/ExternalDisplayManager.kt
  • app/src/main/java/com/limelight/Game.kt
  • app/src/main/java/com/limelight/preferences/ExternalDisplayPreference.kt
  • app/src/main/java/com/limelight/preferences/StreamSettings.kt
  • app/src/main/res/layout/dual_screen_control_panel.xml
🚧 Files skipped from review as they are similar to previous changes (3)
  • app/src/main/java/com/limelight/preferences/ExternalDisplayPreference.kt
  • app/src/main/java/com/limelight/Game.kt
  • app/src/main/java/com/limelight/ExternalDisplayManager.kt

📝 Walkthrough

Walkthrough

The pull request adds dual-screen display selection, external-display presentation management, a control dashboard, input forwarding, connection metrics, localized resources, and configurable idle backgrounds.

Changes

Dual-screen streaming

Layer / File(s) Summary
Display selection and preferences
app/src/main/java/com/limelight/TargetDisplayResolver.kt, app/src/main/java/com/limelight/preferences/ExternalDisplayPreference.kt, app/src/test/java/com/limelight/DisplaySelectionPolicyTest.kt
Display selection uses saved preferences and shared policies. The settings UI supports stream-display selection, dual-screen activation, display labels, and availability summaries.
External-display presentation and game wiring
app/src/main/java/com/limelight/ExternalDisplayManager.kt, app/src/main/java/com/limelight/Game.kt, app/src/main/java/com/limelight/ConnectionCallbackHandler.kt, app/src/main/java/com/limelight/gamemenu/GameMenu.kt, app/src/main/java/com/limelight/binding/input/advance_setting/KeyboardUIController.kt
External-display reconciliation manages stream, control, and idle presentations. Game callbacks forward control input, connection state, performance data, menu actions, and keyboard lifecycle events.
Dashboard UI and metric presentation
app/src/main/java/com/limelight/DualScreenControlPanel.kt, app/src/main/res/layout/dual_screen_*, app/src/main/res/drawable/dual_screen_*, app/src/main/res/values/*, app/src/test/java/com/limelight/DualScreenMetricFormatterTest.kt
The control panel displays session details, connection state, battery data, stream metrics, actions, and localized dual-screen text. Supporting layouts, styles, colors, drawables, and formatter tests are added.
Idle background loading
app/src/main/java/com/limelight/preferences/StreamSettings.kt, app/src/main/java/com/limelight/ExternalDisplayManager.kt, app/src/main/res/layout/dual_screen_idle_background.xml
Idle display backgrounds load from local or remote configured sources, refresh after preference changes, and clean up with the activity lifecycle.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Game
  participant ExternalDisplayManager
  participant DualScreenControlPanel
  participant ConnectionCallbackHandler
  Game->>ExternalDisplayManager: initialize and reconcile displays
  ExternalDisplayManager->>Game: onDualScreenControlPanelReady
  Game->>DualScreenControlPanel: initialize and show target display
  ConnectionCallbackHandler->>DualScreenControlPanel: forward connection stage and quality
  Game->>DualScreenControlPanel: update performance metrics
  ExternalDisplayManager->>Game: forward control-display input
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the pull request's primary change: adding dual-screen streaming controls.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (2)
app/src/main/res/drawable/dual_screen_danger_bg.xml (1)

5-11: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the hardcoded fills into named colors.

dual_screen_action_bg.xml references named colors for both fills. Here the fills are inline hex values while the stroke uses @color/dual_screen_danger. Add the two danger fills to advance_setting_colors.xml and reference them, so the palette stays in one place.

♻️ Proposed refactor
     <item android:state_pressed="true">
         <shape android:shape="rectangle">
-            <solid android:color="`#553D0F16`" />
+            <solid android:color="`@color/dual_screen_danger_pressed`" />
             <corners android:radius="12dp" />
         </shape>
     </item>
     <item>
         <shape android:shape="rectangle">
-            <solid android:color="`#33241118`" />
+            <solid android:color="`@color/dual_screen_danger_background`" />

Add to app/src/main/res/values/advance_setting_colors.xml:

<color name="dual_screen_danger_pressed">`#553D0F16`</color>
<color name="dual_screen_danger_background">`#33241118`</color>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/res/drawable/dual_screen_danger_bg.xml` around lines 5 - 11,
Replace the inline fill colors in the dual_screen_danger_bg drawable with named
color references. Add dual_screen_danger_pressed and
dual_screen_danger_background to advance_setting_colors.xml using the existing
hex values, then reference those resources for the corresponding shape fills
while leaving the stroke unchanged.
app/src/main/java/com/limelight/ExternalDisplayManager.kt (1)

227-239: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Deduplicate the identical log message.

Both branches log the same text. Only the following action differs. Log once before the branch.

♻️ Proposed refactor
-        if (streamDisplay.displayId != Display.DEFAULT_DISPLAY) {
-            LimeLog.info(
-                "Using selected stream display: ${streamDisplay.name} " +
-                    "(ID: ${streamDisplay.displayId}); controls on display ${controlDisplay.displayId}"
-            )
-            startExternalDisplayPresentation(streamDisplay)
-            return
-        }
-
         LimeLog.info(
             "Using selected stream display: ${streamDisplay.name} " +
                 "(ID: ${streamDisplay.displayId}); controls on display ${controlDisplay.displayId}"
         )
+        if (streamDisplay.displayId != Display.DEFAULT_DISPLAY) {
+            startExternalDisplayPresentation(streamDisplay)
+            return
+        }
+
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/limelight/ExternalDisplayManager.kt` around lines 227 -
239, Deduplicate the repeated selected-stream-display log in the surrounding
ExternalDisplayManager flow by moving the identical LimeLog.info call before the
streamDisplay.displayId branch. Keep the branch-specific behavior unchanged:
startExternalDisplayPresentation(streamDisplay) and return only for non-default
displays, while default displays continue through the existing path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@app/src/main/java/com/limelight/binding/input/advance_setting/KeyboardUIController.kt`:
- Around line 137-139: Update the three visibility assignments in the keyboard
UI initialization to use nullable-safe lookups for btn_keyboard_resize,
keyboard_resize_handle, and keyboard_opacity_label, preventing dereferences when
optional controls are absent from the inflated layout.

In `@app/src/main/java/com/limelight/DualScreenControlPanel.kt`:
- Around line 301-310: The fps, latency, and packetLoss formatters currently
force Locale.US instead of respecting the active device locale. Update these
methods in the metrics formatter to use Locale.getDefault() (or Android’s
locale-aware formatting), and add a test that sets a non-US locale and verifies
the localized decimal separator.

In `@app/src/main/java/com/limelight/ExternalDisplayManager.kt`:
- Line 375: Update the model selection around the target check in
ExternalDisplayManager so remote sources are identified by a URI scheme rather
than the broad startsWith("http") prefix; only pass a String for valid HTTP(S)
targets, while treating local paths such as "httpcache/bg.png" as File
instances.

In `@app/src/main/java/com/limelight/Game.kt`:
- Around line 2388-2395: Update the GameMenu construction in Game.kt so
hostContext and hostWindowToken use the same source: only use hostView.context
when hostView.windowToken is non-null, and otherwise fall back to the activity
context (this) with a null token. Preserve the existing hostWindowToken value
when the host view is attached.

In `@app/src/main/java/com/limelight/preferences/ExternalDisplayPreference.kt`:
- Around line 34-35: Update ExternalDisplayPreference’s initialize and lifecycle
methods to register a DisplayManager.DisplayListener for the preference
lifetime, invoking updateSummary() on display added, removed, or changed
callbacks. Unregister the same listener when the preference detaches, preserving
existing summary refresh behavior.
- Around line 92-96: Update ExternalDisplayPreference.updateSummary() so the
displays.size < 2 path only updates the summary and sets isEnabled to false; do
not assign isChecked, since it persists the use_external_display preference.
Apply the same change to the exception path while preserving its existing
summary and disabled-state behavior.

In `@app/src/main/java/com/limelight/preferences/StreamSettings.kt`:
- Around line 4264-4270: Update loadBackgroundImage() to increment a monotonic
load generation whenever a new background load starts, and pass that generation
through both the cached/network and worker-based paths. In
tryCachedThenNetwork() and loadBackgroundImageFromSource(), check that the
generation still matches before recursing to fallbacks and before applying any
UI result via runOnUiThread, preventing stale loads from replacing newer images.

In `@app/src/main/res/layout/dual_screen_control_panel.xml`:
- Around line 68-93: Update the header LinearLayout containing
dualScreenSessionTitle and dualScreenTargetDisplay so the session and target
columns use bounded widths instead of wrap_content, while preserving their
existing ellipsize behavior. Ensure both dynamic labels fit within their
assigned bounds and do not push the weighted status column off screen.

---

Nitpick comments:
In `@app/src/main/java/com/limelight/ExternalDisplayManager.kt`:
- Around line 227-239: Deduplicate the repeated selected-stream-display log in
the surrounding ExternalDisplayManager flow by moving the identical LimeLog.info
call before the streamDisplay.displayId branch. Keep the branch-specific
behavior unchanged: startExternalDisplayPresentation(streamDisplay) and return
only for non-default displays, while default displays continue through the
existing path.

In `@app/src/main/res/drawable/dual_screen_danger_bg.xml`:
- Around line 5-11: Replace the inline fill colors in the dual_screen_danger_bg
drawable with named color references. Add dual_screen_danger_pressed and
dual_screen_danger_background to advance_setting_colors.xml using the existing
hex values, then reference those resources for the corresponding shape fills
while leaving the stroke unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d60eb610-8a77-4e35-ab7e-fe45fe6b00db

📥 Commits

Reviewing files that changed from the base of the PR and between 96cb9e4 and 9ba15ca.

📒 Files selected for processing (31)
  • .gitignore
  • app/src/main/java/com/limelight/ConnectionCallbackHandler.kt
  • app/src/main/java/com/limelight/DualScreenControlPanel.kt
  • app/src/main/java/com/limelight/ExternalDisplayManager.kt
  • app/src/main/java/com/limelight/Game.kt
  • app/src/main/java/com/limelight/TargetDisplayResolver.kt
  • app/src/main/java/com/limelight/binding/input/advance_setting/KeyboardUIController.kt
  • app/src/main/java/com/limelight/gamemenu/GameMenu.kt
  • app/src/main/java/com/limelight/preferences/ExternalDisplayPreference.kt
  • app/src/main/java/com/limelight/preferences/StreamSettings.kt
  • app/src/main/res/drawable/dual_screen_action_bg.xml
  • app/src/main/res/drawable/dual_screen_danger_bg.xml
  • app/src/main/res/drawable/dual_screen_metric_bg.xml
  • app/src/main/res/drawable/dual_screen_status_dot.xml
  • app/src/main/res/layout/activity_game.xml
  • app/src/main/res/layout/dual_screen_control_panel.xml
  • app/src/main/res/layout/dual_screen_idle_background.xml
  • app/src/main/res/layout/dual_screen_metric_bandwidth.xml
  • app/src/main/res/layout/dual_screen_metric_battery.xml
  • app/src/main/res/layout/dual_screen_metric_decode.xml
  • app/src/main/res/layout/dual_screen_metric_fps.xml
  • app/src/main/res/layout/dual_screen_metric_loss.xml
  • app/src/main/res/layout/dual_screen_metric_resolution.xml
  • app/src/main/res/layout/dual_screen_metric_rtt.xml
  • app/src/main/res/layout/layer_6_keyboard.xml
  • app/src/main/res/values-zh-rCN/strings.xml
  • app/src/main/res/values/advance_setting_colors.xml
  • app/src/main/res/values/strings.xml
  • app/src/main/res/values/styles.xml
  • app/src/test/java/com/limelight/DisplaySelectionPolicyTest.kt
  • app/src/test/java/com/limelight/DualScreenMetricFormatterTest.kt

Comment on lines +137 to +139
keyboardLayout.findViewById<View>(R.id.btn_keyboard_resize).visibility = View.GONE
keyboardLayout.findViewById<View>(R.id.keyboard_resize_handle).visibility = View.GONE
keyboardLayout.findViewById<View>(R.id.keyboard_opacity_label).visibility = View.GONE

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Use safe lookups for the optional controls.

Line 311 fetches R.id.btn_keyboard_resize as nullable and null-checks it at line 370. Here the same view is dereferenced directly. If the inflated layout omits the view, the constructor throws a NullPointerException. Use safe calls for all three lookups.

🐛 Proposed fix
-        keyboardLayout.findViewById<View>(R.id.btn_keyboard_resize).visibility = View.GONE
-        keyboardLayout.findViewById<View>(R.id.keyboard_resize_handle).visibility = View.GONE
-        keyboardLayout.findViewById<View>(R.id.keyboard_opacity_label).visibility = View.GONE
+        keyboardLayout.findViewById<View?>(R.id.btn_keyboard_resize)?.visibility = View.GONE
+        keyboardLayout.findViewById<View?>(R.id.keyboard_resize_handle)?.visibility = View.GONE
+        keyboardLayout.findViewById<View?>(R.id.keyboard_opacity_label)?.visibility = View.GONE
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/limelight/binding/input/advance_setting/KeyboardUIController.kt`
around lines 137 - 139, Update the three visibility assignments in the keyboard
UI initialization to use nullable-safe lookups for btn_keyboard_resize,
keyboard_resize_handle, and keyboard_opacity_label, preventing dereferences when
optional controls are absent from the inflated layout.

Comment on lines +301 to +310
fun fps(renderedFps: Float, receivedFps: Float): String =
String.format(Locale.US, "%.1f / %.1f", renderedFps, receivedFps)

fun rtt(rttInfo: Long): String = "${(rttInfo shr 32).toInt().coerceAtLeast(0)} ms"

fun latency(milliseconds: Float): String =
String.format(Locale.US, "%.1f ms", milliseconds.coerceAtLeast(0f))

fun packetLoss(rate: Float): String =
String.format(Locale.US, "%.2f%%", rate.coerceAtLeast(0f))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate file =="
fd -a 'DualScreenControlPanel\.kt$' . || true

echo "== file excerpt =="
file="$(fd 'DualScreenControlPanel\.kt$' . | head -n1)"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  sed -n '260,330p' "$file" | nl -ba -v260
fi

echo "== locale/localization usages nearby / project-wide numeric formatting =="
rg -n "String\.format|Locale\.US|Locale\.getDefault|DecimalFormat|NumberFormat|r24|`@android`:style/TextAppearance|`@android`:style/TextAppearance|res/values" -S . -g '*.kotlin' -g '*.kt' -g '*.xml' -g '*.java' | head -200 || true

Repository: qiin2333/moonlight-vplus

Length of output: 384


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file excerpt =="
sed -n '260,330p' /home/jailuser/git/app/src/main/java/com/limelight/DualScreenControlPanel.kt

echo "== numeric formatting usages =="
rg -n "String\.format|Locale\.US|Locale\.getDefault|NumberFormat|DecimalFormat|String\.format\(Locale" /home/jailuser/git/app/src/main/java/com/limelight /home/jailuser/git/app/src/main/java -S 2>/dev/null | head -200 || true

echo "== deterministic locale formatting probe =="
python3 - <<'PY'
from locale import setlocale, LC_NUMERIC, LC_ALL, C
for locale in ('en_US', 'de_DE', 'ar_SA'):
    try:
        setlocale(LC_NUMERIC, locale)
    except Exception as e:
        print(f"{locale}: unavailable: {e}")
        continue
    for value in (9.876, 9.8):
        print(f"{locale}; value={value}; default={value:.1f} vs US={value:.1f}")
PY

Repository: qiin2333/moonlight-vplus

Length of output: 35499


Use the active device locale for formatted metrics.

String.format(Locale.US, ...) forces a period decimal separator for FPS, latency, and packet loss output. Let these user-facing values use Locale.getDefault() or Android string formatting so non-US numeric locales are respected. Add a formatter test with a non-US locale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/limelight/DualScreenControlPanel.kt` around lines 301 -
310, The fps, latency, and packetLoss formatters currently force Locale.US
instead of respecting the active device locale. Update these methods in the
metrics formatter to use Locale.getDefault() (or Android’s locale-aware
formatting), and add a test that sets a non-US locale and verifies the localized
decimal separator.

Comment thread app/src/main/java/com/limelight/ExternalDisplayManager.kt Outdated
Comment thread app/src/main/java/com/limelight/Game.kt
Comment thread app/src/main/java/com/limelight/preferences/StreamSettings.kt
Comment thread app/src/main/res/layout/dual_screen_control_panel.xml
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant