Add standard session picture in picture (#156)

* Add session picture in picture controls

* Simplify PiP to standard Android controls

* Remove PiP testing document
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-03 15:06:16 -04:00
1 parent b04900b1b2
commit e60eae3646
14 files changed
+368 -11

No files matched your search

@@ -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())
+2 -1
View File
@@ -126,7 +126,8 @@
<activity
android:name=".SessionActivity"
android:exported="false"
android:launchMode="singleTop"
android:supportsPictureInPicture="true"
android:launchMode="singleTask"
android:theme="@style/SessionTheme"
android:preferMinimalPostProcessing="true"
android:screenOrientation="sensorLandscape"
@@ -1064,6 +1064,8 @@ class MainActivity : ComponentActivity() {
gpuDrivers = drivers.summary(),
touchMode = touchMode,
suspendPolicy = suspendPolicy,
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
pipAutoEnter = pipAutoEnter,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
backActionsInverted = backActionsInverted,
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
@@ -1119,6 +1121,7 @@ class MainActivity : ComponentActivity() {
onUpscaleSharpness = { pct -> 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
@@ -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<Int>()
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()
@@ -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)
@@ -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<Int, Int> = 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
}
}
@@ -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)
}
}
@@ -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 }
@@ -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
@@ -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
@@ -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),
@@ -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()
}
+6
View File
@@ -918,6 +918,12 @@
<string name="game_env_change_title">Change variable</string>
<string name="game_env_restore_default">Back to default</string>
<string name="game_env_eyebrow">Proton games</string>
<string name="pip_title">Picture in picture</string>
<string name="pip_auto">Automatically enter PiP</string>
<string name="pip_suspended">Suspended</string>
<string name="pip_not_ready">Picture in picture is available once the session is ready.</string>
<string name="pip_permission">Allow picture in picture for DroidDeck in Android settings.</string>
<string name="pip_unavailable">Android could not open picture in picture.</string>
<string name="game_shortcut">Shortcut</string>
<string name="game_shortcut_add">Add to home screen</string>
<string name="game_shortcut_choose">Choose game</string>
@@ -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))
}
}