mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
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:
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())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user