Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Thumbs.db
#.idea/workspace.xml - remove # and delete .idea if it better suit your needs.
.gradle
build/
.deps/
*.iml

# Compiled JNI libraries folder
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/com/limelight/ConnectionCallbackHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class ConnectionCallbackHandler(private val game: Game) {

fun stageStarting(stage: String) {
game.runOnUiThread {
game.dualScreenControlPanelOrNull()?.updateConnectionStage(stage)
game.progressOverlay?.setMessage(
game.resources.getString(R.string.conn_starting) + " " + stage
)
Expand All @@ -39,6 +40,7 @@ class ConnectionCallbackHandler(private val game: Game) {
)

game.runOnUiThread {
game.dualScreenControlPanelOrNull()?.updateConnectionFailed("$stage ($errorCode)")
game.progressOverlay?.dismiss()
game.progressOverlay = null

Expand Down Expand Up @@ -102,6 +104,9 @@ class ConnectionCallbackHandler(private val game: Game) {
// Display the error dialog if it was an unexpected termination.
// Otherwise, just finish the activity immediately.
if (errorCode != MoonBridge.ML_ERROR_GRACEFUL_TERMINATION) {
game.dualScreenControlPanelOrNull()?.updateConnectionFailed(
game.getString(R.string.conn_terminated_msg) + " ($errorCode)"
)
val message: String = if (portTestResult != MoonBridge.ML_TEST_RESULT_INCONCLUSIVE && portTestResult != 0) {
game.resources.getString(R.string.nettest_text_blocked)
} else {
Expand Down Expand Up @@ -146,6 +151,7 @@ class ConnectionCallbackHandler(private val game: Game) {

fun connectionStatusUpdate(connectionStatus: Int) {
game.runOnUiThread {
game.dualScreenControlPanelOrNull()?.updateConnectionQuality(connectionStatus)
if (game.prefConfig.disableWarnings) {
return@runOnUiThread
}
Expand Down Expand Up @@ -186,6 +192,7 @@ class ConnectionCallbackHandler(private val game: Game) {
game.orientationManager.connected = true
game.connecting = false
game.updatePipAutoEnter()
game.dualScreenControlPanelOrNull()?.updateConnectionStarted()

// Hide the mouse cursor now after a short delay.
val h = Handler(Looper.getMainLooper())
Expand Down Expand Up @@ -294,6 +301,7 @@ class ConnectionCallbackHandler(private val game: Game) {
game.connected = false
game.orientationManager.connected = false
game.updatePipAutoEnter()
game.dualScreenControlPanelOrNull()?.updateConnectionStopped()

// 停止智能码率
game.stopAdaptiveBitrate()
Expand Down
311 changes: 311 additions & 0 deletions app/src/main/java/com/limelight/DualScreenControlPanel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
package com.limelight

import android.content.res.ColorStateList
import android.graphics.Point
import android.os.Build
import android.view.Display
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.limelight.binding.video.PerformanceInfo
import com.limelight.nvstream.jni.MoonBridge
import com.limelight.utils.UiHelper
import java.util.Locale

/**
* Controls the dashboard shown alongside the stream. On built-in dual-screen handhelds the
* dashboard is hosted by a Presentation on the lower panel. For a conventional external
* display, it remains in the Activity while the stream is rendered externally.
*/
class DualScreenControlPanel(private val game: Game) {
private val actionExecutor = StreamActionExecutor(game, { game.conn })

private var root: View? = null
private var statusDot: View? = null
private var statusText: TextView? = null
private var statusDetail: TextView? = null
private var sessionTitle: TextView? = null
private var targetDisplayText: TextView? = null
private var resolutionValue: TextView? = null
private var codecValue: TextView? = null
private var fpsValue: TextView? = null
private var rttValue: TextView? = null
private var decodeValue: TextView? = null
private var packetLossValue: TextView? = null
private var bandwidthValue: TextView? = null
private var batteryValue: TextView? = null
private var connectedControls: List<View> = emptyList()

private var active = false
private enum class ConnectionQuality {
CONNECTING,
GOOD,
POOR,
DISCONNECTED
}

fun initialize(
container: View = game.window.decorView,
allowCloseControlScreen: Boolean = false
) {
active = false
clearBindings()

val panelRoot = container.findViewById<View>(R.id.dualScreenControlPanel) ?: return
root = panelRoot
statusDot = panelRoot.findViewById(R.id.dualScreenStatusDot)
statusText = panelRoot.findViewById(R.id.dualScreenStatusText)
statusDetail = panelRoot.findViewById(R.id.dualScreenStatusDetail)
sessionTitle = panelRoot.findViewById(R.id.dualScreenSessionTitle)
targetDisplayText = panelRoot.findViewById(R.id.dualScreenTargetDisplay)
resolutionValue = panelRoot.findViewById(R.id.dualScreenResolutionValue)
codecValue = panelRoot.findViewById(R.id.dualScreenCodecValue)
fpsValue = panelRoot.findViewById(R.id.dualScreenFpsValue)
rttValue = panelRoot.findViewById(R.id.dualScreenRttValue)
decodeValue = panelRoot.findViewById(R.id.dualScreenDecodeValue)
packetLossValue = panelRoot.findViewById(R.id.dualScreenPacketLossValue)
bandwidthValue = panelRoot.findViewById(R.id.dualScreenBandwidthValue)
batteryValue = panelRoot.findViewById(R.id.dualScreenBatteryValue)
panelRoot.findViewById<FrameLayout>(R.id.dualScreenKeyboardContainer)?.let {
game.bindDualScreenKeyboard(it)
}

val menu = panelRoot.findViewById<View>(R.id.dualScreenMenuButton)?.also { view ->
view.setOnClickListener { game.showGameMenuOnControlDisplay(view) }
}
val keyboard = bindAction(R.id.dualScreenKeyboardButton) {
game.toggleDualScreenVirtualKeyboard()
}
val escape = bindAction(R.id.dualScreenEscapeButton) { actionExecutor.execute("send_esc") }
val windows = bindAction(R.id.dualScreenWindowsButton) { actionExecutor.execute("send_win") }
val taskSwitch = bindAction(R.id.dualScreenTaskSwitchButton) { actionExecutor.execute("send_alt_tab") }
val controller = bindAction(R.id.dualScreenControllerButton) { game.toggleVirtualController() }
val microphone = bindAction(R.id.dualScreenMicrophoneButton) { game.handleMicrophoneMenuAction() }
panelRoot.findViewById<View>(R.id.dualScreenCloseControlButton)?.also { view ->
view.visibility = if (allowCloseControlScreen) View.VISIBLE else View.GONE
view.setOnClickListener(
if (allowCloseControlScreen) {
View.OnClickListener { game.setDualScreenControlsEnabled(false) }
} else {
null
}
)
}
bindAction(R.id.dualScreenDisconnectButton) { game.disconnect() }

connectedControls = listOfNotNull(
menu,
keyboard,
escape,
windows,
taskSwitch,
controller,
microphone
)

sessionTitle?.text = game.getString(
R.string.dual_screen_session_title,
game.pcName ?: game.getString(R.string.dual_screen_unknown_host),
game.appName ?: game.app.appName
)
batteryValue?.text = game.getString(
R.string.dual_screen_battery_value,
UiHelper.getBatteryLevel(game)
)
resolutionValue?.setText(R.string.dual_screen_metric_pending)
codecValue?.setText(R.string.dual_screen_metric_pending)
setControlsEnabled(false)
}

fun show(targetDisplay: Display) {
active = true
targetDisplayText?.text = formatTargetDisplay(targetDisplay)
root?.visibility = View.VISIBLE
if (game.connected) {
updateConnectionQuality(MoonBridge.CONN_STATUS_OKAY)
} else {
updateStatus(
ConnectionQuality.CONNECTING,
R.string.dual_screen_status_connecting,
game.getString(R.string.dual_screen_status_connecting_detail)
)
}
}

fun hide() {
active = false
game.hideDualScreenVirtualKeyboard()
root?.visibility = View.GONE
}

fun updateConnectionStage(stage: String) {
if (!active) return
updateStatus(
ConnectionQuality.CONNECTING,
R.string.dual_screen_status_connecting,
game.getString(R.string.dual_screen_status_stage, stage)
)
}

fun updateConnectionStarted() {
if (!active) return
updateStatus(
ConnectionQuality.GOOD,
R.string.dual_screen_status_connected,
game.getString(R.string.dual_screen_status_connected_detail)
)
}

fun updateConnectionQuality(connectionStatus: Int) {
if (!active) return
if (connectionStatus == MoonBridge.CONN_STATUS_POOR) {
updateStatus(
ConnectionQuality.POOR,
R.string.dual_screen_status_poor,
game.getString(R.string.dual_screen_status_poor_detail)
)
} else if (connectionStatus == MoonBridge.CONN_STATUS_OKAY) {
updateStatus(
ConnectionQuality.GOOD,
R.string.dual_screen_status_connected,
game.getString(R.string.dual_screen_status_connected_detail)
)
}
}

fun updateConnectionFailed(detail: String) {
if (!active) return
updateStatus(
ConnectionQuality.DISCONNECTED,
R.string.dual_screen_status_failed,
detail
)
}

fun updateConnectionStopped() {
if (!active) return
updateStatus(
ConnectionQuality.DISCONNECTED,
R.string.dual_screen_status_disconnected,
game.getString(R.string.dual_screen_status_disconnected_detail)
)
}

fun updatePerformanceInfo(info: PerformanceInfo) {
if (!active) return
game.runOnUiThread {
resolutionValue?.text = DualScreenMetricFormatter.resolution(
info.initialWidth,
info.initialHeight
)
codecValue?.text = DualScreenMetricFormatter.codec(info.decoder, info.isHdrActive)
fpsValue?.text = DualScreenMetricFormatter.fps(info.renderedFps, info.receivedFps)
rttValue?.text = DualScreenMetricFormatter.rtt(info.rttInfo)
decodeValue?.text = DualScreenMetricFormatter.latency(info.decodeTimeMs)
packetLossValue?.text = DualScreenMetricFormatter.packetLoss(info.lostFrameRate)
bandwidthValue?.text = info.bandWidth?.takeIf { it.isNotBlank() }
?: game.getString(R.string.dual_screen_metric_pending)
batteryValue?.text = game.getString(
R.string.dual_screen_battery_value,
UiHelper.getBatteryLevel(game)
)
}
}

fun release() {
active = false
clearBindings()
}

private fun clearBindings() {
game.releaseDualScreenKeyboard()
root = null
statusDot = null
statusText = null
statusDetail = null
sessionTitle = null
targetDisplayText = null
resolutionValue = null
codecValue = null
fpsValue = null
rttValue = null
decodeValue = null
packetLossValue = null
bandwidthValue = null
batteryValue = null
connectedControls = emptyList()
}

private fun bindAction(viewId: Int, action: () -> Unit): View? {
return root?.findViewById<View>(viewId)?.also { view ->
view.setOnClickListener { action() }
}
}

private fun updateStatus(
quality: ConnectionQuality,
titleRes: Int,
detail: String
) {
statusText?.setText(titleRes)
statusDetail?.text = detail

val colorRes = when (quality) {
ConnectionQuality.CONNECTING -> R.color.dual_screen_status_connecting
ConnectionQuality.GOOD -> R.color.dual_screen_status_good
ConnectionQuality.POOR -> R.color.dual_screen_status_poor
ConnectionQuality.DISCONNECTED -> R.color.dual_screen_status_disconnected
}
statusDot?.backgroundTintList = ColorStateList.valueOf(
ContextCompat.getColor(game, colorRes)
)
setControlsEnabled(quality == ConnectionQuality.GOOD || quality == ConnectionQuality.POOR)
}

private fun setControlsEnabled(enabled: Boolean) {
connectedControls.forEach { control ->
control.isEnabled = enabled
control.alpha = if (enabled) 1f else 0.45f
}
}

@Suppress("DEPRECATION")
private fun formatTargetDisplay(display: Display): String {
val realSize = Point()
display.getRealSize(realSize)
val refreshRate: Float
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
refreshRate = display.mode.refreshRate
} else {
refreshRate = display.refreshRate
}
return game.getString(
R.string.dual_screen_target_display,
display.name,
realSize.x,
realSize.y,
refreshRate
)
}
}

internal object DualScreenMetricFormatter {
fun resolution(width: Int, height: Int): String = "${width}x${height}"

fun codec(decoder: String?, hdrActive: Boolean): String {
val codec = decoder?.takeIf { it.isNotBlank() } ?: "--"
return if (hdrActive) "$codec · HDR" else codec
}

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))
Comment on lines +301 to +310

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.

}
Loading