diff --git a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt index 7afac4587..262eeefdb 100644 --- a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt @@ -121,6 +121,8 @@ class MainActivity : ComponentActivity() { private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO) private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO) private var renderer by mutableStateOf("pixman") + private var emulators by mutableStateOf>>(emptyList()) + private var showEmulatorHelp by mutableStateOf(false) private var romsDir by mutableStateOf(null) private var logsEnabled by mutableStateOf(true) private var showRoms by mutableStateOf(false) @@ -138,7 +140,7 @@ class MainActivity : ComponentActivity() { frameGenLabel = frameGenLabel, desktopInstalled = desktopInstalled, offlineAccount = offlineAccount, offline = offline, - romsDir = romsDir, logsEnabled = logsEnabled, + romsDir = romsDir, logsEnabled = logsEnabled, emulators = emulators, ), onPlay = { startActivity(Intent(this, SessionActivity::class.java)) }, onDesktop = { @@ -158,6 +160,8 @@ class MainActivity : ComponentActivity() { onPerformance = { refreshCores(); showPerformance = true }, onRoms = { showRoms = true }, onFiles = { startActivity(Intent(this, com.steamdeck.launcher.files.FileManagerActivity::class.java)) }, + onLaunchEmulator = { path -> launchProgram(path) }, + onEmulatorHelp = { showEmulatorHelp = true }, onLogs = { SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this)) logsEnabled = SessionPrefs.logsEnabled(this) @@ -177,12 +181,7 @@ class MainActivity : ComponentActivity() { rows = packageRows, busyStage = pkgStage, busyPercent = pkgPercent, onInstall = { id -> installPackage(id) }, onRemove = { id -> catalog?.firstOrNull { it.id == id }?.let { DesktopCatalog.remove(this, it) }; refreshPackages() }, - onLaunch = { path -> - showApps = false - startActivity(Intent(this, SessionActivity::class.java) - .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN) - .putExtra(SessionService.EXTRA_PROGRAM, path)) - }, + onLaunch = { path -> showApps = false; launchProgram(path) }, onDismiss = { showApps = false }, ) if (showProtons) ProtonDialog( @@ -271,6 +270,7 @@ class MainActivity : ComponentActivity() { onDismiss = { showFrameGen = false }, ) if (showCredits) CreditsDialog { showCredits = false } + if (showEmulatorHelp) com.steamdeck.launcher.ui.EmulatorHelpDialog { showEmulatorHelp = false } } } @@ -305,15 +305,39 @@ class MainActivity : ComponentActivity() { DesktopCatalog.installed(this, it.id), launchersOf(it)) } desktopInstalled = DesktopCatalog.desktopInstalled(this) + emulators = installedEmulators() } /** What a package can start on its own under gamescope; paths inside the runtime. */ - private fun launchersOf(entry: DesktopCatalog.Entry): List> = when { - entry.kind == "appimage" -> listOf(entry.name.substringBefore(" (").trim() to "/opt/appimages/${entry.id}.AppImage") - entry.id == "emulators" -> listOf("PPSSPP" to "/usr/bin/PPSSPPSDL", "RetroArch" to "/usr/bin/retroarch") + private fun launchersOf(entry: DesktopCatalog.Entry): List> = launchersOf(entry.id) + + /** + * The same, by package id alone, so the main screen's row is known from the install markers + * before (or without) the catalog. The AppImage packages launch as /opt/appimages/.AppImage. + */ + private fun launchersOf(id: String): List> = when (id) { + "emulators" -> listOf("PPSSPP" to "/usr/bin/PPSSPPSDL", "RetroArch" to "/usr/bin/retroarch") + "rpcs3" -> listOf("RPCS3" to "/opt/appimages/rpcs3.AppImage") + "pcsx2" -> listOf("PCSX2" to "/opt/appimages/pcsx2.AppImage") + "dolphin" -> listOf("Dolphin" to "/opt/appimages/dolphin.AppImage") + "duckstation" -> listOf("DuckStation" to "/opt/appimages/duckstation.AppImage") + "melonds" -> listOf("melonDS" to "/opt/appimages/melonds.AppImage") + "cemu" -> listOf("Cemu" to "/opt/appimages/cemu.AppImage") else -> emptyList() } + /** Every installed emulator's launcher, for the main screen. */ + private fun installedEmulators(): List> = + listOf("rpcs3", "pcsx2", "dolphin", "duckstation", "melonds", "cemu", "emulators") + .filter { DesktopCatalog.installed(this, it) != null } + .flatMap { launchersOf(it) } + + private fun launchProgram(path: String) { + startActivity(Intent(this, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN) + .putExtra(SessionService.EXTRA_PROGRAM, path)) + } + private fun installPackage(id: String) { val entry = catalog?.firstOrNull { it.id == id } ?: return if (pkgStage != null) return @@ -444,6 +468,7 @@ class MainActivity : ComponentActivity() { frameGenLabel = FrameGen.label(this) romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() } logsEnabled = SessionPrefs.logsEnabled(this) + emulators = if (ready) installedEmulators() else emptyList() } private fun onRuntimeButton() { diff --git a/app/src/main/java/com/steamdeck/launcher/ui/MainScreen.kt b/app/src/main/java/com/steamdeck/launcher/ui/MainScreen.kt index bd22862ff..1aa36fdd1 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/MainScreen.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/MainScreen.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -57,6 +59,8 @@ class MainUiState( /** The folder every session shows at /root/ROMs, or null if none is chosen. */ val romsDir: String?, val logsEnabled: Boolean, + /** Installed emulators that can run under gamescope: label to path inside the runtime. */ + val emulators: List> = emptyList(), ) /** @@ -72,6 +76,7 @@ class MainUiState( * setting's current value under it, so the menu is read at a glance and fits without scrolling * on a handheld. */ +@OptIn(ExperimentalLayoutApi::class) @Composable fun MainScreen( state: MainUiState, @@ -87,6 +92,8 @@ fun MainScreen( onOffline: () -> Unit, onRoms: () -> Unit, onFiles: () -> Unit, + onLaunchEmulator: (path: String) -> Unit, + onEmulatorHelp: () -> Unit, onLogs: () -> Unit, onCredits: () -> Unit, ) { @@ -168,6 +175,28 @@ fun MainScreen( ) { Text(if (state.desktopInstalled) "Desktop" else "Desktop — install it under Desktop & apps", fontSize = 13.sp * k, maxLines = 1, overflow = TextOverflow.Ellipsis) } CogButton(k, onDesktopSettings) } + // Every installed emulator, as a session of its own under gamescope (where the GPU + // is); the ? says why they are here and not on the desktop. + if (state.emulators.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp * k), + verticalArrangement = Arrangement.spacedBy(4.dp * k), + modifier = Modifier.fillMaxWidth().padding(top = 5.dp * k), + ) { + for ((label, path) in state.emulators) { + OutlinedButton( + onClick = { onLaunchEmulator(path) }, enabled = state.ready && !state.busy, + contentPadding = PaddingValues(horizontal = 10.dp * k, vertical = 0.dp), + modifier = Modifier.height(30.dp * k), + ) { Text("▶ $label", fontSize = 11.sp * k, maxLines = 1) } + } + OutlinedButton( + onClick = onEmulatorHelp, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.width(30.dp * k).height(30.dp * k), + ) { Text("?", fontSize = 12.sp * k) } + } + } // Offline is read while the client starts, so it is decided here rather than in the // session's drawer, and it needs credentials from an earlier sign-in to be possible. OutlinedButton( @@ -265,6 +294,44 @@ private fun MenuTile( } } +/** The ? beside the emulator row: what the ▶ buttons are, how to use them, and why. */ +@Composable +fun EmulatorHelpDialog(onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Emulators") }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Text("What the ▶ buttons are", style = MaterialTheme.typography.titleSmall) + Text( + "One per emulator installed under Desktop & apps. Each starts that emulator fullscreen, as a " + + "session of its own under gamescope — the same way the Steam client runs — with the Steam " + + "cog's display, driver and HDR settings.", + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(8.dp)) + Text("How to use them", style = MaterialTheme.typography.titleSmall) + Text( + "Put your games in the ROMs folder (main screen). In the emulator, open root › ROMs " + + "(/root/ROMs); the whole phone is at root › Storage. Firmware and BIOS files go in the " + + "same way (RPCS3: File › Install Firmware). Back opens the drawer; Stop session ends it.", + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(8.dp)) + Text("Why not from the desktop", style = MaterialTheme.typography.titleSmall) + Text( + "The desktop draws in software (labwc on pixman) and cannot hand a program the GPU: a Vulkan " + + "emulator started there dies at its first frame (\"Surface lost\"). gamescope can, so an " + + "emulator that renders with Vulkan or OpenGL belongs here. The desktop stays for files, " + + "Firefox and anything that draws in software.", + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } }, + ) +} + @Composable fun ConfirmDialog(title: String, text: String, confirm: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { AlertDialog(