Front end, slice 1: a rail of Steam and its games, the desktop and its emulators and their games

FrontEndScreen replaces the main screen on this branch. Steam's installed games come from the
client's own appmanifests (both libraries, with the library-cache art) and launch straight
into a Steam session with steam://rungameid; the emulators list their games from the ROMs
folder (a system folder as a hint, by extension) and launch under gamescope with the game on
the command line (EXTRA_PROGRAM_ARGS). A session running in the background is the first thing
on the rail and tapping it goes back to it. Landscape: rail beside content; narrow: above.
Every dialog and setting from the main screen is reachable from the rail's lower list.
This commit is contained in:
The412Banner committed 2026-09-22 11:30:47 -04:00
1 parent d39fd02d04
commit 8581adcc46
5 files changed
+557 -36

No files matched your search

@@ -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<List<Pair<String, String>>>(emptyList())
private var showEmulatorHelp by mutableStateOf(false)
private var romsDir by mutableStateOf<String?>(null)
private var steamGames by mutableStateOf<List<Library.SteamGame>>(emptyList())
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
private var runningLabel by mutableStateOf<String?>(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() {
@@ -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<Rom>)
/** 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<SteamGame> {
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<Int, SteamGame>()
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<String>, val exts: Set<String>)
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<Emulator> {
val romsRoot = SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
return specs.map { spec ->
val games = ArrayList<Rom>()
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<String> = when (emulatorId) {
"rpcs3" -> listOf("--no-gui", guestPath)
"dolphin" -> listOf("-e", guestPath)
"cemu" -> listOf("-g", guestPath)
else -> listOf(guestPath)
}
}
@@ -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"
@@ -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<String> = 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
@@ -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<Library.SteamGame>,
val emulators: List<Library.Emulator>,
/** 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<Tile>, 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)
}
}
}
}
}