From e60eae36460dead7d9db948e9fe00523515413bd Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:06:16 -0400 Subject: [PATCH] Add standard session picture in picture (#156) * Add session picture in picture controls * Simplify PiP to standard Android controls * Remove PiP testing document --- .../launcher/agent/AgentBridgeProvider.kt | 1 + app/src/main/AndroidManifest.xml | 3 +- .../com/droiddeck/launcher/MainActivity.kt | 6 + .../com/droiddeck/launcher/SessionActivity.kt | 138 ++++++++++++++++-- .../launcher/input/TouchpadGestures.kt | 11 ++ .../droiddeck/launcher/session/PipPolicy.kt | 15 ++ .../launcher/session/SessionPipController.kt | 126 ++++++++++++++++ .../launcher/session/SessionPrefs.kt | 6 + .../launcher/session/SessionService.kt | 21 +++ .../launcher/session/SessionState.kt | 1 + .../launcher/ui/ModeSettingsDialog.kt | 7 + .../droiddeck/launcher/ui/SessionOverlay.kt | 11 ++ app/src/main/res/values/strings.xml | 6 + .../launcher/session/PipPolicyTest.kt | 27 ++++ 14 files changed, 368 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/PipPolicy.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/session/SessionPipController.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/PipPolicyTest.kt diff --git a/app/src/debug/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt b/app/src/debug/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt index b89a06f85..4e8b066eb 100644 --- a/app/src/debug/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt +++ b/app/src/debug/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt @@ -73,6 +73,7 @@ class AgentBridgeProvider : ContentProvider() { .put("steamUi", SessionState.steamUi ?: JSONObject.NULL) .put("steamUrl", SessionState.steamUrl ?: JSONObject.NULL) .put("suspended", SessionState.suspended) + .put("pip", SessionState.pipActive) .put("firstFrame", SessionState.firstFrameSeen) .put("output", JSONArray().put(SessionState.outputSize.first).put(SessionState.outputSize.second)) .put("refreshHz", SessionState.refreshHz.toDouble()) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 699946f9f..f07b62f30 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -126,7 +126,8 @@ SessionPrefs.setUpscaleSharpness(this, pct); upscaleSharpness = pct }, onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t }, onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy }, + onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on }, onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o }, onBackActionsInverted = { inverted -> SessionPrefs.setBackActionsInverted(this, inverted) @@ -1238,7 +1241,10 @@ class MainActivity : ComponentActivity() { } } + private var pipAutoEnter by mutableStateOf(false) + private fun openModeSettings(mode: String) { + pipAutoEnter = SessionPrefs.pipAutoEnter(this) showPerformance = false showProtons = false showComponents = false diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 82e1db70a..9e68726c4 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -1,5 +1,7 @@ package com.droiddeck.launcher +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.fillMaxSize import android.hardware.input.InputManager import android.hardware.display.DisplayManager @@ -90,6 +92,10 @@ import kotlin.math.abs * the HUD line, the loading overlay, the drawer and its dialogs. */ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { + private lateinit var pip: com.droiddeck.launcher.session.SessionPipController + private var pipUi by mutableStateOf(false) + private var pipAutoEnter by mutableStateOf(false) + private val guestKeysDown = mutableSetOf() private lateinit var surfaceView: SurfaceView private lateinit var sessionOverlay: ComposeView private lateinit var loading: LoadingState @@ -122,7 +128,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { /** Motion is read while the session shows and only if the pad is a Deck controller - which * the service can decide after this activity has resumed (SessionState.deckPadListener). */ private fun updatePadMotion() { - if (resumed && SessionState.deckPad) padMotion?.start() else padMotion?.stop() + if (resumed && !pipUi && SessionState.deckPad) padMotion?.start() else padMotion?.stop() } private var onScreenControls: OnScreenControls? = null private var keyboard: KeyboardHost? = null @@ -354,6 +360,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { sessionOverlay = createSessionOverlay() root.addView(sessionOverlay) setContentView(root) + pip = com.droiddeck.launcher.session.SessionPipController(this, ::pipBounds, ::showPipUi) + surfaceView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> pip.refresh() } + pip.refresh() handleHomeGuideIntent(intent) updateOnScreenControls() @@ -364,6 +373,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // The single action waits out the double-press window so the two actions stay distinct. onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { + if (pipUi) return // The PC keyboard closes first (B on a controller), then Back is the drawer again. when { ::loading.isInitialized && loading.ended -> finish() @@ -388,6 +398,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { defaultFocusHighlightEnabled = false setContent { DroidDeckTheme { + if (pipUi) { + if (SessionState.suspended) androidx.compose.foundation.layout.Box( + Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center, + ) { + androidx.compose.material3.Text(getString(R.string.pip_suspended), + color = androidx.compose.ui.graphics.Color.White, + modifier = Modifier.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.7f)).padding(6.dp)) + } + } else { com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) { CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density, cursorImage, cursorHotX, cursorHotY, cursorImageScale) @@ -422,7 +441,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // The PC keyboard takes it too, for moving over the keys; the drawer opens over it. androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() } if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard( - sendKey = { code, down -> if (CompositorHost.isStarted) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) }, + sendKey = { code, down -> if (CompositorHost.isStarted) sendGuestKey(code, down) }, onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() }, onClose = { pcKeyboardOpen = false }, ) @@ -502,7 +521,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show() } }, - onBackground = { drawerOpen = false; moveTaskToBack(true) }, + pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this@SessionActivity), + pipAutoEnter = pipAutoEnter, + onPip = { pip.enter() }, + onPipAutoEnter = { on -> + SessionPrefs.setPipAutoEnter(this@SessionActivity, on) + pipAutoEnter = on + pip.refresh(true) + }, + onBackground = { drawerOpen = false; pip.background(); moveTaskToBack(true) }, onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() }, onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() }, onStopFrom = { onScreen -> @@ -538,6 +565,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } } + } } private fun refreshHomeApp() { @@ -670,6 +698,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun readPrefs() { + pipAutoEnter = SessionPrefs.pipAutoEnter(this) hudOn = SessionPrefs.hudEnabled(this) fillScreen = SessionPrefs.forceFullscreen(this) upscaler = SessionPrefs.upscaler(this) @@ -1019,6 +1048,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val poll = object : Runnable { override fun run() { if (!watching) return + if (::pip.isInitialized) pip.refresh() if (SessionState.stopRequested && !SessionState.running && !installingRuntime) { SessionState.stopRequested = false SessionEvents.transition(SessionPhase.IDLE, "session.cancelled") @@ -1037,6 +1067,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun onSessionEnded(status: Int) { + if (isInPictureInPictureMode) { + runOnUiThread { finish() } + return + } closeSecondScreen(reset = true) if (status == 0) { runOnUiThread { @@ -1138,6 +1172,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE -> return super.dispatchKeyEvent(event) } + if (pipUi) return true val fromController = event.device != null && PadBridge.isFromController(event.device) if (fromController && event.action == KeyEvent.ACTION_DOWN) { if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus() @@ -1217,11 +1252,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (evdev <= 0 && event.scanCode > 0) evdev = event.scanCode if (evdev > 0) { if (down) { - if (shiftEvdev != 0) WaylandCompositor.nativeSendKey(shiftEvdev, 1) - WaylandCompositor.nativeSendKey(evdev, 1) + if (shiftEvdev != 0) sendGuestKey(shiftEvdev, true) + sendGuestKey(evdev, true) } else { - WaylandCompositor.nativeSendKey(evdev, 0) - if (shiftEvdev != 0) WaylandCompositor.nativeSendKey(shiftEvdev, 0) + sendGuestKey(evdev, false) + if (shiftEvdev != 0) sendGuestKey(shiftEvdev, false) } return true } @@ -1231,6 +1266,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (pipUi) return true if (drawerOpen && event.device != null && PadBridge.isFromController(event.device)) { if (!drawerControllerActive) sessionOverlay.requestFocus() drawerControllerActive = true @@ -1249,6 +1285,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (pipUi) return true if (event.actionMasked == MotionEvent.ACTION_DOWN && event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) { drawerControllerActive = false } @@ -1450,6 +1487,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun showSecondScreen(mode: SecondScreenMode) { + if (pipUi || !started) return val target = displayManager.getDisplay(selectedSecondScreenDisplay) if (target == null || !target.isValid || (target.flags and Display.FLAG_PRESENTATION) == 0) { // Not there right now - a panel still waking, say. The choice is kept; the display @@ -1555,6 +1593,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // the compositor still holds (an up lost to the drawer opening mid-touch) goes first. MotionEvent.ACTION_DOWN -> { WaylandCompositor.nativeSendTouch(3, -1, 0, 0) + listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach { + WaylandCompositor.nativeSendSceneInput(3, it, 0) + } sendTouch(0, event.actionIndex) } MotionEvent.ACTION_POINTER_DOWN -> sendTouch(0, event.actionIndex) @@ -1606,6 +1647,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { ?.let { FileUtils.readString(it)?.trim()?.lowercase() } ?: SessionPrefs.oscMode(this) val controls = onScreenControls ?: return + if (pipUi) { + controls.releaseAll() + controls.visibility = View.GONE + return + } val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM controls.setButtonsOnly(buttonsOnly) val show = when (forced) { @@ -1627,6 +1673,76 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { }) } + private fun sendGuestKey(code: Int, down: Boolean) { + if (down && pipUi) return + if (down) guestKeysDown.add(code) else guestKeysDown.remove(code) + WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) + } + + private fun pipBounds(): android.graphics.Rect { + val rect = android.graphics.Rect() + if (!::surfaceView.isInitialized || !surfaceView.getGlobalVisibleRect(rect)) return rect + val (w, h) = SessionState.outputSize + if (w <= 0 || h <= 0) return rect + val scale = minOf(rect.width().toFloat() / w, rect.height().toFloat() / h) + val fittedW = (w * scale).toInt() + val fittedH = (h * scale).toInt() + rect.inset((rect.width() - fittedW) / 2, (rect.height() - fittedH) / 2) + return rect + } + + private fun showPipUi(show: Boolean) { + pipUi = show + if (show) { + drawerOpen = false + pcKeyboardOpen = false + pendingBackAction?.let(uiHandler::removeCallbacks) + pendingBackAction = null + releaseDrawerDirection() + onScreenControls?.releaseAll() + padBridge?.releaseAll() + touchpad.cancel() + guestKeysDown.toList().forEach { sendGuestKey(it, false) } + keyboard?.hide() + sessionClipboard.stop() + padMotion?.stop() + cursorVisible = false + closeSecondScreen(reset = false) + onScreenControls?.visibility = View.GONE + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + if (CompositorHost.isStarted) { + WaylandCompositor.nativeSendTouch(3, -1, 0, 0) + listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach { + WaylandCompositor.nativeSendSceneInput(3, it, 0) + } + WaylandCompositor.nativeSetScaleMode(SCALE_FIT, ALIGN_CENTER) + } + } else if (!isFinishing && !isDestroyed) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + updateOnScreenControls() + if (resumed) { sessionClipboard.start(); updatePadMotion() } + if (started) refreshSecondScreenDisplays() + if (CompositorHost.isStarted) applyFrameGen() + } + } + + override fun onUserLeaveHint() { + if (::pip.isInitialized) pip.leaving() + super.onUserLeaveHint() + } + + override fun onPictureInPictureModeChanged(inPip: Boolean, newConfig: android.content.res.Configuration) { + super.onPictureInPictureModeChanged(inPip, newConfig) + if (::pip.isInitialized) pip.changed(inPip) + } + + override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) { + if (::pip.isInitialized && (requestCode >= 0 || intent.component?.packageName == packageName || + intent.action in setOf(Intent.ACTION_CHOOSER, Intent.ACTION_SEND, Intent.ACTION_OPEN_DOCUMENT, + Intent.ACTION_GET_CONTENT, "android.settings.PICTURE_IN_PICTURE_SETTINGS"))) pip.background() + super.startActivityForResult(intent, requestCode, options) + } + // ── Lifecycle ─────────────────────────────────────────────────────────────────────────── override fun onStart() { @@ -1681,9 +1797,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onScreenControls?.reload() updateOnScreenControls() resumed = true - sessionClipboard.start() + if (!pipUi) sessionClipboard.start() updatePadMotion() readPrefs() + if (::pip.isInitialized) pip.resumed() if (CompositorHost.isStarted) applyFrameGen() } @@ -1713,7 +1830,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) - if (hasFocus) { + if (hasFocus && !pipUi) { goFullscreen() refreshClipboard() } @@ -1757,7 +1874,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { releaseDrawerDirection() pendingBackAction?.let(uiHandler::removeCallbacks) pendingBackAction = null - closeSecondScreen(reset = true) + if (::pip.isInitialized) pip.destroyed() + closeSecondScreen(reset = false) if (::hud.isInitialized) hud.stop() padBridge?.stop() padMotion?.stop() diff --git a/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt b/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt index 48e4642a6..d17b096e9 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt @@ -66,6 +66,17 @@ class TouchpadGestures( listener.onButton(PointerGestures.BTN_LEFT, true, x, y) } + fun cancel() { + handler.removeCallbacks(longPress) + if (dragging) listener.onButton(PointerGestures.BTN_LEFT, false, x, y) + dragging = false + held = false + holdDrag = false + anchorId = -1 + moverId = -1 + lastTapAt = 0L + } + fun place(px: Float, py: Float) { x = px.coerceIn(bounds.left, bounds.right) y = py.coerceIn(bounds.top, bounds.bottom) diff --git a/app/src/main/java/com/droiddeck/launcher/session/PipPolicy.kt b/app/src/main/java/com/droiddeck/launcher/session/PipPolicy.kt new file mode 100644 index 000000000..5626e35e2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/PipPolicy.kt @@ -0,0 +1,15 @@ +package com.droiddeck.launcher.session + +/** Platform-independent eligibility; a service existing alone does not mean it has a picture. */ +object PipPolicy { + fun eligible(running: Boolean, frameSeen: Boolean, phase: SessionPhase, suspended: Boolean, automatic: Boolean): Boolean = + running && frameSeen && phase in setOf(SessionPhase.READY, SessionPhase.SUSPENDED) && + (!automatic || !suspended) + + fun aspect(width: Int, height: Int): Pair = when { + width <= 0 || height <= 0 -> 16 to 9 + width.toDouble() / height > 2.39 -> 239 to 100 + height.toDouble() / width > 2.39 -> 100 to 239 + else -> width to height + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPipController.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPipController.kt new file mode 100644 index 000000000..92070761d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPipController.kt @@ -0,0 +1,126 @@ +package com.droiddeck.launcher.session + +import android.app.AppOpsManager +import android.app.PictureInPictureParams +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Rect +import android.net.Uri +import android.os.Build +import android.util.Rational +import android.widget.Toast +import com.droiddeck.launcher.R +import com.droiddeck.launcher.SessionActivity + +/** Owns Android's PiP parameters, while the service continues to own the session. */ +class SessionPipController( + private val activity: SessionActivity, + private val bounds: () -> Rect, + private val showPipUi: (Boolean) -> Unit, +) { + var transitioning = false + private set + private var suppressAuto = false + private var visit = false + private var lastParams = "" + val floating get() = transitioning || activity.isInPictureInPictureMode + + fun eligible(automatic: Boolean = false) = supported(activity) && !activity.isFinishing && + PipPolicy.eligible(SessionState.running, SessionState.firstFrameSeen, SessionState.phase, + SessionState.suspended, automatic) + + fun automatic() = !suppressAuto && SessionPrefs.pipAutoEnter(activity) && eligible(true) + + fun refresh(force: Boolean = false) { + if (!supported(activity)) return + val rect = bounds() + val key = "$rect:${SessionState.outputSize}:${automatic()}:${SessionState.suspended}" + if (!force && key == lastParams) return + lastParams = key + runCatching { activity.setPictureInPictureParams(params(rect)) } + } + + private fun params(rect: Rect): PictureInPictureParams { + val (w, h) = PipPolicy.aspect(SessionState.outputSize.first, SessionState.outputSize.second) + val builder = PictureInPictureParams.Builder().setAspectRatio(Rational(w, h)) + if (!rect.isEmpty) builder.setSourceRectHint(rect) + if (Build.VERSION.SDK_INT >= 31) builder.setAutoEnterEnabled(automatic()).setSeamlessResizeEnabled(false) + return builder.build() + } + + fun enter() { + if (!eligible()) { + Toast.makeText(activity, R.string.pip_not_ready, Toast.LENGTH_SHORT).show() + return + } + if (!allowed()) { + Toast.makeText(activity, R.string.pip_permission, Toast.LENGTH_SHORT).show() + runCatching { activity.startActivity(Intent("android.settings.PICTURE_IN_PICTURE_SETTINGS", + Uri.parse("package:${activity.packageName}"))) } + return + } + prepare() + val entered = runCatching { activity.enterPictureInPictureMode(params(bounds())) }.getOrDefault(false) + if (!entered) { + restore() + Toast.makeText(activity, R.string.pip_unavailable, Toast.LENGTH_SHORT).show() + } + } + + fun leaving() { + if (!automatic() || !allowed()) return + if (Build.VERSION.SDK_INT < 31) enter() else prepare() + } + + private fun prepare() { + if (floating) return + transitioning = true + showPipUi(true) + } + + fun changed(inPip: Boolean) { + if (inPip) { + transitioning = false + visit = true + SessionState.pipActive = true + showPipUi(true) + SessionService.beginPip(activity) + SessionService.setActivityVisible(activity, true) + } else restore() + refresh(true) + } + + private fun restore() { + val needsRestore = transitioning || visit + transitioning = false + if (visit) { + SessionState.pipActive = false + visit = false + } + if (needsRestore) showPipUi(false) + } + + fun resumed() { + suppressAuto = false + if (!activity.isInPictureInPictureMode) { + restore() + if (SessionState.running) SessionService.setActivityVisible(activity, true) + } + refresh(true) + } + + fun background() { suppressAuto = true; refresh(true) } + fun destroyed() { if (visit) restore() } + + private fun allowed(): Boolean { + val ops = activity.getSystemService(AppOpsManager::class.java) + @Suppress("DEPRECATION") + return ops.checkOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, + android.os.Process.myUid(), activity.packageName) == AppOpsManager.MODE_ALLOWED + } + + companion object { + fun supported(context: android.content.Context) = context.packageManager + .hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 9c85d43e5..79274ab81 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -627,6 +627,12 @@ object SessionPrefs { */ fun prefMode(mode: String): String = if (mode == SessionService.MODE_RUN) SessionService.MODE_STEAM else mode + fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", false) + + fun setPipAutoEnter(context: Context, enabled: Boolean) { + prefs(context).edit().putBoolean("pipAutoEnter", enabled).apply() + } + fun suspendPolicy(context: Context, mode: String): String = prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL) ?.takeIf { it == SUSPEND_AUTO || it == SUSPEND_MANUAL || it == SUSPEND_NEVER } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 2f3c57f19..4c31cba4f 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -78,6 +78,7 @@ class SessionService : Service() { /** An explicit Steam sleep request is independent of the background policy. */ private var steamSleepToken: String? = null private var suspendOperationPending = false + private var pipTask = false private var suspendAttemptFailed = false /** One validated game request waiting for this Steam session to reach a usable state. */ private var pendingSteamGameId: String? = null @@ -151,7 +152,12 @@ class SessionService : Service() { stopSession(0) return START_NOT_STICKY } + ACTION_PIP_BEGIN -> { + pipTask = true + return START_NOT_STICKY + } ACTION_ACTIVITY_VISIBLE -> { + if (!SessionState.pipActive) pipTask = false activityVisible = true resumeSteamSleepOnReturn() suspendAttemptFailed = false @@ -193,6 +199,8 @@ class SessionService : Service() { suspendAttemptFailed = false suspendController = null SessionState.suspended = false + SessionState.pipActive = false + pipTask = false SessionState.program = intent?.getStringExtra(EXTRA_PROGRAM) SessionState.programArgs = intent?.getStringArrayExtra(EXTRA_PROGRAM_ARGS)?.toList().orEmpty() SessionState.steamUi = intent?.getStringExtra(EXTRA_STEAM_UI) @@ -1158,6 +1166,8 @@ class SessionService : Service() { return@post } SessionState.suspended = false + SessionState.pipActive = false + pipTask = false SessionState.guestPid = -1 releaseLocks() if (status == 0) { @@ -1267,6 +1277,12 @@ class SessionService : Service() { * guest would survive as an orphan holding the rootfs and the GPU. Treat the swipe as "quit". */ override fun onTaskRemoved(rootIntent: Intent?) { + if (pipTask) { + Log.i(TAG, "PiP task dismissed - retaining the session under its background policy") + activityVisible = false + updateSuspendPolicy() + return + } Log.i(TAG, "task removed - ending the session") stopSession(0) super.onTaskRemoved(rootIntent) @@ -1407,6 +1423,7 @@ class SessionService : Service() { private const val NOTIFICATION_ID = 1001 const val ACTION_STOP = "com.droiddeck.launcher.STOP_SESSION" const val ACTION_RESUME = "com.droiddeck.launcher.RESUME_SESSION" + private const val ACTION_PIP_BEGIN = "com.droiddeck.launcher.PIP_BEGIN" const val ACTION_LAUNCH_GAME = "com.droiddeck.launcher.LAUNCH_STEAM_GAME" const val ACTION_HOME_GUIDE = "com.droiddeck.launcher.HOME_GUIDE" const val ACTION_AGENT_START = "com.droiddeck.launcher.AGENT_START" @@ -1467,6 +1484,10 @@ class SessionService : Service() { context.startService(Intent(context, SessionService::class.java).setAction(ACTION_STOP)) } + fun beginPip(context: Context) { + context.startService(Intent(context, SessionService::class.java).setAction(ACTION_PIP_BEGIN)) + } + fun setActivityVisible(context: Context, visible: Boolean) { if (!SessionState.running) return val action = if (visible) ACTION_ACTIVITY_VISIBLE else ACTION_ACTIVITY_HIDDEN diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index e197a6f8b..4ad975e12 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -47,6 +47,7 @@ object SessionState { @Volatile var running = false var suspended by mutableStateOf(false) + @Volatile var pipActive = false /** MODE_RUN: the program inside the runtime the session was started for. */ @Volatile var program: String? = null diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 45d1b3879..61e3a96fe 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -58,6 +58,8 @@ class ModeSettings( val upscaleSharpness: Int = 75, val touchMode: String, val suspendPolicy: String, + val pipSupported: Boolean = false, + val pipAutoEnter: Boolean = false, /** Steam only. */ val oscMode: String?, /** Steam only: whether single and double Back actions are swapped. */ @@ -119,6 +121,7 @@ class ModeSettingsActions( val onUpscaleSharpness: (Int) -> Unit = {}, val onTouch: (String) -> Unit, val onSuspendPolicy: (String) -> Unit, + val onPipAutoEnter: (Boolean) -> Unit = {}, val onOsc: (String) -> Unit, val onBackActionsInverted: (Boolean) -> Unit = {}, val onDirectAudio: (Boolean) -> Unit, @@ -271,6 +274,10 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { onPick = a.onSuspendPolicy, ) } + if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) { + ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null, + s.pipAutoEnter, onChange = a.onPipAutoEnter) + } if (steam) SettingsGroup(stringResource(R.string.mode_startup)) { ToggleRow( host, "steam-startup", stringResource(R.string.mode_steam_startup), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 6f3c35fd5..257ba6db2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -200,6 +200,10 @@ class DrawerActions( val onSecondScreenDisplay: (Int) -> Unit, val onLaunchAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit, val onBackground: () -> Unit, + val pipSupported: Boolean = false, + val pipAutoEnter: Boolean = false, + val onPip: () -> Unit = {}, + val onPipAutoEnter: (Boolean) -> Unit = {}, val onShareLogs: () -> Unit, val onStop: () -> Unit, /** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */ @@ -472,7 +476,14 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } } } + if (a.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) { + ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null, + a.pipAutoEnter, onChange = a.onPipAutoEnter) + } Spacer(Modifier.height(18.dp)) + if (a.pipSupported) DrawerOutlineButton(stringResource(R.string.pip_title), modifier = focus.track(page, "pip")) { + host.open = null; a.onPip() + } if (!a.isHomeApp) DrawerOutlineButton(stringResource(R.string.drawer_background), modifier = focus.track(page, "background")) { host.open = null; a.onBackground() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c6187c3f6..320821120 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -918,6 +918,12 @@ Change variable Back to default Proton games + Picture in picture + Automatically enter PiP + Suspended + Picture in picture is available once the session is ready. + Allow picture in picture for DroidDeck in Android settings. + Android could not open picture in picture. Shortcut Add to home screen Choose game diff --git a/app/src/test/java/com/droiddeck/launcher/session/PipPolicyTest.kt b/app/src/test/java/com/droiddeck/launcher/session/PipPolicyTest.kt new file mode 100644 index 000000000..1261abd54 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/PipPolicyTest.kt @@ -0,0 +1,27 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.* +import org.junit.Test + +class PipPolicyTest { + @Test fun automaticEntryNeverOpensStartingFailedOrSuspendedSessions() { + for (phase in SessionPhase.entries) { + assertEquals(phase == SessionPhase.READY, + PipPolicy.eligible(true, true, phase, phase == SessionPhase.SUSPENDED, true)) + } + assertFalse(PipPolicy.eligible(true, false, SessionPhase.READY, false, true)) + assertFalse(PipPolicy.eligible(false, true, SessionPhase.READY, false, true)) + } + + @Test fun manualEntryAllowsAVisibleSuspendedPicture() { + assertTrue(PipPolicy.eligible(true, true, SessionPhase.SUSPENDED, true, false)) + assertFalse(PipPolicy.eligible(true, false, SessionPhase.SUSPENDED, true, false)) + } + + @Test fun aspectRatiosStayWithinAndroidLimitsWithoutChangingNormalContent() { + assertEquals(1920 to 1080, PipPolicy.aspect(1920, 1080)) + assertEquals(239 to 100, PipPolicy.aspect(5760, 1080)) + assertEquals(100 to 239, PipPolicy.aspect(1080, 5760)) + assertEquals(16 to 9, PipPolicy.aspect(0, 0)) + } +}