diff --git a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt index 67212fa4b..22d58adc0 100644 --- a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt @@ -43,8 +43,10 @@ import com.steamdeck.launcher.ui.DriverRow import com.steamdeck.launcher.ui.ConfirmDialog import com.steamdeck.launcher.ui.CreditsDialog import com.steamdeck.launcher.ui.FrameGenDialog -import com.steamdeck.launcher.ui.MainScreen -import com.steamdeck.launcher.ui.MainUiState +import com.steamdeck.launcher.ui.FrontEndScreen +import com.steamdeck.launcher.ui.FrontEndState +import com.steamdeck.launcher.ui.FrontEndActions +import com.steamdeck.launcher.frontend.Library import com.steamdeck.launcher.ui.SteamDeckTheme import com.steamdeck.launcher.ui.RomsDialog import com.steamdeck.launcher.files.InAppFilePicker @@ -130,6 +132,9 @@ class MainActivity : ComponentActivity() { private var emulators by mutableStateOf>>(emptyList()) private var showEmulatorHelp by mutableStateOf(false) private var romsDir by mutableStateOf(null) + private var steamGames by mutableStateOf>(emptyList()) + private var emulatorList by mutableStateOf>(emptyList()) + private var runningLabel by mutableStateOf(null) private var logsEnabled by mutableStateOf(true) private var showRoms by mutableStateOf(false) @@ -137,42 +142,59 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { SteamDeckTheme { - MainScreen( - state = MainUiState( - installed = installed, ready = ready, - available = available?.version, - availableSize = available?.let { FileUtils.sizeToString(it.size) }, - busy = busy, stage = stage, percent = percent, failed = failed, - frameGenLabel = frameGenLabel, + FrontEndScreen( + FrontEndState( + installed = installed, ready = ready, available = available?.version, + busy = busy, stage = stage, percent = percent, desktopInstalled = desktopInstalled, offlineAccount = offlineAccount, offline = offline, - romsDir = romsDir, logsEnabled = logsEnabled, emulators = emulators, + frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, + steamGames = steamGames, emulators = emulatorList, running = runningLabel, + ), + FrontEndActions( + onPlay = { startActivity(Intent(this, SessionActivity::class.java)) }, + onPlayDesktopUi = { + startActivity(Intent(this, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_STEAM_UI, "desktop")) + }, + onSteamGame = { g -> + startActivity(Intent(this, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_STEAM_URL, "steam://rungameid/${g.appId}")) + }, + onDesktop = { + startActivity(Intent(this, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)) + }, + onEmulator = { e -> launchProgram(e.program) }, + onRom = { g -> + val e = emulatorList.first { it.id == g.emulatorId } + startActivity(Intent(this, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN) + .putExtra(SessionService.EXTRA_PROGRAM, e.program) + .putExtra(SessionService.EXTRA_PROGRAM_ARGS, Library.launchArgs(e.id, g.guestPath).toTypedArray())) + }, + // The activity re-attaches to the session that is running; nothing restarts. + onResume = { startActivity(Intent(this, SessionActivity::class.java)) }, + onSteamSettings = { openModeSettings(SessionService.MODE_STEAM) }, + onDesktopSettings = { openModeSettings(SessionService.MODE_DESKTOP) }, + onApps = { openApps() }, + onRuntime = { onRuntimeButton() }, + onFrameGen = { showFrameGen = true }, + onProtons = { refreshProtons(); showProtons = true }, + onPerformance = { refreshCores(); showPerformance = true }, + onRoms = { showRoms = true }, + onFiles = { startActivity(Intent(this, com.steamdeck.launcher.files.FileManagerActivity::class.java)) }, + onLogs = { + SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this)) + logsEnabled = SessionPrefs.logsEnabled(this) + }, + onOffline = { + OfflineMode.setEnabled(this, !OfflineMode.enabled(this)) + offline = OfflineMode.enabled(this) + }, + onEmulatorHelp = { showEmulatorHelp = true }, + onCredits = { showCredits = true }, ), - onPlay = { startActivity(Intent(this, SessionActivity::class.java)) }, - onDesktop = { - startActivity(Intent(this, SessionActivity::class.java) - .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)) - }, - onApps = { openApps() }, - onOffline = { - OfflineMode.setEnabled(this, !OfflineMode.enabled(this)) - offline = OfflineMode.enabled(this) - }, - onRuntime = { onRuntimeButton() }, - onFrameGen = { showFrameGen = true }, - onProtons = { refreshProtons(); showProtons = true }, - onSteamSettings = { openModeSettings(SessionService.MODE_STEAM) }, - onDesktopSettings = { openModeSettings(SessionService.MODE_DESKTOP) }, - 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) - }, - onCredits = { showCredits = true }, ) if (showRoms) RomsDialog( path = romsDir, @@ -496,6 +518,17 @@ class MainActivity : ComponentActivity() { romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() } logsEnabled = SessionPrefs.logsEnabled(this) emulators = if (ready) installedEmulators() else emptyList() + runningLabel = if (SessionState.running) when (SessionState.mode) { + SessionService.MODE_DESKTOP -> "Desktop" + SessionService.MODE_RUN -> SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: "Program" + else -> "Steam" + } else null + // The libraries, off the main thread: manifests and a folder scan. + Thread({ + val games = if (ready) Library.steamGames(this) else emptyList() + val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null } + ui.post { steamGames = games; emulatorList = emus } + }, "library").start() } private fun onRuntimeButton() { diff --git a/app/src/main/java/com/steamdeck/launcher/frontend/Library.kt b/app/src/main/java/com/steamdeck/launcher/frontend/Library.kt new file mode 100644 index 000000000..eb306e5d1 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/frontend/Library.kt @@ -0,0 +1,99 @@ +package com.steamdeck.launcher.frontend + +import android.content.Context +import com.steamdeck.launcher.runtime.LinuxRuntime +import com.steamdeck.launcher.session.GameStorage +import com.steamdeck.launcher.session.SessionPrefs +import java.io.File + +/** + * What the front end lists: the Steam client's installed games (from its own appmanifests, both + * libraries) and each emulator's games (files in the ROMs folder, by extension, with a system + * folder as a hint). Read on a worker thread; nothing here is cached beyond one screen refresh. + */ +object Library { + class SteamGame(val appId: Int, val name: String, val art: File?, val library: String) + class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String) + class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List) + + /** The client's own tools and runtimes live in steamapps beside the games; they are not titles. */ + private val NOT_GAMES = setOf(228980, 1493710, 3127680, 4183110, 4427310, 4185400) + private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE) + private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE) + + fun steamGames(context: Context): List { + val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam") + val cache = File(root, "appcache/librarycache") + val libraries = listOfNotNull( + root to "internal", + GameStorage.effective(context)?.let { File(it.path) to it.label }, + ) + val out = LinkedHashMap() + for ((library, label) in libraries) { + val steamapps = File(library, "steamapps") + steamapps.listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") } + ?.sortedBy { it.name }?.forEach { manifest -> + val appId = manifest.name.removePrefix("appmanifest_").removeSuffix(".acf").toIntOrNull() ?: return@forEach + if (appId in NOT_GAMES || out.containsKey(appId)) return@forEach + val text = try { manifest.readText() } catch (e: Exception) { return@forEach } + val name = NAME.find(text)?.groupValues?.get(1)?.trim().orEmpty() + val flags = STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0 + // StateFlags 4 = fully installed; anything else is downloading, updating or broken. + if (name.isEmpty() || flags and 4 == 0) return@forEach + val dir = File(cache, appId.toString()) + val art = listOf("library_600x900.jpg", "logo.png", "library_header.jpg", "header.jpg") + .map { File(dir, it) }.firstOrNull { it.isFile } + out[appId] = SteamGame(appId, name, art, label) + } + } + return out.values.toList() + } + + private class Spec(val id: String, val name: String, val system: String, val program: String, val folders: List, val exts: Set) + private val specs = listOf( + Spec("rpcs3", "RPCS3", "PS3", "/opt/appimages/rpcs3.AppImage", listOf("ps3"), setOf("iso")), + Spec("pcsx2", "PCSX2", "PS2", "/opt/appimages/pcsx2.AppImage", listOf("ps2"), setOf("iso", "chd", "cso", "gz")), + Spec("dolphin", "Dolphin", "GameCube / Wii", "/opt/appimages/dolphin.AppImage", listOf("gc", "gamecube", "wii"), setOf("iso", "rvz", "gcz", "wbfs", "ciso")), + Spec("duckstation", "DuckStation", "PS1", "/opt/appimages/duckstation.AppImage", listOf("ps1", "psx"), setOf("cue", "chd", "pbp", "iso", "bin", "img", "ecm", "m3u")), + Spec("melonds", "melonDS", "DS", "/opt/appimages/melonds.AppImage", listOf("ds", "nds"), setOf("nds", "dsi")), + Spec("cemu", "Cemu", "Wii U", "/opt/appimages/cemu.AppImage", listOf("wiiu", "wii u"), setOf("wua", "wud", "wux", "rpx")), + Spec("ppsspp", "PPSSPP", "PSP", "/usr/bin/PPSSPPSDL", listOf("psp"), setOf("iso", "cso", "pbp", "chd")), + Spec("retroarch", "RetroArch", "many systems", "/usr/bin/retroarch", emptyList(), emptySet()), + ) + private val installedIds = mapOf( + "rpcs3" to "rpcs3", "pcsx2" to "pcsx2", "dolphin" to "dolphin", "duckstation" to "duckstation", + "melonds" to "melonds", "cemu" to "cemu", "ppsspp" to "emulators", "retroarch" to "emulators", + ) + + /** Every emulator the app knows, installed or not, with the games its system folder holds. */ + fun emulators(context: Context, installedPackage: (String) -> Boolean): List { + val romsRoot = SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory } + return specs.map { spec -> + val games = ArrayList() + if (romsRoot != null && spec.exts.isNotEmpty()) { + // The system's folder(s), matched without regard to case, then the root itself for + // a file left loose there. + val dirs = romsRoot.listFiles { f -> f.isDirectory && f.name.lowercase() in spec.folders }.orEmpty().toList() + romsRoot + for (dir in dirs) { + dir.listFiles()?.sortedBy { it.name.lowercase() }?.forEach { f -> + val ext = f.extension.lowercase() + // A .bin beside a .cue is a track, not a game. + if (f.isFile && ext in spec.exts && !(ext == "bin" && File(dir, f.nameWithoutExtension + ".cue").isFile)) { + val rel = f.relativeTo(romsRoot).path + games.add(Rom(f.nameWithoutExtension, f, "/root/ROMs/$rel", spec.id)) + } + } + } + } + Emulator(spec.id, spec.name, spec.system, spec.program, installedPackage(installedIds.getValue(spec.id)), games) + } + } + + /** How the emulator is told which game to boot, on its command line. */ + fun launchArgs(emulatorId: String, guestPath: String): List = when (emulatorId) { + "rpcs3" -> listOf("--no-gui", guestPath) + "dolphin" -> listOf("-e", guestPath) + "cemu" -> listOf("-g", guestPath) + else -> listOf(guestPath) + } +} diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt index ba41a0972..2d1d81b18 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt @@ -69,6 +69,7 @@ class SessionService : Service() { if (SessionState.running) return START_NOT_STICKY SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM SessionState.program = intent?.getStringExtra(EXTRA_PROGRAM) + SessionState.programArgs = intent?.getStringArrayExtra(EXTRA_PROGRAM_ARGS)?.toList().orEmpty() SessionState.steamUi = intent?.getStringExtra(EXTRA_STEAM_UI) SessionState.steamUrl = intent?.getStringExtra(EXTRA_STEAM_URL) // Another Steam client on the device signs ours out seconds after every login; the one that @@ -320,7 +321,8 @@ class SessionService : Service() { return } guest.add(program) - Log.i(TAG, "run: $program under gamescope") + guest.addAll(SessionState.programArgs) + Log.i(TAG, "run: $program ${SessionState.programArgs.joinToString(" ")} under gamescope") } // Android has no /dev/shm; the cache stands in for it and, unlike the real thing, keeps @@ -692,6 +694,8 @@ class SessionService : Service() { /** A program inside the runtime, fullscreen under gamescope (EXTRA_PROGRAM = its path). */ const val MODE_RUN = "run" const val EXTRA_PROGRAM = "program" + /** MODE_RUN: arguments after the program (a game to boot). */ + const val EXTRA_PROGRAM_ARGS = "programArgs" /** MODE_STEAM: "desktop" for the client's desktop UI (default Big Picture); a steam:// URL to hand it. */ const val EXTRA_STEAM_UI = "steamUi" const val EXTRA_STEAM_URL = "steamUrl" diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionState.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionState.kt index 41b83fce2..5210a5e80 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionState.kt @@ -12,6 +12,7 @@ object SessionState { var running = false /** MODE_RUN: the program inside the runtime the session was started for. */ var program: String? = null + var programArgs: List = emptyList() /** MODE_STEAM: "desktop" for the client's desktop UI, else Big Picture; and a steam:// URL to hand it. */ var steamUi: String? = null var steamUrl: String? = null diff --git a/app/src/main/java/com/steamdeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/steamdeck/launcher/ui/FrontEndScreen.kt new file mode 100644 index 000000000..294c29a47 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/ui/FrontEndScreen.kt @@ -0,0 +1,384 @@ +package com.steamdeck.launcher.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +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 coil.compose.AsyncImage +import com.steamdeck.launcher.R +import com.steamdeck.launcher.frontend.Library + +/** Everything the front end shows; the activity owns the values and the work behind them. */ +class FrontEndState( + val installed: String?, + val ready: Boolean, + val available: String?, + val busy: Boolean, + val stage: String, + val percent: Int, + val desktopInstalled: Boolean, + val offlineAccount: String?, + val offline: Boolean, + val frameGenLabel: String, + val romsDir: String?, + val logsEnabled: Boolean, + val steamGames: List, + val emulators: List, + /** A session alive in the background: what it is, or null. */ + val running: String?, +) + +class FrontEndActions( + val onPlay: () -> Unit, + val onPlayDesktopUi: () -> Unit, + val onSteamGame: (Library.SteamGame) -> Unit, + val onDesktop: () -> Unit, + val onEmulator: (Library.Emulator) -> Unit, + val onRom: (Library.Rom) -> Unit, + val onResume: () -> Unit, + val onSteamSettings: () -> Unit, + val onDesktopSettings: () -> Unit, + val onApps: () -> Unit, + val onRuntime: () -> Unit, + val onFrameGen: () -> Unit, + val onProtons: () -> Unit, + val onPerformance: () -> Unit, + val onRoms: () -> Unit, + val onFiles: () -> Unit, + val onLogs: () -> Unit, + val onOffline: () -> Unit, + val onEmulatorHelp: () -> Unit, + val onCredits: () -> Unit, +) + +/** + * The front end: a rail of what can be launched - Steam and its games, the desktop and its + * emulators and their games - and the chosen thing on the right with its launch button. A + * session running in the background is the first thing on the rail, and tapping it goes back + * to it. Landscape puts the rail beside the content; a narrow screen puts it above. + */ +@Composable +fun FrontEndScreen(s: FrontEndState, a: FrontEndActions) { + var selected by rememberSaveable { mutableStateOf("steam") } + var openDesktop by rememberSaveable { mutableStateOf(true) } + var openSteam by rememberSaveable { mutableStateOf(true) } + var openEmu by rememberSaveable { mutableStateOf("") } + val colors = MaterialTheme.colorScheme + + BoxWithConstraints(modifier = Modifier.fillMaxSize().background(colors.background).systemBarsPadding()) { + val wide = maxWidth >= 640.dp + val rail: @Composable () -> Unit = { + Rail( + s, selected, openSteam, openDesktop, openEmu, + onSelect = { key -> + selected = key + if (key == "steam") openSteam = !openSteam || selected != "steam" + if (key == "desktop") openDesktop = !openDesktop || selected != "desktop" + if (key.startsWith("emu:")) openEmu = if (openEmu == key) "" else key + }, + a, + modifier = if (wide) Modifier.width(236.dp).fillMaxHeight() else Modifier.fillMaxWidth().height(maxHeight * 0.42f), + ) + } + val content: @Composable (Modifier) -> Unit = { m -> Content(s, selected, a, m) } + if (wide) Row(modifier = Modifier.fillMaxSize()) { rail(); content(Modifier.weight(1f).fillMaxHeight()) } + else Column(modifier = Modifier.fillMaxSize()) { rail(); content(Modifier.weight(1f).fillMaxWidth()) } + } +} + +@Composable +private fun Rail( + s: FrontEndState, selected: String, openSteam: Boolean, openDesktop: Boolean, openEmu: String, + onSelect: (String) -> Unit, a: FrontEndActions, modifier: Modifier, +) { + val colors = MaterialTheme.colorScheme + Column( + modifier = modifier.background(colors.surface).verticalScroll(rememberScrollState()).padding(horizontal = 10.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp, bottom = 10.dp)) { + Image(painterResource(R.drawable.logo), null, modifier = Modifier.size(30.dp)) + Spacer(Modifier.width(10.dp)) + Column { + Text("SteamDeck", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + Text( + when { + s.busy -> if (s.percent >= 0) "${s.stage} ${s.percent}%" else s.stage + !s.ready -> "runtime not installed" + s.available != null && s.available != s.installed -> "runtime ${s.installed} · ${s.available} available" + else -> "runtime ${s.installed ?: "?"}" + }, + fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + } + } + if (s.busy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) + + // A session in the background: the way back to it, first. + if (s.running != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).background(colors.primary.copy(alpha = 0.18f)) + .clickable(onClick = a.onResume).padding(horizontal = 10.dp, vertical = 9.dp), + ) { + Box(modifier = Modifier.size(8.dp).clip(RoundedCornerShape(4.dp)).background(Color(0xFF7FD8A0))) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(s.running, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("running · tap to go back", fontSize = 11.sp, color = colors.onSurfaceVariant) + } + } + Spacer(Modifier.height(6.dp)) + } + + NavItem("Steam", selected == "steam", caret = openSteam, count = s.steamGames.size) { onSelect("steam") } + if (openSteam) Sub { + for (g in s.steamGames) NavItem(g.name, selected == "app:${g.appId}", small = true) { onSelect("app:${g.appId}") } + if (s.steamGames.isEmpty()) NavItem("no games installed", false, small = true, muted = true) {} + } + NavItem("Desktop", selected == "desktop", caret = openDesktop, count = s.emulators.count { it.installed }) { onSelect("desktop") } + if (openDesktop) Sub { + for (e in s.emulators.filter { it.installed }) { + val key = "emu:${e.id}" + NavItem(e.name, selected == key, small = true, caret = openEmu == key, count = e.games.size) { onSelect(key) } + if (openEmu == key) Sub { + for ((i, g) in e.games.withIndex()) NavItem(g.name, selected == "rom:${e.id}:$i", small = true) { onSelect("rom:${e.id}:$i") } + if (e.games.isEmpty()) NavItem(if (s.romsDir == null) "choose a ROMs folder" else "nothing for ${e.system} in ROMs", false, small = true, muted = true) { a.onRoms() } + } + } + if (s.emulators.none { it.installed }) NavItem("install emulators under Desktop & apps", false, small = true, muted = true) { a.onApps() } + } + + Spacer(Modifier.height(10.dp)) + for ((label, act) in listOf( + "Files" to a.onFiles, "Desktop & apps" to a.onApps, "Compatibility tools" to a.onProtons, + "Frame generation: ${s.frameGenLabel}" to a.onFrameGen, "Performance" to a.onPerformance, + "ROMs: ${s.romsDir?.substringAfterLast('/')?.ifEmpty { s.romsDir } ?: "choose folder"}" to a.onRoms, + (if (s.logsEnabled) "Session logs: on" else "Session logs: off") to a.onLogs, + (when { + s.offlineAccount == null -> "Start offline: sign in first" + s.offline -> "Start offline: on" + else -> "Start offline: off" + }) to a.onOffline, + (when { + s.busy -> "Working…" + !s.ready -> "Install Linux runtime" + s.available != null && s.available != s.installed -> "Update Linux runtime" + else -> "Remove Linux runtime" + }) to a.onRuntime, + )) NavItem(label, false, small = true, muted = true) { act() } + Text( + "credits", fontSize = 11.sp, color = colors.onSurfaceVariant, + modifier = Modifier.clickable(onClick = a.onCredits).padding(horizontal = 10.dp, vertical = 8.dp), + ) + } +} + +@Composable +private fun Sub(content: @Composable () -> Unit) { + Column( + modifier = Modifier.padding(start = 16.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { content() } +} + +@Composable +private fun NavItem( + label: String, current: Boolean, small: Boolean = false, muted: Boolean = false, + caret: Boolean? = null, count: Int? = null, onClick: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(if (current) colors.primary else Color.Transparent) + .clickable(onClick = onClick).padding(horizontal = 10.dp, vertical = if (small) 7.dp else 9.dp), + ) { + if (caret != null) { + Text(if (caret) "▾" else "▸", fontSize = 11.sp, color = if (current) colors.onPrimary else colors.onSurfaceVariant, modifier = Modifier.width(14.dp)) + } + Text( + label, fontSize = if (small) 13.sp else 15.sp, fontWeight = if (small) FontWeight.Normal else FontWeight.SemiBold, + color = if (current) colors.onPrimary else if (muted) colors.onSurfaceVariant else colors.onBackground, + maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), + ) + if (count != null) Text( + count.toString(), fontSize = 11.sp, + color = if (current) colors.onPrimary else colors.onSurfaceVariant, + modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(if (current) Color.White.copy(alpha = 0.22f) else colors.background).padding(horizontal = 7.dp, vertical = 2.dp), + ) + } +} + +@Composable +private fun Content(s: FrontEndState, selected: String, a: FrontEndActions, modifier: Modifier) { + val colors = MaterialTheme.colorScheme + Column(modifier = modifier.padding(horizontal = 22.dp, vertical = 18.dp)) { + when { + selected == "steam" -> { + Eyebrow("Steam") + Title("Valve's native client, under gamescope") + Lede("Big Picture with a controller, or the client's desktop UI. Games launch straight from the list on the left.") + Actions { + Button(onClick = a.onPlay, enabled = s.ready && !s.busy) { Text("▶ Play") } + OutlinedButton(onClick = a.onPlayDesktopUi, enabled = s.ready && !s.busy) { Text("Desktop UI") } + Cog(a.onSteamSettings) + } + SectionTitle("Installed") + if (s.steamGames.isEmpty()) Note("Nothing installed yet. Press Play, sign in, and install from the store; games appear here and on the left.") + else ArtGrid(s.steamGames.map { g -> Tile(g.name, g.library, g.art, "steam:${g.appId}") { a.onSteamGame(g) } }) + } + selected.startsWith("app:") -> { + val g = s.steamGames.firstOrNull { "app:${it.appId}" == selected } + if (g == null) Note("That game is no longer installed.") else { + Eyebrow("Steam · ${g.library}") + Title(g.name) + Lede("Starts the Steam session and launches the game straight away (steam://rungameid/${g.appId}).") + Actions { + Button(onClick = { a.onSteamGame(g) }, enabled = s.ready && !s.busy) { Text("▶ Launch") } + Cog(a.onSteamSettings) + } + Art(g.art, g.name, Modifier.width(240.dp)) + } + } + selected == "desktop" -> { + Eyebrow("Desktop") + Title("LXQt on labwc") + Lede("Files, Firefox and the emulators' own windows. Emulators and their games launch from the left, under gamescope, where the GPU is.") + Actions { + Button(onClick = a.onDesktop, enabled = s.ready && !s.busy && s.desktopInstalled) { Text(if (s.desktopInstalled) "▶ Desktop" else "Install the desktop first") } + OutlinedButton(onClick = a.onApps) { Text("Desktop & apps") } + Cog(a.onDesktopSettings) + } + SectionTitle("Emulators") + ArtGrid(s.emulators.map { e -> Tile(e.name, if (e.installed) "${e.games.size} game${if (e.games.size == 1) "" else "s"}" else "not installed", null, "emu:${e.id}") { if (e.installed) a.onEmulator(e) else a.onApps() } }) + Spacer(Modifier.height(10.dp)) + Text("? why emulators do not run on the desktop", fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.clickable(onClick = a.onEmulatorHelp).padding(4.dp)) + } + selected.startsWith("emu:") -> { + val e = s.emulators.firstOrNull { "emu:${it.id}" == selected } + if (e == null) Note("Not installed.") else { + Eyebrow("Desktop · ${e.system}") + Title(e.name) + Lede("Opens fullscreen under gamescope. Games are the ${e.system} files in the ROMs folder; each launches straight in.") + Actions { + Button(onClick = { a.onEmulator(e) }, enabled = s.ready && !s.busy) { Text("▶ Open ${e.name}") } + OutlinedButton(onClick = a.onRoms) { Text("ROMs folder") } + } + SectionTitle("Games") + if (e.games.isEmpty()) Note( + if (s.romsDir == null) "Choose a ROMs folder first (left, or the button above)." + else if (e.id == "retroarch") "RetroArch loads its games itself: open it and browse to root › ROMs." + else "Put ${e.system} games in ROMs/${e.system.substringBefore(' ')} (or the ROMs folder itself); the list rebuilds when this screen opens.", + ) + else ArtGrid(e.games.map { g -> Tile(g.name, g.hostPath.extension.uppercase(), null, "rom:${g.hostPath}") { a.onRom(g) } }, wide = true) + } + } + selected.startsWith("rom:") -> { + val parts = selected.split(":") + val e = s.emulators.firstOrNull { it.id == parts.getOrNull(1) } + val g = e?.games?.getOrNull(parts.getOrNull(2)?.toIntOrNull() ?: -1) + if (e == null || g == null) Note("That game is gone from the ROMs folder.") else { + Eyebrow("Desktop · ${e.name}") + Title(g.name) + Lede(g.guestPath) + Actions { + Button(onClick = { a.onRom(g) }, enabled = s.ready && !s.busy) { Text("▶ Launch in ${e.name}") } + } + } + } + else -> Note("Pick something on the left.") + } + } +} + +private class Tile(val title: String, val sub: String, val art: java.io.File?, val key: String, val onClick: () -> Unit) + +@Composable private fun Eyebrow(t: String) = Text(t, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary) +@Composable private fun Title(t: String) = Text(t, fontSize = 26.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(top = 2.dp, bottom = 4.dp)) +@Composable private fun Lede(t: String) = Text(t, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 12.dp)) +@Composable private fun SectionTitle(t: String) = Text(t, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)) +@Composable private fun Note(t: String) = Text(t, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.surface).padding(12.dp)) +@Composable private fun Actions(content: @Composable () -> Unit) = Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { content() } +@Composable private fun Cog(onClick: () -> Unit) = OutlinedButton(onClick = onClick, contentPadding = PaddingValues(0.dp), modifier = Modifier.size(40.dp)) { Icon(Icons.Filled.Settings, "Settings", modifier = Modifier.size(18.dp)) } + +@Composable +private fun Art(art: java.io.File?, label: String, modifier: Modifier, wide: Boolean = false) { + val colors = MaterialTheme.colorScheme + val shape = RoundedCornerShape(8.dp) + Box(modifier = modifier.aspectRatio(if (wide) 16f / 9f else 2f / 3f).clip(shape).background(Brush.linearGradient(listOf(colors.surfaceVariant, colors.surface)))) { + if (art != null) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + else Text(label, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.align(Alignment.BottomStart).padding(10.dp), maxLines = 3, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +private fun ColumnScope.ArtGrid(tiles: List, wide: Boolean = false) { + val colors = MaterialTheme.colorScheme + // The grid takes the rest of the column; each tile is the art with its name and one line under it. + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = if (wide) 180.dp else 132.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(bottom = 12.dp), + modifier = Modifier.fillMaxWidth().weight(1f), + ) { + items(tiles, key = { it.key }) { t -> + Column( + modifier = Modifier.clip(RoundedCornerShape(10.dp)).background(colors.surface) + .clickable(onClick = t.onClick), + ) { + Art(t.art, t.title, Modifier.fillMaxWidth(), wide) + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)) { + Text(t.title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(t.sub, fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +}