diff --git a/.gitignore b/.gitignore index 93f01f2cae..1441925e8b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/src/main/java/com/limelight/ConnectionCallbackHandler.kt b/app/src/main/java/com/limelight/ConnectionCallbackHandler.kt index 9b71fbb008..206b0feea0 100644 --- a/app/src/main/java/com/limelight/ConnectionCallbackHandler.kt +++ b/app/src/main/java/com/limelight/ConnectionCallbackHandler.kt @@ -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 ) @@ -39,6 +40,7 @@ class ConnectionCallbackHandler(private val game: Game) { ) game.runOnUiThread { + game.dualScreenControlPanelOrNull()?.updateConnectionFailed("$stage ($errorCode)") game.progressOverlay?.dismiss() game.progressOverlay = null @@ -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 { @@ -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 } @@ -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()) @@ -294,6 +301,7 @@ class ConnectionCallbackHandler(private val game: Game) { game.connected = false game.orientationManager.connected = false game.updatePipAutoEnter() + game.dualScreenControlPanelOrNull()?.updateConnectionStopped() // 停止智能码率 game.stopAdaptiveBitrate() diff --git a/app/src/main/java/com/limelight/DualScreenControlPanel.kt b/app/src/main/java/com/limelight/DualScreenControlPanel.kt new file mode 100644 index 0000000000..8f29f6c0e5 --- /dev/null +++ b/app/src/main/java/com/limelight/DualScreenControlPanel.kt @@ -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 = 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(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(R.id.dualScreenKeyboardContainer)?.let { + game.bindDualScreenKeyboard(it) + } + + val menu = panelRoot.findViewById(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(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(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)) +} diff --git a/app/src/main/java/com/limelight/ExternalDisplayManager.kt b/app/src/main/java/com/limelight/ExternalDisplayManager.kt index 287e0ab04c..60f81ebf64 100644 --- a/app/src/main/java/com/limelight/ExternalDisplayManager.kt +++ b/app/src/main/java/com/limelight/ExternalDisplayManager.kt @@ -1,25 +1,28 @@ @file:Suppress("DEPRECATION") package com.limelight -import android.annotation.SuppressLint import android.app.Activity import android.app.Presentation import android.content.Context +import android.content.SharedPreferences import android.hardware.display.DisplayManager import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.TypedValue import android.view.Display -import android.view.Gravity +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent import android.view.View import android.view.WindowManager -import android.widget.FrameLayout -import android.widget.TextView +import android.widget.ImageView import android.widget.Toast +import androidx.preference.PreferenceManager +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.limelight.binding.input.ControllerHandler +import com.limelight.preferences.BackgroundSource import com.limelight.preferences.PreferenceConfiguration import com.limelight.ui.StreamView -import com.limelight.utils.UiHelper +import java.io.File /** * 外接显示器管理器 @@ -34,36 +37,40 @@ class ExternalDisplayManager( activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager private var displayListener: DisplayManager.DisplayListener? = null private var externalPresentation: ExternalDisplayPresentation? = null + private var dualScreenPresentation: DualScreenControlPresentation? = null + private var idleBackgroundPresentation: IdleBackgroundPresentation? = null + private var dualScreenControlsEnabled = true private var displayModeSelection: DisplayModeManager.DisplayModeSelection? = null + private var backgroundPrefsListener: SharedPreferences.OnSharedPreferenceChangeListener? = null interface ExternalDisplayCallback { fun onExternalDisplayConnected(display: Display) fun onExternalDisplayDisconnected() fun onStreamViewReady(streamView: StreamView) + fun onDualScreenControlPanelReady( + rootView: View, + streamDisplay: Display, + controlDisplay: Display + ) + fun onControlDisplayKeyEvent(event: KeyEvent): Boolean = false + fun onControlDisplayMotionEvent(event: MotionEvent): Boolean = false + fun onDualScreenDisconnected() } var callback: ExternalDisplayCallback? = null fun initialize(initialDisplayMode: DisplayModeManager.DisplayModeSelection? = null) { displayModeSelection = initialDisplayMode - targetDisplayResolver.resolve(prefConfig.useExternalDisplay) - + registerIdleBackgroundPreferenceListener() setupDisplayListener() - checkForExternalDisplay() - - if (isUsingExternalDisplay()) { - val window = activity.window - if (window != null) { - val layoutParams = window.attributes - layoutParams.screenBrightness = 0.3f - window.attributes = layoutParams - } - startExternalDisplayPresentation() - } + reconcileDisplays() } fun cleanup() { dismissExternalPresentation() + dismissDualScreenPresentation() + dismissIdleBackgroundPresentation() + unregisterIdleBackgroundPreferenceListener() displayListener?.let { displayManager.unregisterDisplayListener(it) @@ -77,6 +84,27 @@ class ExternalDisplayManager( fun isUsingExternalDisplay(): Boolean = targetDisplayResolver.isExternalDisplaySelected() + fun setDualScreenControlsEnabled(enabled: Boolean) { + dualScreenControlsEnabled = enabled + if (!enabled) { + val hadControlPresentation = dualScreenPresentation != null + dismissDualScreenPresentation() + if (hadControlPresentation) { + callback?.onDualScreenDisconnected() + } + return + } + + reconcileDisplays() + } + + fun canRestoreDualScreenControls(): Boolean { + if (dualScreenControlsEnabled || !prefConfig.useExternalDisplay) return false + val streamDisplay = targetDisplayResolver.currentDisplay() + return streamDisplay.displayId == Display.DEFAULT_DISPLAY && + targetDisplayResolver.controlDisplayFor(streamDisplay.displayId) != null + } + /** * Stores the mode selected for a specific display and applies it to the Presentation when * that display is rendered. The display id prevents a stale mode id from being used after a @@ -95,25 +123,40 @@ class ExternalDisplayManager( override fun onDisplayAdded(displayId: Int) { LimeLog.info("Display added: $displayId") if (prefConfig.useExternalDisplay && displayId != Display.DEFAULT_DISPLAY) { - checkForExternalDisplay() - if (isUsingExternalDisplay()) { - startExternalDisplayPresentation() - } + reconcileDisplays() } } override fun onDisplayRemoved(displayId: Int) { LimeLog.info("Display removed: $displayId") + val wasIdleBackground = + idleBackgroundPresentation?.isForDisplay(displayId) == true + if (wasIdleBackground) { + dismissIdleBackgroundPresentation() + } + + val wasDualScreen = dualScreenPresentation?.isForDisplay(displayId) == true + if (wasDualScreen) { + dismissDualScreenPresentation() + callback?.onDualScreenDisconnected() + } + val wasTargetDisplay = displayId != Display.DEFAULT_DISPLAY && targetDisplayResolver.onDisplayRemoved(displayId) if (wasTargetDisplay) { dismissExternalPresentation() - val surfaceView = activity.findViewById(R.id.surfaceView) - surfaceView?.visibility = View.VISIBLE - Toast.makeText(activity, activity.getString(R.string.toast_external_display_disconnected), Toast.LENGTH_SHORT).show() + if (callback != null) { + val surfaceView = activity.findViewById(R.id.surfaceView) + surfaceView?.visibility = View.VISIBLE + Toast.makeText(activity, activity.getString(R.string.toast_external_display_disconnected), Toast.LENGTH_SHORT).show() + + callback?.onExternalDisplayDisconnected() + } + } - callback?.onExternalDisplayDisconnected() + if (wasIdleBackground || wasDualScreen || wasTargetDisplay) { + reconcileDisplays() } } @@ -131,21 +174,75 @@ class ExternalDisplayManager( externalPresentation = null } - private fun checkForExternalDisplay() { + private fun dismissDualScreenPresentation() { + dualScreenPresentation?.dismiss() + dualScreenPresentation = null + } + + private fun dismissIdleBackgroundPresentation() { + idleBackgroundPresentation?.clearBackground() + idleBackgroundPresentation?.dismiss() + idleBackgroundPresentation = null + } + + private fun reconcileDisplays() { if (!prefConfig.useExternalDisplay) { - LimeLog.info("External display disabled by user preference") + LimeLog.info("Dual-screen and external display support disabled by user preference") targetDisplayResolver.resolve(false) + dismissExternalPresentation() + dismissDualScreenPresentation() + dismissIdleBackgroundPresentation() return } - val display = targetDisplayResolver.resolve(true) - if (display.displayId == Display.DEFAULT_DISPLAY) { - LimeLog.info("No external display found, using default display") + val streamDisplay = targetDisplayResolver.resolve(true) + if (callback == null) { + val activityDisplayId = activity.windowManager.defaultDisplay.displayId + val idleDisplay = displayManager.displays + .sortedBy { it.displayId } + .firstOrNull { it.displayId != activityDisplayId } + if (idleDisplay == null) { + LimeLog.info("No secondary display found for settings background") + dismissIdleBackgroundPresentation() + return + } + + LimeLog.info( + "Showing settings background on ${idleDisplay.name} " + + "(ID: ${idleDisplay.displayId})" + ) + startIdleBackgroundPresentation(idleDisplay) return } - LimeLog.info("Found external display: ${display.name} (ID: ${display.displayId})") - callback?.onExternalDisplayConnected(display) + dismissIdleBackgroundPresentation() + val controlDisplay = targetDisplayResolver.controlDisplayFor(streamDisplay.displayId) + if (controlDisplay == null) { + LimeLog.info("No secondary display found, using default display") + dismissExternalPresentation() + dismissDualScreenPresentation() + return + } + + 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 (!dualScreenControlsEnabled) { + LimeLog.info("Dual-screen controls hidden for the current streaming session") + dismissDualScreenPresentation() + return + } + startDualScreenPresentation(controlDisplay, streamDisplay) } private inner class ExternalDisplayPresentation( @@ -179,75 +276,199 @@ class ExternalDisplayManager( override fun onDisplayRemoved() { super.onDisplayRemoved() - activity.finish() + // DisplayManager.DisplayListener restores the main StreamView and hides the + // lower-screen dashboard. Finishing here races that fallback path. + } + + fun isForDisplay(displayId: Int): Boolean = presentationDisplayId == displayId + } + + private inner class DualScreenControlPresentation( + outerContext: Context, + private val controlDisplay: Display, + private val streamDisplay: Display + ) : Presentation(outerContext, controlDisplay) { + private val presentationDisplayId = controlDisplay.displayId + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setCancelable(false) + window?.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + LimeLog.info( + "Control display forwards physical controller input to the stream" + ) + @Suppress("DEPRECATION") + window?.decorView?.systemUiVisibility = + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + + setContentView(R.layout.dual_screen_control_panel) + findViewById(R.id.dualScreenControlPanel)?.let { panelRoot -> + callback?.onDualScreenControlPanelReady( + panelRoot, + streamDisplay, + controlDisplay + ) + } + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.device != null && + ControllerHandler.isGameControllerDevice(event.device) && + callback?.onControlDisplayKeyEvent(event) == true + ) { + return true + } + return super.dispatchKeyEvent(event) + } + + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (event.isFromSource(InputDevice.SOURCE_CLASS_JOYSTICK) && + callback?.onControlDisplayMotionEvent(event) == true + ) { + return true + } + return super.dispatchGenericMotionEvent(event) } fun isForDisplay(displayId: Int): Boolean = presentationDisplayId == displayId } - @SuppressLint("ResourceAsColor", "SetTextI18n") - private fun startExternalDisplayPresentation() { - if (!isUsingExternalDisplay() || externalPresentation != null) { + private inner class IdleBackgroundPresentation( + outerContext: Context, + display: Display + ) : Presentation(outerContext, display) { + private val presentationDisplayId = display.displayId + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + window?.setBackgroundDrawableResource(R.color.advance_setting_background) + window?.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + window?.decorView?.systemUiVisibility = + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + + setContentView(R.layout.dual_screen_idle_background) + refreshBackground() + } + + fun refreshBackground() { + val imageView = findViewById(R.id.dualScreenIdleBackgroundImage) ?: return + Glide.with(activity).clear(imageView) + imageView.setImageDrawable(null) + + val target = BackgroundSource.current(activity).resolveTarget( + activity, + resources.configuration.orientation + ) ?: return + val isRemoteTarget = target.startsWith("http://", ignoreCase = true) || + target.startsWith("https://", ignoreCase = true) + val model: Any = if (isRemoteTarget) target else File(target) + Glide.with(activity) + .load(model) + .centerCrop() + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .into(imageView) + } + + fun clearBackground() { + findViewById(R.id.dualScreenIdleBackgroundImage)?.let { imageView -> + Glide.with(activity).clear(imageView) + imageView.setImageDrawable(null) + } + } + + fun isForDisplay(displayId: Int): Boolean = presentationDisplayId == displayId + } + + private fun startExternalDisplayPresentation(display: Display) { + if (!isUsingExternalDisplay() || externalPresentation?.isForDisplay(display.displayId) == true) { return } - externalPresentation = ExternalDisplayPresentation(activity, targetDisplayResolver.currentDisplay()) + if (dualScreenPresentation != null) { + dismissDualScreenPresentation() + callback?.onDualScreenDisconnected() + } + + callback?.onExternalDisplayConnected(display) + externalPresentation = ExternalDisplayPresentation(activity, display) externalPresentation?.show() val surfaceView = activity.findViewById(R.id.surfaceView) surfaceView?.visibility = View.GONE - if (prefConfig.enablePerfOverlay) { - val batteryTextView = TextView(activity) - batteryTextView.gravity = Gravity.CENTER - batteryTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 48f) - batteryTextView.setTextColor(androidx.core.content.ContextCompat.getColor(activity, R.color.scene_color_1)) + Toast.makeText(activity, activity.getString(R.string.toast_switched_to_external_display), Toast.LENGTH_LONG).show() + } - val params = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.WRAP_CONTENT, - FrameLayout.LayoutParams.WRAP_CONTENT - ) - params.gravity = Gravity.CENTER - batteryTextView.layoutParams = params - - val rootView = activity.findViewById(android.R.id.content) - rootView?.addView(batteryTextView) - - val handler = Handler(Looper.getMainLooper()) - val gravityOptions = intArrayOf( - Gravity.CENTER, - Gravity.TOP or Gravity.CENTER_HORIZONTAL, - Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL, - Gravity.CENTER_VERTICAL or Gravity.LEFT, - Gravity.CENTER_VERTICAL or Gravity.RIGHT, - Gravity.TOP or Gravity.LEFT, - Gravity.TOP or Gravity.RIGHT, - Gravity.BOTTOM or Gravity.LEFT, - Gravity.BOTTOM or Gravity.RIGHT - ) + private fun startIdleBackgroundPresentation(display: Display) { + if (idleBackgroundPresentation?.isForDisplay(display.displayId) == true) { + idleBackgroundPresentation?.refreshBackground() + return + } + + dismissExternalPresentation() + dismissDualScreenPresentation() + dismissIdleBackgroundPresentation() + idleBackgroundPresentation = IdleBackgroundPresentation(activity, display) + idleBackgroundPresentation?.show() + } - val updateBatteryTask = object : Runnable { - override fun run() { - batteryTextView.text = String.format("🔋 %d%%", UiHelper.getBatteryLevel(activity)) + private fun startDualScreenPresentation(controlDisplay: Display, streamDisplay: Display) { + if (externalPresentation != null || + dualScreenPresentation?.isForDisplay(controlDisplay.displayId) == true + ) { + return + } - val randomGravity = gravityOptions[(Math.random() * gravityOptions.size).toInt()] - val randomMarginLeft = (Math.random() * 401).toInt() - 200 - val randomMarginTop = (Math.random() * 401).toInt() - 200 - val randomMarginRight = (Math.random() * 401).toInt() - 200 - val randomMarginBottom = (Math.random() * 401).toInt() - 200 + dualScreenPresentation = DualScreenControlPresentation( + activity, + controlDisplay, + streamDisplay + ) + dualScreenPresentation?.show() + Toast.makeText( + activity, + activity.getString(R.string.toast_dual_screen_controls_ready), + Toast.LENGTH_LONG + ).show() + } - val p = batteryTextView.layoutParams as FrameLayout.LayoutParams - p.gravity = randomGravity - p.setMargins(randomMarginLeft, randomMarginTop, randomMarginRight, randomMarginBottom) - batteryTextView.layoutParams = p + private fun registerIdleBackgroundPreferenceListener() { + if (callback != null || backgroundPrefsListener != null) return - handler.postDelayed(this, 60000) + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == BackgroundSource.KEY_SOURCE || + key == BackgroundSource.KEY_API_URL || + key == BackgroundSource.KEY_LOCAL_PATH + ) { + activity.runOnUiThread { + idleBackgroundPresentation?.refreshBackground() } } - updateBatteryTask.run() } + backgroundPrefsListener = listener + PreferenceManager.getDefaultSharedPreferences(activity) + .registerOnSharedPreferenceChangeListener(listener) + } - Toast.makeText(activity, activity.getString(R.string.toast_switched_to_external_display), Toast.LENGTH_LONG).show() + private fun unregisterIdleBackgroundPreferenceListener() { + backgroundPrefsListener?.let { listener -> + PreferenceManager.getDefaultSharedPreferences(activity) + .unregisterOnSharedPreferenceChangeListener(listener) + } + backgroundPrefsListener = null } companion object { diff --git a/app/src/main/java/com/limelight/Game.kt b/app/src/main/java/com/limelight/Game.kt index 376152f5e7..7ebd833cfe 100644 --- a/app/src/main/java/com/limelight/Game.kt +++ b/app/src/main/java/com/limelight/Game.kt @@ -139,6 +139,7 @@ class Game : Activity(), SurfaceHolder.Callback, var controllerManager: ControllerManager? = null private val crownSessionController = CrownSessionController(this) { controllerManager } private var standaloneKeyboardUI: KeyboardUIController? = null + private var dualScreenKeyboardUI: KeyboardUIController? = null private val performanceInfoDisplays = ArrayList() var microphoneManager: MicrophoneManager? = null @@ -250,8 +251,12 @@ class Game : Activity(), SurfaceHolder.Callback, var usbDriverServiceManager: UsbDriverServiceManager? = null var externalDisplayManager: ExternalDisplayManager? = null + lateinit var dualScreenControlPanel: DualScreenControlPanel private lateinit var targetDisplayResolver: TargetDisplayResolver + fun dualScreenControlPanelOrNull(): DualScreenControlPanel? = + if (::dualScreenControlPanel.isInitialized) dualScreenControlPanel else null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -509,6 +514,8 @@ class Game : Activity(), SurfaceHolder.Callback, DisplayPositionManager(this, prefConfig, streamView).setupDisplayPosition() + dualScreenControlPanel = DualScreenControlPanel(this) + dualScreenControlPanel.initialize() setupExternalDisplay() floatBallHandler = FloatBallHandler(this, prefConfig) @@ -535,6 +542,29 @@ class Game : Activity(), SurfaceHolder.Callback, getOrCreateKeyboardUIController()?.toggle() } + fun bindDualScreenKeyboard(container: FrameLayout) { + releaseDualScreenKeyboard() + dualScreenKeyboardUI = KeyboardUIController( + container, + createKeyboardEventListener(), + container.context, + forceFullscreen = true + ) + } + + fun toggleDualScreenVirtualKeyboard() { + dualScreenKeyboardUI?.toggle() + } + + fun hideDualScreenVirtualKeyboard() { + dualScreenKeyboardUI?.hide() + } + + fun releaseDualScreenKeyboard() { + dualScreenKeyboardUI?.hide() + dualScreenKeyboardUI = null + } + // region ---- Extracted helpers to reduce duplication ---- /** Resolve the display currently used for rendering (external or built-in). */ @@ -568,12 +598,17 @@ class Game : Activity(), SurfaceHolder.Callback, return object : ExternalDisplayManager.ExternalDisplayCallback { override fun onExternalDisplayConnected(display: Display) { LimeLog.info("External display connected, reinitializing input capture provider") + dualScreenControlPanel.initialize() + dualScreenControlPanel.show(display) + progressOverlay?.dismiss() + progressOverlay = null inputCaptureProvider.disableCapture() inputCaptureProvider = InputCaptureManager.getInputCaptureProviderForExternalDisplay(this@Game, this@Game) } override fun onExternalDisplayDisconnected() { externalStreamView = null + dualScreenControlPanel.hide() LimeLog.info("External display disconnected, cleared externalStreamView") retargetTouchContexts(this@Game.streamView) inputCaptureProvider.disableCapture() @@ -586,6 +621,34 @@ class Game : Activity(), SurfaceHolder.Callback, setupStreamViewListeners(streamView) LimeLog.info("External display StreamView ready: ${streamView.width}x${streamView.height}") } + + override fun onDualScreenControlPanelReady( + rootView: View, + streamDisplay: Display, + controlDisplay: Display + ) { + dualScreenControlPanel.initialize( + rootView, + allowCloseControlScreen = true + ) + dualScreenControlPanel.show(streamDisplay) + LimeLog.info( + "Dual-screen controls ready; stream display=${streamDisplay.displayId}, " + + "control display=${controlDisplay.displayId}" + ) + } + + override fun onControlDisplayKeyEvent(event: KeyEvent): Boolean = + dispatchKeyEvent(event) + + override fun onControlDisplayMotionEvent(event: MotionEvent): Boolean = + dispatchGenericMotionEvent(event) + + override fun onDualScreenDisconnected() { + dualScreenControlPanel.hide() + dualScreenControlPanel.initialize() + LimeLog.info("Dual-screen control panel hidden or disconnected") + } } } @@ -697,6 +760,7 @@ class Game : Activity(), SurfaceHolder.Callback, /** Create or re-create ExternalDisplayManager with the standard callback. */ private fun setupExternalDisplay() { + externalDisplayManager?.cleanup() val manager = ExternalDisplayManager( this, prefConfig, @@ -1237,6 +1301,9 @@ class Game : Activity(), SurfaceHolder.Callback, inputCaptureProvider.destroy() } externalDisplayManager?.cleanup() + if (::dualScreenControlPanel.isInitialized) { + dualScreenControlPanel.release() + } microphoneManager?.stopMicrophoneStream() clipboardSyncManager?.stop() clipboardSyncManager = null @@ -2201,6 +2268,9 @@ class Game : Activity(), SurfaceHolder.Callback, } enrichFramegenPerformanceInfo(performanceInfo) performanceOverlayManager?.updatePerformanceInfo(performanceInfo) + if (::dualScreenControlPanel.isInitialized) { + dualScreenControlPanel.updatePerformanceInfo(performanceInfo) + } } override fun isPerfOverlayVisible(): Boolean { @@ -2280,6 +2350,21 @@ class Game : Activity(), SurfaceHolder.Callback, } override fun showGameMenu(device: GameInputDevice?) { + showGameMenu(device, null) + } + + fun showGameMenuOnControlDisplay(hostView: View) { + showGameMenu(null, hostView) + } + + fun setDualScreenControlsEnabled(enabled: Boolean) { + externalDisplayManager?.setDualScreenControlsEnabled(enabled) + } + + fun canRestoreDualScreenControls(): Boolean = + externalDisplayManager?.canRestoreDualScreenControls() == true + + private fun showGameMenu(device: GameInputDevice?, hostView: View?) { when (crownSessionController.backKeyMenuMode) { BackKeyMenuMode.CROWN_MODE -> { if (controllerManager != null && prefConfig.enableCrownFeatures) { @@ -2300,7 +2385,20 @@ class Game : Activity(), SurfaceHolder.Callback, existingMenu?.dismiss() activeGameMenu = null - val menu = GameMenu(this, app, conn!!, device) { dismissedMenu -> + val hostWindowToken = hostView?.windowToken + val hostContext = if (hostWindowToken != null) { + requireNotNull(hostView).context + } else { + this + } + val menu = GameMenu( + game = this, + app = app, + conn = conn!!, + device = device, + hostContext = hostContext, + hostWindowToken = hostWindowToken + ) { dismissedMenu -> if (activeGameMenu === dismissedMenu) { activeGameMenu = null } @@ -2320,6 +2418,9 @@ class Game : Activity(), SurfaceHolder.Callback, } fun disconnect() { + progressOverlay?.dismiss() + progressOverlay = null + Dialog.closeDialogs() finish() } diff --git a/app/src/main/java/com/limelight/TargetDisplayResolver.kt b/app/src/main/java/com/limelight/TargetDisplayResolver.kt index 1a239981f0..e2bec4dc68 100644 --- a/app/src/main/java/com/limelight/TargetDisplayResolver.kt +++ b/app/src/main/java/com/limelight/TargetDisplayResolver.kt @@ -1,11 +1,14 @@ package com.limelight import android.content.Context +import android.graphics.Point import android.hardware.display.DisplayManager import android.view.Display +import androidx.core.content.edit +import androidx.preference.PreferenceManager /** - * Resolves the display used by a streaming session. + * Resolves the display selected by the user for a streaming session. * * Display selection is intentionally kept separate from Presentation creation. The stream * configuration is built before [ExternalDisplayManager] creates its Presentation, so using @@ -13,33 +16,22 @@ import android.view.Display * decisions fall back to the default display. */ class TargetDisplayResolver(context: Context) { + private val appContext = context.applicationContext private val displayManager = - context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + appContext.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager private var targetDisplayId = Display.DEFAULT_DISPLAY fun resolve(useExternalDisplay: Boolean): Display { - if (!useExternalDisplay) { - targetDisplayId = Display.DEFAULT_DISPLAY - return getDefaultDisplay() - } - - val currentTarget = displayManager.getDisplay(targetDisplayId) - if (currentTarget != null && currentTarget.displayId != Display.DEFAULT_DISPLAY) { - return currentTarget - } - - val externalDisplay = displayManager.displays.firstOrNull { - it.displayId != Display.DEFAULT_DISPLAY - } - - if (externalDisplay != null) { - targetDisplayId = externalDisplay.displayId - return externalDisplay - } + val availableDisplays = displayManager.displays.sortedBy { it.displayId } + val selectedDisplayId = DisplaySelectionPolicy.resolveStreamDisplayId( + useExternalDisplay, + DisplaySelectionPreferences.getPrimaryStreamDisplayId(appContext), + availableDisplays.map { it.displayId } + ) - targetDisplayId = Display.DEFAULT_DISPLAY - return getDefaultDisplay() + targetDisplayId = selectedDisplayId + return displayManager.getDisplay(selectedDisplayId) ?: getDefaultDisplay() } fun currentDisplay(): Display { @@ -51,7 +43,17 @@ class TargetDisplayResolver(context: Context) { displayManager.getDisplay(targetDisplayId) != null } - /** Clears the selected target when it is removed and reports whether it was selected. */ + /** Returns the first connected display other than the selected stream display. */ + fun controlDisplayFor(streamDisplayId: Int): Display? { + val displays = displayManager.displays.sortedBy { it.displayId } + val controlDisplayId = DisplaySelectionPolicy.resolveControlDisplayId( + streamDisplayId, + displays.map { it.displayId } + ) ?: return null + return displayManager.getDisplay(controlDisplayId) + } + + /** Clears the active target when it is removed while retaining the user's preference. */ fun onDisplayRemoved(displayId: Int): Boolean { if (targetDisplayId != displayId) { return false @@ -66,3 +68,61 @@ class TargetDisplayResolver(context: Context) { ?: error("Default display is unavailable") } } + +object DisplaySelectionPreferences { + private const val PRIMARY_STREAM_DISPLAY_ID_PREF = "primary_stream_display_id" + + fun getPrimaryStreamDisplayId(context: Context): Int? { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + return if (preferences.contains(PRIMARY_STREAM_DISPLAY_ID_PREF)) { + preferences.getInt(PRIMARY_STREAM_DISPLAY_ID_PREF, Display.DEFAULT_DISPLAY) + } else { + null + } + } + + fun setPrimaryStreamDisplayId(context: Context, displayId: Int) { + PreferenceManager.getDefaultSharedPreferences(context).edit { + putInt(PRIMARY_STREAM_DISPLAY_ID_PREF, displayId) + } + } +} + +internal object DisplaySelectionPolicy { + fun resolveStreamDisplayId( + enabled: Boolean, + preferredDisplayId: Int?, + availableDisplayIds: List + ): Int { + if (!enabled) return Display.DEFAULT_DISPLAY + + if (preferredDisplayId != null && preferredDisplayId in availableDisplayIds) { + return preferredDisplayId + } + + return if (Display.DEFAULT_DISPLAY in availableDisplayIds) { + Display.DEFAULT_DISPLAY + } else { + availableDisplayIds.firstOrNull() ?: Display.DEFAULT_DISPLAY + } + } + + fun resolveControlDisplayId( + streamDisplayId: Int, + availableDisplayIds: List + ): Int? = availableDisplayIds.firstOrNull { it != streamDisplayId } +} + +object DisplaySelectionFormatter { + @Suppress("DEPRECATION") + fun label(display: Display): String { + val realSize = Point() + display.getRealSize(realSize) + return label(display.name, display.displayId, realSize.x, realSize.y) + } + + internal fun label(name: String?, displayId: Int, width: Int, height: Int): String { + val displayName = name?.trim().takeUnless { it.isNullOrEmpty() } ?: "display$displayId" + return "$displayName (${width}×${height})" + } +} diff --git a/app/src/main/java/com/limelight/binding/input/advance_setting/KeyboardUIController.kt b/app/src/main/java/com/limelight/binding/input/advance_setting/KeyboardUIController.kt index 075707cf79..5585e58cdf 100644 --- a/app/src/main/java/com/limelight/binding/input/advance_setting/KeyboardUIController.kt +++ b/app/src/main/java/com/limelight/binding/input/advance_setting/KeyboardUIController.kt @@ -3,6 +3,7 @@ package com.limelight.binding.input.advance_setting import android.content.Context import android.content.SharedPreferences import android.graphics.Color +import android.view.Gravity import android.view.LayoutInflater import android.view.MotionEvent import android.view.View @@ -12,12 +13,14 @@ import android.widget.FrameLayout import android.widget.SeekBar import android.widget.SeekBar.OnSeekBarChangeListener import android.widget.TextView +import androidx.core.content.ContextCompat import com.limelight.R class KeyboardUIController( private val parentContainer: FrameLayout, private val listener: OnKeyboardEventListener, - context: Context + context: Context, + private val forceFullscreen: Boolean = false ) : KeyboardGestureDetector.GestureListener { interface OnKeyboardEventListener { @@ -98,11 +101,44 @@ class KeyboardUIController( updateTabStyle(btnNum, false) updateTabStyle(btnMini, false) - loadSettings() + if (forceFullscreen) { + configureFullscreen(context) + } else { + loadSettings() + } setupTouchListeners(keyboardLayout) } + private fun configureFullscreen(context: Context) { + layoutMini?.visibility = View.GONE + layoutMain?.visibility = View.VISIBLE + layoutNav?.visibility = View.GONE + layoutNum?.visibility = View.GONE + panelAlpha?.visibility = View.VISIBLE + panelNumMini?.visibility = View.GONE + panelPcMini?.visibility = View.GONE + + (keyboardContent.layoutParams as FrameLayout.LayoutParams).apply { + width = ViewGroup.LayoutParams.MATCH_PARENT + height = ViewGroup.LayoutParams.MATCH_PARENT + gravity = Gravity.FILL + setMargins(0, 0, 0, 0) + }.also(keyboardContent::setLayoutParams) + keyboardContent.translationX = 0f + keyboardContent.translationY = 0f + + keyboardLayout.setBackgroundColor( + ContextCompat.getColor(context, R.color.dual_screen_background) + ) + opacitySeekbar.progress = 10 + keyboardLayout.alpha = 1f + btnMini.visibility = View.GONE + keyboardLayout.findViewById(R.id.btn_keyboard_resize).visibility = View.GONE + keyboardLayout.findViewById(R.id.keyboard_resize_handle).visibility = View.GONE + keyboardLayout.findViewById(R.id.keyboard_opacity_label).visibility = View.GONE + } + private fun initModifiers() { modifierStates.put(KEY_LCTRL, MOD_NEUTRAL) modifierStates.put(KEY_RCTRL, MOD_NEUTRAL) @@ -627,4 +663,4 @@ class KeyboardUIController( private const val KEY_LWIN = 117 private const val KEY_SPACE = 62 } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/limelight/gamemenu/GameMenu.kt b/app/src/main/java/com/limelight/gamemenu/GameMenu.kt index bf65a2c759..95fb99fd73 100644 --- a/app/src/main/java/com/limelight/gamemenu/GameMenu.kt +++ b/app/src/main/java/com/limelight/gamemenu/GameMenu.kt @@ -7,9 +7,12 @@ import android.content.Context import android.content.res.Configuration import android.os.Build import android.os.Handler +import android.os.IBinder import android.os.Looper +import android.view.InputDevice import android.view.KeyEvent import android.view.LayoutInflater +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.WindowManager @@ -20,6 +23,7 @@ import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast import androidx.activity.ComponentDialog +import androidx.activity.OnBackPressedCallback import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.platform.ComposeView @@ -33,6 +37,7 @@ import com.limelight.LimeLog import com.limelight.QuickActionRegistry import com.limelight.R import com.limelight.StreamActionExecutor +import com.limelight.binding.input.ControllerHandler import com.limelight.binding.input.GameInputDevice import com.limelight.binding.input.KeyboardTranslator import com.limelight.binding.input.advance_setting.config.PageConfigController @@ -61,6 +66,8 @@ class GameMenu( private val app: NvApp, private val conn: NvConnection, private val device: GameInputDevice?, + private val hostContext: Context = game, + private val hostWindowToken: IBinder? = null, private val onDismiss: (GameMenu) -> Unit = {} ) { // 当前激活的对话框(如果有) @@ -645,7 +652,7 @@ class GameMenu( onCustomKey = { sendKeys(it.keys) } ) - val composeView = ComposeView(game).apply { + val composeView = ComposeView(hostContext).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) setContent { GameMenuScreen( @@ -655,7 +662,26 @@ class GameMenu( ) } } - dialog = ComponentDialog(game, R.style.GameMenuDialogStyle).apply { + dialog = object : ComponentDialog(hostContext, R.style.GameMenuDialogStyle) { + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (hostWindowToken != null && + event.device != null && + ControllerHandler.isGameControllerDevice(event.device) + ) { + return game.dispatchKeyEvent(event) + } + return super.dispatchKeyEvent(event) + } + + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (hostWindowToken != null && + event.isFromSource(InputDevice.SOURCE_CLASS_JOYSTICK) + ) { + return game.dispatchGenericMotionEvent(event) + } + return super.dispatchGenericMotionEvent(event) + } + }.apply { setContentView(composeView) setCanceledOnTouchOutside(true) } @@ -663,13 +689,22 @@ class GameMenu( setupDialogProperties(dialog) + dialog.onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (!navigateBack()) { + dialog.dismiss() + } + } + }) + // 返回键监听器 dialog.setOnKeyListener { _, keyCode, event -> if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_DOWN) { if (navigateBack()) { return@setOnKeyListener true } - return@setOnKeyListener false + dialog.dismiss() + return@setOnKeyListener true } false } @@ -959,6 +994,10 @@ class GameMenu( private fun setupDialogProperties(dialog: ComponentDialog) { dialog.window?.let { window -> val layoutParams = window.attributes + hostWindowToken?.let { token -> + layoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG + layoutParams.token = token + } layoutParams.alpha = renderingProfile.windowAlpha layoutParams.dimAmount = DIALOG_DIM_AMOUNT layoutParams.width = resolveDialogWidth() @@ -982,17 +1021,18 @@ class GameMenu( private fun resolveDialogWidth(): Int { val widthFraction = if ( - game.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + hostContext.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE ) { DIALOG_LANDSCAPE_WIDTH_FRACTION } else { DIALOG_PORTRAIT_WIDTH_FRACTION } val windowWidth = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - game.windowManager.currentWindowMetrics.bounds.width() + val windowManager = hostContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager + windowManager.currentWindowMetrics.bounds.width() } else { - game.window.decorView.width - }.takeIf { it > 0 } ?: game.resources.displayMetrics.widthPixels + hostContext.resources.displayMetrics.widthPixels + }.takeIf { it > 0 } ?: hostContext.resources.displayMetrics.widthPixels return (windowWidth * widthFraction) .toInt() @@ -1341,6 +1381,14 @@ class GameMenu( showChevron = true )) + if (game.canRestoreDualScreenControls()) { + normalOptions.add(MenuOption( + getString(R.string.game_menu_enable_multi_screen), false, + { game.setDualScreenControlsEnabled(true) }, + "game_menu_enable_multi_screen" + )) + } + normalOptions.add(MenuOption(getString(R.string.game_menu_disconnect), true, { game.disconnect() }, "game_menu_disconnect", true)) @@ -1423,6 +1471,7 @@ class GameMenu( "game_menu_toggle_virtual_controller" to R.drawable.ic_controller_cute, "game_menu_disconnect" to R.drawable.ic_disconnect_cute, "game_menu_send_keys" to R.drawable.ic_send_keys_cute, + "game_menu_enable_multi_screen" to R.drawable.ic_resolution_cute, "game_menu_toggle_host_keyboard" to R.drawable.ic_host_keyboard, "game_menu_disconnect_and_quit" to R.drawable.ic_btn_quit, "game_menu_cancel" to R.drawable.ic_cancel_cute, diff --git a/app/src/main/java/com/limelight/preferences/ExternalDisplayPreference.kt b/app/src/main/java/com/limelight/preferences/ExternalDisplayPreference.kt index 2e551cdd68..d305843eea 100644 --- a/app/src/main/java/com/limelight/preferences/ExternalDisplayPreference.kt +++ b/app/src/main/java/com/limelight/preferences/ExternalDisplayPreference.kt @@ -1,32 +1,45 @@ package com.limelight.preferences +import android.app.AlertDialog import android.content.Context import android.hardware.display.DisplayManager -import android.os.Build -import androidx.preference.CheckBoxPreference import android.util.AttributeSet import android.view.Display - -import com.limelight.ExternalDisplayManager +import androidx.preference.CheckBoxPreference +import com.limelight.DisplaySelectionFormatter +import com.limelight.DisplaySelectionPreferences +import com.limelight.R +import com.limelight.utils.AppDialogStyler /** - * 外接显示器状态偏好设置 + * Dual-screen preference that requires the user to select the display used for streaming. */ class ExternalDisplayPreference : CheckBoxPreference { + private val displayManager: DisplayManager + get() = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + private val displayListener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) = updateSummary() + + override fun onDisplayRemoved(displayId: Int) = updateSummary() + + override fun onDisplayChanged(displayId: Int) = updateSummary() + } + private var displayListenerRegistered = false constructor(context: Context) : super(context) { - init(context) + initialize() } constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - init(context) + initialize() } - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - init(context) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : + super(context, attrs, defStyleAttr) { + initialize() } - private fun init(context: Context) { + private fun initialize() { updateSummary() } @@ -35,31 +48,101 @@ class ExternalDisplayPreference : CheckBoxPreference { updateSummary() } + override fun onAttached() { + super.onAttached() + if (!displayListenerRegistered) { + displayManager.registerDisplayListener(displayListener, null) + displayListenerRegistered = true + } + updateSummary() + } + + override fun onDetached() { + if (displayListenerRegistered) { + displayManager.unregisterDisplayListener(displayListener) + displayListenerRegistered = false + } + super.onDetached() + } + + override fun onClick() { + val displays = connectedDisplays() + if (displays.size < 2) { + updateSummary() + return + } + + var selectedIndex = findSelectedDisplayIndex(displays) + val labels = displays.map(DisplaySelectionFormatter::label).toTypedArray() + val dialog = AlertDialog.Builder(context, R.style.AppDialogStyle) + .setTitle(R.string.dual_screen_select_primary_title) + .setSingleChoiceItems(labels, selectedIndex) { _, which -> + selectedIndex = which + } + .setPositiveButton(R.string.dual_screen_enable) { _, _ -> + val selectedDisplay = displays[selectedIndex] + if (callChangeListener(true)) { + DisplaySelectionPreferences.setPrimaryStreamDisplayId( + context, + selectedDisplay.displayId + ) + isChecked = true + updateSummary() + } + } + .setNeutralButton(R.string.dual_screen_disable) { _, _ -> + if (callChangeListener(false)) { + isChecked = false + updateSummary() + } + } + .setNegativeButton(android.R.string.cancel, null) + .create() + dialog.show() + AppDialogStyler.applySystemChoiceList(dialog, context) + } + + private fun connectedDisplays(): List = + displayManager.displays.sortedBy { it.displayId } + + private fun findSelectedDisplayIndex(displays: List): Int { + val selectedId = DisplaySelectionPreferences.getPrimaryStreamDisplayId(context) + ?: Display.DEFAULT_DISPLAY + return displays.indexOfFirst { it.displayId == selectedId }.coerceAtLeast(0) + } + private fun updateSummary() { try { - if (ExternalDisplayManager.hasExternalDisplay(context)) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { - val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager - if (displayManager != null) { - val displays = displayManager.displays - for (display in displays) { - if (display.displayId != Display.DEFAULT_DISPLAY) { - summary = "检测到外接显示器: ${display.name} (ID: ${display.displayId})" - isEnabled = true - return - } - } - } - } - } else { - summary = "未检测到外接显示器" + val displays = connectedDisplays() + if (displays.size < 2) { + summary = context.getString(R.string.external_display_not_detected) isEnabled = false - isChecked = false + return } + + isEnabled = true + if (!isChecked) { + summary = context.getString( + R.string.dual_screen_available_summary, + displays.size + ) + return + } + + val streamDisplay = displays.getOrNull(findSelectedDisplayIndex(displays)) + ?: displays.first() + val controlDisplay = displays.first { it.displayId != streamDisplay.displayId } + summary = context.getString( + R.string.dual_screen_selection_summary, + DisplaySelectionFormatter.label(streamDisplay), + DisplaySelectionFormatter.label(controlDisplay) + ) } catch (e: Exception) { - summary = "检测外接显示器失败: $e" + summary = context.getString( + R.string.external_display_detection_failed, + e.localizedMessage ?: e.javaClass.simpleName + ) isEnabled = false - isChecked = false } } } diff --git a/app/src/main/java/com/limelight/preferences/StreamSettings.kt b/app/src/main/java/com/limelight/preferences/StreamSettings.kt index d7abdcf639..8a1d4e332e 100644 --- a/app/src/main/java/com/limelight/preferences/StreamSettings.kt +++ b/app/src/main/java/com/limelight/preferences/StreamSettings.kt @@ -62,6 +62,7 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.MultiTransformation import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.CustomTarget @@ -110,6 +111,9 @@ class StreamSettings : AppCompatActivity() { private lateinit var previousPrefs: PreferenceConfiguration private var previousDisplayPixelCount = 0 private var externalDisplayManager: ExternalDisplayManager? = null + private var settingsBackgroundPrefsListener: SharedPreferences.OnSharedPreferenceChangeListener? = null + @Volatile + private var backgroundLoadGeneration = 0L // 抽屉菜单相关 private var drawerLayout: DrawerLayout? = null // 竖屏时使用,横屏时为 null @@ -141,8 +145,6 @@ class StreamSettings : AppCompatActivity() { // HACK for Android 9 var displayCutoutP: DisplayCutout? = null - private const val SETTINGS_BG_URL = "https://raw.githubusercontent.com/qiin2333/qiin.github.io/assets/img/moonlight-bg2.webp" - /** * 获取分类对应的 Phosphor 矢量图标资源 ID(与鸿蒙项目一致)。 */ @@ -222,6 +224,7 @@ class StreamSettings : AppCompatActivity() { initDrawerMenu() // 加载背景图片 + registerSettingsBackgroundPreferenceListener() loadBackgroundImage() // 设置版本号 @@ -790,9 +793,10 @@ class StreamSettings : AppCompatActivity() { } override fun onDestroy() { - super.onDestroy() + unregisterSettingsBackgroundPreferenceListener() externalDisplayManager?.cleanup() externalDisplayManager = null + super.onDestroy() } @Deprecated("Deprecated in Java") @@ -4258,7 +4262,15 @@ class StreamSettings : AppCompatActivity() { } private fun loadBackgroundImage() { + val generation = ++backgroundLoadGeneration val imageView = findViewById(R.id.settingsBackgroundImage) + Glide.with(this).clear(imageView) + imageView.setImageDrawable(null) + + val target = BackgroundSource.current(this).resolveTarget( + this, + resources.configuration.orientation + ) ?: return // 解码尺寸根据当前可用堆按比例约束(详见 computeBackgroundDecodeSize): // - 4K 电视 + 大堆设备保持原分辨率 @@ -4276,6 +4288,7 @@ class StreamSettings : AppCompatActivity() { Color.argb(96, 255, 255, 255) } val transformations = MultiTransformation( + CenterCrop(), BlurTransformation(2, 3), ColorFilterTransformation(filterColor) ) @@ -4285,31 +4298,24 @@ class StreamSettings : AppCompatActivity() { .transform(transformations) .diskCacheStrategy(DiskCacheStrategy.ALL) - // 候选 URL(含原始与所有代理变体)。Glide 缓存键以 URL 为基础, - // 因此可能上次走代理 A 命中、原始 URL 在缓存中并不存在;这里逐个尝试, - // 任一变体在缓存中就立即贴图,体验等同本地资源。 - val candidates = mutableListOf().apply { - add(SETTINGS_BG_URL) - try { addAll(UpdateManager.buildProxiedUrls(SETTINGS_BG_URL)) } catch (_: Exception) {} - }.distinct() + val candidates: List = listOf( + if (target.startsWith("http")) target else File(target) + ) - tryCachedThenNetwork(imageView, options, candidates, 0) + tryCachedThenNetwork(imageView, options, candidates, 0, generation) } - /** - * 依次对候选 URL 做 onlyRetrieveFromCache 同步缓存查询: - * - 命中:直接贴图,无线程切换、无渐入,体验秒开 - * - 全部未命中:转入网络下载路径,下载完成后带 crossFade 渐入 - */ + /** Try the selected source from Glide cache before loading it asynchronously. */ private fun tryCachedThenNetwork( imageView: ImageView, options: RequestOptions, - candidates: List, - index: Int + candidates: List, + index: Int, + generation: Long ) { - if (isDestroyed || isFinishing) return + if (generation != backgroundLoadGeneration || isDestroyed || isFinishing) return if (index >= candidates.size) { - loadBackgroundImageFromNetwork(imageView, options, candidates) + loadBackgroundImageFromSource(imageView, options, candidates, generation) return } Glide.with(this) @@ -4317,42 +4323,44 @@ class StreamSettings : AppCompatActivity() { .apply(options.clone().onlyRetrieveFromCache(true)) .into(object : CustomTarget() { override fun onResourceReady(resource: Drawable, transition: Transition?) { + if (generation != backgroundLoadGeneration || isDestroyed || isFinishing) return imageView.setImageDrawable(resource) } override fun onLoadCleared(placeholder: Drawable?) {} override fun onLoadFailed(errorDrawable: Drawable?) { - tryCachedThenNetwork(imageView, options, candidates, index + 1) + tryCachedThenNetwork(imageView, options, candidates, index + 1, generation) } }) } - private fun loadBackgroundImageFromNetwork( + private fun loadBackgroundImageFromSource( imageView: ImageView, options: RequestOptions, - preBuiltCandidates: List? + candidates: List, + generation: Long ) { Thread { - // 代理列表可能在调用前还未就绪,需要刷新一次 - UpdateManager.ensureProxyListUpdated(this) - val candidates = preBuiltCandidates?.takeIf { it.isNotEmpty() } - ?: UpdateManager.buildProxiedUrls(SETTINGS_BG_URL) - for (url in candidates) { + for (model in candidates) { try { - if (isDestroyed || isFinishing) return@Thread - // 后台同步预热:把图解码到 Glide 缓存中(包含 blur+mask 变换); - // 失败则尝试下一条代理,不会污染 UI + if (generation != backgroundLoadGeneration || isDestroyed || isFinishing) { + return@Thread + } + // 后台同步预热:把图解码到 Glide 缓存中(包含裁切、模糊和蒙版)。 val ready = Glide.with(applicationContext) .asDrawable() - .load(url) + .load(model) .apply(options) .submit() .get() if (ready != null) { runOnUiThread { - if (isDestroyed || isFinishing) return@runOnUiThread + if (generation != backgroundLoadGeneration || + isDestroyed || isFinishing) { + return@runOnUiThread + } // 用同一份缓存渲染,加 400ms 渐入避免突兀 pop-in Glide.with(this@StreamSettings) - .load(url) + .load(model) .apply(options) .transition(DrawableTransitionOptions.withCrossFade(400)) .into(imageView) @@ -4365,4 +4373,28 @@ class StreamSettings : AppCompatActivity() { } }.start() } + + private fun registerSettingsBackgroundPreferenceListener() { + if (settingsBackgroundPrefsListener != null) return + + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == BackgroundSource.KEY_SOURCE || + key == BackgroundSource.KEY_API_URL || + key == BackgroundSource.KEY_LOCAL_PATH + ) { + runOnUiThread { loadBackgroundImage() } + } + } + settingsBackgroundPrefsListener = listener + PreferenceManager.getDefaultSharedPreferences(this) + .registerOnSharedPreferenceChangeListener(listener) + } + + private fun unregisterSettingsBackgroundPreferenceListener() { + settingsBackgroundPrefsListener?.let { listener -> + PreferenceManager.getDefaultSharedPreferences(this) + .unregisterOnSharedPreferenceChangeListener(listener) + } + settingsBackgroundPrefsListener = null + } } diff --git a/app/src/main/res/drawable/dual_screen_action_bg.xml b/app/src/main/res/drawable/dual_screen_action_bg.xml new file mode 100644 index 0000000000..018ae7de4e --- /dev/null +++ b/app/src/main/res/drawable/dual_screen_action_bg.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/dual_screen_danger_bg.xml b/app/src/main/res/drawable/dual_screen_danger_bg.xml new file mode 100644 index 0000000000..016223c55e --- /dev/null +++ b/app/src/main/res/drawable/dual_screen_danger_bg.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/dual_screen_metric_bg.xml b/app/src/main/res/drawable/dual_screen_metric_bg.xml new file mode 100644 index 0000000000..fe9cf962d7 --- /dev/null +++ b/app/src/main/res/drawable/dual_screen_metric_bg.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/dual_screen_status_dot.xml b/app/src/main/res/drawable/dual_screen_status_dot.xml new file mode 100644 index 0000000000..3626003544 --- /dev/null +++ b/app/src/main/res/drawable/dual_screen_status_dot.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/layout/activity_game.xml b/app/src/main/res/layout/activity_game.xml index 4bd9c4f11f..6fe6a76465 100644 --- a/app/src/main/res/layout/activity_game.xml +++ b/app/src/main/res/layout/activity_game.xml @@ -260,6 +260,9 @@ android:scaleType="centerInside" android:padding="8dp" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +