diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 0df4cf372..fbdfb9670 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -365,7 +365,7 @@ class MainActivity : ComponentActivity() { val result = runCatching { kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@MainActivity, host, port, code) } } - val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless ADB pairing failed" } + val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging pairing failed" } ui.post { complete(error) } }, "wireless-adb-pair").start() }, @@ -378,7 +378,7 @@ class MainActivity : ComponentActivity() { onWirelessAdbApply = { host, port, enabled, complete -> Thread({ val result = runCatching { WirelessAdbFix.setChildProcessLimit(this@MainActivity, host, port, enabled) } - val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless ADB command failed" } + val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging command failed" } ui.post { if (error == null) refreshPhantomStatus() complete(error) @@ -388,7 +388,7 @@ class MainActivity : ComponentActivity() { onSetPhantomProcessLimit = { enabled, complete -> Thread({ val result = runCatching { WirelessAdbFix.setUsingSavedPairing(this@MainActivity, enabled) } - val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless ADB is unavailable" } + val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging is unavailable" } ui.post { if (error == null) refreshPhantomStatus() complete(error) @@ -518,6 +518,7 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() + com.droiddeck.launcher.ui.Motion.refresh(this) refreshPhantomStatus() // Swaps queued while a game ran on that Proton go in once nothing uses it (usually the // session has just ended). Cheap when nothing is queued. diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index be96e6328..1777066c5 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -273,6 +273,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun handleOnBackPressed() { // The PC keyboard closes first (B on a controller), then Back is the drawer again. when { + ::loading.isInitialized && loading.ended -> finish() drawerOpen -> drawerOpen = false pcKeyboardOpen -> pcKeyboardOpen = false else -> routeBackAction() @@ -299,6 +300,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { title = loadingTitle(), steam = loadingMode() == SessionService.MODE_STEAM, // As the drawer's Stop does: the service stops, and a start still installing is cancelled. onCancel = { SessionService.stop(this@SessionActivity); finish() }, + endedDetail = loading.endedDetail, + onRetry = { retrySession() }, + onShareLogs = { shareCurrentSessionLogs() }, + onClose = { finish() }, ) // Opening the drawer takes the controller away from the game: release its pad. androidx.compose.runtime.LaunchedEffect(drawerOpen) { @@ -599,6 +604,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionEvents.fail(code, problem) collectStartArtifacts("session start failed") loading.showEnded(problem) + focusEndedScreen() return@post } loading.percent = -1 @@ -888,19 +894,30 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun showEnded(status: Int, hint: String?) { - run { - if (status != 0) { - loading.showEnded( - "The session ended ($status)\n${SessionState.logFile?.path ?: "-"}" + - (if (hint != null) "\n\n$hint" else "") - ) - // A moment on screen, so a failure is readable rather than a flash of black; - // longer when there is advice to read. - Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (hint != null) 9000 else 4000) - } else { - finish() - } + if (status == 0) { + finish() + return } + // It stays until the user leaves: a failure read against a timer is a failure not read. + loading.showEnded( + hint ?: "Steam stopped unexpectedly. Share the logs with a bug report, or try again.", + "Exit status $status · ${SessionState.logFile?.path ?: "no log"}", + ) + focusEndedScreen() + } + + /** The ended screen's buttons take the pad: it is no longer driving a game. */ + private fun focusEndedScreen() { + drawerOpen = false + padBridge?.releaseAll() + uiHandler.post { sessionOverlay.requestFocus() } + } + + /** The same start again, in a fresh activity: the ended one keeps no session to reuse. */ + private fun retrySession() { + val again = Intent(intent) + finish() + startActivity(again) } /** @@ -956,6 +973,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } return true } + // The ended screen is a page of buttons: the pad drives them, and B leaves. + if (::loading.isInitialized && loading.ended) { + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B || event.keyCode == KeyEvent.KEYCODE_BACK) { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) finish() + return true + } + return super.dispatchKeyEvent(event) + } 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) { @@ -1443,6 +1468,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onResume() { super.onResume() + com.droiddeck.launcher.ui.Motion.refresh(this) refreshHomeApp() if (intent?.action == SessionService.ACTION_RESUME) { intent.action = null diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt index 90ea86478..236c17a00 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt @@ -40,9 +40,11 @@ object FrameGen { } /** "Off", "Win-FG 2×", "LSFG 3×" - for the main screen. */ - fun label(context: Context): String = when (engine(context)) { - ENGINE_WINFG -> "Win-FG ${multiplier(context)}×" - ENGINE_LSFG -> "LSFG ${multiplier(context)}×" + fun label(context: Context): String = label(engine(context), multiplier(context)) + + fun label(engine: String, multiplier: Int): String = when (engine) { + ENGINE_WINFG -> "Win-FG $multiplier×" + ENGINE_LSFG -> "LSFG $multiplier×" else -> "Off" } @@ -82,47 +84,5 @@ object FrameGen { return problem } - /** - * Off, or an engine at 2x/3x/4x; saved on pick, then [onChanged]. The LSFG rows are greyed - * and unpickable until Lossless Scaling is installed in the Steam client - the engine cannot - * run without its DLL, so offering it would only produce a setting that silently does nothing. - */ - fun showPicker(activity: android.app.Activity, onChanged: () -> Unit) { - val lsfgReady = LsfgNative.isInstalled(activity) - val lsfgNote = if (lsfgReady) "" else " - install Lossless Scaling in Steam" - val choices = listOf("Off", - "Win-FG 2×", "Win-FG 3×", "Win-FG 4×", - "LSFG 2×$lsfgNote", "LSFG 3×$lsfgNote", "LSFG 4×$lsfgNote") - val current = when (engine(activity)) { - ENGINE_WINFG -> multiplier(activity) - 1 - ENGINE_LSFG -> if (lsfgReady) multiplier(activity) + 2 else 0 - else -> 0 - } - val adapter = object : android.widget.ArrayAdapter( - activity, android.R.layout.simple_list_item_single_choice, choices) { - override fun isEnabled(position: Int) = lsfgReady || position < 4 - override fun areAllItemsEnabled() = lsfgReady - override fun getView(position: Int, convertView: android.view.View?, parent: android.view.ViewGroup): android.view.View { - val view = super.getView(position, convertView, parent) - view.isEnabled = isEnabled(position) - view.alpha = if (isEnabled(position)) 1f else 0.38f - return view - } - } - android.app.AlertDialog.Builder(activity) - .setTitle(com.droiddeck.launcher.R.string.frame_gen_title) - .setSingleChoiceItems(adapter, current) { dialog, which -> - when (which) { - 0 -> set(activity, ENGINE_OFF, 2) - in 1..3 -> set(activity, ENGINE_WINFG, which + 1) - else -> set(activity, ENGINE_LSFG, which - 2) - } - dialog.dismiss() - onChanged() - } - .setNeutralButton(android.R.string.ok, null) - .show() - } - private const val TAG = "FrameGen" } diff --git a/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt b/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt index 4295747b2..50929d87c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt @@ -24,6 +24,8 @@ class LoadingState(context: Context) { var elapsed by mutableStateOf("") var hint by mutableStateOf("") var ended by mutableStateOf(false) + /** The technical side of an ended session (exit status, log path), under the advice in [step]. */ + var endedDetail by mutableStateOf(null) private val hints = context.resources.getStringArray(com.droiddeck.launcher.R.array.loading_hints) private val startedAt = SystemClock.elapsedRealtime() @@ -35,10 +37,11 @@ class LoadingState(context: Context) { hint = hints[((seconds / 8) % hints.size).toInt()] } - fun showEnded(message: String) { + fun showEnded(message: String, detail: String? = null) { visible = true ended = true step = message + endedDetail = detail } /** Re-reads the end of the log and updates the line and the bar. */ diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index f23ff5cf4..7822ef44e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -325,7 +325,7 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = val hot = (src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value) && enabled Box( contentAlignment = Alignment.Center, - modifier = modifier.heightIn(min = 40.dp).clip(RoundedCornerShape(10.dp)) + modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp)) .background(if (hot) pal.signal else colors.surfaceVariant) .border(2.dp, if (hot) colors.onBackground else pal.line2, RoundedCornerShape(10.dp)) .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt index a424ba136..bbbb78892 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt @@ -41,12 +41,12 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre oscMode, note = "Auto shows all controls without a controller. Steam + QAM shows only those buttons.", onPick = a.onOsc, ) val tintOpen = host.open == "controller-tint" - SettingsRow("Colour", "Tint of the on-screen buttons", highlighted = tintOpen) { + SettingsRow("Color", "Tint of the on-screen buttons", highlighted = tintOpen) { Box { ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second ?: "Custom", tintOpen) { host.open = if (tintOpen) null else "controller-tint" } - AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = "Colour") { firstItemFocus -> + AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = "Color") { firstItemFocus -> ControllerPrefs.tints.forEachIndexed { index, (color, name) -> MenuItem(name, checked = c.tint == color, leading = { Swatch(color) }, focusRequester = if (index == 0) firstItemFocus else null) { a.onTint(color) @@ -59,7 +59,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre ChoiceRow(host, "controller-opacity", "Opacity", null, ControllerPrefs.opacities.map { it to "$it%" }, c.opacity, onPick = a.onOpacity) ChoiceRow(host, "controller-size", "Button size", "100% keeps the standard size", ControllerPrefs.sizes.map { it to "$it%" }, c.size, onPick = a.onSize) ToggleRow(host, "controller-stick-click", "Stick click", "Double-tap a stick and hold for L3 or R3", c.stickClick, onChange = a.onStickClick) - ToggleRow(host, "controller-adaptive", "Adaptive sticks", "A stick centres where your thumb lands and returns home when you let go", c.adaptiveSticks, onChange = a.onAdaptiveSticks) + ToggleRow(host, "controller-adaptive", "Adaptive sticks", "A stick centers where your thumb lands and returns home when you let go", c.adaptiveSticks, onChange = a.onAdaptiveSticks) SettingsRow("Layout", if (c.customLayout) "Custom positions saved" else "Placed for this screen's size and your grip") { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { SecondaryButton("Edit") { a.onEditLayout() } @@ -68,7 +68,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre } val remapped = c.mapping.count { (id, target) -> id != target } ActionRow("Button mapping", if (remapped == 0) "Every button sends its own input" else "$remapped of ${c.mapping.size} buttons remapped", "Configure", a.onMapping) - ActionRow("Reset controller", "Restore the default colour, opacity, size, stick behaviour, mapping and layout", "Reset", a.onResetAll) + ActionRow("Reset controller", "Restore the default color, opacity, size, stick behavior, mapping and layout", "Reset", a.onResetAll) } @Composable diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt new file mode 100644 index 000000000..2d533fd52 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt @@ -0,0 +1,22 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.runtime.Composable +import com.droiddeck.launcher.gpu.FrameGen + +/** + * The frame generation picker, anchored to whatever opened it (host key "fg"). One copy for Setup, + * a game's launch settings and the session drawer, so they say the same thing. + */ +@Composable +internal fun FrameGenMenu( + host: MenuHost, engine: String, multiplier: Int, lsfgReady: Boolean, + onPick: (engine: String, multiplier: Int) -> Unit, +) { + AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus -> + val need = if (lsfgReady) null else "Needs Lossless Scaling installed in Steam" + fun pick(e: String, m: Int) { onPick(e, m); host.open = null } + MenuItem("Off", checked = engine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { pick(FrameGen.ENGINE_OFF, 2) } + for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_WINFG, m), checked = engine == FrameGen.ENGINE_WINFG && multiplier == m) { pick(FrameGen.ENGINE_WINFG, m) } + for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_LSFG, m), checked = engine == FrameGen.ENGINE_LSFG && multiplier == m, enabled = lsfgReady, detail = need) { pick(FrameGen.ENGINE_LSFG, m) } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index b1f0d8c17..3d6424001 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -223,10 +223,11 @@ private fun Content( if (e.installed) { Rise(3) { Actions { - PrimaryButton("Open ${e.name}", enabled = s.ready && !s.busy, main = true) { a.onEmulator(e) } + PrimaryButton("Open ${e.name}", enabled = !s.busy, main = true) { a.onEmulator(e) } + BusyChip(s) SecondaryButton("ROMs folder", onClick = a.onRoms) if (pkg != null) SecondaryButton( - if (pkg.kind == "appimage") "Remove" else "Forget", + if (pkg.kind == "appimage") "Remove" else "Hide", enabled = s.packageBusyId == null && !s.sessionRunning, ) { a.onRemovePackage(pkg.id) } } @@ -274,7 +275,7 @@ private fun Content( if (s.packagePercent >= 0) LinearProgressIndicator(progress = { s.packagePercent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp)) else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)) } - if (pkg?.kind == "tar") Rise(5) { Note("Forgetting this package hides it from Desktop; its files remain in the Linux runtime.") } + if (pkg?.kind == "tar") Rise(5) { Note("Hiding this package takes it off Desktop; its files stay in the Linux runtime.") } } } } @@ -296,8 +297,8 @@ private fun Content( Spacer(Modifier.height(14.dp)) Actions { Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp)) - PrimaryButton("Launch in ${e.name}", enabled = s.ready && !s.busy, main = true) { a.onRom(g) } - ActionChip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false) + PrimaryButton("Launch in ${e.name}", enabled = !s.busy, main = true) { a.onRom(g) } + if (s.busy) BusyChip(s) else ActionChip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false) } } if (g.art != null && !narrow) Poster(g.art, g.name, Modifier.width(detailPosterWidth)) @@ -315,7 +316,7 @@ private fun Content( } } } - else -> Note("Select an item.") + else -> Note("That page is gone. Pick a section on the left.") } } } @@ -331,6 +332,7 @@ private fun DesktopCard(s: FrontEndState, a: FrontEndActions) { // Enabled without a runtime or the desktop: the session's loading screen installs them first. PrimaryButton(if (s.desktopInstalled) "Open desktop" else "Install & open desktop", enabled = !s.busy, main = true, onClick = a.onDesktop) Cog(onClick = a.onDesktopSettings) + BusyChip(s) } } Column( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index 2dcb0946f..f607c5c0e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -62,16 +62,29 @@ import com.droiddeck.launcher.frontend.Library @Composable internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, onSelect: (String) -> Unit, modifier: Modifier) { val games = remember(s.steamGames) { s.steamGames.sortedByDescending { it.lastPlayed } } - val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull() ?: return - val host = rememberMenuHost() val narrow = LocalNarrowPane.current + val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull() + if (current == null) { + Column(modifier = modifier.padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { + Rise(0) { PageHeader("Games") } + Rise(1) { Note("Games you install in Steam show up here. Open Steam, install one, and it appears after the session.") } + Rise(2) { + Actions { PrimaryButton("Play Steam", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp), onClick = a.onPlay) } + } + } + return + } + val host = rememberMenuHost() if (games.size == 1) { Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { Rise(0) { Row(verticalAlignment = Alignment.Bottom) { GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) { GameHeroCopy(current, if (narrow) 28.sp else 38.sp) - PrimaryButton("Launch", enabled = s.ready && !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) } + Actions { + PrimaryButton("Launch", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) } + BusyChip(s) + } } if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp)) } @@ -92,7 +105,10 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o ) { GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) { GameHeroCopy(current, if (narrow) 24.sp else 32.sp) - PrimaryButton("Launch", enabled = s.ready && !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) } + Actions { + PrimaryButton("Launch", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) } + BusyChip(s) + } } SectionTitle("Launch settings", null) LaunchSettings(s, a, host) @@ -183,6 +199,12 @@ internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit } } +/** Why a launch button is greyed out: the runtime is being worked on. Nothing when it is not. */ +@Composable +internal fun BusyChip(s: FrontEndState) { + if (s.busy) ActionChip(if (s.percent >= 0) "Runtime busy · ${s.percent}%" else "Runtime busy", ok = false) +} + /** Whether Steam can start, said where Play is rather than only in Setup. */ @Composable internal fun RuntimeChip(s: FrontEndState) = when { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt index 64ca8571f..41f38fe41 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -53,6 +53,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -78,7 +80,7 @@ import com.droiddeck.launcher.core.PhantomProcessLimit /** What the pad's face buttons do here, along the bottom edge as on a console. */ @Composable -internal fun ControllerHints() { +internal fun ControllerHints(select: String = "Select", tabs: Boolean = false) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current Column(modifier = Modifier.fillMaxWidth()) { @@ -88,7 +90,8 @@ internal fun ControllerHints() { horizontalArrangement = Arrangement.spacedBy(18.dp, Alignment.End), modifier = Modifier.fillMaxWidth().height(34.dp).background(colors.surface).padding(horizontal = 20.dp), ) { - HintGlyph("A", "Select") + if (tabs) HintGlyph("LB RB", "Tabs") + HintGlyph("A", select) HintGlyph("B", "Back") } } @@ -98,7 +101,12 @@ internal fun ControllerHints() { private fun HintGlyph(button: String, action: String) { val colors = MaterialTheme.colorScheme Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) { + // Face buttons are round; bumpers are a wider keycap. + val shape = if (button.length == 1) CircleShape else RoundedCornerShape(6.dp) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.height(20.dp).widthIn(min = 20.dp).clip(shape).background(colors.onBackground).padding(horizontal = if (button.length == 1) 0.dp else 6.dp), + ) { Text(button, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background) } Text(action, fontSize = 13.sp, color = colors.onSurfaceVariant) @@ -133,12 +141,13 @@ internal fun SideRail( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()), ) { - if (s.isHomeApp) RailItem("Apps", Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") } RailItem("Steam", Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, onFocus = { onFocusSelect("steam") }) { onSelect("steam") } - // Games appears once there is one: an empty list is no place to land. - if (s.steamGames.isNotEmpty()) RailItem("Games", Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, onFocus = { onFocusSelect("games") }) { onSelect("games") } + // Always there, so the items below it never move; an empty library says how to fill it. + RailItem("Games", Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, onFocus = { onFocusSelect("games") }) { onSelect("games") } RailItem("Desktop", Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") } RailItem("Components", Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, onFocus = { onFocusSelect("components") }) { onSelect("components") } + // Home mode's extra section goes last, so it shifts nothing above it. + if (s.isHomeApp) RailItem("Apps", Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") } RailItem("Setup", Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") } } AnimatedVisibility(s.busy, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { @@ -222,7 +231,9 @@ private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, on val focused by src.collectIsFocusedAsState() val hovered by src.collectIsHoveredAsState() val pulse = rememberInfiniteTransition(label = "pulse") - val ringScale by pulse.animateFloat(0.4f, 1.6f, infiniteRepeatable(tween(1600, easing = Motion.Ease), RepeatMode.Restart), label = "ring") + val ringScale by pulse.animateFloat(0.4f, 1.6f, infiniteRepeatable(tween(Motion.ms(1600).coerceAtLeast(1), easing = Motion.Ease), RepeatMode.Restart), label = "ring") + // Animations off: the live dot alone, no pulsing ring. + val still = Motion.scale == 0f Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp), @@ -238,7 +249,7 @@ private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, on .padding(vertical = if (iconOnly) 15.dp else if (compact) 6.dp else 9.dp, horizontal = 4.dp), ) { Box(modifier = Modifier.size(14.dp), contentAlignment = Alignment.Center) { - Box(modifier = Modifier.size(14.dp).graphicsLayer { scaleX = ringScale; scaleY = ringScale; alpha = (1.6f - ringScale) / 1.2f }.border(1.5.dp, pal.good, CircleShape)) + if (!still) Box(modifier = Modifier.size(14.dp).graphicsLayer { scaleX = ringScale; scaleY = ringScale; alpha = (1.6f - ringScale) / 1.2f }.border(1.5.dp, pal.good, CircleShape)) Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(pal.good)) } // Icons only: the live dot alone says something is running; its description says what. diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 422d1a155..307ae05b2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -71,6 +71,7 @@ import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus import com.droiddeck.launcher.session.SessionPrefs import kotlin.math.roundToInt +import kotlinx.coroutines.withTimeoutOrNull class FrontEndState( val installed: String?, @@ -147,10 +148,10 @@ class FrontEndActions( val onCheckLatestBuild: () -> Unit = {}, val onRefreshPhantomStatus: () -> Unit = {}, val onOpenDeveloperOptions: (Int?) -> Unit = {}, - val onWirelessAdbPair: (String, Int, String, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless ADB is unavailable") }, + val onWirelessAdbPair: (String, Int, String, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless debugging is unavailable") }, val onFindWirelessAdbPort: (String, (Int?) -> Unit) -> Unit = { _, done -> done(null) }, - val onWirelessAdbApply: (String, Int, Boolean, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless ADB is unavailable") }, - val onSetPhantomProcessLimit: (Boolean, (String?) -> Unit) -> Unit = { _, done -> done("Wireless ADB is unavailable") }, + val onWirelessAdbApply: (String, Int, Boolean, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless debugging is unavailable") }, + val onSetPhantomProcessLimit: (Boolean, (String?) -> Unit) -> Unit = { _, done -> done("Wireless debugging is unavailable") }, val onCopyPhantomCommand: (Boolean) -> Unit = {}, val onDismissPhantomGate: () -> Unit = {}, val controller: ControllerActions? = null, @@ -158,6 +159,10 @@ class FrontEndActions( internal object Motion { var scale = 1f + /** The system's animator scale, re-read on each resume so "Remove animations" applies without a restart. */ + fun refresh(context: android.content.Context) { + scale = Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + } val Ease = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f) fun ms(base: Int) = (base * scale).roundToInt() fun tw(base: Int, delay: Int = 0): FiniteAnimationSpec = if (scale == 0f) snap() else tween(ms(base), ms(delay), Ease) @@ -229,7 +234,8 @@ internal class FrontFocus { // and the last one focused. val items = HashMap() val attached = HashMap() - var last: String? = null + // State, so the controller hints follow it: A launches from a game row but selects elsewhere. + var last by mutableStateOf(null) // The first tile of the page's grid: Down from the page's buttons goes to it, not to whichever // tile happens to sit under the button. val firstTile = FocusRequester() @@ -278,6 +284,22 @@ internal fun Modifier.paneItem(id: String): Modifier { internal val LocalFrontFocus = staticCompositionLocalOf { null } +/** The pane's exit animation (170ms) and a frame's margin: a new page is in after this. */ +private const val PAGE_EXIT_MS = 200 + +/** + * Asks [target] for focus once a frame until [done] (or a focus request lands), for at most half a + * second: a control can take focus only once it is laid out, and that is a frame or two, not a + * fixed number of milliseconds. + */ +internal suspend fun focusWithinFrames(done: () -> Boolean, target: () -> FocusRequester) { + repeat(30) { + androidx.compose.runtime.withFrameNanos { } + if (done()) return + if (runCatching { target().requestFocus() }.isSuccess && done()) return + } +} + @Composable fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null) { val frontFocus = remember { FrontFocus() } @@ -323,11 +345,11 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com selected = if (selected.startsWith("emu:")) "desktop" else "emu:" + selected.removePrefix("rom:").substringBefore(':') } LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" } - // The last game uninstalled takes the Games tab with it. + // The last game uninstalled leaves the Games tab on its empty state. LaunchedEffect(s.steamGames.isEmpty()) { - if (s.steamGames.isEmpty() && (selected == "games" || selected.startsWith("app:"))) selected = "steam" + if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games" } - remember { Motion.scale = Settings.Global.getFloat(ctx.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f); true } + remember { Motion.refresh(ctx); true } val railSelection = when { s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup" @@ -367,32 +389,27 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com LaunchedEffect(processSettingsPageVisible) { if (processSettingsPageVisible) return@LaunchedEffect snapshotFlow { window.isWindowFocused }.first { it } - repeat(20) { - if (anyFocused) return@LaunchedEffect - if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard) - val target = if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.railFor(railSelection) - runCatching { target.requestFocus() } - kotlinx.coroutines.delay(100) - } + if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard) + focusWithinFrames({ anyFocused }) { if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.railFor(railSelection) } } // A tile or button that opens a page goes away with the page it was on, and focus with it; // the pad then had nothing to move from (a press landed back on the rail's first item). So // once the new page is in, a controller lands on its main button. LaunchedEffect(selected, s.pageKey, processSettingsPageVisible) { if (processSettingsPageVisible) return@LaunchedEffect - kotlinx.coroutines.delay(450) - if (!anyFocused && inputModeManager.inputMode == InputMode.Keyboard) runCatching { - if (frontFocus.primaryAttached > 0) frontFocus.primary.requestFocus() - else frontFocus.railFor(railSelection).requestFocus() - } + // Past the old page's exit (170ms), then the first frame the new page takes focus. + kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong()) + if (anyFocused || inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect + focusWithinFrames({ anyFocused }) { if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.railFor(railSelection) } } // A rail item picked with a controller moves on into its page, once the page is in. LaunchedEffect(railPicks) { if (railPicks == 0 || processSettingsPageVisible) return@LaunchedEffect - kotlinx.coroutines.delay(450) - if (inputModeManager.inputMode == InputMode.Keyboard && frontFocus.primaryAttached > 0) runCatching { - frontFocus.paneEntry().requestFocus() - } + kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong()) + if (inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect + // Waits for the new page's main button rather than a fixed guess at how long it takes. + withTimeoutOrNull(1_000) { snapshotFlow { frontFocus.primaryAttached > 0 }.first { it } } ?: return@LaunchedEffect + focusWithinFrames({ frontFocus.focusedRail == null && anyFocused }) { frontFocus.paneEntry() } } val paneFocus = Modifier .focusProperties { enter = { frontFocus.paneEntry() } } @@ -417,7 +434,13 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com AnimatedVisibility( inputModeManager.inputMode == InputMode.Keyboard, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), - ) { ControllerHints() } + ) { + val onGame = frontFocus.focusedRail == null && frontFocus.last?.startsWith("game:") == true + ControllerHints( + select = if (onGame) "Launch" else "Select", + tabs = railSelection == "setup" && s.pageKey == null && frontFocus.focusedRail == null, + ) + } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index debe97504..debd67444 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -65,12 +65,10 @@ import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.input.InputMode import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.droiddeck.launcher.gpu.FrameGen import com.droiddeck.launcher.core.DeviceSupport import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus @@ -209,8 +207,8 @@ internal fun SetupPanel( ) { SecondaryButton(runtime, enabled = !s.busy, compact = true, onClick = a.onRuntime) } CheckRow( if (limitBlocks) CheckState.WARN else CheckState.OK, - if (limitBlocks) "Android may close Steam" else "Android process limit", - if (limitBlocks) "“Restrict child processes” is on - it takes a minute to turn off" else PhantomProcessLimit.title(s.phantomProcessStatus), + "Child-process limit", + if (limitBlocks) "On · Android may close Steam. Turning it off takes a minute" else PhantomProcessLimit.title(s.phantomProcessStatus), ) { if (limitBlocks) PrimaryButton(if (showLimitDetails) "Hide" else "Fix it", compact = true) { showLimitDetails = !showLimitDetails } else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) { @@ -218,32 +216,25 @@ internal fun SetupPanel( } } AnimatedVisibility(showLimitDetails, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { + // One sentence and at most three buttons. The computer route and its + // raw command live on the full page Wireless debugging opens. Column(modifier = Modifier.fillMaxWidth().padding(start = 56.dp, end = 14.dp, top = 4.dp, bottom = 10.dp)) { Text( - PhantomProcessLimit.instructions(s.phantomProcessStatus), + if (limitBlocks) PhantomProcessLimit.gateInstructions(s.phantomProcessStatus) + else PhantomProcessLimit.instructions(s.phantomProcessStatus), fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp), ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 6.dp)) { - SecondaryButton("Developer options", compact = true, onClick = onOpenDeveloperOptions) - SecondaryButton("Check again", compact = true, onClick = a.onRefreshPhantomStatus) - SecondaryButton("Copy ADB command", compact = true, onClick = { a.onCopyPhantomCommand(false) }) - } - Text( - PhantomProcessLimit.ADB_COMMAND, fontSize = 12.sp, fontFamily = FontFamily.Monospace, - color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp), - ) - if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) { - Text( - "With wireless debugging paired, DroidDeck can switch the limit itself. Turning it off is recommended for Steam sessions.", - fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp), - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 6.dp)) { - SecondaryButton("Turn limit off", compact = true, enabled = !processLimitBusy && s.phantomProcessStatus != PhantomProcessStatus.DISABLED) { setProcessLimit(false) } - SecondaryButton("Turn limit on", compact = true, enabled = !processLimitBusy && s.phantomProcessStatus != PhantomProcessStatus.ENABLED) { setProcessLimit(true) } + Actions { + if (limitBlocks) { + PrimaryButton("Developer options", compact = true, onClick = onOpenDeveloperOptions) + SecondaryButton("Use Wireless debugging", compact = true, enabled = !processLimitBusy) { setProcessLimit(false) } + SecondaryButton("Check again", compact = true, onClick = a.onRefreshPhantomStatus) + } else if (s.phantomProcessStatus == PhantomProcessStatus.DISABLED) { + SecondaryButton("Turn limit on", compact = true, enabled = !processLimitBusy) { setProcessLimit(true) } } - if (processLimitBusy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) - processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) } } + if (processLimitBusy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) + processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) } } } CheckRow( @@ -278,15 +269,13 @@ internal fun SetupPanel( FrameGenMenu(s, a, host) } } - SettingsRow("Session logs", "${if (s.logsEnabled) "Enabled" else "Disabled"} · logs are saved after each session") { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - SecondaryButton(if (s.logsEnabled) "Turn off" else "Turn on") { a.onLogs() } - SecondaryButton("Share latest") { a.onShareLogs() } - } - } - SettingsRow("Offline mode", s.offlineAccount?.let { if (s.offline) "Enabled for $it" else "Signed in as $it" } ?: "Sign in to Steam first") { - SecondaryButton(if (s.offline) "Turn off" else "Turn on", enabled = s.offlineAccount != null) { a.onOffline() } - } + ToggleRow(host, "logs", "Session logs", "Saved after each session", s.logsEnabled) { a.onLogs() } + ActionRow("Latest session logs", "Send them with a bug report", "Share logs", a.onShareLogs) + ToggleRow( + host, "offline", "Offline mode", + s.offlineAccount?.let { "Signed in as $it" } ?: "Sign in to Steam first", + s.offline, enabled = s.offlineAccount != null, + ) { a.onOffline() } } } 3 -> { @@ -337,7 +326,7 @@ private fun ToolGrid(s: FrontEndState, a: FrontEndActions) { val columns = if (LocalNarrowPane.current) 2 else 4 val tools = listOf( ToolSpec(Icons.Outlined.Folder, "Files", "Browse and manage files", a.onFiles), - ToolSpec(Icons.Outlined.Extension, "Compatibility tools", "Install ARM64 Proton builds", a.onProtons), + ToolSpec(Icons.Outlined.Extension, "Proton versions", "Install ARM64 Proton builds", a.onProtons), ToolSpec(Icons.Outlined.Speed, "Performance", "CPU core assignment", a.onPerformance), ToolSpec(Icons.Outlined.VideogameAsset, "ROMs folder", s.romsDir ?: "Choose where emulator games are stored", a.onRoms), ) @@ -380,12 +369,7 @@ private fun ToolCard(t: ToolSpec, modifier: Modifier) { @Composable private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) { - AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus -> - val need = if (s.lsfgReady) null else "Install Lossless Scaling in Steam" - MenuItem("Off", checked = s.frameGenEngine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { a.onFrameGenPick(FrameGen.ENGINE_OFF, 2); host.open = null } - for (m in 2..4) MenuItem("Win-FG ${m}×", checked = s.frameGenEngine == FrameGen.ENGINE_WINFG && s.frameGenMultiplier == m) { a.onFrameGenPick(FrameGen.ENGINE_WINFG, m); host.open = null } - for (m in 2..4) MenuItem("LSFG ${m}×", checked = s.frameGenEngine == FrameGen.ENGINE_LSFG && s.frameGenMultiplier == m, enabled = s.lsfgReady, detail = need) { a.onFrameGenPick(FrameGen.ENGINE_LSFG, m); host.open = null } - } + FrameGenMenu(host, s.frameGenEngine, s.frameGenMultiplier, s.lsfgReady, a.onFrameGenPick) } /** @@ -398,7 +382,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost val columns = if (LocalNarrowPane.current) 2 else 3 val controller = a.controller val cards = buildList<@Composable (Modifier) -> Unit> { - add { m -> SettingCard("Compatibility", "Proton & components", "card:components", m) { a.onComponents(true) } } + add { m -> SettingCard("Components", "FEX, DXVK, VKD3D", "card:components", m) { a.onComponents(true) } } add { m -> Box(m) { SettingCard("Frame generation", s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt index d365ca0aa..53339b3dd 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -111,7 +111,7 @@ internal fun BackLink(label: String, compact: Boolean = false, onClick: () -> Un val shape = RoundedCornerShape(22.dp) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.paneItem("back:$label").heightIn(min = if (compact) 36.dp else 44.dp) + modifier = Modifier.paneItem("back:$label").heightIn(min = if (compact) 44.dp else 48.dp) .clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f)) .border(2.dp, if (hot) pal.signal else Color.Transparent, shape) @@ -166,6 +166,8 @@ internal fun PrimaryButton( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier.then(track) + // 48dp to touch on a handheld; compact rows keep 44. + .heightIn(min = if (compact) 44.dp else 48.dp) .then(if (frontFocus != null) Modifier.focusRequester(frontFocus.primary).onFocusChanged { if (it.isFocused) frontFocus.last = FrontFocus.PRIMARY } else Modifier) .graphicsLayer { scaleX = scale; scaleY = scale; shadowElevation = if (enabled) lift.dp.toPx() else 0f; shape = Shape12; clip = false; ambientShadowColor = pal.signal; spotShadowColor = pal.signal } .clip(Shape12) @@ -202,7 +204,8 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "secEdge") val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f), Motion.tw(250), label = "secFill") Box( - modifier = modifier.paneItem("btn:$text").downToFirstTile().graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).border(1.dp, edge, Shape12) + contentAlignment = Alignment.Center, + modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).border(1.dp, edge, Shape12) .alpha(if (enabled) 1f else 0.5f) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) @@ -211,7 +214,7 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo } @Composable -internal fun Cog(size: androidx.compose.ui.unit.Dp = 42.dp, onClick: () -> Unit) { +internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val src = remember { MutableInteractionSource() } val hot = rememberHot(src) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PhantomProcessGatePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/PhantomProcessGatePage.kt index c11ae3ab9..36d880365 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PhantomProcessGatePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PhantomProcessGatePage.kt @@ -94,7 +94,7 @@ private fun CompactGateInstructions( ) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { PrimaryButton("Open Developer options", compact = true, onClick = onOpenDeveloperOptions) - SecondaryButton("Use on-device ADB", compact = true, onClick = onSetUpWirelessAdb) + SecondaryButton("Use Wireless debugging", compact = true, onClick = onSetUpWirelessAdb) } } } @@ -133,7 +133,7 @@ private fun GateInstructions( color = MaterialTheme.colorScheme.onSurfaceVariant, ) PrimaryButton("Open Developer options", onClick = onOpenDeveloperOptions) - SecondaryButton("Use on-device ADB", onClick = onSetUpWirelessAdb) + SecondaryButton("Use Wireless debugging", onClick = onSetUpWirelessAdb) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt index 2b0aaf5f4..1d94234d5 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt @@ -33,7 +33,7 @@ fun ProtonPage( val colors = MaterialTheme.colorScheme SettingsPage( host, - title = "Compatibility tools", + title = "Proton versions", eyebrow = "Setup", lede = "Download and install an ARM64 Proton build now, then select it per game in Steam > Properties > Compatibility.", onBack = onBack, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt index acbd90281..0f965d56f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset @@ -125,6 +126,11 @@ private fun readableStep(step: String): Boolean { fun LoadingOverlay( step: String, percent: Int, elapsed: String, hint: String, ended: Boolean, title: String = "Starting Steam", steam: Boolean = true, onCancel: (() -> Unit)? = null, + /** An ended session's exit status and log path, shown small under the advice. */ + endedDetail: String? = null, + onRetry: (() -> Unit)? = null, + onShareLogs: (() -> Unit)? = null, + onClose: (() -> Unit)? = null, ) { val colors = MaterialTheme.colorScheme val stages = remember(steam) { loadStages(steam) } @@ -176,6 +182,12 @@ fun LoadingOverlay( modifier = Modifier.padding(top = 6.dp).widthIn(max = 520.dp), ) if (!ended) LoadSegments(stages.size, active, percent, Modifier.padding(top = 18.dp)) + if (ended && endedDetail != null) Text( + endedDetail, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant.copy(alpha = 0.8f), + textAlign = TextAlign.Center, maxLines = 3, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 12.dp).widthIn(max = 520.dp), + ) + if (ended) EndedActions(onRetry, onShareLogs, onClose) } if (!ended && hint.isNotEmpty()) Text( hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant.copy(alpha = 0.8f), textAlign = TextAlign.Center, @@ -186,13 +198,31 @@ fun LoadingOverlay( } } +/** What to do about an ended session: nothing leaves on a timer, and the pad starts on Try again. */ +@Composable +private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onClose: (() -> Unit)?) { + val first = remember { FocusRequester() } + LaunchedEffect(Unit) { + androidx.compose.runtime.withFrameNanos { } + runCatching { first.requestFocus() } + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 22.dp)) { + var firstUsed = false + fun claim(): Modifier = if (firstUsed) Modifier else { firstUsed = true; Modifier.focusRequester(first) } + if (onRetry != null) PrimaryButton("Try again", modifier = claim(), onClick = onRetry) + if (onShareLogs != null) SecondaryButton("Share logs", modifier = claim(), onClick = onShareLogs) + if (onClose != null) SecondaryButton("Back", modifier = claim(), onClick = onClose) + } +} + /** One segment per stage: done ones filled, the current one filling (or running, with no percent). */ @Composable private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifier = Modifier) { val pal = LocalPalette.current val track = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.12f) val run = rememberInfiniteTransition(label = "segment") - .animateFloat(-0.4f, 1f, infiniteRepeatable(tween(1300, easing = FastOutSlowInEasing)), label = "run") + .animateFloat(-0.4f, 1f, infiniteRepeatable(tween(Motion.ms(1300).coerceAtLeast(1), easing = FastOutSlowInEasing)), label = "run") + val still = Motion.scale == 0f Row( horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier.semantics { contentDescription = "Step ${active + 1} of $count" }, @@ -204,6 +234,7 @@ private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifi .drawBehind { if (i != active) return@drawBehind if (percent >= 0) drawRect(pal.signal, size = Size(size.width * percent.coerceIn(0, 100) / 100f, size.height)) + else if (still) drawRect(pal.signal.copy(alpha = 0.5f)) else drawRect(pal.signal, topLeft = Offset(size.width * run.value, 0f), size = Size(size.width * 0.4f, size.height)) }, ) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 82872433f..a9efc01c9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -316,20 +316,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen, ) val fgOpen = host.open == "fg" - val fgLabel = when (a.frameGenEngine) { - FrameGen.ENGINE_WINFG -> "Win-FG ${a.frameGenMultiplier}×" - FrameGen.ENGINE_LSFG -> "LSFG ${a.frameGenMultiplier}×" - else -> "Off" - } + val fgLabel = FrameGen.label(a.frameGenEngine, a.frameGenMultiplier) SettingsRow("Frame generation", null, highlighted = fgOpen) { Box { ValueChip(fgLabel, fgOpen, modifier = focus.track(page, "fg")) { host.open = if (fgOpen) null else "fg" } - AnchoredMenu(fgOpen, onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus -> - val need = if (a.lsfgReady) null else "Requires Lossless Scaling" - MenuItem("Off", checked = a.frameGenEngine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { a.onFrameGenPick(FrameGen.ENGINE_OFF, 2); host.open = null } - for (m in 2..4) MenuItem("Win-FG ${m}×", checked = a.frameGenEngine == FrameGen.ENGINE_WINFG && a.frameGenMultiplier == m) { a.onFrameGenPick(FrameGen.ENGINE_WINFG, m); host.open = null } - for (m in 2..4) MenuItem("LSFG ${m}×", checked = a.frameGenEngine == FrameGen.ENGINE_LSFG && a.frameGenMultiplier == m, enabled = a.lsfgReady, detail = need) { a.onFrameGenPick(FrameGen.ENGINE_LSFG, m); host.open = null } - } + FrameGenMenu(host, a.frameGenEngine, a.frameGenMultiplier, a.lsfgReady, a.onFrameGenPick) } } } @@ -349,16 +340,16 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } SettingsGroup("Keyboard") { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp)) { - DrawerOutlineButton("Hardware", modifier = Modifier.weight(1f).height(42.dp).then(focus.track(page, "hardware"))) { + DrawerOutlineButton("PC keyboard", modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "hardware"))) { host.open = null; a.onHardwareKeyboard() } - DrawerOutlineButton("Android", modifier = Modifier.weight(1f).height(42.dp).then(focus.track(page, "android"))) { + DrawerOutlineButton("Android", modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "android"))) { host.open = null; a.onKeyboard() } } } if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup("Second screen") { - ChoiceRow(host, "second-screen-mode", "Controls", null, + ChoiceRow(host, "second-screen-mode", "Shows", null, listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL).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", "Display", null, @@ -420,7 +411,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } SettingsGroup("Support") { SettingsRow("Session logs", "Send this session's logs with a bug report") { - DrawerOutlineButton("Send logs", modifier = focus.track(page, "share-logs")) { + DrawerOutlineButton("Share logs", modifier = focus.track(page, "share-logs")) { host.open = null a.onShareLogs() } @@ -509,7 +500,7 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit val shape = RoundedCornerShape(20.dp) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = modifier.heightIn(min = 40.dp).semantics { contentDescription = "Stop session" } + modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = "Stop session" } .clip(shape).background(fill).border(if (hot) 2.dp else 1.dp, colors.error.copy(alpha = if (hot) 0.9f else 0.55f), shape) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick) .controllerConfirm(onClick = onClick) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt index 5c71788bc..1b36b1c4b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -19,6 +19,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.semantics.Role 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.background import androidx.compose.foundation.border @@ -149,7 +151,7 @@ internal fun TabStrip( .border(2.dp, if (!hot) Color.Transparent else if (on) colors.onBackground else pal.signal, tabShape) .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick) .controllerConfirm(onClick = pick) - .padding(horizontal = 16.dp, vertical = 9.dp), + .padding(horizontal = 16.dp, vertical = 12.dp), ) } } @@ -301,6 +303,7 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = modifier .widthIn(min = LocalChipMinWidth.current) + .heightIn(min = 44.dp) .clip(RoundedCornerShape(10.dp)) .background(colors.surfaceVariant) .border(1.dp, edge, RoundedCornerShape(10.dp)) @@ -385,7 +388,46 @@ fun ChoiceRow( @Composable fun ToggleRow(host: MenuHost, key: String, label: String, hint: String?, checked: Boolean, enabled: Boolean = true, chipModifier: Modifier = Modifier, onChange: (Boolean) -> Unit) = - ChoiceRow(host, key, label, hint, listOf(true to "On", false to "Off"), checked, enabled, chipModifier = chipModifier, onPick = onChange) + SettingsRow(label, hint) { + ToggleSwitch(checked, enabled, label, chipModifier) { host.open = null; onChange(it) } + } + +/** An on/off switch: one tap or one A press flips it, where a menu of On and Off took three. */ +@Composable +fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = null, modifier: Modifier = Modifier, onChange: (Boolean) -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + val track by animateColorAsState(if (checked) pal.signal else colors.surfaceVariant, Motion.tw(180), label = "switchTrack") + val edge by animateColorAsState(if (hot) pal.signal else if (checked) pal.signal else pal.line2, Motion.tw(180), label = "switchEdge") + val knob by animateFloatAsState(if (checked) 1f else 0f, Motion.sp(0.7f), label = "switchKnob") + val flip = { onChange(!checked) } + val shape = RoundedCornerShape(99.dp) + // 48dp tall to touch; the visible track sits inside it. + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .heightIn(min = 48.dp).widthIn(min = 64.dp) + .alpha(if (enabled) 1f else 0.5f) + .hoverable(src) + .clickable(interactionSource = src, indication = null, enabled = enabled, role = Role.Switch, onClick = flip) + .controllerConfirm(enabled = enabled, onClick = flip) + .semantics { stateDescription = if (checked) "On" else "Off"; if (label != null) contentDescription = label }, + ) { + Box( + Modifier.size(width = 52.dp, height = 30.dp).clip(shape).background(track) + .border(if (hot) 2.dp else 1.dp, if (hot && checked) colors.onBackground else edge, shape), + ) { + Box( + Modifier.padding(4.dp).size(22.dp) + .graphicsLayer { translationX = knob * 22.dp.toPx() } + .clip(CircleShape) + .background(if (checked) pal.onSignal else colors.onSurfaceVariant), + ) + } + } +} @Composable fun MultiRow( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt index 3153092e1..2d944ab41 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt @@ -172,9 +172,9 @@ fun WirelessAdbFixPage( SettingsPage( host = rememberMenuHost(), title = when { - compactSplit && step < 2 -> "Wireless ADB" - step == 2 -> "Wireless ADB ready" - else -> "Set up on-device ADB" + compactSplit && step < 2 -> "Wireless debugging" + step == 2 -> "Child-process limit updated" + else -> "Change the child-process limit" }, eyebrow = "Setup", lede = when { @@ -201,7 +201,7 @@ fun WirelessAdbFixPage( ) { SettingsGroup("Child-process limit", compact = compactSplit) { Text( - "Android confirmed the limit is ${if (desiredEnabled) "enabled" else "disabled"}.", + "Android confirmed the limit is ${if (desiredEnabled) "on" else "off"}.", color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(14.dp), ) @@ -332,7 +332,7 @@ private fun WirelessStepForm( StatusMessage(busy, message, Modifier.weight(1f), isError = messageIsError) SecondaryButton("Pair again", compact = compact, enabled = !busy, onClick = onPairAgain) PrimaryButton( - if (desiredEnabled) "Enable limit" else "Disable limit", + if (desiredEnabled) "Turn limit on" else "Turn limit off", compact = compact, enabled = !busy && parseAdbAddress(connectionAddress) != null, onClick = onApply, diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt b/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt index aeccd398c..6df5ec7f2 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt @@ -24,7 +24,7 @@ class HdrProbe( ) { /** Why HDR cannot be offered, or null when it can. */ val reason: String? = when { - Build.VERSION.SDK_INT < 29 -> "needs Android 10 or newer (display layers with a colour space)" + Build.VERSION.SDK_INT < 29 -> "needs Android 10 or newer (display layers with a color space)" !hdr10 -> if (formats.isEmpty()) "this display reports no HDR support" else "this display supports $formats, not HDR10" else -> null }