mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
Merge branch 'main' into fix/steam-sd-preallocation
This commit is contained in:
43 files changed
+2170
-497
No files matched your search
+2
-2
@@ -79,8 +79,8 @@ android {
|
||||
// file out of its own data directory, and this app's entire job is to exec a rootfs:
|
||||
// proot, gamescope, the Steam client and everything it launches all live under files/.
|
||||
targetSdkVersion 28
|
||||
versionCode 10
|
||||
versionName "0.3.0"
|
||||
versionCode 11
|
||||
versionName "0.3.1"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
|
||||
@@ -42,6 +42,7 @@ import com.droiddeck.launcher.ui.ComponentsPage
|
||||
import com.droiddeck.launcher.session.GameSaves
|
||||
import com.droiddeck.launcher.session.SessionLogShare
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SteamRepair
|
||||
import com.droiddeck.launcher.ui.ProtonPage
|
||||
import com.droiddeck.launcher.core.CpuCores
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
@@ -110,6 +111,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var glThread by mutableStateOf(true)
|
||||
private var noGlError by mutableStateOf(true)
|
||||
private var steamDeckMode by mutableStateOf(false)
|
||||
private var steamRepairQueued by mutableStateOf(false)
|
||||
private var mangoapp by mutableStateOf(true)
|
||||
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
private var showProtons by mutableStateOf(false)
|
||||
@@ -304,13 +306,12 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
|
||||
private var settingsMode by mutableStateOf<String?>(null)
|
||||
private var resolutionCap by mutableStateOf(1080)
|
||||
private var customResolution by mutableStateOf<Pair<Int, Int>?>(null)
|
||||
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
|
||||
private var runSteamAtStartup by mutableStateOf(false)
|
||||
private var theme by mutableStateOf("graphite")
|
||||
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
|
||||
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
|
||||
private var hdrOn by mutableStateOf(false)
|
||||
private var fpsLimit by mutableStateOf(0)
|
||||
private var upscaler by mutableStateOf(0)
|
||||
@@ -480,6 +481,7 @@ class MainActivity : ComponentActivity() {
|
||||
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
|
||||
refreshPhantomStatus()
|
||||
theme = SessionPrefs.theme(this)
|
||||
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
|
||||
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
|
||||
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
@@ -489,7 +491,7 @@ class MainActivity : ComponentActivity() {
|
||||
applyLauncherFullscreen()
|
||||
updates.start()
|
||||
setContent {
|
||||
DroidDeckTheme(theme) {
|
||||
DroidDeckTheme(theme, appScale = appScale) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
val sm = settingsMode
|
||||
val page: (@Composable () -> Unit)? = when {
|
||||
@@ -516,6 +518,7 @@ class MainActivity : ComponentActivity() {
|
||||
lossless = lossless,
|
||||
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
|
||||
theme = theme,
|
||||
appScale = appScale,
|
||||
isHomeApp = homeAppSelected,
|
||||
homeScreenEnabled = homeScreenEnabled,
|
||||
defaultHomeLabel = defaultHomeLabel,
|
||||
@@ -645,6 +648,10 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
|
||||
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
|
||||
onAppScale = { percent ->
|
||||
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
|
||||
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
|
||||
},
|
||||
onLauncherFullscreen = { on ->
|
||||
SessionPrefs.setLauncherFullscreen(this, on)
|
||||
launcherFullscreen = on
|
||||
@@ -1117,7 +1124,8 @@ class MainActivity : ComponentActivity() {
|
||||
private fun ModeSettingsHost(mode: String) {
|
||||
ModeSettingsPage(
|
||||
ModeSettings(
|
||||
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
|
||||
mode = mode, resolution = resolution,
|
||||
panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this),
|
||||
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
|
||||
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
|
||||
gpuDrivers = drivers.summary(),
|
||||
@@ -1140,6 +1148,7 @@ class MainActivity : ComponentActivity() {
|
||||
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
|
||||
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
|
||||
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
|
||||
steamRepairQueued = steamRepairQueued,
|
||||
mangoapp = mangoapp,
|
||||
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
|
||||
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
|
||||
@@ -1172,9 +1181,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
},
|
||||
onWifiDiscoverySettings = { openWifiDiscoverySettings() },
|
||||
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
|
||||
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
|
||||
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
|
||||
onResolution = { value -> SessionPrefs.setResolutionChoice(this, mode, value); resolution = value },
|
||||
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
|
||||
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
|
||||
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
|
||||
@@ -1218,6 +1225,7 @@ class MainActivity : ComponentActivity() {
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
},
|
||||
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
|
||||
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
|
||||
onSteamDeckMode = { on ->
|
||||
SessionPrefs.setSteamDeckMode(this, on)
|
||||
steamDeckMode = on
|
||||
@@ -1329,19 +1337,18 @@ class MainActivity : ComponentActivity() {
|
||||
showProtons = false
|
||||
showComponents = false
|
||||
showMapping = false
|
||||
resolutionCap = SessionPrefs.resolutionCap(this, mode)
|
||||
customResolution = SessionPrefs.customResolution(this, mode)
|
||||
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
steamChannel = SessionPrefs.steamChannel(this)
|
||||
steamDeckMode = SessionPrefs.steamDeckMode(this)
|
||||
steamRepairQueued = SteamRepair.queued(this)
|
||||
mangoapp = SessionPrefs.mangoapp(this)
|
||||
steamController = SessionPrefs.steamController(this)
|
||||
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
|
||||
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
|
||||
shapeMode = SessionPrefs.shapeMode(this)
|
||||
hdrOn = SessionPrefs.hdr(this, mode)
|
||||
fpsLimit = SessionPrefs.fpsLimit(this, mode)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
|
||||
@@ -59,6 +59,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.LoadingState
|
||||
import com.droiddeck.launcher.session.PerfHud
|
||||
import com.droiddeck.launcher.session.PerfMode
|
||||
import com.droiddeck.launcher.session.SteamRepair
|
||||
import com.droiddeck.launcher.session.GameEnvironmentStore
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
@@ -82,7 +83,9 @@ import com.droiddeck.launcher.wayland.CompositorHost
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.ui.DRAWER_PAGES
|
||||
import com.droiddeck.launcher.ui.SessionDrawerPage
|
||||
import com.droiddeck.launcher.ui.sessionDrawerPages
|
||||
import com.droiddeck.launcher.ui.step
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
@@ -158,6 +161,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val cursorBuf = IntArray(WaylandCompositor.CURSOR_BUF_INTS)
|
||||
private var cursorSerial = 0
|
||||
private var clientHidesCursor = false
|
||||
/**
|
||||
* A program holds a pointer lock (zwp_locked_pointer_v1: a mouse-look game, through gamescope).
|
||||
* While it does, a physical mouse is captured and its raw deltas go to the program as relative
|
||||
* motion. Without capture the deltas are worked out from Android's pointer position, which stops
|
||||
* at the screen edge, and so did the camera (#153).
|
||||
*/
|
||||
private var pointerLocked = false
|
||||
private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ ->
|
||||
uiHandler.post {
|
||||
if (pointerLocked == locked) return@post
|
||||
pointerLocked = locked
|
||||
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
|
||||
updatePointerCapture()
|
||||
}
|
||||
}
|
||||
private var cursorImage by mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null)
|
||||
private var cursorHotX by mutableStateOf(0)
|
||||
private var cursorHotY by mutableStateOf(0)
|
||||
@@ -205,7 +223,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var closeAtOnce = false
|
||||
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
|
||||
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
|
||||
private var drawerPage by mutableIntStateOf(0)
|
||||
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
|
||||
private var drawerControllerActive by mutableStateOf(false)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
|
||||
@@ -225,7 +243,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
|
||||
private var onScreenButtonsVisible by mutableStateOf(false)
|
||||
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
|
||||
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
|
||||
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
|
||||
@@ -267,10 +285,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
logInputDevice("connected", deviceId)
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
override fun onInputDeviceRemoved(deviceId: Int) {
|
||||
Log.i(TAG, "input device $deviceId disconnected")
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
|
||||
}
|
||||
@@ -321,7 +341,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// below) and the session starts when it is in.
|
||||
if (!LinuxRuntime.isInstalled(this)) Log.i(TAG, "the Linux runtime is not installed; the loading screen installs it")
|
||||
|
||||
val root = FrameLayout(this)
|
||||
// Captured mouse events go down the focus chain, and focus sits somewhere in the overlay:
|
||||
// the root sees every one of them whichever view that is.
|
||||
val root = object : FrameLayout(this) {
|
||||
override fun dispatchCapturedPointerEvent(event: MotionEvent): Boolean =
|
||||
onCapturedPointer(event) || super.dispatchCapturedPointerEvent(event)
|
||||
override fun dispatchPointerCaptureChanged(hasCapture: Boolean) {
|
||||
super.dispatchPointerCaptureChanged(hasCapture)
|
||||
Log.i(TAG, "pointer capture " + if (hasCapture) "on" else "off")
|
||||
}
|
||||
}
|
||||
surfaceView = SurfaceView(this)
|
||||
surfaceView.holder.addCallback(this)
|
||||
root.addView(surfaceView)
|
||||
@@ -384,6 +413,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
updateOnScreenControls()
|
||||
WaylandCompositor.setFirstFrameListener(firstFrameListener)
|
||||
WaylandCompositor.setPointerLockListener(pointerLockListener)
|
||||
SessionEvents.markReadyIfPossible()
|
||||
SessionState.endListener = endListener
|
||||
// A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord.
|
||||
@@ -414,6 +444,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// this full-screen host and remain as a white veil after the drawer closes.
|
||||
defaultFocusHighlightEnabled = false
|
||||
setContent {
|
||||
// The drawer, the PC keyboard and picture in picture take the mouse back.
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, pipUi) { updatePointerCapture() }
|
||||
DroidDeckTheme {
|
||||
if (pipUi) {
|
||||
if (SessionState.suspended) androidx.compose.foundation.layout.Box(
|
||||
@@ -474,9 +506,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias,
|
||||
frameGen = frameGen,
|
||||
lossless = lossless,
|
||||
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
|
||||
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, touchMode = touchMode,
|
||||
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
|
||||
shapeMode = shapeMode, fexPreset = fexPreset,
|
||||
fexPreset = fexPreset,
|
||||
secondScreenMode = secondScreenMode,
|
||||
secondScreenDisplays = secondScreenDisplays,
|
||||
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
|
||||
@@ -528,7 +560,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionService.suspendPolicyChanged(this@SessionActivity)
|
||||
},
|
||||
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
|
||||
onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
|
||||
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
|
||||
onSecondScreenMode = ::selectSecondScreenMode,
|
||||
onSecondScreenDisplay = ::selectSecondScreenDisplay,
|
||||
@@ -756,7 +787,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
shapeMode = SessionPrefs.shapeMode(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
}
|
||||
|
||||
@@ -1012,38 +1042,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun outputSize(): Pair<Int, Int> {
|
||||
val bounds = panelBounds()
|
||||
val panelW = maxOf(bounds.width(), bounds.height()).toFloat()
|
||||
val panelH = minOf(bounds.width(), bounds.height()).toFloat()
|
||||
// Never narrower than 16:9. A foldable's inner panel is nearly square, and a game handed a
|
||||
// square display draws for the frame it was made for and cuts the sides off itself.
|
||||
// Wider than 16:9 is fine - games and the client cope with a phone's 20:9 - so the
|
||||
// panel's aspect is kept above that, unless the user pinned 16:9 for a foldable, and the
|
||||
// compositor letterboxes onto a squarer panel.
|
||||
// "Match screen" drops that floor, for a 4:3 or 3:2 handheld whose games should
|
||||
// fill it.
|
||||
val aspect = when (SessionPrefs.shapeMode(this)) {
|
||||
SessionPrefs.SHAPE_WIDE -> 16f / 9f
|
||||
SessionPrefs.SHAPE_EXACT -> panelW / panelH
|
||||
else -> maxOf(panelW / panelH, 16f / 9f)
|
||||
}
|
||||
// 720 tall at most by default, client and desktop alike: the client's CEF is the heaviest
|
||||
// thing in the session, and pixels above that cost frames for nothing anyone can see on a
|
||||
// handheld panel. The mode's settings (the cog beside Play / Desktop) can change
|
||||
// the cap or lift it to the panel.
|
||||
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
|
||||
// A custom resolution is taken as given; the compositor fits it to the panel.
|
||||
SessionPrefs.customResolution(this, mode)?.let { return it }
|
||||
// An emulator whose frames cost next to nothing (Library.drawsAtPanel) gets the panel,
|
||||
// unless the user chose a resolution for the mode.
|
||||
val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this)
|
||||
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
|
||||
val cap = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
|
||||
Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode)) 0
|
||||
else SessionPrefs.resolutionCap(this, mode)
|
||||
val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt()
|
||||
val width = (height * aspect).toInt()
|
||||
// Odd sizes upset the scaler; both dimensions even is what every mode here would be.
|
||||
return Pair(width and 1.inv(), height and 1.inv())
|
||||
// Lightweight emulators keep their panel-sized default until a resolution is chosen.
|
||||
val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
|
||||
Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode))
|
||||
com.droiddeck.launcher.session.SessionDisplay.MATCH_SCREEN
|
||||
else SessionPrefs.resolutionChoice(this, mode, panel)
|
||||
return com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panel, choice)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1261,7 +1268,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (drawerOpen) {
|
||||
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else DRAWER_PAGES - 1) % DRAWER_PAGES
|
||||
drawerPage = sessionDrawerPages(SessionState.mode == SessionService.MODE_STEAM && secondScreenDisplays.isNotEmpty())
|
||||
.step(drawerPage, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1)
|
||||
releaseDrawerDirection()
|
||||
}
|
||||
return true
|
||||
@@ -1348,6 +1356,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
/** With no view focused, captured mouse events skip the root and arrive here as trackball events. */
|
||||
override fun dispatchTrackballEvent(event: MotionEvent): Boolean =
|
||||
onCapturedPointer(event) || super.dispatchTrackballEvent(event)
|
||||
|
||||
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
||||
if (pipUi) return true
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN && event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) {
|
||||
@@ -1698,6 +1710,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return true
|
||||
}
|
||||
|
||||
/** A physical mouse, not a touchscreen, stylus or the system's own virtual devices. */
|
||||
private fun mouseAttached(): Boolean = InputDevice.getDeviceIds().any { id ->
|
||||
val d = InputDevice.getDevice(id)
|
||||
d != null && !d.isVirtual && d.supportsSource(InputDevice.SOURCE_MOUSE)
|
||||
}
|
||||
|
||||
/** Capture the mouse exactly while a program holds a lock and the session has the mouse. */
|
||||
private fun updatePointerCapture() {
|
||||
if (!::surfaceView.isInitialized) return
|
||||
val view = window.decorView
|
||||
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !pipUi && mouseAttached()
|
||||
if (want == view.hasPointerCapture()) return
|
||||
if (want) view.requestPointerCapture() else view.releasePointerCapture()
|
||||
}
|
||||
|
||||
/**
|
||||
* A captured mouse: raw motion as relative pointer deltas, buttons and the wheel as on the
|
||||
* uncaptured path. Anything else (a captured touchpad's absolute fingers) is left alone.
|
||||
*/
|
||||
private fun onCapturedPointer(event: MotionEvent): Boolean {
|
||||
if (!pointerLocked || !event.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
var dx = event.x
|
||||
var dy = event.y
|
||||
for (i in 0 until event.historySize) {
|
||||
dx += event.getHistoricalX(i)
|
||||
dy += event.getHistoricalY(i)
|
||||
}
|
||||
WaylandCompositor.sendPointerDelta(dx, dy)
|
||||
}
|
||||
MotionEvent.ACTION_BUTTON_PRESS, MotionEvent.ACTION_BUTTON_RELEASE -> {
|
||||
val button = when (event.actionButton) {
|
||||
MotionEvent.BUTTON_SECONDARY -> PointerGestures.BTN_RIGHT
|
||||
MotionEvent.BUTTON_TERTIARY -> PointerGestures.BTN_MIDDLE
|
||||
else -> PointerGestures.BTN_LEFT
|
||||
}
|
||||
WaylandCompositor.nativeSendSceneInput(3, button, if (event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) 1 else 0)
|
||||
}
|
||||
MotionEvent.ACTION_SCROLL -> {
|
||||
val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||
if (v != 0f) WaylandCompositor.nativeSendSceneInput(4, -Math.round(v), 0)
|
||||
}
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> {} // the BUTTON_* events carry these
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** The mode decides which controls appear; `droiddeck-osc` in Downloads still overrides. */
|
||||
/** Which controller the session has, so a pad report says what was plugged in and when. */
|
||||
private fun logInputDevice(what: String, deviceId: Int) {
|
||||
@@ -1714,6 +1775,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
?: SessionPrefs.oscMode(this)
|
||||
val controls = onScreenControls ?: return
|
||||
if (pipUi) {
|
||||
onScreenButtonsVisible = false
|
||||
controls.releaseAll()
|
||||
controls.visibility = View.GONE
|
||||
return
|
||||
@@ -1729,6 +1791,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
|
||||
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
|
||||
}
|
||||
onScreenButtonsVisible = show
|
||||
if (show == (controls.visibility == View.VISIBLE)) return
|
||||
if (!show) controls.releaseAll()
|
||||
controls.visibility = if (show) View.VISIBLE else View.GONE
|
||||
@@ -1867,6 +1930,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
controllerSettings = ControllerPrefs.read(this)
|
||||
updateOnScreenControls()
|
||||
resumed = true
|
||||
updatePointerCapture()
|
||||
if (!pipUi) sessionClipboard.start()
|
||||
updatePadMotion()
|
||||
readPrefs()
|
||||
@@ -1886,6 +1950,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// A button held when the app goes away would stay held in the ring for the whole session.
|
||||
onScreenControls?.releaseAll()
|
||||
resumed = false
|
||||
updatePointerCapture()
|
||||
updatePadMotion()
|
||||
keyboard?.takeIf { it.shown }?.hide()
|
||||
super.onPause()
|
||||
@@ -1906,6 +1971,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
goFullscreen()
|
||||
refreshClipboard()
|
||||
}
|
||||
// Android drops a capture with the focus; a lock still held takes it again on return.
|
||||
updatePointerCapture()
|
||||
}
|
||||
|
||||
/** The keyboard/trackpad presentation can own focus instead of the main display. */
|
||||
@@ -1933,6 +2000,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val endListener: (Int) -> Unit = { status -> onSessionEnded(status) }
|
||||
private val firstFrameListener = Runnable {
|
||||
SessionEvents.firstFrame()
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) SteamRepair.clientShown(this)
|
||||
runOnUiThread {
|
||||
loading.visible = false
|
||||
hud.start()
|
||||
@@ -1955,6 +2023,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
|
||||
if (SessionState.endListener === endListener) SessionState.endListener = null
|
||||
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
|
||||
WaylandCompositor.clearPointerLockListener(pointerLockListener)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object AppUiPrefs {
|
||||
const val DEFAULT_SCALE = 100
|
||||
val scales = listOf(75, 85, 100, 115, 125, 150)
|
||||
|
||||
internal fun normalizeScale(percent: Int): Int = percent.takeIf { it in scales } ?: DEFAULT_SCALE
|
||||
|
||||
fun scale(context: Context): Int = normalizeScale(
|
||||
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).getInt("scale", DEFAULT_SCALE),
|
||||
)
|
||||
|
||||
fun setScale(context: Context, percent: Int) {
|
||||
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit()
|
||||
.putInt("scale", normalizeScale(percent)).apply()
|
||||
}
|
||||
}
|
||||
@@ -108,9 +108,9 @@ object DeviceReport {
|
||||
h("Display")
|
||||
k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" })
|
||||
k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz))
|
||||
k("Shape setting", SessionPrefs.shapeMode(context))
|
||||
k("Custom resolution (Steam)", SessionPrefs.customResolution(context, SessionService.MODE_STEAM)?.let { "${it.first}x${it.second}" } ?: "off")
|
||||
k("Custom resolution (Desktop)", SessionPrefs.customResolution(context, SessionService.MODE_DESKTOP)?.let { "${it.first}x${it.second}" } ?: "off")
|
||||
val panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(context)
|
||||
k("Resolution (Steam)", SessionPrefs.resolutionChoice(context, SessionService.MODE_STEAM, panelSize))
|
||||
k("Resolution (Desktop)", SessionPrefs.resolutionChoice(context, SessionService.MODE_DESKTOP, panelSize))
|
||||
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
|
||||
|
||||
h("Drivers")
|
||||
|
||||
@@ -22,7 +22,7 @@ class FileManagerActivity : ComponentActivity() {
|
||||
}
|
||||
val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File)
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
|
||||
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
|
||||
FileManagerScreen(initialDir = initialDir)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class FilePickerActivity : ComponentActivity() {
|
||||
?.let { File(it) }?.takeIf { it.isDirectory }
|
||||
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
|
||||
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
|
||||
FileManagerScreen(
|
||||
pickMode = true,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.droiddeck.launcher.input;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* Where the fake input rings live and how the guest is told about them. Kept apart from
|
||||
* {@link FakeInputWriter}, which loads native code, so it can be tested on the JVM.
|
||||
*/
|
||||
final class FakeInputRingPaths {
|
||||
static final String RING_DIR_NAME = "fakeinput-rings";
|
||||
|
||||
private FakeInputRingPaths() {}
|
||||
|
||||
/** The rings sit beside the fake evdev nodes: session/dev/input -> session/dev/fakeinput-rings. */
|
||||
static File ringDir(File fakeInputDir) {
|
||||
if (fakeInputDir == null) {
|
||||
return null;
|
||||
}
|
||||
File inputDir = fakeInputDir.getAbsoluteFile();
|
||||
File parent = inputDir.getParentFile();
|
||||
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
|
||||
}
|
||||
|
||||
static File ringFile(File fakeInputDir, int slot) {
|
||||
File ringDir = ringDir(fakeInputDir);
|
||||
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ring's path as the guest opens it. The session tree is bound into the guest at its own
|
||||
* host path, spelled the way {@code Context.getFilesDir()} spells it (LinuxRuntime.binds), so
|
||||
* the export has to use that same spelling. A canonical path is not that: where
|
||||
* /data/user/0 is a link, or a separate mount, onto /data/data (ColorOS 16, #166) it resolves
|
||||
* to a prefix nothing is bound at, and every pad, physical and on-screen, goes dead.
|
||||
*/
|
||||
static String exportPath(File ringFile) {
|
||||
return ringFile.getAbsolutePath();
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,6 @@ public class FakeInputWriter {
|
||||
private static final int RING_SNAPSHOT_SEQ_OFFSET = 32;
|
||||
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
|
||||
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
|
||||
private static final String RING_DIR_NAME = "fakeinput-rings";
|
||||
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
|
||||
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
|
||||
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
|
||||
@@ -198,26 +197,8 @@ public class FakeInputWriter {
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static File getRingDir(File fakeInputDir) {
|
||||
if (fakeInputDir == null) {
|
||||
return null;
|
||||
}
|
||||
File inputDir = fakeInputDir.getAbsoluteFile();
|
||||
File parent = inputDir.getParentFile();
|
||||
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
|
||||
}
|
||||
|
||||
private static File getRingFile(File fakeInputDir, int slot) {
|
||||
File ringDir = getRingDir(fakeInputDir);
|
||||
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
|
||||
}
|
||||
|
||||
private static String getCanonicalOrAbsolutePath(File file) {
|
||||
try {
|
||||
return file.getCanonicalPath();
|
||||
} catch (IOException e) {
|
||||
return file.getAbsolutePath();
|
||||
}
|
||||
return FakeInputRingPaths.ringFile(fakeInputDir, slot);
|
||||
}
|
||||
|
||||
private static void initializeRingHeader(ByteBuffer data) {
|
||||
@@ -325,7 +306,7 @@ public class FakeInputWriter {
|
||||
ringSlot.ringFile = ringFile.getAbsoluteFile();
|
||||
ringSlot.ringRaf = raf;
|
||||
ringSlot.ringChannel = channel;
|
||||
ringSlot.exportPath = getCanonicalOrAbsolutePath(ringFile);
|
||||
ringSlot.exportPath = FakeInputRingPaths.exportPath(ringFile);
|
||||
Log.i(TAG, "Created fake input file ring for slot " + slot + ": " + ringSlot.exportPath);
|
||||
return ringSlot;
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -76,6 +76,17 @@ public final class PadBridge {
|
||||
private final java.util.Set<Integer> seenDevices = new java.util.HashSet<>();
|
||||
private int lastDeviceId = Integer.MIN_VALUE;
|
||||
|
||||
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
|
||||
public static final int NO_CONTROLLER = -1;
|
||||
/**
|
||||
* The Android input device id of the physical controller that last drove the pad, or
|
||||
* {@link #NO_CONTROLLER}. Every pad feeds the one exported slot, so this is how a game's rumble
|
||||
* on that slot finds the motors in the player's hands (RumbleComponent).
|
||||
*/
|
||||
private static volatile int activeControllerId = NO_CONTROLLER;
|
||||
|
||||
public static int activeControllerId() { return activeControllerId; }
|
||||
|
||||
public PadBridge(File fakeInputDir) {
|
||||
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
|
||||
}
|
||||
@@ -90,6 +101,7 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void stop() {
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
systemGuidePressed = false;
|
||||
systemQamPressed = false;
|
||||
qamChordActive = false;
|
||||
@@ -218,6 +230,7 @@ public final class PadBridge {
|
||||
*/
|
||||
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
|
||||
mutation.accept(state);
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
notePlayerInput();
|
||||
statOnScreen++;
|
||||
scheduleStats();
|
||||
@@ -299,6 +312,7 @@ public final class PadBridge {
|
||||
|
||||
/** Per event, so the common case - the same pad as last time - is a single compare. */
|
||||
private void noteDevice(InputDevice device) {
|
||||
activeControllerId = device.getId();
|
||||
if (device.getId() == lastDeviceId) return;
|
||||
lastDeviceId = device.getId();
|
||||
statDevice = device.getName();
|
||||
|
||||
@@ -14,7 +14,7 @@ import kotlin.math.abs
|
||||
* The client reads `/sys/class/power_supply/BAT<n>/...` - a laptop's or a Deck's naming and files
|
||||
* (`energy_now`, `power_now`, `time_to_empty_now`, a `uevent` of `POWER_SUPPLY_*` lines). Android
|
||||
* names its supply `battery` and lays it out differently, and on many phones an app may not read
|
||||
* it at all, so the client showed no battery. This writes a `BAT0` (and a `BAT1`, the Deck's name)
|
||||
* it at all, so the client showed no battery. This writes one `BAT1` (the Deck's name)
|
||||
* from Android's own battery API - the same on every device - into a directory the session binds
|
||||
* over `/sys/class/power_supply`, refreshed every few seconds while the session runs.
|
||||
*
|
||||
@@ -31,6 +31,8 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
|
||||
private var thread: Thread? = null
|
||||
|
||||
override fun start() {
|
||||
// Older builds exported the same aggregate battery twice; MangoHud counts both supplies.
|
||||
File(dir, "BAT0").deleteRecursively()
|
||||
running = true
|
||||
write()
|
||||
thread = Thread({
|
||||
@@ -39,7 +41,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
|
||||
if (running) write()
|
||||
}
|
||||
}, "battery-sysfs").apply { isDaemon = true; start() }
|
||||
Log.i(TAG, "battery: BAT0/BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
|
||||
Log.i(TAG, "battery: BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
@@ -87,7 +89,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
|
||||
pct <= 15 -> "Low"
|
||||
else -> "Normal"
|
||||
}
|
||||
for (name in listOf("BAT0", "BAT1")) {
|
||||
for (name in listOf("BAT1")) {
|
||||
val bat = File(dir, name).apply { mkdirs() }
|
||||
val attrs = linkedMapOf(
|
||||
"type" to "Battery",
|
||||
|
||||
@@ -3,43 +3,58 @@ package com.droiddeck.launcher.session
|
||||
import android.net.LocalServerSocket
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.droiddeck.launcher.core.SessionPart
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import java.io.DataInputStream
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Rumble for the on-screen pad, on the phone itself.
|
||||
* Rumble for the pad, on the motors in the player's hands.
|
||||
*
|
||||
* When a game plays a force-feedback effect on the fake pad, the fake evdev layer inside the guest
|
||||
* connects to the abstract socket [NAME] and sends one packet: strong, weak, duration in ms and
|
||||
* the pad slot, four little-endian 16-bit values. This listens for those and drives the device's
|
||||
* vibrator with the stronger motor's strength for the effect's duration. Best effort both ways:
|
||||
* a missing listener costs the guest nothing, and a bad packet is dropped.
|
||||
* When a game plays a force-feedback effect on the fake pad (or Steam Input sends the Deck's
|
||||
* rumble report), the fake evdev layer inside the guest connects to the abstract socket [NAME]
|
||||
* and sends one packet: strong, weak, duration in ms and the pad slot, four little-endian 16-bit
|
||||
* values. This listens for those and plays the effect on the physical controller that last drove
|
||||
* the pad (PadBridge.activeControllerId) when Android exposes its motors - strong and weak
|
||||
* separately where it has two - and otherwise on the device's own vibrator, with the stronger
|
||||
* motor's strength: the on-screen pad, or a controller without rumble. Best effort both ways: a
|
||||
* missing listener costs the guest nothing, and a bad packet is dropped.
|
||||
*/
|
||||
class RumbleComponent : SessionPart() {
|
||||
@Volatile private var server: LocalServerSocket? = null
|
||||
private var vibrator: Vibrator? = null
|
||||
private var phone: Motors? = null
|
||||
/** Where the last effect went, so the next one (or a stop) can end it there. */
|
||||
private var playing: Motors? = null
|
||||
private var controllerId = PadBridge.NO_CONTROLLER
|
||||
private var controller: Motors? = null
|
||||
private var preferences: SharedPreferences? = null
|
||||
private var enabled = false
|
||||
private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == null || key == "rumble") refreshEnabled()
|
||||
}
|
||||
|
||||
/** The motors of the controller with this input device id, or null when it has none. Tests swap it. */
|
||||
internal var controllerMotors: (Int) -> Motors? = ::lookupController
|
||||
|
||||
@Synchronized private fun refreshEnabled() {
|
||||
enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true
|
||||
if (!enabled) vibrator?.cancel()
|
||||
if (!enabled) { playing?.cancel(); playing = null }
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
val ctx = app() ?: return
|
||||
val s = try { LocalServerSocket(NAME) } catch (e: Exception) { Log.w(TAG, "rumble: no listener ($e)"); return }
|
||||
vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||
val vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||
else @Suppress("DEPRECATION") ctx.getSystemService(Vibrator::class.java)
|
||||
phone = vibrator?.takeIf { it.hasVibrator() }?.let { PhoneMotors(it) }
|
||||
preferences = ControllerPrefs.prefs(ctx).also { it.registerOnSharedPreferenceChangeListener(preferenceListener) }
|
||||
refreshEnabled()
|
||||
server = s
|
||||
@@ -49,8 +64,7 @@ class RumbleComponent : SessionPart() {
|
||||
try {
|
||||
val bytes = ByteArray(8)
|
||||
DataInputStream(client.inputStream).readFully(bytes)
|
||||
val strong = u16(bytes, 0); val weak = u16(bytes, 2); val ms = u16(bytes, 4)
|
||||
buzz(max(strong, weak), ms)
|
||||
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4))
|
||||
} catch (e: Exception) {
|
||||
// A partial packet or a closed peer: nothing to play.
|
||||
} finally {
|
||||
@@ -65,8 +79,11 @@ class RumbleComponent : SessionPart() {
|
||||
preferences?.unregisterOnSharedPreferenceChangeListener(preferenceListener)
|
||||
preferences = null
|
||||
enabled = false
|
||||
vibrator?.cancel()
|
||||
vibrator = null
|
||||
playing?.cancel()
|
||||
playing = null
|
||||
phone = null
|
||||
controller = null
|
||||
controllerId = PadBridge.NO_CONTROLLER
|
||||
val s = server
|
||||
server = null
|
||||
try { s?.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ }
|
||||
@@ -74,24 +91,91 @@ class RumbleComponent : SessionPart() {
|
||||
|
||||
private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
@Synchronized private fun buzz(strength: Int, ms: Int) {
|
||||
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int) {
|
||||
if (!enabled) return
|
||||
val vibrator = vibrator ?: return
|
||||
if (!vibrator.hasVibrator()) return
|
||||
if (strength == 0 || ms == 0) { vibrator.cancel(); return }
|
||||
val amplitude = (strength * 255L / 65535L).toInt().coerceIn(1, 255)
|
||||
val duration = ms.toLong().coerceIn(1L, 5000L)
|
||||
try {
|
||||
if (vibrator.hasAmplitudeControl()) vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude))
|
||||
else vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "rumble: $e")
|
||||
val target = target()
|
||||
if (target !== playing) {
|
||||
playing?.cancel()
|
||||
playing = target
|
||||
target?.let { Log.i(TAG, "rumble: playing on ${it.name}") }
|
||||
}
|
||||
if (target == null) return
|
||||
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return }
|
||||
try {
|
||||
target.play(strong, weak, ms.toLong().coerceIn(1L, 5000L))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "rumble: ${target.name}: $e")
|
||||
}
|
||||
}
|
||||
|
||||
/** The active controller's motors when it has any, else the phone's. */
|
||||
private fun target(): Motors? {
|
||||
val id = PadBridge.activeControllerId()
|
||||
if (id != controllerId) {
|
||||
controllerId = id
|
||||
controller = if (id == PadBridge.NO_CONTROLLER) null else try { controllerMotors(id) } catch (e: Exception) { null }
|
||||
}
|
||||
return controller ?: phone
|
||||
}
|
||||
|
||||
/** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */
|
||||
internal interface Motors {
|
||||
val name: String
|
||||
fun play(strong: Int, weak: Int, ms: Long)
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
private class PhoneMotors(private val vibrator: Vibrator) : Motors {
|
||||
override val name = "the device's vibrator"
|
||||
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
|
||||
override fun cancel() = vibrator.cancel()
|
||||
}
|
||||
|
||||
/** Android 12+: each of the pad's motors on its own, the lower id being the strong one. */
|
||||
@RequiresApi(31)
|
||||
private class PadMotors(override val name: String, private val manager: VibratorManager, private val ids: IntArray) : Motors {
|
||||
override fun play(strong: Int, weak: Int, ms: Long) {
|
||||
val combined = CombinedVibration.startParallel()
|
||||
var any = false
|
||||
fun add(id: Int, strength: Int) {
|
||||
if (strength == 0) return
|
||||
combined.addVibrator(id, oneShot(manager.getVibrator(id), strength, ms))
|
||||
any = true
|
||||
}
|
||||
if (ids.size >= 2) { add(ids[0], strong); add(ids[1], weak) } else add(ids[0], max(strong, weak))
|
||||
if (any) manager.vibrate(combined.combine()) else manager.cancel()
|
||||
}
|
||||
override fun cancel() = manager.cancel()
|
||||
}
|
||||
|
||||
/** Before Android 12 a pad has one vibrator. */
|
||||
private class LegacyPadMotors(override val name: String, private val vibrator: Vibrator) : Motors {
|
||||
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
|
||||
override fun cancel() = vibrator.cancel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
/** Must match the fake evdev layer (fakeinput_steam.cpp). */
|
||||
const val NAME = "droiddeck-rumble"
|
||||
|
||||
private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect {
|
||||
val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255)
|
||||
else VibrationEffect.DEFAULT_AMPLITUDE
|
||||
return VibrationEffect.createOneShot(ms, amplitude)
|
||||
}
|
||||
|
||||
private fun lookupController(id: Int): Motors? {
|
||||
val device = InputDevice.getDevice(id) ?: return null
|
||||
val name = "\"${device.name}\""
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val manager = device.vibratorManager
|
||||
val ids = manager.vibratorIds.sortedArray()
|
||||
return if (ids.isEmpty()) null else PadMotors(name, manager, ids)
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
val vibrator = device.vibrator
|
||||
return if (vibrator.hasVibrator()) LegacyPadMotors(name, vibrator) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.view.WindowManager
|
||||
|
||||
/** The virtual display advertised to the guest, before it is fitted onto the Android panel. */
|
||||
object SessionDisplay {
|
||||
const val MATCH_SCREEN = "screen"
|
||||
const val DEFAULT_RESOLUTION = "1280x720"
|
||||
|
||||
fun screenSize(panel: Pair<Int, Int>): Pair<Int, Int> =
|
||||
(maxOf(panel.first, panel.second) and 1.inv()) to (minOf(panel.first, panel.second) and 1.inv())
|
||||
|
||||
fun resolveChoice(panel: Pair<Int, Int>, choice: String): Pair<Int, Int> =
|
||||
if (choice == MATCH_SCREEN) screenSize(panel) else {
|
||||
// Legacy displays may exceed the custom dialog's size limit. Keep their exact size.
|
||||
val parts = choice.split('x').map { it.toIntOrNull() }
|
||||
if (parts.size == 2 && parts.all { it != null && it > 0 }) parts[0]!! to parts[1]!!
|
||||
else 1280 to 720
|
||||
}
|
||||
|
||||
/** Fixed dimensions and the current panel, with no duplicate panel-size preset. */
|
||||
fun resolutionOptions(panel: Pair<Int, Int>): List<String> =
|
||||
listOf(DEFAULT_RESOLUTION, "1600x900", "1920x1080")
|
||||
.filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN
|
||||
|
||||
fun panelSize(context: Context): Pair<Int, Int> {
|
||||
val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
@Suppress("DEPRECATION")
|
||||
val mode = manager.defaultDisplay.mode
|
||||
return maxOf(mode.physicalWidth, mode.physicalHeight) to minOf(mode.physicalWidth, mode.physicalHeight)
|
||||
}
|
||||
|
||||
/** Interpret an older cap/aspect combination without altering the saved picture size. */
|
||||
fun resolve(panel: Pair<Int, Int>, cap: Int, shape: String, custom: Pair<Int, Int>? = null): Pair<Int, Int> {
|
||||
custom?.let { return it }
|
||||
val panelW = maxOf(panel.first, panel.second).toFloat()
|
||||
val panelH = minOf(panel.first, panel.second).toFloat().coerceAtLeast(1f)
|
||||
// Auto retains wide panels but floors squarer ones at 16:9 for game compatibility.
|
||||
// Match screen removes that floor; fixed 16:9 also keeps foldable sessions stable.
|
||||
val aspect = when (shape) {
|
||||
SessionPrefs.SHAPE_WIDE -> 16f / 9f
|
||||
SessionPrefs.SHAPE_EXACT -> panelW / panelH
|
||||
else -> maxOf(panelW / panelH, 16f / 9f)
|
||||
}
|
||||
val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt()
|
||||
return ((height * aspect).toInt() and 1.inv()) to (height and 1.inv())
|
||||
}
|
||||
|
||||
/** Mirrors gamescope's even-width 16:9 calculation, including near-16:9 rounding. */
|
||||
fun canStretch16x9(size: Pair<Int, Int>): Boolean =
|
||||
(size.second * 16 + 4) / 9 / 2 * 2 > size.first
|
||||
}
|
||||
@@ -88,27 +88,16 @@ object SessionPrefs {
|
||||
const val SHAPE_WIDE = "16:9"
|
||||
const val SHAPE_EXACT = "exact"
|
||||
|
||||
/** The choices the settings offer, in order. */
|
||||
val shapeChoices = listOf(
|
||||
SHAPE_AUTO to "Auto (16:9+)",
|
||||
SHAPE_EXACT to "Match screen",
|
||||
SHAPE_WIDE to "Always 16:9",
|
||||
)
|
||||
|
||||
/**
|
||||
* The shape of the display the session presents: the panel's own (never narrower than 16:9),
|
||||
* exactly the panel's (a 4:3 or 3:2 handheld, drawn edge to edge), or a fixed 16:9. A foldable defaults to 16:9, which sits with modest bars on either of its
|
||||
* panels; the panel's own shape would fit one and leave a strip on the other, and gamescope's
|
||||
* display cannot change size once the session is up.
|
||||
*/
|
||||
fun shapeMode(context: Context): String =
|
||||
private fun shapeMode(context: Context): String =
|
||||
prefs(context).getString("shape", null)
|
||||
?: if (context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")) SHAPE_WIDE else SHAPE_AUTO
|
||||
|
||||
fun setShapeMode(context: Context, mode: String) {
|
||||
prefs(context).edit().putString("shape", mode).apply()
|
||||
}
|
||||
|
||||
fun oscMode(context: Context): String = prefs(context).getString("osc", OSC_AUTO) ?: OSC_AUTO
|
||||
|
||||
fun setOscMode(context: Context, mode: String) {
|
||||
@@ -512,11 +501,33 @@ object SessionPrefs {
|
||||
* and the emulators under it get the same GPU headroom. Read once, when the session's display
|
||||
* is sized; a cap the user chose wins over the default.
|
||||
*/
|
||||
fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", defaultResolutionCap(mode))
|
||||
private fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", 720)
|
||||
|
||||
/** Whether the user chose the mode's resolution (a cap or a custom size) rather than the default. */
|
||||
fun resolutionChosen(context: Context, mode: String): Boolean =
|
||||
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
|
||||
prefs(context).contains("displayResolution.$mode") ||
|
||||
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
|
||||
|
||||
/** One per-mode choice now owns both dimensions; old caps/shapes are read only for migration. */
|
||||
fun resolutionChoice(context: Context, mode: String, panel: Pair<Int, Int>): String {
|
||||
val saved = prefs(context)
|
||||
saved.getString("displayResolution.$mode", null)?.let { value ->
|
||||
if (value == SessionDisplay.MATCH_SCREEN) return value
|
||||
parseResolution(value)?.let { return "${it.first}x${it.second}" }
|
||||
}
|
||||
if (!resolutionChosen(context, mode) && !saved.contains("shape")) return SessionDisplay.DEFAULT_RESOLUTION
|
||||
val legacy = SessionDisplay.resolve(panel, resolutionCap(context, mode), shapeMode(context), customResolution(context, mode))
|
||||
return if (legacy == SessionDisplay.screenSize(panel)) SessionDisplay.MATCH_SCREEN
|
||||
else "${legacy.first}x${legacy.second}"
|
||||
}
|
||||
|
||||
fun setResolutionChoice(context: Context, mode: String, choice: String) {
|
||||
val value = if (choice == SessionDisplay.MATCH_SCREEN) choice else {
|
||||
val size = requireNotNull(parseResolution(choice)) { "Invalid resolution" }
|
||||
"${size.first}x${size.second}"
|
||||
}
|
||||
prefs(context).edit().putString("displayResolution.$mode", value).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults.
|
||||
@@ -596,25 +607,13 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("theme", id).apply()
|
||||
}
|
||||
|
||||
/** What a mode gets when nothing was chosen. */
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun defaultResolutionCap(mode: String): Int = 720
|
||||
|
||||
fun setResolutionCap(context: Context, mode: String, cap: Int) {
|
||||
prefs(context).edit().putInt("resolutionCap.$mode", cap).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* A fixed size for the session's display, per mode, or null. When set it replaces both the
|
||||
* cap and the shape: the compositor fits it to the panel with bars where the shapes differ.
|
||||
*/
|
||||
fun customResolution(context: Context, mode: String): Pair<Int, Int>? =
|
||||
private fun customResolution(context: Context, mode: String): Pair<Int, Int>? =
|
||||
parseResolution(prefs(context).getString("customRes.$mode", null))
|
||||
|
||||
fun setCustomResolution(context: Context, mode: String, size: Pair<Int, Int>?) {
|
||||
prefs(context).edit().putString("customRes.$mode", size?.let { "${it.first}x${it.second}" }).apply()
|
||||
}
|
||||
|
||||
/** "1024x768" (or ×, or *) to an even size inside 320x240..3840x2160; anything else is null. */
|
||||
fun parseResolution(text: String?): Pair<Int, Int>? {
|
||||
val parts = text?.trim()?.split('x', 'X', '×', '*')?.map { it.trim() } ?: return null
|
||||
@@ -669,20 +668,27 @@ object SessionPrefs {
|
||||
|
||||
/**
|
||||
* How the compositor resizes the session onto the panel (WaylandCompositor.nativeSetUpscaler's
|
||||
* modes): Off and Linear both filter bilinearly, Nearest keeps pixels square for 2D and old
|
||||
* titles, the rest sharpen where the picture is enlarged; FSR (fit) rounds the picture to
|
||||
* FSR's preferred size first. Sharpen only works at any size.
|
||||
* modes). Linear is the default; Nearest preserves hard pixel edges. Spatial filters work
|
||||
* when enlarged; Sharpen only works at any size. The old Off/Linear and FSR/FSR Fit pairs
|
||||
* are equivalent on Wayland, so saved aliases resolve to one choice.
|
||||
*/
|
||||
val upscalerChoices = listOf(
|
||||
0 to "Off", 1 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 5 to "AMD FSR 1 (fit)", 3 to "Snapdragon GSR",
|
||||
0 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 3 to "Snapdragon GSR",
|
||||
8 to "Snapdragon GSR (quality)", 7 to "NVIDIA NIS", 6 to "Sharpen only",
|
||||
)
|
||||
|
||||
fun upscaler(context: Context): Int =
|
||||
prefs(context).getInt("upscaler", 0).takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0
|
||||
fun canonicalUpscaler(mode: Int): Int = when (mode) {
|
||||
1 -> 0
|
||||
5 -> 4
|
||||
else -> mode.takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0
|
||||
}
|
||||
|
||||
fun upscalerHasSharpness(mode: Int): Boolean = canonicalUpscaler(mode) in 3..8
|
||||
|
||||
fun upscaler(context: Context): Int = canonicalUpscaler(prefs(context).getInt("upscaler", 0))
|
||||
|
||||
fun setUpscaler(context: Context, mode: Int) {
|
||||
prefs(context).edit().putInt("upscaler", mode).apply()
|
||||
prefs(context).edit().putInt("upscaler", canonicalUpscaler(mode)).apply()
|
||||
}
|
||||
|
||||
fun upscaleSharpness(context: Context): Int = prefs(context).getInt("upscaleSharpness", 75).coerceIn(0, 100)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
|
||||
object SteamRepair {
|
||||
private fun home(context: Context) = File(LinuxRuntime.rootDir(context), "root")
|
||||
private fun request(context: Context) = File(home(context), ".bl-steam-repair")
|
||||
private fun unconfirmed(context: Context) = File(home(context), ".local/share/Steam/package/droiddeck-unconfirmed")
|
||||
|
||||
fun queued(context: Context): Boolean = request(context).exists()
|
||||
|
||||
fun queue(context: Context): Boolean = runCatching {
|
||||
request(context).apply { parentFile?.mkdirs() }.writeText("1\n")
|
||||
}.isSuccess
|
||||
|
||||
fun clientShown(context: Context) {
|
||||
unconfirmed(context).delete()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.ui.unit.Density
|
||||
import com.droiddeck.launcher.core.AppUiPrefs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal fun scaledAppDensity(base: Density, percent: Int): Density {
|
||||
val scale = AppUiPrefs.normalizeScale(percent) / 100f
|
||||
return if (scale == 1f) base else Density(base.density * scale, base.fontScale)
|
||||
}
|
||||
|
||||
/** Responsive layouts and popup height limits must see the same dp space as the scaled UI. */
|
||||
internal fun scaledAppConfiguration(base: Configuration, percent: Int): Configuration {
|
||||
val scale = AppUiPrefs.normalizeScale(percent) / 100f
|
||||
if (scale == 1f) return base
|
||||
return Configuration(base).apply {
|
||||
if (densityDpi > 0) densityDpi = (densityDpi * scale).roundToInt()
|
||||
if (screenWidthDp > 0) screenWidthDp = (screenWidthDp / scale).roundToInt()
|
||||
if (screenHeightDp > 0) screenHeightDp = (screenHeightDp / scale).roundToInt()
|
||||
if (smallestScreenWidthDp > 0) smallestScreenWidthDp = (smallestScreenWidthDp / scale).roundToInt()
|
||||
}
|
||||
}
|
||||
@@ -284,7 +284,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni
|
||||
}
|
||||
}
|
||||
|
||||
/** A page's title, with room at its right for a status chip. */
|
||||
/** A page's title, with room at its right for controls or status. */
|
||||
@Composable
|
||||
internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit = {}) {
|
||||
Row(
|
||||
|
||||
@@ -101,6 +101,7 @@ class FrontEndState(
|
||||
val gameSyncFolder: String? = null,
|
||||
val pageKey: String? = null,
|
||||
val theme: String = Themes.GRAPHITE,
|
||||
val appScale: Int = com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE,
|
||||
val isHomeApp: Boolean = false,
|
||||
val homeScreenEnabled: Boolean = false,
|
||||
val defaultHomeLabel: String? = null,
|
||||
@@ -168,6 +169,7 @@ class FrontEndActions(
|
||||
val onOffline: () -> Unit,
|
||||
val onPageBack: () -> Unit = {},
|
||||
val onTheme: (String) -> Unit = {},
|
||||
val onAppScale: (Int) -> Unit = {},
|
||||
val onLauncherFullscreen: (Boolean) -> Unit = {},
|
||||
val onAnimationsEnabled: (Boolean) -> Unit = {},
|
||||
val onStoreEnabled: (Boolean) -> Unit = {},
|
||||
|
||||
@@ -162,8 +162,6 @@ internal fun SetupPanel(
|
||||
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
|
||||
val signedIn = s.offlineAccount != null
|
||||
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
|
||||
val checks = 4
|
||||
val readyCount = listOf(gpuOk, s.ready && !s.busy, !limitBlocks, signedIn).count { it }
|
||||
// Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
|
||||
// and credits are on the Updates page.
|
||||
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher))
|
||||
@@ -187,9 +185,8 @@ internal fun SetupPanel(
|
||||
),
|
||||
) {
|
||||
PageHeader(stringResource(R.string.setup_title)) {
|
||||
Chip(if (readyCount == checks) stringResource(R.string.setup_all_set) else stringResource(R.string.setup_n_ready, readyCount, checks), ok = readyCount == checks)
|
||||
TabStrip(tabs, tab, pick, Modifier.weight(1f), tabFocus)
|
||||
}
|
||||
TabStrip(tabs, tab, pick, Modifier.padding(bottom = 4.dp), tabFocus)
|
||||
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
|
||||
when (tab) {
|
||||
0 -> {
|
||||
@@ -314,6 +311,15 @@ internal fun SetupPanel(
|
||||
}
|
||||
3 -> {
|
||||
SettingsGroup(stringResource(R.string.setup_launcher)) {
|
||||
ChoiceRow(
|
||||
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
|
||||
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
|
||||
percent to stringResource(
|
||||
if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default
|
||||
else R.string.ctrl_percent, percent,
|
||||
)
|
||||
}, s.appScale, onPick = a.onAppScale,
|
||||
)
|
||||
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
|
||||
Box {
|
||||
ValueChip(Themes.byId(s.theme).label, host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
|
||||
|
||||
@@ -52,13 +52,13 @@ import kotlinx.coroutines.withContext
|
||||
// dialog, with each variable's actions in a menu off its value as everywhere else.
|
||||
|
||||
@Composable
|
||||
fun GameEnvironmentRow(modifier: Modifier = Modifier) {
|
||||
fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
// Opened with a pad, the editor starts with focus in it; the dialog is a window of its own and
|
||||
// would otherwise give the pad nothing to move from.
|
||||
var byPad by remember { mutableStateOf(false) }
|
||||
val inputMode = LocalInputModeManager.current
|
||||
SettingsRow(stringResource(R.string.game_env_title), stringResource(R.string.game_env_hint)) {
|
||||
SettingsRow(stringResource(R.string.game_env_title), if (showHint) stringResource(R.string.game_env_hint) else null) {
|
||||
SecondaryButton(stringResource(R.string.game_env_edit), modifier = modifier) {
|
||||
byPad = inputMode.inputMode == InputMode.Keyboard
|
||||
open = true
|
||||
|
||||
@@ -43,10 +43,8 @@ class DownloadRow(val key: String, val label: String, val detail: String, val pr
|
||||
|
||||
class ModeSettings(
|
||||
val mode: String,
|
||||
val resolutionCap: Int,
|
||||
/** A fixed session size, or null for the cap and shape. */
|
||||
val customResolution: Pair<Int, Int>? = null,
|
||||
val shapeMode: String,
|
||||
val resolution: String,
|
||||
val panelSize: Pair<Int, Int> = 1280 to 720,
|
||||
val hdr: Boolean,
|
||||
val hdrReason: String?,
|
||||
/** The GPU drivers in use, as the row that opens them on the Components page says it. */
|
||||
@@ -74,13 +72,14 @@ class ModeSettings(
|
||||
val fexPreset: String? = null,
|
||||
/** Steam only: SessionPrefs.SYNC_* chosen for Proton games; null outside Steam. */
|
||||
val syncBackend: String? = null,
|
||||
/** Steam only: games are stretched to fill the screen (null = not a Steam page). */
|
||||
/** Steam only: forces game windows fullscreen (null = not a Steam page). */
|
||||
val forceFullscreen: Boolean? = null,
|
||||
val stretch16x9: Boolean? = null,
|
||||
/** Steam only: the client branch forced on the command line. */
|
||||
val steamChannel: String? = null,
|
||||
/** Steam only: enable the SteamOS client interface and its performance controls. */
|
||||
val steamDeckMode: Boolean = false,
|
||||
val steamRepairQueued: Boolean = false,
|
||||
/** Steam, Deck mode: the QAM's performance overlay (mangoapp) is started. */
|
||||
val mangoapp: Boolean = true,
|
||||
/** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */
|
||||
@@ -112,10 +111,7 @@ class ModeSettings(
|
||||
class AddedGameRow(val folderPath: String, val folderName: String, val exePath: String, val exeName: String, val candidates: List<Pair<String, String>>)
|
||||
|
||||
class ModeSettingsActions(
|
||||
val onResolution: (Int) -> Unit,
|
||||
/** Null clears it. */
|
||||
val onCustomResolution: (Pair<Int, Int>?) -> Unit = {},
|
||||
val onShape: (String) -> Unit,
|
||||
val onResolution: (String) -> Unit,
|
||||
val onHdr: (Boolean) -> Unit,
|
||||
/** Opens the GPU drivers on the Components page: they are shared by every session. */
|
||||
val onGpuDrivers: () -> Unit = {},
|
||||
@@ -139,6 +135,7 @@ class ModeSettingsActions(
|
||||
val onForceFullscreen: (Boolean) -> Unit = {},
|
||||
val onStretch16x9: (Boolean) -> Unit = {},
|
||||
val onSteamChannel: (String) -> Unit = {},
|
||||
val onSteamRepair: () -> Unit = {},
|
||||
val onSteamDeckMode: (Boolean) -> Unit = {},
|
||||
val onMangoapp: (Boolean) -> Unit = {},
|
||||
val onSteamController: (String) -> Unit = {},
|
||||
@@ -158,139 +155,158 @@ class ModeSettingsActions(
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
|
||||
private enum class ModeSettingsTab(val label: Int) {
|
||||
DISPLAY(R.string.drawer_page_display),
|
||||
CONTROLS(R.string.drawer_page_controls),
|
||||
GAMES(R.string.game_settings_title),
|
||||
AUDIO(R.string.mode_audio),
|
||||
SESSION(R.string.mode_session),
|
||||
STEAM(R.string.mode_tab_steam),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
val steam = s.mode == SessionService.MODE_STEAM
|
||||
val host = rememberMenuHost()
|
||||
var confirmDeckyRemoval by remember { mutableStateOf(false) }
|
||||
var explainWifiDiscovery by remember { mutableStateOf(false) }
|
||||
val pageScroll = androidx.compose.foundation.rememberScrollState()
|
||||
var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) }
|
||||
val tabs = if (steam) ModeSettingsTab.entries else listOf(
|
||||
ModeSettingsTab.DISPLAY, ModeSettingsTab.CONTROLS, ModeSettingsTab.SESSION,
|
||||
)
|
||||
val tab = tabs[tabIndex]
|
||||
val scrolls = tabs.map { androidx.compose.foundation.rememberScrollState() }
|
||||
val tabFocus = remember(s.mode) { tabs.map { androidx.compose.ui.focus.FocusRequester() } }
|
||||
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
// One frame first: the box has to be laid out before it can take focus. Opened from the
|
||||
// cog, focus starts on the first control (Resolution) so the d-pad works at once.
|
||||
fun selectTab(index: Int) { host.open = null; tabIndex = index }
|
||||
androidx.compose.runtime.LaunchedEffect(steam, tabIndex) {
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
runCatching { firstChip.requestFocus() }
|
||||
runCatching {
|
||||
if (tab == ModeSettingsTab.DISPLAY) firstChip.requestFocus()
|
||||
else tabFocus[tabIndex].requestFocus()
|
||||
}
|
||||
}
|
||||
SettingsPage(
|
||||
host,
|
||||
title = if (steam) stringResource(R.string.mode_steam_title) else stringResource(R.string.mode_desktop_title),
|
||||
title = null,
|
||||
onBack = a.onDismiss,
|
||||
scroll = pageScroll,
|
||||
scroll = scrolls[tabIndex],
|
||||
modifier = Modifier.bumpers(
|
||||
onPrevious = { selectTab((tabIndex + tabs.size - 1) % tabs.size) },
|
||||
onNext = { selectTab((tabIndex + 1) % tabs.size) },
|
||||
),
|
||||
header = {
|
||||
TabStrip(tabs.map { stringResource(it.label) }, tabIndex, ::selectTab,
|
||||
modifier = Modifier.fillMaxWidth(), focusRequesters = tabFocus)
|
||||
},
|
||||
) {
|
||||
SettingsGroup(stringResource(R.string.mode_display)) {
|
||||
val default = SessionPrefs.defaultResolutionCap(s.mode)
|
||||
var editCustom by remember { mutableStateOf(false) }
|
||||
val custom = s.customResolution
|
||||
ChoiceRow(
|
||||
host, "res", stringResource(R.string.mode_resolution), stringResource(R.string.common_applies_next_session),
|
||||
listOf(720 to stringResource(R.string.mode_res_720), 900 to stringResource(R.string.mode_res_900), 1080 to stringResource(R.string.mode_res_1080), 0 to stringResource(R.string.mode_res_native))
|
||||
.map { (cap, label) -> cap to (if (cap == default) stringResource(R.string.mode_res_default, label) else label) } +
|
||||
(CUSTOM to (custom?.let { stringResource(R.string.mode_res_custom_value, it.first, it.second) } ?: stringResource(R.string.mode_res_custom))),
|
||||
if (custom != null) CUSTOM else s.resolutionCap, note = stringResource(R.string.mode_res_note),
|
||||
chipModifier = androidx.compose.ui.Modifier.focusRequester(firstChip),
|
||||
onPick = { v -> if (v == CUSTOM) editCustom = true else { a.onCustomResolution(null); a.onResolution(v) } },
|
||||
)
|
||||
ChoiceRow(
|
||||
host, "shape", stringResource(R.string.mode_ratio),
|
||||
if (custom != null) stringResource(R.string.mode_ratio_custom) else stringResource(R.string.mode_ratio_auto),
|
||||
com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape,
|
||||
)
|
||||
ChoiceRow(
|
||||
host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
|
||||
com.droiddeck.launcher.session.SessionPrefs.fpsLimitChoices, s.fpsLimit,
|
||||
note = stringResource(R.string.mode_fps_note),
|
||||
onPick = a.onFpsLimit,
|
||||
)
|
||||
ChoiceRow(
|
||||
host, "upscaler", stringResource(R.string.drawer_scaling), "How the picture is resized to the screen; the sharpening modes sharpen where it is enlarged.",
|
||||
com.droiddeck.launcher.session.SessionPrefs.upscalerChoices, s.upscaler,
|
||||
note = "The sharpening modes work only when the session is smaller than the screen; Linear, Nearest and Sharpen only work at any size. Costs a little GPU time.",
|
||||
onPick = a.onUpscaler,
|
||||
)
|
||||
SliderRow(
|
||||
stringResource(R.string.drawer_scaling_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
|
||||
enabled = s.upscaler != 0, format = { "$it%" }, onChange = a.onUpscaleSharpness,
|
||||
)
|
||||
if (editCustom) CustomResolutionDialog(
|
||||
initial = custom,
|
||||
onSave = { size -> editCustom = false; a.onCustomResolution(size) },
|
||||
onDismiss = { editCustom = false },
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_hdr)) {
|
||||
ToggleRow(
|
||||
host, "hdr", stringResource(R.string.mode_hdr10),
|
||||
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
|
||||
?: stringResource(R.string.mode_hdr_restart),
|
||||
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_drivers)) {
|
||||
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
|
||||
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
|
||||
if (tab == ModeSettingsTab.DISPLAY) {
|
||||
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
|
||||
SettingsGroup(stringResource(R.string.display_session)) {
|
||||
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution,
|
||||
chipModifier = Modifier.focusRequester(firstChip))
|
||||
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
|
||||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
|
||||
val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
|
||||
ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit),
|
||||
stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable),
|
||||
listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)),
|
||||
s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9)
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_image_scaling)) {
|
||||
ChoiceRow(
|
||||
host, "upscaler", stringResource(R.string.display_filter), null,
|
||||
SessionPrefs.upscalerChoices, s.upscaler,
|
||||
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
|
||||
)
|
||||
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
|
||||
stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
|
||||
format = { "$it%" }, onChange = a.onUpscaleSharpness,
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_frame_rate)) {
|
||||
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
|
||||
SessionPrefs.fpsLimitChoices, s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_hdr)) {
|
||||
ToggleRow(
|
||||
host, "hdr", stringResource(R.string.mode_hdr10),
|
||||
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
|
||||
?: stringResource(R.string.mode_hdr_restart),
|
||||
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_drivers)) {
|
||||
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
|
||||
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
|
||||
ChoiceRow(
|
||||
host, "touch", stringResource(R.string.mode_touch), null,
|
||||
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
|
||||
note = stringResource(R.string.mode_touch_note),
|
||||
onPick = a.onTouch,
|
||||
)
|
||||
if (steam && s.oscMode != null) ChoiceRow(
|
||||
host, "osc", stringResource(R.string.mode_osc), null,
|
||||
listOf(
|
||||
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
|
||||
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
|
||||
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
|
||||
), s.oscMode,
|
||||
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
|
||||
)
|
||||
if (steam && s.steamController != null) ChoiceRow(
|
||||
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
|
||||
listOf(
|
||||
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
|
||||
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
|
||||
), s.steamController,
|
||||
note = stringResource(R.string.mode_controller_note),
|
||||
onPick = a.onSteamController,
|
||||
)
|
||||
if (steam) ChoiceRow(
|
||||
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
|
||||
listOf(
|
||||
false to SessionPrefs.BACK_MENU_THEN_QAM,
|
||||
true to SessionPrefs.BACK_QAM_THEN_MENU,
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
)
|
||||
if (tab == ModeSettingsTab.CONTROLS) {
|
||||
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
|
||||
ChoiceRow(
|
||||
host, "touch", stringResource(R.string.mode_touch), null,
|
||||
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
|
||||
note = stringResource(R.string.mode_touch_note),
|
||||
onPick = a.onTouch,
|
||||
)
|
||||
if (steam && s.oscMode != null) ChoiceRow(
|
||||
host, "osc", stringResource(R.string.mode_osc), null,
|
||||
listOf(
|
||||
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
|
||||
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
|
||||
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
|
||||
), s.oscMode,
|
||||
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
|
||||
)
|
||||
if (steam && s.steamController != null) ChoiceRow(
|
||||
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
|
||||
listOf(
|
||||
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
|
||||
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
|
||||
), s.steamController,
|
||||
note = stringResource(R.string.mode_controller_note),
|
||||
onPick = a.onSteamController,
|
||||
)
|
||||
if (steam) ChoiceRow(
|
||||
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
|
||||
listOf(
|
||||
false to SessionPrefs.BACK_MENU_THEN_QAM,
|
||||
true to SessionPrefs.BACK_QAM_THEN_MENU,
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_session)) {
|
||||
ChoiceRow(
|
||||
host, "suspend", stringResource(R.string.mode_suspend),
|
||||
stringResource(R.string.mode_suspend_hint),
|
||||
listOf(
|
||||
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
|
||||
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
|
||||
),
|
||||
s.suspendPolicy,
|
||||
note = stringResource(R.string.mode_suspend_note),
|
||||
onPick = a.onSuspendPolicy,
|
||||
)
|
||||
if (tab == ModeSettingsTab.SESSION) {
|
||||
SettingsGroup(stringResource(R.string.mode_session)) {
|
||||
ChoiceRow(
|
||||
host, "suspend", stringResource(R.string.mode_suspend),
|
||||
stringResource(R.string.mode_suspend_hint),
|
||||
listOf(
|
||||
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
|
||||
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
|
||||
),
|
||||
s.suspendPolicy,
|
||||
note = stringResource(R.string.mode_suspend_note),
|
||||
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),
|
||||
stringResource(R.string.mode_steam_startup_hint),
|
||||
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
|
||||
)
|
||||
}
|
||||
}
|
||||
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),
|
||||
stringResource(R.string.mode_steam_startup_hint),
|
||||
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
|
||||
)
|
||||
}
|
||||
if (steam) SettingsGroup(stringResource(R.string.mode_decky)) {
|
||||
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) {
|
||||
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
|
||||
s.deckyInstalled != s.deckyLatestRelease.tag
|
||||
val status = when {
|
||||
@@ -349,7 +365,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (steam && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
|
||||
if (steam && tab == ModeSettingsTab.STEAM && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
|
||||
ToggleRow(
|
||||
host, "steamdeck", stringResource(R.string.mode_deck_mode),
|
||||
stringResource(R.string.mode_deck_mode_hint),
|
||||
@@ -368,8 +384,14 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
note = stringResource(R.string.mode_branch_note),
|
||||
onPick = a.onSteamChannel,
|
||||
)
|
||||
SettingsRow(
|
||||
stringResource(R.string.mode_steam_repair),
|
||||
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
|
||||
) {
|
||||
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
|
||||
}
|
||||
}
|
||||
if (steam && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
|
||||
if (steam && tab == ModeSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
|
||||
val hint = when {
|
||||
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
|
||||
!s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied)
|
||||
@@ -388,7 +410,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (steam && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
|
||||
if ((tab == ModeSettingsTab.GAMES || (!steam && tab == ModeSettingsTab.DISPLAY)) && s.forceFullscreen != null) SettingsGroup(stringResource(R.string.display_window_compatibility)) {
|
||||
ToggleRow(host, "fill", stringResource(R.string.display_force_fullscreen),
|
||||
stringResource(R.string.display_force_fullscreen_hint), s.forceFullscreen, onChange = a.onForceFullscreen)
|
||||
}
|
||||
if (steam && tab == ModeSettingsTab.GAMES && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
|
||||
for (dir in s.addedGamesDirs) {
|
||||
val n = s.addedGames.count { it.folderPath.startsWith("$dir/") }
|
||||
ActionRow(
|
||||
@@ -413,7 +439,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
|
||||
)
|
||||
}
|
||||
if (steam && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
if (steam && tab == ModeSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
if (s.syncBackend != null) SettingsRow(
|
||||
stringResource(R.string.sync_backend_title),
|
||||
stringResource(R.string.sync_backend_hint),
|
||||
@@ -434,18 +460,9 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
|
||||
)
|
||||
GameEnvironmentRow()
|
||||
if (s.forceFullscreen != null) ToggleRow(
|
||||
host, "fill", stringResource(R.string.mode_fill),
|
||||
stringResource(R.string.mode_fill_hint),
|
||||
s.forceFullscreen, onChange = a.onForceFullscreen,
|
||||
)
|
||||
if (s.stretch16x9 != null) ToggleRow(
|
||||
host, "stretch169", stringResource(R.string.mode_stretch169),
|
||||
stringResource(R.string.mode_stretch169_hint),
|
||||
s.stretch16x9, onChange = a.onStretch16x9,
|
||||
)
|
||||
|
||||
}
|
||||
if (steam && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
|
||||
if (steam && tab == ModeSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
|
||||
ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio)
|
||||
ChoiceRow(
|
||||
host, "clientAudio", stringResource(R.string.mode_client_audio), stringResource(R.string.mode_client_audio_hint),
|
||||
@@ -454,7 +471,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
|
||||
}
|
||||
if (steam && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
|
||||
if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
|
||||
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.second == s.gameStorage }
|
||||
val options = buildList {
|
||||
add("" to (if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto)))
|
||||
@@ -490,7 +507,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
onChange = a.onStorageDiagnostics,
|
||||
)
|
||||
}
|
||||
if (!steam && s.renderer != null) SettingsGroup(stringResource(R.string.mode_renderer)) {
|
||||
if (!steam && tab == ModeSettingsTab.DISPLAY && s.renderer != null) SettingsGroup(stringResource(R.string.mode_renderer)) {
|
||||
ChoiceRow(
|
||||
host, "renderer", stringResource(R.string.mode_desktop_renderer), stringResource(R.string.mode_renderer_hint),
|
||||
listOf("vulkan" to stringResource(R.string.mode_renderer_vulkan), "gles2" to stringResource(R.string.mode_renderer_gles2), "pixman" to stringResource(R.string.mode_renderer_pixman)), s.renderer,
|
||||
@@ -523,13 +540,10 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
}
|
||||
|
||||
/** The Resolution menu's "Custom…" entry. */
|
||||
private const val CUSTOM = -1
|
||||
|
||||
/** Width × height for the session, with the common handheld shapes one tap away. */
|
||||
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int, Int>) -> Unit, onDismiss: () -> Unit) {
|
||||
internal fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int, Int>) -> Unit, onDismiss: () -> Unit) {
|
||||
var w by remember { mutableStateOf(initial?.first?.toString() ?: "") }
|
||||
var h by remember { mutableStateOf(initial?.second?.toString() ?: "") }
|
||||
val parsed = SessionPrefs.parseResolution("${w}x$h")
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.SessionDisplay
|
||||
|
||||
/** Pre-session resolution choice, applied when the session starts. */
|
||||
@Composable
|
||||
fun ResolutionRow(
|
||||
host: MenuHost, choice: String, panel: Pair<Int, Int>, onPick: (String) -> Unit,
|
||||
chipModifier: Modifier = Modifier,
|
||||
) {
|
||||
val custom = "custom"
|
||||
val size = SessionDisplay.resolveChoice(panel, choice)
|
||||
val screen = SessionDisplay.screenSize(panel)
|
||||
val choices = SessionDisplay.resolutionOptions(panel)
|
||||
val selected = if (size == screen) SessionDisplay.MATCH_SCREEN else choice
|
||||
var editCustom by remember { mutableStateOf(false) }
|
||||
val options = choices.map { value ->
|
||||
val dimensions = SessionDisplay.resolveChoice(panel, value)
|
||||
value to if (value == SessionDisplay.MATCH_SCREEN) stringResource(R.string.display_match_screen, screen.first, screen.second)
|
||||
else if (value == SessionDisplay.DEFAULT_RESOLUTION) stringResource(R.string.mode_res_default, "${dimensions.first}×${dimensions.second}")
|
||||
else "${dimensions.first}×${dimensions.second}"
|
||||
} + (custom to if (selected in choices) stringResource(R.string.mode_res_custom)
|
||||
else stringResource(R.string.mode_res_custom_value, size.first, size.second))
|
||||
ChoiceRow(host, "resolution", stringResource(R.string.display_resolution),
|
||||
stringResource(R.string.common_applies_next_session),
|
||||
options, if (selected in choices) selected else custom, chipModifier = chipModifier,
|
||||
onPick = { value -> if (value == custom) editCustom = true else onPick(value) })
|
||||
if (editCustom) CustomResolutionDialog(size,
|
||||
onSave = { value -> editCustom = false; onPick("${value.first}x${value.second}") },
|
||||
onDismiss = { editCustom = false })
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
/** Stable page identities keep focus and scroll positions when a secondary display comes and goes. */
|
||||
internal enum class SessionDrawerPage(val firstControl: String) {
|
||||
CONTROLLER("touch"),
|
||||
SECOND_SCREEN("second-screen:none"),
|
||||
DISPLAY("hud"),
|
||||
EFFECTS("look"),
|
||||
GAMES("anisotropy"),
|
||||
SESSION("suspend"),
|
||||
}
|
||||
|
||||
internal fun sessionDrawerPages(hasSecondScreen: Boolean): List<SessionDrawerPage> =
|
||||
SessionDrawerPage.entries.filter { hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN }
|
||||
|
||||
internal fun List<SessionDrawerPage>.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage {
|
||||
val index = indexOf(current).coerceAtLeast(0)
|
||||
return this[Math.floorMod(index + direction, size)]
|
||||
}
|
||||
@@ -70,6 +70,9 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.PlatformTextStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
// The session's loading screen, its paused screen and the HUD text style.
|
||||
@@ -273,7 +276,11 @@ private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) {
|
||||
.padding(horizontal = 12.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) {
|
||||
Text("B", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background)
|
||||
Text("B", color = colors.background, style = TextStyle(
|
||||
fontSize = 12.sp, lineHeight = 12.sp, fontWeight = FontWeight.Bold,
|
||||
platformStyle = PlatformTextStyle(includeFontPadding = false),
|
||||
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.Both),
|
||||
))
|
||||
}
|
||||
Text(stringResource(R.string.common_cancel), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
|
||||
@@ -28,15 +28,24 @@ import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.material.icons.outlined.AutoFixHigh
|
||||
import androidx.compose.material.icons.outlined.ExpandMore
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
||||
import androidx.compose.material.icons.outlined.PowerSettingsNew
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.material.icons.outlined.DesktopWindows
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material.icons.outlined.ScreenShare
|
||||
import androidx.compose.material.icons.outlined.PictureInPictureAlt
|
||||
import androidx.compose.material.icons.automirrored.outlined.ExitToApp
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.SportsEsports
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.RadioButtonDefaults
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import android.view.Display
|
||||
import androidx.activity.compose.BackHandler
|
||||
@@ -79,6 +88,8 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
@@ -93,7 +104,6 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -129,22 +139,33 @@ import com.droiddeck.launcher.input.SecondScreenMode
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import java.util.Locale
|
||||
|
||||
private val drawerPageTitles = listOf(R.string.drawer_page_display, R.string.drawer_page_effects, R.string.drawer_page_controls, R.string.drawer_page_components, R.string.drawer_page_settings)
|
||||
private val SessionDrawerPage.title: Int get() = when (this) {
|
||||
SessionDrawerPage.CONTROLLER -> R.string.drawer_page_controller
|
||||
SessionDrawerPage.SECOND_SCREEN -> R.string.drawer_second_screen
|
||||
SessionDrawerPage.DISPLAY -> R.string.drawer_page_display
|
||||
SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects
|
||||
SessionDrawerPage.GAMES -> R.string.game_settings_title
|
||||
SessionDrawerPage.SESSION -> R.string.mode_session
|
||||
}
|
||||
|
||||
/** One icon per drawer page, in page order (QAM-style tabs). */
|
||||
private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.AutoFixHigh, Icons.Outlined.SportsEsports, Icons.Outlined.Layers, Icons.Outlined.Settings)
|
||||
|
||||
private val drawerPageEntries = listOf("hud", "look", "touch", "cmp-proton", "suspend")
|
||||
private val SessionDrawerPage.icon get() = when (this) {
|
||||
SessionDrawerPage.CONTROLLER -> Icons.Outlined.SportsEsports
|
||||
SessionDrawerPage.SECOND_SCREEN -> Icons.Outlined.ScreenShare
|
||||
SessionDrawerPage.DISPLAY -> Icons.Outlined.DesktopWindows
|
||||
SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh
|
||||
SessionDrawerPage.GAMES -> Icons.Outlined.Layers
|
||||
SessionDrawerPage.SESSION -> Icons.Outlined.Settings
|
||||
}
|
||||
|
||||
private class DrawerFocus {
|
||||
private val requesters = HashMap<String, FocusRequester>()
|
||||
private val last = arrayOfNulls<String>(DRAWER_PAGES)
|
||||
private val last = mutableMapOf<SessionDrawerPage, String>()
|
||||
var focused by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
private fun requester(key: String) = requesters.getOrPut(key) { FocusRequester() }
|
||||
|
||||
fun track(page: Int, key: String): Modifier = Modifier.focusRequester(requester(key)).onFocusChanged {
|
||||
fun track(page: SessionDrawerPage, key: String): Modifier = Modifier.focusRequester(requester(key)).onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
last[page] = key
|
||||
focused = key
|
||||
@@ -152,9 +173,9 @@ private class DrawerFocus {
|
||||
}
|
||||
|
||||
/** Where focus was last on [page], if anywhere. */
|
||||
fun target(page: Int) = last[page]
|
||||
fun target(page: SessionDrawerPage) = last[page]
|
||||
fun request(key: String) = runCatching { requester(key).requestFocus() }
|
||||
fun forget(page: Int) { last[page] = null }
|
||||
fun forget(page: SessionDrawerPage) { last.remove(page) }
|
||||
}
|
||||
|
||||
/** Everything the drawer shows and does. */
|
||||
@@ -168,13 +189,13 @@ class DrawerActions(
|
||||
val frameGen: FrameGen.Mode,
|
||||
val lossless: Lossless.State,
|
||||
val oscMode: String,
|
||||
val onScreenButtonsVisible: Boolean,
|
||||
val suspendPolicy: String,
|
||||
val backActionsInverted: Boolean,
|
||||
val touchMode: String,
|
||||
val touchAuto: String,
|
||||
val shapeMode: String,
|
||||
val fexPreset: String,
|
||||
/** Steam only: games stretched to the screen's size, changed live (null = not Steam). */
|
||||
/** Steam only: forces game windows fullscreen, changed live (null = not Steam). */
|
||||
val fillScreen: Boolean? = null,
|
||||
val upscaler: Int = 0,
|
||||
val upscaleSharpness: Int = 75,
|
||||
@@ -204,7 +225,6 @@ class DrawerActions(
|
||||
val onSuspendPolicy: (String) -> Unit,
|
||||
val onBackActionsInverted: (Boolean) -> Unit,
|
||||
val onTouch: (String) -> Unit,
|
||||
val onShape: (String) -> Unit,
|
||||
val onFexPreset: (String) -> Unit,
|
||||
val onFillScreen: (Boolean) -> Unit = {},
|
||||
val onUpscaler: (Int) -> Unit = {},
|
||||
@@ -225,7 +245,7 @@ class DrawerActions(
|
||||
/** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */
|
||||
val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null,
|
||||
val onClose: () -> Unit,
|
||||
/** Components tab: every Proton with what it uses; null until first read. */
|
||||
/** Games tab: every Proton with what it uses; null until first read. */
|
||||
val components: ComponentsManager.Snapshot? = null,
|
||||
/** Re-reads the Protons (and runs swaps that waited for a game to close). */
|
||||
val onComponentsRefresh: () -> Unit = {},
|
||||
@@ -235,16 +255,20 @@ class DrawerActions(
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageChange: (Int) -> Unit, a: DrawerActions) {
|
||||
internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, controllerActive: Boolean, onPageChange: (SessionDrawerPage) -> Unit, a: DrawerActions) {
|
||||
val pages = sessionDrawerPages(a.steam && a.secondScreenDisplays.isNotEmpty())
|
||||
val page = requestedPage.takeIf { it in pages } ?: SessionDrawerPage.CONTROLLER
|
||||
LaunchedEffect(requestedPage, pages) { if (page != requestedPage) onPageChange(page) }
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val host = rememberMenuHost()
|
||||
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var advancedEffectsExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var appToChooseDisplay by remember { mutableStateOf<HomeApp.LaunchableApp?>(null) }
|
||||
var confirmStop by remember { mutableStateOf(false) }
|
||||
var sheetCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
var stopCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } }
|
||||
val pageScroll = remember { SessionDrawerPage.entries.associateWith { ScrollState(0) } }
|
||||
val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
|
||||
val focus = remember { DrawerFocus() }
|
||||
val inputModeManager = LocalInputModeManager.current
|
||||
@@ -254,13 +278,13 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
}
|
||||
BackHandler(enabled = open && confirmStop) { confirmStop = false }
|
||||
LaunchedEffect(page) { host.open = null; appToChooseDisplay = null }
|
||||
LaunchedEffect(open, page) { if (open && page == DRAWER_PAGE_COMPONENTS) a.onComponentsRefresh() }
|
||||
LaunchedEffect(open, page) { if (open && page == SessionDrawerPage.GAMES) a.onComponentsRefresh() }
|
||||
LaunchedEffect(open, controllerActive) {
|
||||
if (open && !controllerActive) focusManager.clearFocus(force = true)
|
||||
}
|
||||
// The page focus was last put on: turning to another (LB, RB, a tab) starts it at its first
|
||||
// option; anything else (a menu closing, the drawer opening again) goes back where it was.
|
||||
var focusedPage by remember { mutableIntStateOf(page) }
|
||||
var focusedPage by remember { mutableStateOf(page) }
|
||||
LaunchedEffect(open, page, controllerActive, host.open, appToChooseDisplay, confirmStop) {
|
||||
if (!open) {
|
||||
host.open = null
|
||||
@@ -270,8 +294,8 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
if (host.open != null || appToChooseDisplay != null || confirmStop) return@LaunchedEffect
|
||||
val turned = page != focusedPage
|
||||
focusedPage = page
|
||||
// The page's top control: on Settings that is Android apps, when it is shown.
|
||||
val first = if (page == drawerPageEntries.lastIndex && a.isHomeApp) "apps" else drawerPageEntries[page]
|
||||
// The page's top control: on Session that is Android apps, when it is shown.
|
||||
val first = if (page == SessionDrawerPage.SESSION && a.isHomeApp) "apps" else page.firstControl
|
||||
if (turned) focus.forget(page)
|
||||
val target = focus.target(page) ?: first
|
||||
repeat(24) {
|
||||
@@ -284,7 +308,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
}
|
||||
}
|
||||
if (open || veil > 0.01f) Box(
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = veil }.background(Color(0x8A000000))
|
||||
modifier = Modifier.fillMaxSize()
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { host.open = null; a.onClose() },
|
||||
)
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
|
||||
@@ -350,31 +374,21 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
modifier = Modifier.fillMaxWidth().padding(top = if (short) 6.dp else 12.dp),
|
||||
) {
|
||||
DrawerBumper("LB", stringResource(R.string.drawer_prev_page), modifier = focus.track(page, "prev")) {
|
||||
host.open = null; onPageChange((page + DRAWER_PAGES - 1) % DRAWER_PAGES)
|
||||
host.open = null; onPageChange(pages.step(page, -1))
|
||||
}
|
||||
DrawerPageTabs(page = page, compact = short, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) }
|
||||
DrawerPageTabs(page = page, pages = pages, compact = short, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) }
|
||||
DrawerBumper("RB", stringResource(R.string.drawer_next_page), modifier = focus.track(page, "next")) {
|
||||
host.open = null; onPageChange((page + 1) % DRAWER_PAGES)
|
||||
host.open = null; onPageChange(pages.step(page, 1))
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll[page]),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll.getValue(page)),
|
||||
) { CompositionLocalProvider(LocalChipMinWidth provides 120.dp) {
|
||||
when (page) {
|
||||
0 -> {
|
||||
SessionDrawerPage.DISPLAY -> {
|
||||
SettingsGroup(stringResource(R.string.drawer_page_display)) {
|
||||
ToggleRow(host, "hud", stringResource(R.string.drawer_hud), null, a.hudOn,
|
||||
chipModifier = focus.track(page, "hud"), onChange = a.onHud)
|
||||
if (a.fillScreen != null) ToggleRow(
|
||||
host, "fill", stringResource(R.string.drawer_fill), null, a.fillScreen,
|
||||
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen,
|
||||
)
|
||||
ChoiceRow(host, "upscaler", stringResource(R.string.drawer_scaling), null,
|
||||
SessionPrefs.upscalerChoices, a.upscaler,
|
||||
chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler)
|
||||
SliderRow(stringResource(R.string.drawer_scaling_sharpness), null, a.upscaleSharpness, 0..100, step = 5,
|
||||
enabled = a.upscaler != 0, format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"),
|
||||
onChange = a.onUpscaleSharpness)
|
||||
val fgOpen = host.open == "fg"
|
||||
val fgLabel = FrameGen.label(LocalContext.current, a.frameGen)
|
||||
SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) {
|
||||
@@ -384,14 +398,27 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_image_scaling)) {
|
||||
ChoiceRow(host, "upscaler", stringResource(R.string.display_filter), null,
|
||||
SessionPrefs.upscalerChoices, a.upscaler,
|
||||
note = stringResource(R.string.display_filter_note),
|
||||
chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler)
|
||||
if (SessionPrefs.upscalerHasSharpness(a.upscaler)) SliderRow(
|
||||
stringResource(R.string.display_sharpness), null, a.upscaleSharpness, 0..100, step = 5,
|
||||
format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"), onChange = a.onUpscaleSharpness)
|
||||
}
|
||||
if (a.fillScreen != null) SettingsGroup(stringResource(R.string.display_window_compatibility)) {
|
||||
ToggleRow(host, "fill", stringResource(R.string.display_force_fullscreen),
|
||||
stringResource(R.string.display_force_fullscreen_live), a.fillScreen,
|
||||
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen)
|
||||
}
|
||||
}
|
||||
DRAWER_PAGE_EFFECTS -> {
|
||||
ScreenEffectsGroup(host, a) { key -> focus.track(page, key) }
|
||||
TextureFilteringGroup(host, a) { key -> focus.track(page, key) }
|
||||
SessionDrawerPage.EFFECTS -> {
|
||||
ScreenEffectsGroup(host, a, advancedEffectsExpanded,
|
||||
onAdvancedToggle = { advancedEffectsExpanded = !advancedEffectsExpanded }) { key -> focus.track(page, key) }
|
||||
}
|
||||
DRAWER_PAGE_COMPONENTS -> ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
|
||||
DRAWER_PAGE_CONTROLS -> {
|
||||
SettingsGroup(stringResource(R.string.drawer_page_controls)) {
|
||||
SessionDrawerPage.CONTROLLER -> {
|
||||
SettingsGroup(stringResource(R.string.drawer_page_controller)) {
|
||||
ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null,
|
||||
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
|
||||
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
|
||||
@@ -402,12 +429,16 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
a.controller?.let { c ->
|
||||
ToggleRow(host, "rumble", stringResource(R.string.ctrl_rumble), null, c.rumble,
|
||||
chipModifier = focus.track(page, "rumble"), onChange = a.onRumble)
|
||||
ToggleRow(host, "steam-button", stringResource(R.string.ctrl_steam_button), null, c.steamButton,
|
||||
chipModifier = focus.track(page, "steam-button"), onChange = a.onSteamButton)
|
||||
ToggleRow(host, "qam-button", stringResource(R.string.ctrl_qam_button), null, c.qamButton,
|
||||
chipModifier = focus.track(page, "qam-button"), onChange = a.onQamButton)
|
||||
ToggleRow(host, "keyboard-button", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton,
|
||||
chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton)
|
||||
if (a.onScreenButtonsVisible) {
|
||||
if (a.steam) {
|
||||
ToggleRow(host, "steam-button", stringResource(R.string.ctrl_steam_button), null, c.steamButton,
|
||||
chipModifier = focus.track(page, "steam-button"), onChange = a.onSteamButton)
|
||||
ToggleRow(host, "qam-button", stringResource(R.string.ctrl_qam_button), null, c.qamButton,
|
||||
chipModifier = focus.track(page, "qam-button"), onChange = a.onQamButton)
|
||||
}
|
||||
ToggleRow(host, "keyboard-button", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton,
|
||||
chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton)
|
||||
}
|
||||
}
|
||||
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
|
||||
listOf(false to SessionPrefs.BACK_MENU_THEN_QAM, true to SessionPrefs.BACK_QAM_THEN_MENU),
|
||||
@@ -423,18 +454,23 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
}
|
||||
}
|
||||
}
|
||||
if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup(stringResource(R.string.drawer_second_screen)) {
|
||||
ChoiceRow(host, "second-screen-mode", stringResource(R.string.drawer_second_screen_shows), null,
|
||||
(listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
|
||||
(if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()))
|
||||
.map { it to it.label },
|
||||
a.secondScreenMode, chipModifier = focus.track(page, "second-screen-mode"), onPick = a.onSecondScreenMode)
|
||||
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", stringResource(R.string.drawer_page_display), null,
|
||||
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
|
||||
chipModifier = focus.track(page, "second-screen-display"), onPick = a.onSecondScreenDisplay)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
SessionDrawerPage.SECOND_SCREEN -> {
|
||||
SecondScreenGroup(host, a) { key -> focus.track(page, key) }
|
||||
}
|
||||
SessionDrawerPage.GAMES -> {
|
||||
Text(stringResource(R.string.fex_next_launch), fontSize = 12.sp,
|
||||
color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp))
|
||||
TextureFilteringGroup(host, a) { key -> focus.track(page, key) }
|
||||
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), null,
|
||||
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
|
||||
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
|
||||
GameEnvironmentRow(modifier = focus.track(page, "game-env"), showHint = false)
|
||||
}
|
||||
ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
|
||||
}
|
||||
SessionDrawerPage.SESSION -> {
|
||||
if (a.isHomeApp) SettingsGroup(stringResource(R.string.drawer_android_apps)) {
|
||||
SettingsRow(stringResource(R.string.drawer_launch_app), null) {
|
||||
DrawerOutlineButton(if (androidAppsExpanded) stringResource(R.string.common_hide) else stringResource(R.string.common_show), modifier = focus.track(page, "apps")) {
|
||||
@@ -466,7 +502,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
SettingsGroup(stringResource(R.string.drawer_session_behavior)) {
|
||||
ChoiceRow(
|
||||
host, "suspend", stringResource(R.string.mode_suspend),
|
||||
stringResource(R.string.drawer_suspend_hint),
|
||||
null,
|
||||
listOf(
|
||||
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
|
||||
@@ -478,17 +514,6 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
onPick = a.onSuspendPolicy,
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.drawer_next_session)) {
|
||||
ChoiceRow(host, "shape", stringResource(R.string.mode_ratio), null,
|
||||
SessionPrefs.shapeChoices, a.shapeMode,
|
||||
chipModifier = focus.track(page, "shape"), onPick = a.onShape)
|
||||
}
|
||||
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
|
||||
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
|
||||
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
|
||||
GameEnvironmentRow(modifier = focus.track(page, "game-env"))
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.drawer_support)) {
|
||||
SettingsRow(stringResource(R.string.drawer_logs), stringResource(R.string.drawer_logs_hint)) {
|
||||
DrawerOutlineButton(stringResource(R.string.drawer_share_logs), modifier = focus.track(page, "share-logs")) {
|
||||
@@ -501,12 +526,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
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()
|
||||
Column(Modifier.fillMaxWidth().padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (a.pipSupported) DrawerActionButton(stringResource(R.string.pip_title), Icons.Outlined.PictureInPictureAlt,
|
||||
modifier = focus.track(page, "pip")) { host.open = null; a.onPip() }
|
||||
if (!a.isHomeApp) DrawerActionButton(stringResource(R.string.drawer_background), Icons.AutoMirrored.Outlined.ExitToApp,
|
||||
modifier = focus.track(page, "background")) { host.open = null; a.onBackground() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -546,6 +570,27 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawerActionButton(text: String, icon: androidx.compose.ui.graphics.vector.ImageVector, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp).clip(shape)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant)
|
||||
.glideBorder(hot, shape, pal.signal)
|
||||
.hoverable(source).clickable(interactionSource = source, indication = LocalIndication.current, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick).padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(22.dp))
|
||||
Text(text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawerOutlineButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
@@ -636,58 +681,60 @@ private fun DrawerBumper(label: String, description: String, modifier: Modifier
|
||||
/**
|
||||
* The drawer's page tabs, QAM-style: one icon per page standing in a line in page order. The
|
||||
* selected one steps forward - full size, bright, a soft glow - while the others step back to
|
||||
* half size and fade, and the whole line leans a little toward the selection. LB / RB still turn
|
||||
* a little smaller and fade, and the whole line leans a little toward the selection. LB / RB still turn
|
||||
* the page (the buttons beside it and the pad's bumpers); a tab can be tapped or picked with the
|
||||
* pad like the dots it replaces.
|
||||
*/
|
||||
@Composable
|
||||
private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Boolean = false, onSelect: (Int) -> Unit) {
|
||||
private fun DrawerPageTabs(page: SessionDrawerPage, pages: List<SessionDrawerPage>, modifier: Modifier = Modifier, compact: Boolean = false, onSelect: (SessionDrawerPage) -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val count = drawerPageIcons.size
|
||||
val count = pages.size
|
||||
val selectedIndex = pages.indexOf(page)
|
||||
val middle = (count - 1) / 2f
|
||||
val motion = Motion.tw<Float>(340, easing = FastOutSlowInEasing)
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.height(if (compact) 44.dp else 64.dp).clipToBounds(),
|
||||
) {
|
||||
val tab = if (compact) 40f else 56f
|
||||
val tab = if (compact) 36f else 44f
|
||||
// Fit the line to the room between LB and RB. Worst case is an end tab selected: full size
|
||||
// ([tab] dp) and pushed outward by the lean, which scales with the spacing. So
|
||||
// (count-1) * spacing * (1 + lean/64) + 56 <= width
|
||||
// - never more than the mock's 64 dp, never tighter than 32 dp.
|
||||
val spacing = ((maxWidth.value - tab) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(32f, DRAWER_TAB_SPACING_DP)
|
||||
// (count-1) * spacing * (1 + lean/spacingMax) + tab <= width
|
||||
val spacing = ((maxWidth.value - tab) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(24f, DRAWER_TAB_SPACING_DP)
|
||||
val leanStep = DRAWER_TAB_LEAN_DP * spacing / DRAWER_TAB_SPACING_DP
|
||||
val lean by animateFloatAsState(-(page - middle) * leanStep, motion, label = "tabLean")
|
||||
drawerPageIcons.forEachIndexed { index, icon ->
|
||||
val selected = index == page
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val focused = source.collectIsFocusedAsState().value
|
||||
val scale by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_SCALE, motion, label = "tabScale")
|
||||
val alpha by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_ALPHA, motion, label = "tabAlpha")
|
||||
val glow by animateFloatAsState(if (selected) 1f else 0f, motion, label = "tabGlow")
|
||||
val pageDescription = stringResource(R.string.drawer_page_desc, stringResource(drawerPageTitles[index]))
|
||||
val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, Motion.tw(340, easing = FastOutSlowInEasing), label = "tabTint")
|
||||
val select = { onSelect(index) }
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.offset(x = ((index - middle) * spacing + lean).dp)
|
||||
.size(tab.dp)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent),
|
||||
),
|
||||
)
|
||||
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
|
||||
.semantics { contentDescription = pageDescription }
|
||||
.hoverable(source)
|
||||
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
|
||||
.controllerConfirm(onClick = select),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(if (compact) 22.dp else 30.dp))
|
||||
val lean by animateFloatAsState(-(selectedIndex - middle) * leanStep, motion, label = "tabLean")
|
||||
pages.forEachIndexed { index, item ->
|
||||
androidx.compose.runtime.key(item) {
|
||||
val selected = item == page
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val focused = source.collectIsFocusedAsState().value
|
||||
val scale by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_SCALE, motion, label = "tabScale")
|
||||
val alpha by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_ALPHA, motion, label = "tabAlpha")
|
||||
val glow by animateFloatAsState(if (selected) 1f else 0f, motion, label = "tabGlow")
|
||||
val pageDescription = stringResource(R.string.drawer_page_desc, stringResource(item.title))
|
||||
val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, Motion.tw(340, easing = FastOutSlowInEasing), label = "tabTint")
|
||||
val select = { onSelect(item) }
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.offset(x = ((index - middle) * spacing + lean).dp)
|
||||
.size(tab.dp)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent),
|
||||
),
|
||||
)
|
||||
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
|
||||
.semantics { contentDescription = pageDescription }
|
||||
.hoverable(source)
|
||||
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
|
||||
.controllerConfirm(onClick = select),
|
||||
) {
|
||||
Icon(item.icon, contentDescription = null, tint = tint, modifier = Modifier.size(if (compact) 22.dp else 30.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,54 +743,114 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo
|
||||
/** The Look row's value when the rows below match no Look. */
|
||||
private const val LOOK_CUSTOM = "custom"
|
||||
|
||||
/**
|
||||
* Screen effects on the Effects page: a Look row that moves every row under it, then the rows
|
||||
* themselves: switches and sliders the d-pad steps (SliderRow), so a pad drives them.
|
||||
*/
|
||||
/** Presets and simple effects first; detailed adjustments stay behind Advanced. */
|
||||
@Composable
|
||||
private fun ScreenEffectsGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
|
||||
private fun ScreenEffectsGroup(
|
||||
host: MenuHost, a: DrawerActions, advancedExpanded: Boolean, onAdvancedToggle: () -> Unit,
|
||||
track: (String) -> Modifier,
|
||||
) {
|
||||
val e = a.effects
|
||||
val look = ScreenEffectLooks.match(e, a.upscaler)
|
||||
val preset = ScreenEffectLooks.match(e, a.upscaler)
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
SettingsGroup(stringResource(R.string.drawer_effects)) {
|
||||
val looks = ScreenEffectLooks.LOOKS.map { it.name to it.name }
|
||||
ChoiceRow(host, "look", stringResource(R.string.drawer_look),
|
||||
look?.desc ?: stringResource(R.string.drawer_effects_hint),
|
||||
if (look == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + looks else looks,
|
||||
look?.name ?: LOOK_CUSTOM, chipModifier = track("look"), hintLines = 2) { name ->
|
||||
val presets = ScreenEffectLooks.LOOKS.map { it.name to it.name }
|
||||
ChoiceRow(host, "look", stringResource(R.string.drawer_look), null,
|
||||
if (preset == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + presets else presets,
|
||||
preset?.name ?: LOOK_CUSTOM, chipModifier = track("look")) { name ->
|
||||
ScreenEffectLooks.LOOKS.firstOrNull { it.name == name }?.let { picked ->
|
||||
picked.scalingMode?.let(a.onUpscaler)
|
||||
a.onEffects(picked.effects)
|
||||
}
|
||||
}
|
||||
ToggleRow(host, "cas", stringResource(R.string.drawer_cas), null, e.cas, chipModifier = track("cas")) { a.onEffects(e.copy(cas = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_cas_level), null, e.casLevel, 0..100, step = 5, enabled = e.cas,
|
||||
format = { "$it%" }, modifier = track("cas-level")) { a.onEffects(e.copy(casLevel = it)) }
|
||||
ToggleRow(host, "fake-hdr", stringResource(R.string.drawer_fake_hdr), null, e.hdr, chipModifier = track("fake-hdr")) { a.onEffects(e.copy(hdr = it)) }
|
||||
ToggleRow(host, "deband", stringResource(R.string.drawer_deband), null, e.deband, chipModifier = track("deband")) { a.onEffects(e.copy(deband = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_deband_strength), null, e.debandStrength, 0..200, step = 5, enabled = e.deband,
|
||||
format = { "$it%" }, modifier = track("deband-strength")) { a.onEffects(e.copy(debandStrength = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_brightness), null, e.brightness, -100..100, step = 2,
|
||||
format = ::signed, modifier = track("brightness")) { a.onEffects(e.copy(brightness = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_contrast), null, e.contrast, -100..100, step = 2,
|
||||
format = ::signed, modifier = track("contrast")) { a.onEffects(e.copy(contrast = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_gamma), null, (e.gamma * 100f).roundToInt(), 50..300, step = 5,
|
||||
format = { String.format(Locale.US, "%.2f", it / 100f) }, modifier = track("gamma")) { a.onEffects(e.copy(gamma = it / 100f)) }
|
||||
SliderRow(stringResource(R.string.drawer_saturation), null, e.saturation, 0..200, step = 5,
|
||||
format = { "$it%" }, modifier = track("saturation")) { a.onEffects(e.copy(saturation = it)) }
|
||||
ToggleRow(host, "fxaa", stringResource(R.string.drawer_fxaa), null, e.fxaa, chipModifier = track("fxaa")) { a.onEffects(e.copy(fxaa = it)) }
|
||||
ToggleRow(host, "toon", stringResource(R.string.drawer_toon), null, e.toon, chipModifier = track("toon")) { a.onEffects(e.copy(toon = it)) }
|
||||
ToggleRow(host, "crt", stringResource(R.string.drawer_crt), null, e.crt, chipModifier = track("crt")) { a.onEffects(e.copy(crt = it)) }
|
||||
ToggleRow(host, "ntsc", stringResource(R.string.drawer_ntsc), null, e.ntsc, chipModifier = track("ntsc")) { a.onEffects(e.copy(ntsc = it)) }
|
||||
ToggleRow(host, "fake-hdr", stringResource(R.string.drawer_fake_hdr), null, e.hdr, chipModifier = track("fake-hdr")) { a.onEffects(e.copy(hdr = it)) }
|
||||
}
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
|
||||
val rotation by animateFloatAsState(if (advancedExpanded) 180f else 0f, Motion.tw(180), label = "advancedArrow")
|
||||
val description = stringResource(if (advancedExpanded) R.string.widgets_expanded else R.string.widgets_collapsed)
|
||||
val toggle = { host.open = null; onAdvancedToggle() }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = track("effects-advanced").fillMaxWidth().padding(top = 12.dp).heightIn(min = 48.dp)
|
||||
.glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
|
||||
.hoverable(source).clickable(interactionSource = source, indication = LocalIndication.current, role = Role.Button, onClick = toggle)
|
||||
.controllerConfirm(onClick = toggle).semantics { stateDescription = description }.padding(horizontal = 4.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.drawer_effects_advanced).uppercase(), fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant)
|
||||
Box(Modifier.weight(1f).height(1.dp).background(pal.line))
|
||||
Icon(Icons.Outlined.ExpandMore, contentDescription = null, tint = colors.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp).rotate(rotation))
|
||||
}
|
||||
AnimatedVisibility(advancedExpanded) {
|
||||
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(colors.surface).border(1.dp, pal.line, RoundedCornerShape(14.dp))) {
|
||||
ToggleRow(host, "cas", stringResource(R.string.drawer_cas), null, e.cas, chipModifier = track("cas")) { a.onEffects(e.copy(cas = it)) }
|
||||
if (e.cas) SliderRow(stringResource(R.string.drawer_cas_level), null, e.casLevel, 0..100, step = 5,
|
||||
format = { "$it%" }, modifier = track("cas-level")) { a.onEffects(e.copy(casLevel = it)) }
|
||||
ToggleRow(host, "deband", stringResource(R.string.drawer_deband), null, e.deband, chipModifier = track("deband")) { a.onEffects(e.copy(deband = it)) }
|
||||
if (e.deband) SliderRow(stringResource(R.string.drawer_deband_strength), null, e.debandStrength, 0..200, step = 5,
|
||||
format = { "$it%" }, modifier = track("deband-strength")) { a.onEffects(e.copy(debandStrength = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_brightness), null, e.brightness, -100..100, step = 2,
|
||||
format = ::signed, modifier = track("brightness")) { a.onEffects(e.copy(brightness = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_contrast), null, e.contrast, -100..100, step = 2,
|
||||
format = ::signed, modifier = track("contrast")) { a.onEffects(e.copy(contrast = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_gamma), null, (e.gamma * 100f).roundToInt(), 50..300, step = 5,
|
||||
format = { String.format(Locale.US, "%.2f", it / 100f) }, modifier = track("gamma")) { a.onEffects(e.copy(gamma = it / 100f)) }
|
||||
SliderRow(stringResource(R.string.drawer_saturation), null, e.saturation, 0..200, step = 5,
|
||||
format = { "$it%" }, modifier = track("saturation")) { a.onEffects(e.copy(saturation = it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Texture filtering on the Effects page: DXVK options for the next DirectX 9-11 launch. */
|
||||
/** Secondary-display modes are mutually exclusive and apply immediately. */
|
||||
@Composable
|
||||
private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val modes = listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
|
||||
if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()
|
||||
SettingsGroup(stringResource(R.string.drawer_second_screen_mode)) {
|
||||
Column(Modifier.fillMaxWidth().selectableGroup()) {
|
||||
modes.forEach { mode ->
|
||||
val selected = a.secondScreenMode == mode
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
|
||||
val pick = { host.open = null; a.onSecondScreenMode(mode) }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = track("second-screen:${mode.id}").fillMaxWidth().heightIn(min = 56.dp)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
|
||||
.glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
|
||||
.hoverable(source)
|
||||
.selectable(selected, interactionSource = source, indication = null, role = Role.RadioButton, onClick = pick)
|
||||
.controllerConfirm(onClick = pick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(mode.label, fontSize = 15.sp, color = colors.onBackground, modifier = Modifier.weight(1f))
|
||||
RadioButton(selected, onClick = null,
|
||||
colors = RadioButtonDefaults.colors(selectedColor = pal.signal, unselectedColor = colors.onSurfaceVariant))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", stringResource(R.string.drawer_page_display), null,
|
||||
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
|
||||
chipModifier = track("second-screen-display"), onPick = a.onSecondScreenDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
/** Texture filtering on the Games page: DXVK options for the next DirectX 9-11 launch. */
|
||||
@Composable
|
||||
private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
|
||||
SettingsGroup(stringResource(R.string.drawer_texture)) {
|
||||
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), stringResource(R.string.drawer_texture_hint),
|
||||
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), null,
|
||||
SessionPrefs.textureAnisotropyChoices, a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
|
||||
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), stringResource(R.string.drawer_texture_hint),
|
||||
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), null,
|
||||
SessionPrefs.textureLodBiasChoices, a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
|
||||
chipModifier = track("texture-sharpness"), onPick = a.onTextureLodBias)
|
||||
}
|
||||
@@ -751,16 +858,8 @@ private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (Stri
|
||||
|
||||
private fun signed(v: Int) = if (v > 0) "+$v" else "$v"
|
||||
|
||||
/** Display, Effects, Controls, Components, Settings. */
|
||||
const val DRAWER_PAGES = 5
|
||||
|
||||
private const val DRAWER_PAGE_EFFECTS = 1
|
||||
private const val DRAWER_PAGE_CONTROLS = 2
|
||||
|
||||
private const val DRAWER_PAGE_COMPONENTS = 3
|
||||
|
||||
/**
|
||||
* The drawer's Components tab: quick swaps between what is already installed, per Proton. A swap
|
||||
* The Games tab's components: quick swaps between what is already installed, per Proton. A swap
|
||||
* into the Proton a running game uses waits until that game closes; downloading, importing and
|
||||
* deleting stay on the Components page in the app.
|
||||
*/
|
||||
@@ -884,14 +983,14 @@ private fun <T> DrawerStackedChoice(
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
|
||||
/** The tab row's measure, from the approved mock: side tabs at half size, a little faded. */
|
||||
private const val DRAWER_TAB_SPACING_DP = 64f
|
||||
/** A close row of icons: inactive tabs stay legible while the active tab leads. */
|
||||
private const val DRAWER_TAB_SPACING_DP = 48f
|
||||
|
||||
private const val DRAWER_TAB_LEAN_DP = 12f
|
||||
private const val DRAWER_TAB_LEAN_DP = 8f
|
||||
|
||||
private const val DRAWER_TAB_SIDE_SCALE = 0.5f
|
||||
private const val DRAWER_TAB_SIDE_SCALE = 0.8f
|
||||
|
||||
private const val DRAWER_TAB_SIDE_ALPHA = 0.7f
|
||||
private const val DRAWER_TAB_SIDE_ALPHA = 0.8f
|
||||
|
||||
/** Room between the Stop pill and the box that steps out under it: the pull. */
|
||||
private val StepPull = 6.dp
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -60,6 +61,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -82,6 +84,10 @@ import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.progressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.setProgress
|
||||
import androidx.compose.ui.semantics.disabled
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.compose.ui.input.InputMode
|
||||
@@ -468,8 +474,16 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul
|
||||
fun SliderRow(
|
||||
label: String, hint: String?, value: Int, range: IntRange, step: Int = 1, enabled: Boolean = true,
|
||||
format: (Int) -> String = { it.toString() }, modifier: Modifier = Modifier, onChange: (Int) -> Unit,
|
||||
) = SettingsRow(label, hint) {
|
||||
ValueSlider(value, range, step, enabled, label, format, modifier, onChange)
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
ValueSlider(value, range, step, enabled, label, format,
|
||||
modifier = modifier.padding(horizontal = 6.dp, vertical = 3.dp), onChange = onChange)
|
||||
if (hint != null) Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 8.dp))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -479,34 +493,39 @@ fun ValueSlider(
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
|
||||
val currentOnChange by rememberUpdatedState(onChange)
|
||||
val span = (range.last - range.first).coerceAtLeast(1)
|
||||
val fraction = ((value - range.first).toFloat() / span).coerceIn(0f, 1f)
|
||||
val shape = RoundedCornerShape(99.dp)
|
||||
val thumbInset = with(LocalDensity.current) { 8.dp.toPx() }
|
||||
var trackPx by remember { mutableIntStateOf(0) }
|
||||
val text = format(value)
|
||||
// A step from the d-pad lands on the grid of [step] from the range's start; a Look's own
|
||||
// value, which need not be on it, is kept until the user moves.
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
// Preserve off-grid preset values until the user adjusts; every input snaps to the same step.
|
||||
fun stepped(v: Int) = (((v - range.first).toFloat() / step).roundToInt() * step + range.first).coerceIn(range.first, range.last)
|
||||
fun fromX(x: Float) { if (trackPx > 0) onChange(stepped(range.first + (x / trackPx * span).roundToInt())) }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.heightIn(min = 48.dp)
|
||||
fun fromX(x: Float) {
|
||||
val width = trackPx - 2 * thumbInset
|
||||
if (width > 0) {
|
||||
val position = ((x - thumbInset) / width).coerceIn(0f, 1f)
|
||||
currentOnChange(stepped(range.first + (position * span).roundToInt()))
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth().heightIn(min = 72.dp)
|
||||
.clip(shape).background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
|
||||
.glideBorder(hot, shape, pal.signal)
|
||||
.alpha(if (enabled) 1f else 0.5f)
|
||||
.hoverable(src)
|
||||
.focusable(enabled, src)
|
||||
.hoverable(source).focusable(enabled, source)
|
||||
.onPreviewKeyEvent { event ->
|
||||
val keyEvent = event.nativeKeyEvent
|
||||
val left = keyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
|
||||
if (!left && keyEvent.keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) {
|
||||
false
|
||||
} else {
|
||||
// Every down counts, repeats included, so a held direction keeps stepping.
|
||||
if (!left && keyEvent.keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) false
|
||||
else {
|
||||
// Repeated key-down events let a held direction keep adjusting.
|
||||
if (enabled && keyEvent.action == KeyEvent.ACTION_DOWN) {
|
||||
val next = stepped(value + if (left) -step else step)
|
||||
if (next != value) onChange(next)
|
||||
if (next != value) currentOnChange(next)
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -515,28 +534,33 @@ fun ValueSlider(
|
||||
stateDescription = text
|
||||
progressBarRangeInfo = ProgressBarRangeInfo(value.toFloat(), range.first.toFloat()..range.last.toFloat())
|
||||
if (label != null) contentDescription = label
|
||||
},
|
||||
if (!enabled) disabled()
|
||||
setProgress { next ->
|
||||
if (!enabled) false else { currentOnChange(stepped(next.roundToInt())); true }
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, textAlign = TextAlign.End,
|
||||
maxLines = 1, modifier = Modifier.width(56.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier.size(width = 164.dp, height = 30.dp).clip(shape).background(colors.surfaceVariant)
|
||||
.glideBorder(hot, shape, colors.onBackground, pal.line2)
|
||||
.onSizeChanged { trackPx = it.width }
|
||||
.pointerInput(enabled, range, step) { if (enabled) detectTapGestures { fromX(it.x) } }
|
||||
.pointerInput(enabled, range, step) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (label != null) Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = colors.onBackground, modifier = Modifier.weight(1f))
|
||||
else Spacer(Modifier.weight(1f))
|
||||
Text(text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1)
|
||||
}
|
||||
Canvas(
|
||||
Modifier.fillMaxWidth().height(40.dp).onSizeChanged { trackPx = it.width }
|
||||
.pointerInput(enabled, range, step, thumbInset) { if (enabled) detectTapGestures { fromX(it.x) } }
|
||||
.pointerInput(enabled, range, step, thumbInset) {
|
||||
if (enabled) detectHorizontalDragGestures { change, _ -> fromX(change.position.x); change.consume() }
|
||||
},
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(pal.signal))
|
||||
Box(
|
||||
Modifier.padding(4.dp).size(22.dp)
|
||||
.graphicsLayer { translationX = fraction * (trackPx - 30.dp.toPx()).coerceAtLeast(0f) }
|
||||
.clip(CircleShape)
|
||||
.background(pal.onSignal),
|
||||
)
|
||||
val start = Offset(thumbInset, size.height / 2f)
|
||||
val end = Offset(size.width - thumbInset, start.y)
|
||||
val thumb = Offset(start.x + (end.x - start.x) * fraction, start.y)
|
||||
val fill = if (hot) pal.signal else colors.onBackground
|
||||
drawLine(colors.onSurfaceVariant.copy(alpha = 0.35f), start, end, strokeWidth = 4.dp.toPx(), cap = StrokeCap.Round)
|
||||
if (fraction > 0) drawLine(fill, start, thumb, strokeWidth = 4.dp.toPx(), cap = StrokeCap.Round)
|
||||
drawCircle(fill, radius = 7.dp.toPx(), center = thumb)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -574,7 +598,7 @@ fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit)
|
||||
@Composable
|
||||
fun SettingsPage(
|
||||
host: MenuHost,
|
||||
title: String,
|
||||
title: String?,
|
||||
onBack: () -> Unit,
|
||||
eyebrow: String? = null,
|
||||
lede: String? = null,
|
||||
@@ -584,6 +608,9 @@ fun SettingsPage(
|
||||
scroll: androidx.compose.foundation.ScrollState? = null,
|
||||
scrollContent: Boolean = true,
|
||||
compactLayout: Boolean = false,
|
||||
/** Fixed controls below the title, or beside Back when the title is omitted. */
|
||||
header: (@Composable () -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val scrollState = scroll ?: rememberScrollState()
|
||||
@@ -591,13 +618,24 @@ fun SettingsPage(
|
||||
val dim by animateFloatAsState(if (host.open != null) 0.6f else 1f, Motion.tw(220), label = "pageDim")
|
||||
val narrow = LocalNarrowPane.current && !compactLayout
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(
|
||||
modifier = modifier.fillMaxSize().padding(
|
||||
horizontal = if (compactLayout) 14.dp else if (narrow) 16.dp else 22.dp,
|
||||
vertical = if (compactLayout) 6.dp else if (narrow) 12.dp else 18.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(if (compactLayout) 2.dp else 0.dp),
|
||||
) {
|
||||
if (narrow) {
|
||||
if (title == null) {
|
||||
Rise(0) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
BackLink(stringResource(R.string.mode_back), compact = true, onClick = onBack)
|
||||
if (header != null) Box(Modifier.weight(1f)) { header() }
|
||||
}
|
||||
}
|
||||
} else if (narrow) {
|
||||
// Back and the title share one line: a 4:3 screen has no height to spare.
|
||||
Rise(0) {
|
||||
Row(
|
||||
@@ -656,6 +694,7 @@ fun SettingsPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (header != null && title != null) header()
|
||||
Rise(3, Modifier.weight(1f).fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -5,9 +5,13 @@ import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import com.droiddeck.launcher.core.AppUiPrefs
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
class Palette(
|
||||
@@ -67,7 +71,7 @@ val LocalPalette = staticCompositionLocalOf { Themes.byId(Themes.GRAPHITE) }
|
||||
val Palette.onSignal: Color get() = if (signal.luminance() > 0.18f) Color(0xFF03111F) else Color.White
|
||||
|
||||
@Composable
|
||||
fun DroidDeckTheme(theme: String? = null, content: @Composable () -> Unit) {
|
||||
fun DroidDeckTheme(theme: String? = null, appScale: Int = AppUiPrefs.DEFAULT_SCALE, content: @Composable () -> Unit) {
|
||||
val id = theme ?: SessionPrefs.theme(LocalContext.current)
|
||||
val p = Themes.byId(id)
|
||||
val scheme = darkColorScheme(
|
||||
@@ -77,5 +81,13 @@ fun DroidDeckTheme(theme: String? = null, content: @Composable () -> Unit) {
|
||||
surfaceVariant = p.surfaceVariant, onSurfaceVariant = p.onSurfaceVariant,
|
||||
error = p.error,
|
||||
)
|
||||
CompositionLocalProvider(LocalPalette provides p) { MaterialTheme(colorScheme = scheme, content = content) }
|
||||
val baseDensity = LocalDensity.current
|
||||
val baseConfiguration = LocalConfiguration.current
|
||||
val density = remember(baseDensity, appScale) { scaledAppDensity(baseDensity, appScale) }
|
||||
val configuration = remember(baseConfiguration, appScale) { scaledAppConfiguration(baseConfiguration, appScale) }
|
||||
CompositionLocalProvider(
|
||||
LocalPalette provides p,
|
||||
LocalDensity provides density,
|
||||
LocalConfiguration provides configuration,
|
||||
) { MaterialTheme(colorScheme = scheme, content = content) }
|
||||
}
|
||||
@@ -115,6 +115,11 @@ public final class WaylandCompositor {
|
||||
|
||||
public static void setPointerLockListener(PointerLockListener l) { pointerLockListener = l; }
|
||||
|
||||
/** Unhooks {@code l} only if it is still the listener, so a finishing activity never unhooks its successor's. */
|
||||
public static void clearPointerLockListener(PointerLockListener l) {
|
||||
if (pointerLockListener == l) pointerLockListener = null;
|
||||
}
|
||||
|
||||
/** Invoked from native (droiddeck_on_pointer_lock). */
|
||||
@SuppressWarnings("unused")
|
||||
static void onPointerLock(boolean locked, int x, int y) {
|
||||
@@ -500,4 +505,11 @@ public final class WaylandCompositor {
|
||||
if (dx == 0 && dy == 0) return;
|
||||
nativeSendSceneInput(6, dx * 256, dy * 256);
|
||||
}
|
||||
|
||||
/** As {@link #sendPointerDelta(int, int)}, keeping a captured mouse's fractions of a pixel. */
|
||||
public static void sendPointerDelta(float dx, float dy) {
|
||||
int x = Math.round(dx * 256f), y = Math.round(dy * 256f);
|
||||
if (x == 0 && y == 0) return;
|
||||
nativeSendSceneInput(6, x, y);
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,10 @@
|
||||
<string name="mode_branch_public">Public beta</string>
|
||||
<string name="mode_branch_deck_beta">Steam Deck public beta</string>
|
||||
<string name="mode_branch_note">Deck mode always uses Steam Deck public beta.</string>
|
||||
<string name="mode_steam_repair">Repair Steam client</string>
|
||||
<string name="mode_steam_repair_hint">Downloads the Steam client again the next time Steam starts. Use it if Steam stays on its loading screen. Games, sign-in and settings are kept.</string>
|
||||
<string name="mode_steam_repair_queued">The Steam client will be downloaded again the next time Steam starts.</string>
|
||||
<string name="mode_steam_repair_button">Repair</string>
|
||||
<string name="mode_added_games">Added games</string>
|
||||
<string name="mode_added_none">no game folders with a .exe found</string>
|
||||
<string name="mode_games_folder">Games folder</string>
|
||||
@@ -470,6 +474,7 @@
|
||||
<string name="drawer_page_effects">Effects</string>
|
||||
<string name="drawer_page_controls">Controls</string>
|
||||
<string name="drawer_page_components">Components</string>
|
||||
<string name="drawer_page_controller">Controller</string>
|
||||
<string name="drawer_page_settings">Settings</string>
|
||||
<string name="drawer_page_desc">%1$s page</string>
|
||||
<string name="drawer_steam">Steam</string>
|
||||
@@ -485,8 +490,11 @@
|
||||
<string name="drawer_scaling_sharpness">Scaling sharpness</string>
|
||||
<string name="drawer_effects">Screen effects</string>
|
||||
<string name="drawer_effects_hint">Live. Costs a little GPU time while any effect is on.</string>
|
||||
<string name="drawer_look">Look</string>
|
||||
<string name="drawer_look">Presets</string>
|
||||
<string name="drawer_look_custom">Custom</string>
|
||||
<string name="drawer_effects_advanced">Advanced</string>
|
||||
<string name="widgets_expanded">Expanded</string>
|
||||
<string name="widgets_collapsed">Collapsed</string>
|
||||
<string name="drawer_cas">Sharpening (CAS)</string>
|
||||
<string name="drawer_cas_level">Sharpening level</string>
|
||||
<string name="drawer_fake_hdr">Fake HDR</string>
|
||||
@@ -510,6 +518,7 @@
|
||||
<string name="drawer_pc_keyboard">PC keyboard</string>
|
||||
<string name="drawer_android_keyboard">Android</string>
|
||||
<string name="drawer_second_screen">Second screen</string>
|
||||
<string name="drawer_second_screen_mode">Mode</string>
|
||||
<string name="drawer_second_screen_shows">Shows</string>
|
||||
<string name="drawer_android_apps">Android apps</string>
|
||||
<string name="drawer_launch_app">Launch an app</string>
|
||||
@@ -586,6 +595,9 @@
|
||||
<string name="setup_offline">Offline mode</string>
|
||||
<string name="setup_sign_in_first">Sign in to Steam first</string>
|
||||
<string name="setup_launcher">Launcher</string>
|
||||
<string name="setup_app_scale">App scale</string>
|
||||
<string name="setup_app_scale_hint">DroidDeck text and controls</string>
|
||||
<string name="setup_app_scale_default">%1$d%% (Default)</string>
|
||||
<string name="setup_theme">Theme</string>
|
||||
<string name="setup_theme_hint">Launcher appearance</string>
|
||||
<string name="setup_home">Use as Home screen</string>
|
||||
@@ -1011,4 +1023,25 @@
|
||||
<string name="game_file_folder">Choose a folder for frontend game files</string>
|
||||
<string name="game_file_exported">Game files saved to %1$s</string>
|
||||
<string name="game_frontend_files">Frontend files</string>
|
||||
<string name="display_session">Session display</string>
|
||||
<string name="display_resolution">Resolution</string>
|
||||
<string name="display_game_fit">16:9 game fit</string>
|
||||
<string name="display_game_fit_hint">How 16:9 games fit this display. Next session.</string>
|
||||
<string name="display_game_fit_unavailable">Choose Match screen or a custom size narrower than 16:9.</string>
|
||||
<string name="display_preserve">Keep proportions</string>
|
||||
<string name="display_stretch">Stretch to display</string>
|
||||
<string name="display_image_scaling">Image scaling</string>
|
||||
<string name="display_filter">Scaling filter</string>
|
||||
<string name="display_filter_hint">Shared by Steam and Desktop.</string>
|
||||
<string name="display_filter_live">Applies live. Shared by Steam and Desktop.</string>
|
||||
<string name="display_filter_note">FSR, GSR and NIS apply when enlarging the session image. Sharpen only also works without enlargement.</string>
|
||||
<string name="display_sharpness">Filter sharpness</string>
|
||||
<string name="display_frame_rate">Frame rate</string>
|
||||
<string name="display_window_compatibility">Game window compatibility</string>
|
||||
<string name="display_force_fullscreen">Force game windows fullscreen</string>
|
||||
<string name="display_force_fullscreen_hint">Keeps games fullscreen after focus changes. Turn off if a game draws small. Applies live during a session.</string>
|
||||
<string name="display_force_fullscreen_live">Keeps games fullscreen after focus changes. Applies live.</string>
|
||||
<string name="display_look_filter">%1$s Sets scaling to %2$s.</string>
|
||||
<string name="display_match_screen">Match screen (%1$d×%2$d)</string>
|
||||
<string name="mode_tab_steam">Steam</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The ring paths handed to the guest (FAKE_EVDEV_MEMFD_PATHS) must keep the spelling the session
|
||||
* tree is bound under. On ColorOS 16 /data/user/0 resolves to /data/data, the guest only has the
|
||||
* first bound, and a canonical export left every pad dead (#166). A symlinked directory stands in
|
||||
* for that here.
|
||||
*/
|
||||
class FakeInputRingPathTest {
|
||||
private lateinit var base: File
|
||||
private lateinit var real: File
|
||||
private lateinit var link: File
|
||||
|
||||
@Before fun setUp() {
|
||||
base = Files.createTempDirectory("ring-path").toFile()
|
||||
real = File(base, "data-data").apply { mkdirs() }
|
||||
link = File(base, "data-user-0")
|
||||
Files.createSymbolicLink(link.toPath(), real.toPath())
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
base.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test fun ringsSitBesideTheFakeInputNodes() {
|
||||
val fakeInputDir = File(link, "files/session/dev/input")
|
||||
assertEquals(File(link, "files/session/dev/fakeinput-rings/ring2"), FakeInputRingPaths.ringFile(fakeInputDir, 2))
|
||||
}
|
||||
|
||||
@Test fun theExportKeepsTheBoundSpellingThroughALinkedDataDirectory() {
|
||||
val fakeInputDir = File(link, "files/session/dev/input").apply { mkdirs() }
|
||||
val ring = FakeInputRingPaths.ringFile(fakeInputDir, 0)!!.apply { parentFile!!.mkdirs(); writeText("") }
|
||||
// The link really does resolve elsewhere, as /data/user/0 does on the affected devices.
|
||||
assertTrue(ring.canonicalPath.startsWith(real.canonicalPath + "/"))
|
||||
val exported = FakeInputRingPaths.exportPath(ring)
|
||||
assertEquals(File(link, "files/session/dev/fakeinput-rings/ring0").path, exported)
|
||||
assertFalse("$exported resolved through the link", exported.startsWith(real.path + "/"))
|
||||
assertTrue("$exported does not open", File(exported).isFile)
|
||||
}
|
||||
|
||||
@Test fun aRelativeInputDirIsExportedAbsolute() {
|
||||
val exported = FakeInputRingPaths.exportPath(FakeInputRingPaths.ringFile(File("session/dev/input"), 1)!!)
|
||||
assertTrue(exported, File(exported).isAbsolute)
|
||||
assertTrue(exported, exported.endsWith("/session/dev/fakeinput-rings/ring1"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.BatteryManager
|
||||
import java.io.File
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class BatteryComponentTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
private var battery: BatteryComponent? = null
|
||||
|
||||
@After fun stop() { battery?.stop() }
|
||||
|
||||
@Test fun oneAggregateBatteryKeepsOverlayPowerAndLifetimeConsistentWithQam() {
|
||||
val supplies = tmp.newFolder("power_supply")
|
||||
val vpower = tmp.newFolder("vpower")
|
||||
start(supplies, vpower, -2_000_000L)
|
||||
assertTelemetry(supplies, vpower)
|
||||
}
|
||||
|
||||
@Test fun upgradingRemovesTheDuplicateRatherThanLeavingItForOverlayDiscovery() {
|
||||
val supplies = tmp.newFolder("power_supply")
|
||||
val vpower = tmp.newFolder("vpower")
|
||||
val legacy = File(supplies, "BAT0").apply { mkdirs() }
|
||||
File(legacy, "power_now").writeText("8000000\n")
|
||||
File(legacy, "charge_now").writeText("4000000\n")
|
||||
start(supplies, vpower, 2_000_000L)
|
||||
assertFalse(legacy.exists())
|
||||
assertTelemetry(supplies, vpower)
|
||||
}
|
||||
|
||||
private fun start(supplies: File, vpower: File, currentUa: Long) {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
context.sendStickyBroadcast(Intent(Intent.ACTION_BATTERY_CHANGED).apply {
|
||||
putExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_DISCHARGING)
|
||||
putExtra(BatteryManager.EXTRA_LEVEL, 50)
|
||||
putExtra(BatteryManager.EXTRA_SCALE, 100)
|
||||
putExtra(BatteryManager.EXTRA_VOLTAGE, 4000)
|
||||
})
|
||||
shadowOf(context.getSystemService(BatteryManager::class.java)).apply {
|
||||
setLongProperty(BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER, 4_000_000L)
|
||||
setLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW, currentUa)
|
||||
setLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE, currentUa)
|
||||
}
|
||||
battery = BatteryComponent(supplies, vpower).also { it.attach(context); it.start() }
|
||||
}
|
||||
|
||||
private fun assertTelemetry(supplies: File, vpower: File) {
|
||||
val discovered = supplies.listFiles()!!.filter { it.name.contains("BAT") }
|
||||
assertEquals(listOf("BAT1"), discovered.map { it.name })
|
||||
fun read(name: String) = File(discovered.single(), name).readText().trim().toLong()
|
||||
// One 4 Ah battery at 4 V drawing 2 A: 8 W and two hours, regardless of vendor current sign.
|
||||
assertEquals(8_000_000L, read("power_now"))
|
||||
assertEquals(16_000_000L, read("energy_now"))
|
||||
assertEquals(7200L, read("time_to_empty_now"))
|
||||
assertEquals("7200", File(vpower, "secs_until_shutdown_request").readText().trim())
|
||||
assertEquals("50.00", File(vpower, "battery_percent").readText().trim())
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import android.os.Looper
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import java.io.IOException
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
@@ -68,12 +69,74 @@ class RumbleComponentTest {
|
||||
assertTrue(shadowOf(vibrator).isVibrating)
|
||||
}
|
||||
|
||||
private fun effect() {
|
||||
// Inject a decoded force-feedback packet; transport is outside this preference test.
|
||||
RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
|
||||
.apply { isAccessible = true }.invoke(rumble, 65535, 5000)
|
||||
@Test fun aControllerWithMotorsGetsTheEffectInsteadOfThePhone() {
|
||||
val pad = FakeMotors()
|
||||
rumble.controllerMotors = { id -> if (id == PAD_ID) pad else null }
|
||||
activeController(PAD_ID)
|
||||
effect(strong = 40000, weak = 1000)
|
||||
assertEquals(listOf(Triple(40000, 1000, 5000L)), pad.played)
|
||||
assertFalse("the phone buzzed as well", shadowOf(vibrator).isVibrating)
|
||||
}
|
||||
|
||||
@Test fun aControllerWithoutMotorsFallsBackToThePhone() {
|
||||
rumble.controllerMotors = { null }
|
||||
activeController(PAD_ID)
|
||||
effect()
|
||||
assertTrue(shadowOf(vibrator).isVibrating)
|
||||
}
|
||||
|
||||
@Test fun theOnScreenPadTakingOverMovesTheEffectBackToThePhone() {
|
||||
val pad = FakeMotors()
|
||||
rumble.controllerMotors = { pad }
|
||||
activeController(PAD_ID)
|
||||
effect()
|
||||
assertTrue(pad.vibrating)
|
||||
activeController(PadBridge.NO_CONTROLLER)
|
||||
effect()
|
||||
assertFalse("the controller kept rumbling after the switch", pad.vibrating)
|
||||
assertTrue(shadowOf(vibrator).isVibrating)
|
||||
}
|
||||
|
||||
@Test fun aStopPacketDisablingAndStoppingAllEndTheControllersEffect() {
|
||||
val pad = FakeMotors()
|
||||
rumble.controllerMotors = { pad }
|
||||
activeController(PAD_ID)
|
||||
effect()
|
||||
effect(strong = 0, weak = 0)
|
||||
assertFalse(pad.vibrating)
|
||||
effect()
|
||||
ControllerPrefs.setRumble(context, false)
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
assertFalse(pad.vibrating)
|
||||
ControllerPrefs.setRumble(context, true)
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
effect()
|
||||
rumble.stop()
|
||||
assertFalse(pad.vibrating)
|
||||
}
|
||||
|
||||
@After fun clearActiveController() { activeController(PadBridge.NO_CONTROLLER) }
|
||||
|
||||
private fun activeController(id: Int) {
|
||||
PadBridge::class.java.getDeclaredField("activeControllerId").apply { isAccessible = true }.setInt(null, id)
|
||||
}
|
||||
|
||||
private fun effect(strong: Int = 65535, weak: Int = 65535) {
|
||||
// Inject a decoded force-feedback packet; transport is outside these tests.
|
||||
RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
|
||||
.apply { isAccessible = true }.invoke(rumble, strong, weak, 5000)
|
||||
}
|
||||
|
||||
private class FakeMotors : RumbleComponent.Motors {
|
||||
override val name = "fake pad"
|
||||
val played = mutableListOf<Triple<Int, Int, Long>>()
|
||||
var vibrating = false
|
||||
override fun play(strong: Int, weak: Int, ms: Long) { played += Triple(strong, weak, ms); vibrating = true }
|
||||
override fun cancel() { vibrating = false }
|
||||
}
|
||||
|
||||
private companion object { const val PAD_ID = 7 }
|
||||
|
||||
@Implements(LocalServerSocket::class)
|
||||
class NoPacketsSocket {
|
||||
@Implementation fun __constructor__(name: String) = Unit
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class SessionDisplayTest {
|
||||
@Test fun explicitPresetsAndMatchScreenResolveWithoutAnyAspectSetting() {
|
||||
assertEquals(1280 to 720, SessionDisplay.resolveChoice(1440 to 1080, "1280x720"))
|
||||
assertEquals(1600 to 900, SessionDisplay.resolveChoice(1280 to 720, "1600x900"))
|
||||
assertEquals(1440 to 1080, SessionDisplay.resolveChoice(1080 to 1440, SessionDisplay.MATCH_SCREEN))
|
||||
assertEquals(2400 to 1080, SessionDisplay.resolveChoice(1080 to 2400, SessionDisplay.MATCH_SCREEN))
|
||||
}
|
||||
|
||||
@Test fun panelOptionReplacesTheIdenticalPresetAndTracksPanelChanges() {
|
||||
val thor = SessionDisplay.resolutionOptions(1920 to 1080)
|
||||
assertEquals(listOf("1280x720", "1600x900", SessionDisplay.MATCH_SCREEN), thor)
|
||||
val small = SessionDisplay.resolutionOptions(1280 to 720)
|
||||
assertFalse(small.contains("1280x720"))
|
||||
assertEquals(1280 to 720, SessionDisplay.resolveChoice(1280 to 720, SessionDisplay.MATCH_SCREEN))
|
||||
assertEquals(2560 to 1440, SessionDisplay.resolveChoice(2560 to 1440, SessionDisplay.MATCH_SCREEN))
|
||||
}
|
||||
|
||||
@Test fun resolutionCapsPreserveTheSelectedShapeWithoutExceedingPanelHeight() {
|
||||
assertEquals(1280 to 720, SessionDisplay.resolve(1920 to 1080, 720, SessionPrefs.SHAPE_AUTO))
|
||||
assertEquals(960 to 720, SessionDisplay.resolve(1440 to 1080, 720, SessionPrefs.SHAPE_EXACT))
|
||||
assertEquals(1280 to 720, SessionDisplay.resolve(1440 to 1080, 720, SessionPrefs.SHAPE_AUTO))
|
||||
assertEquals(1280 to 720, SessionDisplay.resolve(1280 to 720, 1080, SessionPrefs.SHAPE_AUTO))
|
||||
}
|
||||
|
||||
@Test fun noCapUsesPanelHeightWhileCustomOverridesBothCapAndAspect() {
|
||||
assertEquals(1440 to 1080, SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_EXACT))
|
||||
assertEquals(1920 to 1080, SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_WIDE))
|
||||
assertEquals(1024 to 768, SessionDisplay.resolve(1920 to 1080, 720, SessionPrefs.SHAPE_WIDE, 1024 to 768))
|
||||
}
|
||||
|
||||
@Test fun orientationAndOddDimensionsKeepTheExistingEvenLandscapeDisplay() {
|
||||
assertEquals(1920 to 1080, SessionDisplay.resolve(1081 to 1921, 0, SessionPrefs.SHAPE_EXACT))
|
||||
assertEquals(1600 to 720, SessionDisplay.resolve(2400 to 1080, 720, SessionPrefs.SHAPE_AUTO))
|
||||
}
|
||||
|
||||
@Test fun gameStretchAvailabilityMatchesTheRuntimeIncludingRounding() {
|
||||
assertTrue(SessionDisplay.canStretch16x9(960 to 720))
|
||||
assertFalse(SessionDisplay.canStretch16x9(1280 to 720))
|
||||
assertFalse(SessionDisplay.canStretch16x9(1600 to 720))
|
||||
val wide = SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_WIDE)
|
||||
assertFalse(SessionDisplay.canStretch16x9(wide))
|
||||
}
|
||||
|
||||
@Test fun removedFilterAliasesRetainTheirEquivalentBehavior() {
|
||||
assertEquals(0, SessionPrefs.canonicalUpscaler(1))
|
||||
assertEquals(4, SessionPrefs.canonicalUpscaler(5))
|
||||
assertEquals(0, SessionPrefs.canonicalUpscaler(99))
|
||||
assertFalse(SessionPrefs.upscalerHasSharpness(0))
|
||||
assertFalse(SessionPrefs.upscalerHasSharpness(1))
|
||||
assertFalse(SessionPrefs.upscalerHasSharpness(2))
|
||||
for (mode in 3..8) assertTrue(SessionPrefs.upscalerHasSharpness(mode))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class SessionScalingPrefsTest {
|
||||
@Before fun clearSettings() {
|
||||
RuntimeEnvironment.getApplication().getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
}
|
||||
|
||||
@Test fun explicitLegacyAspectAndCapKeepTheirSizeUntilAResolutionIsPicked() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
prefs.edit().putString("shape", SessionPrefs.SHAPE_EXACT).putInt("resolutionCap.steam", 900).commit()
|
||||
assertEquals("1200x900", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080))
|
||||
SessionPrefs.setResolutionChoice(context, "steam", "1600x900")
|
||||
prefs.edit().putString("shape", SessionPrefs.SHAPE_WIDE).commit()
|
||||
assertEquals("1600x900", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080))
|
||||
assertTrue(SessionPrefs.resolutionChosen(context, "steam"))
|
||||
}
|
||||
|
||||
@Test fun matchScreenOverridesLegacyCustomAndAspectWithoutAffectingTheOtherMode() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
prefs.edit().putString("shape", SessionPrefs.SHAPE_WIDE).putString("customRes.steam", "1024x768")
|
||||
.putString("customRes.lxqt", "800x600").commit()
|
||||
assertEquals("1024x768", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080))
|
||||
SessionPrefs.setResolutionChoice(context, "steam", SessionDisplay.MATCH_SCREEN)
|
||||
val choice = SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080)
|
||||
assertEquals(1440 to 1080, SessionDisplay.resolveChoice(1440 to 1080, choice))
|
||||
assertEquals("800x600", SessionPrefs.resolutionChoice(context, "lxqt", 1440 to 1080))
|
||||
}
|
||||
|
||||
@Test fun freshSettingsUseTheConcreteDefaultAndCustomSizesAreNormalized() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
assertEquals("1280x720", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080))
|
||||
SessionPrefs.setResolutionChoice(context, "steam", "1025×769")
|
||||
assertEquals("1024x768", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080))
|
||||
}
|
||||
|
||||
@Test fun legacySavedFiltersStillResolveToAVisibleEquivalentChoice() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
prefs.edit().putInt("upscaler", 1).putInt("upscaleSharpness", 65).commit()
|
||||
assertEquals(0, SessionPrefs.upscaler(context))
|
||||
prefs.edit().putInt("upscaler", 5).commit()
|
||||
assertEquals(4, SessionPrefs.upscaler(context))
|
||||
assertEquals(65, SessionPrefs.upscaleSharpness(context))
|
||||
SessionPrefs.setUpscaler(context, 5)
|
||||
assertEquals(4, prefs.getInt("upscaler", -1))
|
||||
SessionPrefs.setUpscaler(context, 1)
|
||||
assertEquals(0, prefs.getInt("upscaler", -1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.core.AppUiPrefs
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class AppScaleTest {
|
||||
private val context get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
@Before fun clearScale() {
|
||||
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
}
|
||||
|
||||
@Test fun defaultsToSystemDensityAndCanRestoreItAfterChoosingAnotherScale() {
|
||||
assertEquals(100, AppUiPrefs.scale(context))
|
||||
AppUiPrefs.setScale(context, 75)
|
||||
assertEquals(75, AppUiPrefs.scale(context))
|
||||
AppUiPrefs.setScale(context, 100)
|
||||
assertEquals(100, AppUiPrefs.scale(context))
|
||||
val density = Density(2f, 1.3f)
|
||||
val configuration = Configuration(context.resources.configuration)
|
||||
assertSame(density, scaledAppDensity(density, 100))
|
||||
assertSame(configuration, scaledAppConfiguration(configuration, 100))
|
||||
}
|
||||
|
||||
@Test fun invalidStoredOrRequestedScaleFallsBackToTheDefault() {
|
||||
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().putInt("scale", 0).commit()
|
||||
assertEquals(100, AppUiPrefs.scale(context))
|
||||
AppUiPrefs.setScale(context, 1000)
|
||||
assertEquals(100, AppUiPrefs.scale(context))
|
||||
}
|
||||
|
||||
@Test fun scalingDoesNotModifySessionPreferencesOrAndroidDisplayMetrics() {
|
||||
val session = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
session.edit().putString("resolution.steam", "1280x720").putInt("upscaler", 2).commit()
|
||||
val before = session.all.toMap()
|
||||
val density = context.resources.displayMetrics.density
|
||||
val configuration = Configuration(context.resources.configuration)
|
||||
AppUiPrefs.setScale(context, 150)
|
||||
scaledAppConfiguration(context.resources.configuration, AppUiPrefs.scale(context))
|
||||
assertEquals(before, session.all)
|
||||
assertEquals(density, context.resources.displayMetrics.density, 0f)
|
||||
assertEquals(configuration, context.resources.configuration)
|
||||
}
|
||||
|
||||
@Test fun textAndControlsScaleTogetherWhilePreservingAccessibilityFontScale() {
|
||||
val base = Density(2f, 1.3f)
|
||||
for (percent in AppUiPrefs.scales) {
|
||||
val scaled = scaledAppDensity(base, percent)
|
||||
assertEquals(1.3f, scaled.fontScale, 0f)
|
||||
assertEquals(base.run { 44.dp.toPx() } * percent / 100f, scaled.run { 44.dp.toPx() }, 0.001f)
|
||||
assertEquals(base.run { 16.sp.toPx() } * percent / 100f, scaled.run { 16.sp.toPx() }, 0.001f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun responsiveWidthAndPopupHeightFitTheSamePhysicalWindowAtEveryScale() {
|
||||
val base = Configuration().apply {
|
||||
densityDpi = 320
|
||||
screenWidthDp = 960
|
||||
screenHeightDp = 540
|
||||
smallestScreenWidthDp = 540
|
||||
fontScale = 1.3f
|
||||
orientation = Configuration.ORIENTATION_LANDSCAPE
|
||||
}
|
||||
for (percent in AppUiPrefs.scales) {
|
||||
val scaled = scaledAppConfiguration(base, percent)
|
||||
val density = scaledAppDensity(Density(2f, 1.3f), percent).density
|
||||
assertEquals(1920f, scaled.screenWidthDp * density, 2f)
|
||||
assertEquals(1080f, scaled.screenHeightDp * density, 2f)
|
||||
assertEquals(1080f, scaled.smallestScreenWidthDp * density, 2f)
|
||||
assertEquals(1.3f, scaled.fontScale, 0f)
|
||||
assertEquals(base.orientation, scaled.orientation)
|
||||
}
|
||||
assertEquals(960, base.screenWidthDp)
|
||||
assertEquals(540, base.screenHeightDp)
|
||||
assertEquals(320, base.densityDpi)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class SessionDrawerPageTest {
|
||||
@Test fun singleScreenNavigationStartsWithControllerAndSkipsSecondScreen() {
|
||||
val pages = sessionDrawerPages(false)
|
||||
assertEquals(SessionDrawerPage.CONTROLLER, pages.first())
|
||||
assertFalse(SessionDrawerPage.SECOND_SCREEN in pages)
|
||||
assertEquals(SessionDrawerPage.DISPLAY, pages.step(SessionDrawerPage.CONTROLLER, 1))
|
||||
assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.CONTROLLER, -1))
|
||||
assertEquals(SessionDrawerPage.CONTROLLER, pages.step(SessionDrawerPage.SESSION, 1))
|
||||
}
|
||||
|
||||
@Test fun connectedSecondaryDisplayAddsItsPageAfterControllerAndPreservesOtherPages() {
|
||||
val connected = sessionDrawerPages(true)
|
||||
assertEquals(SessionDrawerPage.SECOND_SCREEN, connected.step(SessionDrawerPage.CONTROLLER, 1))
|
||||
assertEquals(SessionDrawerPage.DISPLAY, connected.step(SessionDrawerPage.SECOND_SCREEN, 1))
|
||||
assertEquals(sessionDrawerPages(false), connected.filter { it != SessionDrawerPage.SECOND_SCREEN })
|
||||
for (pages in listOf(connected, sessionDrawerPages(false))) {
|
||||
assertEquals(SessionDrawerPage.EFFECTS, pages.step(SessionDrawerPage.DISPLAY, 1))
|
||||
assertEquals(SessionDrawerPage.GAMES, pages.step(SessionDrawerPage.EFFECTS, 1))
|
||||
assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.GAMES, 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
Thor SD storage investigation: selected saved evidence
|
||||
Recorded 2026-10-04; event timestamps are EDT.
|
||||
These are excerpts, not complete logs or a continuous attribution profile.
|
||||
See ../thor-sd-storage-investigation.md for context and live-only observations.
|
||||
|
||||
=== Reporter unsupported allocation ===
|
||||
Source: Reporter diagnostic storage.log
|
||||
volume_removable=true
|
||||
filesystem=fuse
|
||||
allocation pid=29062 comm=steam api=fallocate result=-1 api_error=95 errno_after=95 flags=0x0 offset=0 length=374272 elapsed_us=212
|
||||
|
||||
=== Thor unsupported allocation ===
|
||||
Source: candidate-storage.log
|
||||
volume_removable=true
|
||||
filesystem=fuse
|
||||
allocation pid=9810 comm=steam api=fallocate result=-1 api_error=95 errno_after=95 flags=0x0 offset=0 length=650752 elapsed_us=310
|
||||
|
||||
=== Native-path allocation diagnostics ===
|
||||
Source: direct-storage-completed.log / session 64
|
||||
DroidDeck storage diagnostics v1
|
||||
selected_library=secondary
|
||||
volume_removable=true
|
||||
filesystem=exfat
|
||||
truncate_allocation_probe=passed
|
||||
block_size_bytes=131072
|
||||
total_bytes=393855500288
|
||||
available_bytes=101076697088
|
||||
native_observation=enabled
|
||||
public_symbol_interposition=enabled
|
||||
direct_syscalls_and_libc_internal_calls=not_observed
|
||||
pwrite_timing=sampled_1_in_32
|
||||
per_process_allocation_detail_limit=64
|
||||
storage_log_limit_bytes=524288
|
||||
summary pid=26725 comm=python3 allocation_calls=0 allocation_failures=0 allocation_total_us=0 pwrite_sample_every=32 pwrite_samples=0 pwrite_bytes=0 pwrite_failures=0 pwrite_sample_total_us=0 pwrite_sample_max_us=0 ftruncate_calls=0 ftruncate_failures=0 ftruncate_total_us=0 ftruncate_max_us=0 fstat_calls=1 fstat_total_us=0 fstat_max_us=0 fsync_calls=0 fsync_failures=0 fsync_total_us=0 fsync_max_us=0
|
||||
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=667648 elapsed_us=387
|
||||
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=39 elapsed_us=32
|
||||
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=156 elapsed_us=25
|
||||
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=457428 elapsed_us=351
|
||||
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=901928 elapsed_us=864
|
||||
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=15041920 elapsed_us=15292
|
||||
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=337563 elapsed_us=2783
|
||||
|
||||
=== Fresh real Steam install ===
|
||||
Source: direct-content-completed.log / session 64
|
||||
[2026-10-04 17:31:20] AppID 1675830 App update changed : Running Update,Preallocating,
|
||||
[2026-10-04 17:38:04] AppID 1675830 preallocated 2216 files (7626 MB)
|
||||
[2026-10-04 17:38:06] AppID 1675830 App update changed : Running Update,Downloading,Staging,
|
||||
[2026-10-04 17:45:25] AppID 1675830 App update changed : Running Update,Committing,
|
||||
[2026-10-04 17:45:25] AppID 1675830 starting commit from "/mnt/droiddeck-sd/steamapps/downloading/1675830" to "/mnt/droiddeck-sd/steamapps/common/1000xRESIST" : 2230 updated, 0 moved, 0 deleted files
|
||||
[2026-10-04 17:47:55] AppID 1675830 state changed : Fully Installed,Update Queued,Update Running,
|
||||
[2026-10-04 17:47:55] AppID 1675830 finished update, 1 mounted depots (BuildID 21498291) : 1675831 (3663189783241284849),
|
||||
[2026-10-04 17:47:56] AppID 1675830 state changed : Fully Installed,Update Queued,
|
||||
[2026-10-04 17:47:56] AppID 1675830 state changed : Fully Installed,
|
||||
[2026-10-04 17:47:56] AppID 1675830 scheduler finished : removed from schedule (result No Error, state 0xc)
|
||||
|
||||
=== Normal-app native/FUSE directory read ===
|
||||
Source: app-raw-probe-session.log / session 62
|
||||
APP_RAW_PROBE uid=10195 path=/mnt/media_rw/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam/steamapps/downloading/1675830-fuse-control/1000xRESIST_Data/StreamingAssets/aa/StandaloneWindows64 seconds=0.002288 calls=2 bytes=154920 read_error=0
|
||||
APP_RAW_PROBE uid=10195 path=/storage/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam/steamapps/downloading/1675830-fuse-control/1000xRESIST_Data/StreamingAssets/aa/StandaloneWindows64 seconds=0.501306 calls=112 bytes=154872 read_error=0
|
||||
|
||||
=== Binding regression: bind-probe-before.log ===
|
||||
Source: bind-probe-before.log
|
||||
BIND_PROBE index=0 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/SteamLinuxRuntime_4-arm64/.droiddeck-bind-probe
|
||||
BIND_PROBE index=1 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/steamlinuxruntime_4-arm64/.droiddeck-bind-probe
|
||||
BIND_PROBE index=2 pass=0 errno=2 bytes=-1 path=/mnt/droiddeck-sd/steamapps/common/proton experimental (arm64)/.droiddeck-bind-probe
|
||||
BIND_PROBE index=3 pass=0 errno=2 bytes=-1 path=/mnt/bannerlator-sd/steamapps/shadercache/.droiddeck-bind-probe
|
||||
|
||||
=== Binding regression: bind-probe-after.log ===
|
||||
Source: bind-probe-after.log
|
||||
BIND_PROBE index=0 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/SteamLinuxRuntime_4-arm64/.droiddeck-bind-probe
|
||||
BIND_PROBE index=1 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/steamlinuxruntime_4-arm64/.droiddeck-bind-probe
|
||||
BIND_PROBE index=2 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/proton experimental (arm64)/.droiddeck-bind-probe
|
||||
BIND_PROBE index=3 pass=1 errno=0 bytes=17 path=/mnt/bannerlator-sd/steamapps/shadercache/.droiddeck-bind-probe
|
||||
|
||||
=== Root metadata/mount experiment: metadata-mount-comparison.log ===
|
||||
Source: metadata-mount-comparison.log
|
||||
original_mount
|
||||
/dev/block/vold/public:179,1 /mnt/media_rw/FF7F-F56A exfat rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro 0 0
|
||||
files=64 bytes_per_file=262144 open_seconds=6.707435 truncate_seconds=0.084806 wall_seconds=6.794807 error=0
|
||||
mount: '/dev/block/vold/public:179,1'->'/mnt/media_rw/FF7F-F56A': Invalid argument
|
||||
mount: '/dev/block/vold/public:179,1'->'/mnt/media_rw/FF7F-F56A': Invalid argument
|
||||
|
||||
=== Root metadata/mount experiment: metadata-syscall-comparison.log ===
|
||||
Source: metadata-syscall-comparison.log
|
||||
original_mount
|
||||
/dev/block/vold/public:179,1 /mnt/media_rw/FF7F-F56A exfat rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro 0 0
|
||||
files=64 bytes_per_file=262144 open_seconds=25.181255 truncate_seconds=0.309577 wall_seconds=25.493210 error=0
|
||||
temporary_mount
|
||||
/dev/block/vold/public:179,1 /mnt/media_rw/FF7F-F56A exfat rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro 0 0
|
||||
files=64 bytes_per_file=262144 open_seconds=2.013194 truncate_seconds=0.095924 wall_seconds=2.112288 error=0
|
||||
|
||||
=== Post-install Android low-memory exit ===
|
||||
Source: game-launch-exit-info.txt
|
||||
ApplicationExitInfo #0:
|
||||
timestamp=2026-10-04 18:13:48.454 pid=14709 realUid=10195 packageUid=10195 definingUid=10195 user=0
|
||||
process=com.droiddeck.launcher reason=3 (LOW_MEMORY) subreason=0 (UNKNOWN) status=0
|
||||
importance=100 pss=227MB rss=344MB description=null state=empty trace=null
|
||||
|
||||
=== Combined release unit-test completion ===
|
||||
Source: combined-233-tests.log
|
||||
> Task :app:testReleaseUnitTest
|
||||
BUILD SUCCESSFUL in 10s
|
||||
|
||||
=== Combined release build completion ===
|
||||
Source: combined-233-build.log
|
||||
BUILD SUCCESSFUL in 25s
|
||||
APK: /Users/danhimebauch/.codex/worktrees/steam-sd-preallocation/DroidDeck/app/build/outputs/apk/release/app-release.apk
|
||||
@@ -5,6 +5,11 @@ games should keep running under proot, run directly on glibc, or a mix of the tw
|
||||
fastest correct setup is. Everything below was measured on a Snapdragon 8 Elite Gen 5 (SM8850, kernel
|
||||
6.12.38-android16, Android 16) over adb, unless it says otherwise.
|
||||
|
||||
For Steam SD allocation measurements on AYN Thor (Android 13), see the
|
||||
[Thor storage investigation](thor-sd-storage-investigation.md). It separates
|
||||
observed allocation-wrapper time, FUSE directory access and native exFAT metadata
|
||||
waits; it does not establish a full-install PRoot overhead percentage.
|
||||
|
||||
## Short answer
|
||||
|
||||
- **Don't drop proot.** On Android it does two jobs. It translates paths. It also answers every
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
# Steam SD allocation on Thor
|
||||
|
||||
Investigation recorded on 2026-10-04. Device measurements and PR states below are a
|
||||
snapshot of that day's work. All event times are EDT (UTC−04:00).
|
||||
|
||||
A fresh 1000xRESIST install on the Thor SD card completed with the native-path and
|
||||
allocation-fallback patches: 2,216 files preallocated in **6m44s**, followed by a
|
||||
successful download and commit, **16m36s total**. The observed allocation wrappers
|
||||
reported zero failures and roughly 7.1 seconds of aggregate execution time.
|
||||
That wrapper measurement does **not** account for the entire preallocation phase.
|
||||
|
||||
Native directory access was substantially faster than Android's FUSE view in a
|
||||
small controlled probe. Kernel samples during the remaining delay showed exFAT
|
||||
file-creation and size-update waits. This supports further investigation of SD
|
||||
metadata writes. It does not establish that PRoot is free, that `dirsync` alone
|
||||
causes the delay, or that SD now matches internal-storage install speed.
|
||||
|
||||
Selected source-log excerpts are preserved in
|
||||
[the evidence file](evidence/thor-sd-storage-2026-10-04.txt). The original full logs
|
||||
contain unrelated sessions and are not needed to interpret those excerpts.
|
||||
|
||||
## Device and storage
|
||||
|
||||
| Item | Observed configuration |
|
||||
|---|---|
|
||||
| Device | AYN Thor, Android 13, kernel 5.15.123, 12 GB RAM and 8 GB swap |
|
||||
| App | Production-signed `com.droiddeck.launcher`, normal app UID 10195 |
|
||||
| Configured SD library | `/storage/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam` |
|
||||
| Native view of the same library | `/mnt/media_rw/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam` |
|
||||
| Guest content aliases | `/mnt/droiddeck-sd` and legacy `/mnt/bannerlator-sd` |
|
||||
| SD filesystem | Native exFAT; its Android `/storage` view reports FUSE |
|
||||
| Native filesystem magic | `0x2011BAB0`, matching the NDK Linux header |
|
||||
| Internal comparison location | App-private cache under `/data` |
|
||||
|
||||
The native SD mount was observed with these options:
|
||||
|
||||
```text
|
||||
rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro
|
||||
```
|
||||
|
||||
The device already had root and SELinux Permissive. Neither state was introduced
|
||||
by this investigation. Root was used for inspection and temporary experiments.
|
||||
The validated Steam session ran with the app's normal UID and existing groups,
|
||||
without a root launcher or an app-specific Magisk root grant.
|
||||
|
||||
This distinction does not prove the native path works on an unrooted device with
|
||||
enforcing SELinux. The production change tests access in the app and retains the
|
||||
configured path when verification fails.
|
||||
|
||||
## Initial report and allocation failure
|
||||
|
||||
The [linked Discord report](https://discord.com/channels/1552707793247539220/1555632840388116491/1556271955076710560)
|
||||
included a newer diagnostic ZIP with `storage.log`. Unlike the earlier capture
|
||||
that lacked allocation instrumentation, this capture established:
|
||||
|
||||
- The selected secondary library was removable and its exposed filesystem was
|
||||
FUSE.
|
||||
- Steam called mode-zero `fallocate` and received `EOPNOTSUPP` (errno 95).
|
||||
- That observed failure returned in 212 microseconds. It was not itself a
|
||||
multi-minute blocked call.
|
||||
|
||||
A Thor baseline capture likewise showed `fallocate` failing with errno 95 in
|
||||
310 microseconds. These establish an unsupported allocation API on the tested
|
||||
paths. They do not attribute the later Steam delay to any particular fallback.
|
||||
|
||||
The suggestion that glibc's `posix_fallocate` emulation accounts for the whole
|
||||
hang was not established by those logs. The tested Steam calls included
|
||||
`fallocate`; directory operations and other calls occur outside that wrapper.
|
||||
|
||||
## Published patches and the tested build
|
||||
|
||||
The main base was `26cb9e2f038cdbab4cdc8670d2f64c18d1d90875`. These heads were
|
||||
checked again while preparing this report; all three storage PRs were open with
|
||||
successful Build APK checks and clean merge states. This is a dated snapshot,
|
||||
not a statement about their future merge status.
|
||||
|
||||
| PR | Tested head | Behavior |
|
||||
|---|---|---|
|
||||
| [#221](https://github.com/Droid-Deck/DroidDeck/pull/221) | `7962c19283bfb0f18a5c7672e08a879814a3d5a1` | Verify a native SD content alias and handle safe case aliases in the path fast path |
|
||||
| [#229](https://github.com/Droid-Deck/DroidDeck/pull/229) | `4628ae4c92cc5813013537165a0e95886e6de904` | Use a verified truncate fallback for unsupported selected-library allocation |
|
||||
| [#233](https://github.com/Droid-Deck/DroidDeck/pull/233) | `5c6765233ae5405d7bd4c1b29f20c9e8343a1fed` | Preserve the complete fast-path binding table, including later private overrides |
|
||||
| [#225](https://github.com/Droid-Deck/DroidDeck/pull/225) | `0b5908d6d63080f625b51bab3439e2e68466631e` | Repair repeated failed Steam client starts; reviewed separately, excluded from this storage build |
|
||||
|
||||
### Native content path and case handling: #221
|
||||
|
||||
For a configured `/storage/...` library, the launcher checks the corresponding
|
||||
`/mnt/media_rw/...` candidate. It requires usable directory permissions and
|
||||
performs a write/read/delete identity probe through both views. Failure keeps
|
||||
the original configured path.
|
||||
|
||||
Only the shared SD content bindings change. The configured path remains the
|
||||
library's identity for private prefixes, migrations and other private storage.
|
||||
This avoids moving existing saves or runtime state when selecting a faster view.
|
||||
Removable-volume detection also uses the original configured path because
|
||||
Android's volume API need not recognize the native alias.
|
||||
|
||||
The C fast path permits case-only canonical-parent differences on known
|
||||
FUSE/FAT/exFAT filesystems only after checking directory components without
|
||||
following symlinks. It also handles the supported `F_OK` plus
|
||||
`AT_SYMLINK_NOFOLLOW` case. Ambiguous paths still fall back to PRoot.
|
||||
|
||||
The final #221 diff was four files, 98 insertions and 20 deletions, including
|
||||
28 new test lines. The experimental directory cache was removed from that diff.
|
||||
|
||||
### Allocation fallback: #229
|
||||
|
||||
The launcher first creates a temporary 1 MiB file on the selected content path,
|
||||
extends it, and checks that `st_blocks` reports at least 2,048 512-byte blocks.
|
||||
The file must also be cleaned up successfully. The fallback is enabled only for
|
||||
a removable library that passes this reservation probe.
|
||||
|
||||
For file descriptors on the selected library device, the wrapper tries the real
|
||||
allocation API. On mode-zero `fallocate` returning `EOPNOTSUPP`, it extends the
|
||||
file with `ftruncate`. It validates the range, detects overflow and avoids
|
||||
shrinking an existing file. Other modes and other errors retain their normal
|
||||
behavior. The POSIX wrapper tries kernel allocation first to avoid unnecessary
|
||||
libc emulation and preserves POSIX error-return and `errno` semantics.
|
||||
|
||||
This is a capability-tested fallback, not unconditional successful allocation
|
||||
reporting. A failed extension still returns an error. The optimization operates
|
||||
independently of the optional diagnostic toggle.
|
||||
|
||||
The reservation probe and one successful full install do not replace separate
|
||||
future tests for ENOSPC, interrupted installs or other devices/filesystems.
|
||||
|
||||
### Complete binding table: #233
|
||||
|
||||
The real Steam session had **96 bindings**. The old C limit of 64 silently
|
||||
dropped later mappings. Several of those were private overrides inside the
|
||||
shared SD root, so truncation could send an operation to the wrong location or
|
||||
return `ENOENT`.
|
||||
|
||||
#233 raises capacity to 256 and makes both the launcher and C implementation
|
||||
disable the fast path if a complete table cannot be represented. Overlong paths
|
||||
also cause fallback rather than partial mapping. This is a correctness fix as
|
||||
well as a requirement for trusting the storage fast path.
|
||||
|
||||
Marker files existed only in private directories. A normal app-process probe
|
||||
read them through these guest paths:
|
||||
|
||||
| Guest path under the library | Before #233 | After #233 |
|
||||
|---|---|---|
|
||||
| `SteamLinuxRuntime_4-arm64` | Correct private marker | Correct private marker |
|
||||
| `steamlinuxruntime_4-arm64` | Correct private marker | Correct private marker |
|
||||
| `proton experimental (arm64)` | `ENOENT` | Correct private marker |
|
||||
| Legacy `steamapps/shadercache` | `ENOENT` | Correct private marker |
|
||||
|
||||
Each successful read returned the 17-byte `private-override\n` marker. The
|
||||
temporary markers and helper executable were removed afterward.
|
||||
|
||||
### Deployment and checks
|
||||
|
||||
The full fresh install used #221 plus #229. #233 was then integrated locally,
|
||||
tested, built and installed for the binding regression and game-launch check.
|
||||
It was not merged on GitHub during this investigation.
|
||||
|
||||
The combined integration commit was
|
||||
`f5d3e93b8eb8912ca3834f7447a6498c95250177`. The production-signed APK was installed
|
||||
in place with `adb install -r`; its SHA-256 was:
|
||||
|
||||
```text
|
||||
099a0d7e76b9462b3b238c6f1abeb03570ec9708ace3092adf178ec2bee63e2a
|
||||
```
|
||||
|
||||
`tools/build_local.sh` completed for the combined build. The release unit tests
|
||||
for `SecondaryLibraryTest` and `ProotFastPathTest` passed. The latter covers
|
||||
96/256 bindings, exceeding capacity, and overlong paths. #225 was reviewed for
|
||||
scope; its tests were not run as part of this storage validation.
|
||||
|
||||
## Fresh real Steam install
|
||||
|
||||
Game: **1000xRESIST**, app 1675830, depot 1675831, BuildID 21498291. Steam reported
|
||||
6,582,351,824 download bytes and 7,997,085,707 staging bytes, approximately 8 GB.
|
||||
|
||||
| Event | Time | Evidence |
|
||||
|---|---|---|
|
||||
| Fresh preallocation began | 17:31:20 | Steam entered `Preallocating` |
|
||||
| Preallocation completed | 17:38:04 | 2,216 files, 7,626 MB in Steam's log |
|
||||
| Download/staging began | 17:38:06 | `Downloading,Staging` |
|
||||
| Commit began | 17:45:25 | 2,230 updated files |
|
||||
| Fully installed | 17:47:55 | `Fully Installed`, BuildID 21498291 |
|
||||
| Scheduler completed | 17:47:56 | `No Error` |
|
||||
|
||||
Preallocation took **404 seconds**. Start to scheduler completion took
|
||||
**996 seconds**. Live counters showed 2,216 allocation calls, zero allocation
|
||||
failures and about **7.1 seconds aggregate allocation-wrapper time**. The saved
|
||||
diagnostic detail log is capped at the first 64 allocation details per process;
|
||||
that file alone cannot reproduce the live aggregate counter observation.
|
||||
|
||||
The final manifest had `StateFlags=4`, `UpdateResult=0`, all expected download
|
||||
and staging bytes completed, and `SizeOnDisk=7997085707`. The completed executable
|
||||
was under `steamapps/common/1000xRESIST`; the active staging directory was gone.
|
||||
|
||||
Transfer rates were mostly 117–132 Mbps, about 15–16.5 MB/s. Two root metadata
|
||||
experiments ran during downloading, so this is not a clean network/card
|
||||
throughput benchmark. No matched full internal-storage install was performed.
|
||||
|
||||
The run's `storage.log` reported native exFAT and a passed truncate reservation
|
||||
probe. Recorded allocation details used `api=fallocate_ftruncate`, with
|
||||
`result=0` and `api_error=0`.
|
||||
|
||||
## What the metadata measurements show
|
||||
|
||||
Metadata is filesystem bookkeeping: names, directory entries, sizes and space
|
||||
allocation. Steam's `Preallocating` phase includes work outside the observed
|
||||
allocation wrappers, including creating and locating files.
|
||||
|
||||
### Directory reads through native and FUSE paths
|
||||
|
||||
A helper ran in a normal app process and listed the same stable asset directory
|
||||
from a saved staging tree through both host paths. It used the existing fast-path
|
||||
syscall trampoline to bypass PRoot path translation while retaining the app's
|
||||
Android permissions. The read buffer was 128 KiB.
|
||||
|
||||
| View | Elapsed | Successful `getdents64` calls | Directory bytes |
|
||||
|---|---|---|---|
|
||||
| Native exFAT | About 2.3 ms | 2 | 154,920 |
|
||||
| Android FUSE | About 501 ms | 112 | 154,872 |
|
||||
|
||||
The views returned slightly different byte counts. This was a single
|
||||
native-first pass, not a repeated benchmark with fully controlled cache state.
|
||||
It demonstrates a large cost difference for that directory read, not a
|
||||
corresponding multiplier for a full Steam install.
|
||||
|
||||
An earlier root listing during concurrent file creation measured roughly
|
||||
5.56 seconds through FUSE and 9.25 milliseconds native. Its directory was
|
||||
changing, so it is weaker evidence and is not used as a controlled comparison.
|
||||
|
||||
### File creation and size updates
|
||||
|
||||
During the fresh native-path Steam run, sampled kernel stacks included:
|
||||
|
||||
```text
|
||||
__sync_dirty_buffer -> exfat_update_bh -> exfat_init_ext_entry
|
||||
-> exfat_add_entry -> exfat_create -> path_openat
|
||||
|
||||
__wait_on_buffer -> exfat_update_bhs -> exfat_free_dentry_set
|
||||
-> __exfat_write_inode -> exfat_truncate -> do_sys_ftruncate
|
||||
```
|
||||
|
||||
These were live samples, not a continuous attribution profile. They show time
|
||||
waiting in the native filesystem's creation/update paths during the delay.
|
||||
|
||||
A root-only helper created a temporary directory, then 64 long-named files using
|
||||
`open(O_CREAT|O_EXCL|O_WRONLY, 0600)`, `ftruncate(fd, 262144)` and `close`. It timed
|
||||
the creation and truncate calls separately and removed its files afterward.
|
||||
|
||||
| Location/run | Sum of open times | Sum of truncate times | Whole loop |
|
||||
|---|---|---|---|
|
||||
| Native SD, original mount | 6.707435 s | 0.084806 s | 6.794807 s |
|
||||
| Native SD, later repeat | 25.181255 s | 0.309577 s | 25.493210 s |
|
||||
| Internal app cache, live observation | About 7.88 ms | About 0.498 ms | About 9.1 ms |
|
||||
|
||||
The SD results varied substantially, and these root probes do not reproduce the
|
||||
normal app's complete Steam workload. The measured internal probe is not a full
|
||||
internal-install benchmark. Within these probes, file creation cost far more
|
||||
than the observed truncate calls.
|
||||
|
||||
### `dirsync` and the failed remount comparison
|
||||
|
||||
The [Linux mount documentation](https://man7.org/linux/man-pages/man2/mount.2.html)
|
||||
defines `MS_DIRSYNC` as synchronous directory changes. Combined with the observed
|
||||
exFAT buffer waits, this makes synchronous SD metadata updates a strong lead.
|
||||
It remains an inference about the remaining overall delay.
|
||||
|
||||
A toybox remount attempt returned `Invalid argument`. A direct mount-syscall
|
||||
experiment changed the root view, but Steam's observed mount table still showed
|
||||
`dirsync`. A subsequent 64-file probe took 2.112288 seconds; because the effective
|
||||
flag change was not established and timings were variable, this is **not proof
|
||||
that removing `dirsync` improves Steam allocation**. The Linux documentation also
|
||||
notes that attempts to change `MS_DIRSYNC` during a remount are silently ignored.
|
||||
|
||||
Original effective options were restored and checked in root and app/process
|
||||
views. No remount or root-launch behavior is included in the production patches.
|
||||
|
||||
### What remains attributable to PRoot
|
||||
|
||||
One sampled Steam worker was executing an `openat` through the fast-path
|
||||
trampoline at `0xffff00020`, with an absolute native host path. That establishes
|
||||
that some operations bypassed PRoot's path translation in the actual session.
|
||||
Other syscalls still use PRoot.
|
||||
|
||||
There was no matched PRoot-versus-direct full preallocation comparison. The
|
||||
fraction of the 404 seconds attributable to PRoot is therefore unknown. The
|
||||
earlier [PRoot performance notes](proot-performance.md) concern different hardware
|
||||
and workloads and cannot supply that missing percentage.
|
||||
|
||||
Seven seconds in public allocation wrappers does not mean the remaining
|
||||
397 seconds are unrelated to allocation: libc-internal/direct-syscall bypasses,
|
||||
explicit size changes and file-creation work may occur outside those counters.
|
||||
|
||||
## Experiments that did not become the fix
|
||||
|
||||
- **Directory caching:** eager scans held up directory open; creation-driven
|
||||
invalidation repeatedly reread growing directories. The capped design could
|
||||
also read and discard a large directory before rereading it. A lazy variant
|
||||
still left 182 files taking 4m49s while the allocation wrappers accounted for
|
||||
only 0.879 seconds. These were interrupted/resumed runs, not a matched A/B
|
||||
benchmark. The final #221 snapshot removes the cache entirely.
|
||||
- **Allocation fallback alone:** a FUSE-path attempt was still slow after
|
||||
unsupported allocation calls were handled. A later eager-cache resumed run
|
||||
accumulated roughly 8.36 seconds in 1,435 successful allocation calls over
|
||||
about 36 minutes of wall time. Successful wrappers alone were insufficient.
|
||||
- **Launching Steam through root:** experimental wrappers failed compositor
|
||||
startup. They did not produce allocation measurements and were reverted.
|
||||
- **Changing mount flags:** effective removal of `dirsync` from Steam's view was
|
||||
not demonstrated. The apparent probe speedup is not a validated fix.
|
||||
- **Replacing PRoot or reformatting storage:** no such replacement, adoptable
|
||||
storage conversion or ext4 loop-image deployment was validated here.
|
||||
|
||||
The early FUSE runs used partial staging state. Their file counts and durations
|
||||
must not be compared to the fresh 2,216-file run as a precise speedup ratio.
|
||||
|
||||
## Post-install launch and separate memory failure
|
||||
|
||||
After deploying #221 + #229 + #233, the installed SD game was launched through
|
||||
Steam. First-run Proton prefix initialization preceded the game window.
|
||||
|
||||
At **18:10:45**, the compositor changed from Steam Big Picture to **1000xRESIST**
|
||||
and received the game's 1280×720 GPU frames. A screenshot showed the game loading
|
||||
screen and spinner. A main menu or sustained gameplay was not validated.
|
||||
|
||||
At **18:13:48.454**, Android recorded the foreground app's exit as reason 3,
|
||||
`LOW_MEMORY`, with main-app PSS 227 MB and RSS 344 MB. Those figures describe that
|
||||
process, not the complete native process tree or its peak memory use. Subsequent
|
||||
gamescope/reaper shutdown messages followed the parent being killed.
|
||||
|
||||
This was after the install had completed. It is a separate unresolved launch/
|
||||
memory issue and does not undo the successful allocation/download result.
|
||||
Post-kill free-memory readings cannot establish peak memory pressure.
|
||||
|
||||
## Remaining questions and next useful tests
|
||||
|
||||
1. Run the same fresh game install on internal and secondary storage with the
|
||||
same manifest, awake device and no competing benchmarks. Compare preallocation,
|
||||
download/staging and commit separately.
|
||||
2. Match the file-creation/extension workload through PRoot and through the normal
|
||||
app's direct syscall path. Keep UID, filesystem, names and sizes identical,
|
||||
repeat runs and record background I/O. This can quantify the remaining tracer
|
||||
contribution without changing storage semantics.
|
||||
3. Establish an actual controlled difference in synchronous directory behavior
|
||||
before attributing a speedup to mount flags. A remount return code alone is
|
||||
insufficient; this is separate from a portable app patch.
|
||||
4. Validate native-path fallback under enforcing SELinux and on unrooted devices,
|
||||
plus allocation behavior near ENOSPC and after interruption. Preserve the
|
||||
configured-path fallback if either access or reservation verification fails.
|
||||
5. Investigate the low-memory exit with process-tree/cgroup memory and Android
|
||||
kill evidence during loading. Treat it separately from install performance.
|
||||
|
||||
At this point, one fresh SD install and the binding regression are validated.
|
||||
Internal-speed parity, the exact PRoot share, a `dirsync` remedy, broad device
|
||||
coverage and stable gameplay remain unproven.
|
||||
|
||||
## Evidence inventory and handoff state
|
||||
|
||||
The device captures used for the final measurements were:
|
||||
|
||||
- `2026-10-04-62-app-raw-probe`: normal-app native/FUSE directory probe.
|
||||
- `2026-10-04-64-steam`: fresh complete allocation/download/commit.
|
||||
- Probe sessions before and after #233: private binding markers.
|
||||
- `2026-10-04-67-steam`: combined-build launch, followed by Android's low-memory
|
||||
kill; the exit reason was obtained separately with `dumpsys activity exit-info`.
|
||||
|
||||
Local originals during the investigation were under
|
||||
`/tmp/droiddeck-storage-live/`: `direct-content-completed.log`,
|
||||
`direct-storage-completed.log`, `app-raw-probe-session.log`,
|
||||
`bind-probe-before.log`, `bind-probe-after.log`,
|
||||
`metadata-mount-comparison.log`, `metadata-syscall-comparison.log`,
|
||||
`game-launch-exit-info.txt`, `combined-233-build.log` and
|
||||
`combined-233-tests.log`. The repository evidence file preserves the selected
|
||||
excerpts even if those temporary local files disappear. Live-only counters,
|
||||
kernel samples and the internal metadata timing are identified above rather
|
||||
than represented as saved continuous traces.
|
||||
|
||||
The combined signed build and completed SD install remained on Thor. Storage
|
||||
diagnostics remained enabled. Temporary probe files, executables and obsolete
|
||||
partial staging were removed after validation. Original effective mount options
|
||||
were restored; no app-specific Magisk root policy remained. The primary checkout's
|
||||
unrelated `SessionService.kt` modification was preserved.
|
||||
@@ -0,0 +1,62 @@
|
||||
# DroidDeck 0.3.1
|
||||
|
||||
### Steam
|
||||
|
||||
- Decky plugins can be imported from ZIP files, with remote binaries verified and installed copies of the same plugin replaced. (@xXJSONDeruloXx, [#79](https://github.com/Droid-Deck/DroidDeck/pull/79))
|
||||
- Steam's Internet page shows Android Wi-Fi and cellular connections. Optional Wi-Fi discovery adds network names and nearby networks with Android's Location permission. (@xXJSONDeruloXx, [#150](https://github.com/Droid-Deck/DroidDeck/pull/150))
|
||||
- Paste text from the Android clipboard into Steam sessions. (@xXJSONDeruloXx, [#151](https://github.com/Droid-Deck/DroidDeck/pull/151))
|
||||
- The Proton selected in Components stays in sync with Steam's default compatibility tool, and runnable builds are marked **Compatible**. Windows titles with a Valve recommendation use that default unless you choose another compatible tool for the game. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197), [#224](https://github.com/Droid-Deck/DroidDeck/pull/224))
|
||||
|
||||
### Compatibility and performance
|
||||
|
||||
- Choose **esync**, **fsync**, experimental **ntsync**, or **wineserver** for Proton games. These sync options work around Android's missing kernel support, with esync as the default for fresh installs and existing choices preserved. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197), [#223](https://github.com/Droid-Deck/DroidDeck/pull/223))
|
||||
- droiddeck-esync packs are matched to the exact Valve, GE-Proton or CachyOS Proton build and fetched automatically. droiddeck-fsync stands in when a pack is not available. (@maxjivi05, [#189](https://github.com/Droid-Deck/DroidDeck/pull/189), [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
|
||||
- Linux-only x86 Steam titles can run through FEX using Steam's FEX and Steam Linux Runtime, with controller input and the device's graphics drivers. Missing dependencies are requested through Steam. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
|
||||
- .NET and Godot games get automatic launch fixes under FEX, including the settings needed for Slay the Spire 2. Per-game overrides are kept. (@magarto, [#168](https://github.com/Droid-Deck/DroidDeck/pull/168))
|
||||
|
||||
### Display and effects
|
||||
|
||||
- Live image scaling with AMD FSR 1, Snapdragon GSR and GSR quality, NVIDIA NIS, linear and nearest filtering, plus sharpening. (@magarto, @The412Banner, [#128](https://github.com/Droid-Deck/DroidDeck/pull/128), [#198](https://github.com/Droid-Deck/DroidDeck/pull/198))
|
||||
- An **Effects** page adds presets, FXAA, Toon, CRT, NTSC and Fake HDR. Advanced controls cover sharpening, debanding, brightness, contrast, gamma and saturation, with choices saved between sessions. (@The412Banner, @xXJSONDeruloXx, [#198](https://github.com/Droid-Deck/DroidDeck/pull/198), [#247](https://github.com/Droid-Deck/DroidDeck/pull/247))
|
||||
- Anisotropic filtering and texture sharpness for DirectX 9–11 games, applied on the next game launch. (@The412Banner, [#198](https://github.com/Droid-Deck/DroidDeck/pull/198))
|
||||
- One **Resolution** menu before starting a session, with preset dimensions, Custom and **Match screen**. Resolution, image scaling and fullscreen compatibility have separate controls. (@xXJSONDeruloXx, [#247](https://github.com/Droid-Deck/DroidDeck/pull/247))
|
||||
|
||||
### Controls and sessions
|
||||
|
||||
- Standard Android picture-in-picture, opened manually or automatically when leaving a ready session, with second-screen controls restored when you return. (@xXJSONDeruloXx, [#156](https://github.com/Droid-Deck/DroidDeck/pull/156), [#195](https://github.com/Droid-Deck/DroidDeck/pull/195))
|
||||
- A quick toggle hides or restores on-screen controls. **Touch Off** ignores touches on the game picture while keeping Android controls usable. (@xXJSONDeruloXx, [#147](https://github.com/Droid-Deck/DroidDeck/pull/147), [#196](https://github.com/Droid-Deck/DroidDeck/pull/196))
|
||||
- Optional game vibration and on-screen Steam, Quick Access and keyboard buttons. (@xXJSONDeruloXx, [#194](https://github.com/Droid-Deck/DroidDeck/pull/194))
|
||||
- Steam session settings use tabs for Display, Controls, Games, Audio, Session and Steam. The session menu starts with Controller, gives second-screen modes their own page, and groups live display changes separately from settings for the next game launch. (@xXJSONDeruloXx, [#247](https://github.com/Droid-Deck/DroidDeck/pull/247))
|
||||
- Compact Steam Home and Quick Access shortcuts beside Stop. Switching to Desktop stays in Steam's own menu. (@xXJSONDeruloXx, [#188](https://github.com/Droid-Deck/DroidDeck/pull/188), [#196](https://github.com/Droid-Deck/DroidDeck/pull/196))
|
||||
- Back's double-press window is back to 500 ms, making Quick Access easier to open. (@xXJSONDeruloXx, [#193](https://github.com/Droid-Deck/DroidDeck/pull/193))
|
||||
|
||||
### Apps and launcher
|
||||
|
||||
- Android home-screen shortcuts for installed Steam games and added games. (@xXJSONDeruloXx, [#157](https://github.com/Droid-Deck/DroidDeck/pull/157))
|
||||
- Export per-game `.droiddeck` launch files and Steam icons for Android frontends, with optional automatic folder sync as the library changes. (@xXJSONDeruloXx, [#179](https://github.com/Droid-Deck/DroidDeck/pull/179))
|
||||
- Import x86 AppImages and run x86 Linux scripts and programs through FEX. Added apps have a **Use FEX** Auto/On/Off choice, and GitHub imports can use an x86_64 AppImage when no ARM64 build is available. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
|
||||
- Vulkan Qt Quick AppImages such as OpenNOW use Wayland to avoid X11 startup failures. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
|
||||
- Preview updates show recent published changes and how many builds behind you are when the installed Preview can be matched. (@xXJSONDeruloXx, [#159](https://github.com/Droid-Deck/DroidDeck/pull/159))
|
||||
- A new empty-library intro and DroidDeck wordmark on the Steam page, plus an option to reduce app animations. (@xXJSONDeruloXx, [#182](https://github.com/Droid-Deck/DroidDeck/pull/182), [#194](https://github.com/Droid-Deck/DroidDeck/pull/194))
|
||||
- The Linux file manager bookmarks the selected game library automatically. (@xXJSONDeruloXx, [#194](https://github.com/Droid-Deck/DroidDeck/pull/194))
|
||||
|
||||
### Languages and logs
|
||||
|
||||
- Simplified Chinese. (@Dantalian-dev, [#202](https://github.com/Droid-Deck/DroidDeck/pull/202))
|
||||
- Japanese, Korean and Traditional Chinese for Taiwan, Hong Kong and Macau. (@starwishHi, [#216](https://github.com/Droid-Deck/DroidDeck/pull/216))
|
||||
- Optional **Storage diagnostics** include allocation and storage timing in shared session logs to help investigate slow Steam installs. (@xXJSONDeruloXx, [#158](https://github.com/Droid-Deck/DroidDeck/pull/158))
|
||||
- Session logs retain their final output and include more detail when an AppImage or FEX program fails to start or crashes. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Steam's Deck controller not being found on handhelds with older kernels. (@xXJSONDeruloXx, [#146](https://github.com/Droid-Deck/DroidDeck/pull/146))
|
||||
- Physical and on-screen controllers doing nothing on ColorOS devices with linked app data directories. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242))
|
||||
- Game vibration going to the phone instead of the active physical controller. The device vibrator remains the fallback for on-screen controls or controllers without motors. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242))
|
||||
- A physical mouse stopping camera movement at the screen edge. Games that request a pointer lock now use Android's relative pointer capture, released while menus or PiP are open. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242))
|
||||
- Steam remaining on a black screen after idle sleep or Android session resume. (@xXJSONDeruloXx, @jmarti326, [#155](https://github.com/Droid-Deck/DroidDeck/pull/155), [#172](https://github.com/Droid-Deck/DroidDeck/pull/172))
|
||||
- Steam stuck at **Waiting for Steam** after an interrupted client update. After two starts without a frame, DroidDeck fetches the client again while preserving games, settings and user data; **Repair Steam client** also queues a manual repair. (@maxjivi05, [#225](https://github.com/Droid-Deck/DroidDeck/pull/225))
|
||||
- Play racing runtime removal. Removal now shows progress and verifies completion, and first-run setup respects the selected Steam channel and saved UI scaling. (@xXJSONDeruloXx, [#185](https://github.com/Droid-Deck/DroidDeck/pull/185))
|
||||
- GE-Proton and CachyOS Proton failing their protonfixes checks because Android blocks `/proc/sys/fs/file-max`. (@magarto, [#169](https://github.com/Droid-Deck/DroidDeck/pull/169))
|
||||
- DirectAudio not being selected correctly, mismatched FEX helpers surviving component swaps, and FEX or its runtime not being executable from an SD-card Steam library. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
|
||||
|
||||
[Full changelog](https://github.com/Droid-Deck/DroidDeck/compare/0.3.0...0.3.1)
|
||||
@@ -775,6 +775,10 @@ BUSCONF
|
||||
echo "== steam: client branch on disk was '$(cat "$steam_beta" 2>/dev/null)', set to '$steam_channel' to match -clientbeta"
|
||||
echo "$steam_channel" > "$steam_beta"
|
||||
fi
|
||||
if [ -z "${_bl_counted:-}" ] && [ -z "${BL_DESKTOP:-}" ]; then
|
||||
echo start >> "$steam_root/package/droiddeck-unconfirmed"
|
||||
_bl_counted=1
|
||||
fi
|
||||
step "starting the Steam client${BL_STEAM_UI:+ ($BL_STEAM_UI UI)}"
|
||||
"$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" "${_bl_cdp[@]}" -clientbeta "$steam_channel" \
|
||||
-no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 \
|
||||
|
||||
@@ -12,9 +12,33 @@ manifest=steam_client_${steam_channel}_linuxarm64
|
||||
steam_root=${XDG_DATA_HOME:-$HOME/.local/share}/Steam
|
||||
stamp=$steam_root/package/droiddeck-installed
|
||||
legacy_stamp=$steam_root/package/bannerlator-installed
|
||||
unconfirmed=$steam_root/package/droiddeck-unconfirmed
|
||||
repair_request=$HOME/.bl-steam-repair
|
||||
if [ -f "$legacy_stamp" ] && [ ! -f "$stamp" ]; then
|
||||
mv -f "$legacy_stamp" "$stamp" || true
|
||||
fi
|
||||
repair=""
|
||||
if [ -e "$repair_request" ]; then
|
||||
repair="requested"
|
||||
elif [ -f "$unconfirmed" ] && ! grep -qx repaired "$unconfirmed" \
|
||||
&& [ "$(grep -cx start "$unconfirmed")" -ge 2 ]; then
|
||||
repair="the client was started $(grep -cx start "$unconfirmed") times without showing anything"
|
||||
fi
|
||||
if [ -n "$repair" ]; then
|
||||
echo "== STEP $(date -u +%H:%M:%S) repairing the Steam client ($repair)"
|
||||
rm -f "$stamp" "$legacy_stamp" "$steam_root/.crash" "$steam_root/steam.pid" \
|
||||
"$steam_root"/package/steam_client_*.manifest "$steam_root"/package/steam_client_*.installed
|
||||
for part in "$steam_root"/package/*.zip.*; do
|
||||
[ -f "$part" ] || continue
|
||||
sum=${part##*.}
|
||||
sum=${sum%%_*}
|
||||
case $sum in
|
||||
*[!0-9a-f]*|"") continue ;;
|
||||
esac
|
||||
[ ${#sum} -eq 40 ] || continue
|
||||
[ "$(sha1sum "$part" | cut -d' ' -f1)" = "$sum" ] || rm -f "$part"
|
||||
done
|
||||
fi
|
||||
[ -x "$steam_root/steamrtarm64/steam" ] && [ -f "$stamp" ] && exit 0
|
||||
|
||||
work=$(mktemp -d)
|
||||
@@ -68,3 +92,7 @@ ln -sfn "$steam_root/linuxarm64" "$HOME/.steam/sdkarm64"
|
||||
ln -sfn "$steam_root/steamrtarm64" "$HOME/.steam/binarm64"
|
||||
ln -sfn "$steam_root/linux32" "$HOME/.steam/sdk32"
|
||||
echo "$version" > "$stamp"
|
||||
if [ -n "$repair" ]; then
|
||||
echo repaired > "$unconfirmed"
|
||||
rm -f "$repair_request"
|
||||
fi
|
||||
@@ -38,6 +38,10 @@ cp "$TEST_CDN/${2##*/}" "$4"
|
||||
sha = self.bin / 'sha256sum'
|
||||
sha.write_text('#!/bin/bash\nexec shasum -a 256 "$@"\n')
|
||||
sha.chmod(0o755)
|
||||
if shutil.which('sha1sum') is None:
|
||||
sha1 = self.bin / 'sha1sum'
|
||||
sha1.write_text('#!/bin/bash\nexec shasum -a 1 "$@"\n')
|
||||
sha1.chmod(0o755)
|
||||
self.env = dict(os.environ, HOME=str(self.home), XDG_DATA_HOME=str(self.data),
|
||||
PATH=str(self.bin) + os.pathsep + os.environ['PATH'],
|
||||
TEST_CDN=str(self.cdn), TEST_REQUESTS=str(self.requests))
|
||||
@@ -104,6 +108,65 @@ cp "$TEST_CDN/${2##*/}" "$4"
|
||||
self.assertEqual(0, self.install('publicbeta').returncode)
|
||||
self.assertEqual('publicbeta\n', (self.steam / 'package/beta').read_text())
|
||||
|
||||
def break_client(self, starts):
|
||||
self.assert_installed('steamdeck_publicbeta')
|
||||
self.requests.unlink()
|
||||
package = self.steam / 'package'
|
||||
(self.steam / 'steamrtarm64/steam').write_text('half updated')
|
||||
(package / 'steam_client_steamdeck_publicbeta_linuxarm64.manifest').write_text('manifest')
|
||||
(package / 'steam_client_steamdeck_publicbeta_linuxarm64.installed').write_text('installed')
|
||||
good = b'complete package'
|
||||
(package / ('bins_linuxarm64.zip.vz.' + hashlib.sha1(good).hexdigest() + '_16')).write_bytes(good)
|
||||
(package / ('bins_misc_linuxarm64.zip.vz.' + hashlib.sha1(b'whole').hexdigest() + '_5')).write_bytes(b'wh')
|
||||
(package / ('strings_all.zip.' + hashlib.sha1(b'strings').hexdigest())).write_bytes(b'strings')
|
||||
(self.steam / '.crash').write_text('')
|
||||
(package / 'droiddeck-unconfirmed').write_text('start\n' * starts)
|
||||
return package
|
||||
|
||||
def test_two_starts_with_nothing_shown_fetch_the_client_again(self):
|
||||
package = self.break_client(2)
|
||||
self.assert_installed('steamdeck_publicbeta')
|
||||
self.assertEqual('repaired\n', (package / 'droiddeck-unconfirmed').read_text())
|
||||
self.assertFalse((self.steam / '.crash').exists())
|
||||
self.assertEqual([], sorted(p.name for p in package.glob('steam_client_*')))
|
||||
self.assertEqual(['bins_linuxarm64.zip.vz.', 'strings_all.zip.'],
|
||||
sorted(p.name[:p.name.rindex('.') + 1] for p in package.glob('*.zip.*')))
|
||||
|
||||
def test_one_start_with_nothing_shown_is_left_alone(self):
|
||||
package = self.break_client(1)
|
||||
self.assertEqual(0, self.install().returncode)
|
||||
self.assertFalse(self.requests.exists())
|
||||
self.assertEqual('half updated', (self.steam / 'steamrtarm64/steam').read_text())
|
||||
self.assertTrue((package / 'steam_client_steamdeck_publicbeta_linuxarm64.installed').exists())
|
||||
|
||||
def test_a_repair_is_not_repeated_until_the_client_has_been_seen(self):
|
||||
package = self.break_client(2)
|
||||
self.assertEqual(0, self.install().returncode)
|
||||
self.requests.unlink()
|
||||
with (package / 'droiddeck-unconfirmed').open('a') as marker:
|
||||
marker.write('start\nstart\nstart\n')
|
||||
self.assertEqual(0, self.install().returncode)
|
||||
self.assertFalse(self.requests.exists())
|
||||
|
||||
def test_requested_repair_runs_once_and_is_retried_until_it_completes(self):
|
||||
package = self.break_client(0)
|
||||
(package / 'droiddeck-unconfirmed').unlink()
|
||||
request = self.home / '.bl-steam-repair'
|
||||
request.write_text('1\n')
|
||||
archive = self.cdn / 'steamdeck_publicbeta.zip'
|
||||
original = archive.read_bytes()
|
||||
archive.write_bytes(b'corrupt download')
|
||||
self.assertNotEqual(0, self.install().returncode)
|
||||
self.assertTrue(request.exists())
|
||||
self.assertFalse((package / 'droiddeck-installed').exists())
|
||||
archive.write_bytes(original)
|
||||
self.requests.unlink()
|
||||
self.assert_installed('steamdeck_publicbeta')
|
||||
self.assertFalse(request.exists())
|
||||
self.requests.unlink()
|
||||
self.assertEqual(0, self.install().returncode)
|
||||
self.assertFalse(self.requests.exists())
|
||||
|
||||
def test_invalid_channel_fails_before_any_download(self):
|
||||
result = self.install('../unknown')
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
|
||||
Reference in new issue
Block a user