diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index a5da8b357..004bf540d 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -38,7 +38,6 @@ import com.droiddeck.launcher.session.OfflineMode import com.droiddeck.launcher.session.ProtonExtras import com.droiddeck.launcher.session.ComponentsManager import com.droiddeck.launcher.ui.ComponentsPage -import com.droiddeck.launcher.ui.GameSavesPage import com.droiddeck.launcher.session.GameSaves import com.droiddeck.launcher.session.SessionLogShare import com.droiddeck.launcher.session.SessionPrefs @@ -113,14 +112,9 @@ class MainActivity : ComponentActivity() { private var showComponents by mutableStateOf(false) private var focusComponentsContent by mutableStateOf(true) private var showMapping by mutableStateOf(false) - // Game saves page: import / export save zips per game (the floppy beside the Steam cog). - private var showSaves by mutableStateOf(false) - private var saveTools by mutableStateOf?>(null) - private var saveGames by mutableStateOf>(emptyList()) - private var saveTool by mutableStateOf(null) - private var saveLayout by mutableStateOf(GameSaves.Layout.GAMEHUB) - private var saveBusy by mutableStateOf(null) - private var pendingSaveGame: GameSaves.Game? = null + private var saveBusy: String? = null + /** What to do with the zip or folder the file picker hands back after a game page's Manage saves. */ + private var onSavePicked: ((File) -> Unit)? = null private var controllerSettings by mutableStateOf(null) private var catalog by mutableStateOf?>(null) private var catalogLoading by mutableStateOf(false) @@ -162,24 +156,40 @@ class MainActivity : ComponentActivity() { if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) } } private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> - val game = pendingSaveGame ?: return@registerForActivityResult - if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { zip -> - saveAction("Importing into ${game.game.name}") { - val (written, backup) = GameSaves.import(game, zip) - val kind = GameSaves.layoutOf(zip)?.label ?: "zip" - "Imported $written files from the $kind into ${game.game.name}" + (backup?.let { ". Old saves backed up to Download/DroidDeck/Saves/backups" } ?: "") - } - } + val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then) } private val pickSaveDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> - val game = pendingSaveGame ?: return@registerForActivityResult - if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { dir -> - val layout = saveLayout - saveAction("Exporting ${game.game.name}") { - val (zip, count) = GameSaves.export(game, layout, dir) + val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then) + } + + /** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */ + private fun importSaves(name: String, game: () -> GameSaves.Game) { + if (SessionState.running) { + android.widget.Toast.makeText(this, "Close the Steam session first, so the game can't save over the import", android.widget.Toast.LENGTH_LONG).show() + return + } + onSavePicked = { zip -> + saveAction("Importing into $name") { + val (written, backup) = GameSaves.import(game(), zip) + val kind = GameSaves.layoutOf(zip)?.label ?: "zip" + "Imported $written files from the $kind into $name" + (backup?.let { ". Old saves backed up to Download/DroidDeck/Saves/backups" } ?: "") + } + } + pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), "Choose a save zip for $name", GameSaves.savesDir().parentFile?.parentFile?.path)) + } + + /** Export [game]'s saves in [layout] to a folder picked in the app's file picker. */ + private fun exportSaves(name: String, layout: GameSaves.Layout, game: () -> GameSaves.Game) { + onSavePicked = { dir -> + saveAction("Exporting $name") { + val (zip, count) = GameSaves.export(game(), layout, dir) "Exported $count files as a ${layout.label}: ${zip.path.removePrefix("/storage/emulated/0/")}" } } + GameSaves.savesDir().mkdirs() + pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, "Choose where to save $name (${layout.label})", GameSaves.savesDir().path)) } private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> @@ -305,7 +315,6 @@ class MainActivity : ComponentActivity() { showPerformance -> { { PerformanceHost() } } showProtons -> { { ProtonHost() } } showComponents -> { { ComponentsHost() } } - showSaves -> { { SavesHost() } } showMapping -> { { MappingHost() } } else -> null } @@ -320,7 +329,7 @@ class MainActivity : ComponentActivity() { steamGames = steamGames, emulators = emulatorList, running = runningLabel, frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this), lsfgReady = LsfgNative.isInstalled(this), - pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showSaves) "game-saves" else if (showMapping) "controller-mapping" else null, + pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null, theme = theme, isHomeApp = homeAppSelected, homeScreenEnabled = homeScreenEnabled, @@ -379,8 +388,10 @@ class MainActivity : ComponentActivity() { }, onProtons = { openProtons() }, onComponents = { focusContent -> openComponents(focusContent) }, - onGameSaves = { openSaves() }, - onPerformance = { refreshCores(); showProtons = false; showComponents = false; showSaves = false; showMapping = false; showPerformance = true }, + // A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread. + onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } }, + onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } }, + onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true }, onRoms = { showRoms = true }, onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) }, onBrowseFiles = { dir -> @@ -405,7 +416,7 @@ class MainActivity : ComponentActivity() { offline = OfflineMode.enabled(this) }, onCredits = { showCredits = true }, - onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showSaves = false; showMapping = false }, + onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false }, onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id }, onLauncherFullscreen = { on -> SessionPrefs.setLauncherFullscreen(this, on) @@ -484,7 +495,7 @@ class MainActivity : ComponentActivity() { onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() }, onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) }, onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() }, - onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showSaves = false; showMapping = true }, + onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true }, onResetAll = { ControllerPrefs.resetAll(this); refreshController() }, ), ), @@ -588,14 +599,6 @@ class MainActivity : ComponentActivity() { /** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */ override fun dispatchKeyEvent(event: KeyEvent): Boolean { - // On the Game saves page LB / RB flip the zip type exports are written in. - if (showSaves && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) { - if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { - val all = GameSaves.Layout.entries - saveLayout = all[(saveLayout.ordinal + 1) % all.size] - } - return true - } if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) { if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { val all = ComponentsManager.COMPONENTS @@ -699,32 +702,7 @@ class MainActivity : ComponentActivity() { }, "catalog-desktop").start() } - private fun openSaves() { - settingsMode = null - showPerformance = false - showProtons = false - showComponents = false - showMapping = false - showSaves = true - refreshSaves() - } - - /** Protons, which game runs with which, and the saves found in each prefix: files only, off the main thread. */ - private fun refreshSaves() { - val all = steamGames - Thread({ - val tools = runCatching { GameSaves.tools(this) }.getOrDefault(emptyList()) - val games = runCatching { GameSaves.games(this, all) }.getOrDefault(emptyList()) - ui.post { - saveTools = tools - saveGames = games - // Open on the Proton the most launched games use, unless one was picked already. - if (saveTool == null || tools.none { it.name == saveTool }) saveTool = - tools.maxByOrNull { t -> games.count { it.tool == t.name && it.launched } }?.name - } - }, "game-saves").start() - } - + /** Runs a save import or export off the main thread, one at a time, and says how it went. */ private fun saveAction(label: String, work: () -> String) { if (saveBusy != null) return saveBusy = label @@ -733,40 +711,14 @@ class MainActivity : ComponentActivity() { ui.post { saveBusy = null android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show() - refreshSaves() } }, "game-saves-action").start() } - @Composable - private fun SavesHost() { - GameSavesPage( - tools = saveTools, - games = saveGames, - toolName = saveTool, - layout = saveLayout, - busy = saveBusy, - sessionRunning = SessionState.running, - onTool = { saveTool = it }, - onLayout = { saveLayout = it }, - onImport = { g -> - pendingSaveGame = g - pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), "Choose a save zip for ${g.game.name}", - GameSaves.savesDir().parentFile?.parentFile?.path)) - }, - onExport = { g -> - pendingSaveGame = g - GameSaves.savesDir().mkdirs() - pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, "Choose where to save ${g.game.name}", GameSaves.savesDir().path)) - }, - onBack = { showSaves = false }, - ) - } - private fun openProtons() { settingsMode = null showPerformance = false - showComponents = false; showSaves = false + showComponents = false showMapping = false showProtons = true protons.refreshProtons() @@ -793,7 +745,6 @@ class MainActivity : ComponentActivity() { showPerformance = false showProtons = false showMapping = false - showSaves = false showComponents = true components.refreshComponents(snapshotFirst = true) } @@ -1049,7 +1000,7 @@ class MainActivity : ComponentActivity() { private fun openModeSettings(mode: String) { showPerformance = false showProtons = false - showComponents = false; showSaves = false + showComponents = false showMapping = false drivers.refreshDrivers() resolutionCap = SessionPrefs.resolutionCap(this, mode) diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt b/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt index 5c33d4275..e73668468 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt @@ -1,9 +1,7 @@ package com.droiddeck.launcher.session -import android.content.Context import android.os.Environment import com.droiddeck.launcher.frontend.Library -import com.droiddeck.launcher.runtime.LinuxRuntime import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.File @@ -23,9 +21,8 @@ import java.util.zip.ZipOutputStream * "drive_c/users/xuser/…". Proton here runs as steamuser too, so on the way in any user folder but * Public becomes steamuser, and on the way out the chosen layout decides the user segment. * - * Saves belong to the game's prefix (compatdata//pfx), not to a Proton: the Proton a game is - * set to (the client's CompatToolMapping) only decides where the page lists it. Ported from - * Bannerlator's GameSaveBackup and SaveLocator. + * Saves belong to the game's prefix (compatdata//pfx), whichever Proton it runs with. + * Ported from Bannerlator's GameSaveBackup and SaveLocator. */ object GameSaves { enum class Layout(val label: String, val rootPrefix: String, val user: String) { @@ -33,64 +30,21 @@ object GameSaves { WINLATOR("Winlator zip", "drive_c/", "xuser"), } - /** A compatibility tool the client can run a game with, by the name it records per game. */ - data class Tool(val name: String, val label: String) - - /** A game as the page lists it: its Proton, and where its saves are. */ - data class Game(val game: Library.SteamGame, val tool: String?, val prefix: File?, val saves: List) { + /** A game, its prefix, and the save folders found in it. */ + data class Game(val game: Library.SteamGame, val prefix: File?, val saves: List) { val launched get() = prefix?.let { File(it, "drive_c").isDirectory } == true } /** A folder of saves, relative to the steamuser profile. */ data class SaveDir(val relPath: String, val files: Int, val bytes: Long) - /** DroidDeck's launcher around Valve's ARM64 Proton Experimental (bannerlator-steam-compat). */ - private const val WRAPPER_TOOL = "bannerlator-proton-arm64" private const val USER = "steamuser" - private fun steam(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam") - fun savesDir(): File = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck/Saves") private fun backupsDir(): File = File(savesDir(), "backups") - // ------------------------------------------------------------------ Protons and games - - /** The compatibility tools installed for the client, each by its registered name and shown name. */ - fun tools(context: Context): List { - val out = LinkedHashMap() - File(steam(context), "compatibilitytools.d").listFiles()?.sortedBy { it.name }?.forEach { dir -> - val vdf = File(dir, "compatibilitytool.vdf").takeIf { it.isFile } ?: return@forEach - val tools = runCatching { Vdf.parse(vdf.readText()) }.getOrNull() - ?.child("compatibilitytools")?.child("compat_tools") ?: return@forEach - for ((name, v) in tools.children) { - val shown = (v as? Vdf.Node)?.string("display_name") ?: name - out[name] = Tool(name, if (name == WRAPPER_TOOL) "Proton Experimental (ARM64)" else shown) - } - } - return out.values.toList() - } - - /** The client's per-game compatibility tool, by app id; "0" is its default for every other game. */ - fun mapping(context: Context): Map { - val config = File(steam(context), "config/config.vdf").takeIf { it.isFile } ?: return emptyMap() - val root = runCatching { Vdf.parse(config.readText()) }.getOrNull() ?: return emptyMap() - val map = root.find("CompatToolMapping") ?: return emptyMap() - val out = HashMap() - for ((id, v) in map.children) (v as? Vdf.Node)?.string("name")?.takeIf { it.isNotEmpty() }?.let { out[id] = it } - return out - } - - /** Every game with the tool it runs with (null = none set) and the saves found in its prefix. */ - fun games(context: Context, all: List): List { - val mapping = mapping(context) - val fallback = mapping["0"] - return all.map { g -> - val unsigned = (g.appId.toLong() and 0xffffffffL).toString() - val tool = mapping[unsigned] ?: mapping[g.appId.toString()] ?: mapping[g.gameId.toString()] ?: fallback - val prefix = g.protonPrefix - Game(g, tool, prefix, prefix?.let { locate(it, g) } ?: emptyList()) - } - } + /** [game] with its prefix and the saves found there; reads files, so not on the main thread. */ + fun game(game: Library.SteamGame): Game = Game(game, game.protonPrefix, game.protonPrefix?.let { locate(it, game) } ?: emptyList()) // ------------------------------------------------------------------ finding saves @@ -281,55 +235,4 @@ object GameSaves { if (segs.isEmpty() || !segs[0].equals("drive_c", true)) return null return segs.drop(1).toMutableList() } - - // ------------------------------------------------------------------ Valve KeyValues - - /** The client's text KeyValues (VDF): quoted keys, quoted values or { nested blocks }. */ - internal object Vdf { - class Node(val children: LinkedHashMap = LinkedHashMap()) { - fun child(key: String): Node? = children.entries.firstOrNull { it.key.equals(key, true) }?.value as? Node - fun string(key: String): String? = children.entries.firstOrNull { it.key.equals(key, true) }?.value as? String - /** The first block named [key] anywhere below. */ - fun find(key: String): Node? { - child(key)?.let { return it } - for (v in children.values) (v as? Node)?.find(key)?.let { return it } - return null - } - } - - fun parse(text: String): Node { - var i = 0 - fun token(): String? { - while (i < text.length) { - val c = text[i] - when { - c.isWhitespace() -> i++ - c == '/' && i + 1 < text.length && text[i + 1] == '/' -> { while (i < text.length && text[i] != '\n') i++ } - c == '{' || c == '}' -> { i++; return c.toString() } - c == '"' -> { - val sb = StringBuilder(); i++ - while (i < text.length && text[i] != '"') { - if (text[i] == '\\' && i + 1 < text.length) { sb.append(text[i + 1]); i += 2 } else sb.append(text[i++]) - } - i++ - return "\"" + sb - } - else -> { val s = i; while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}') i++; return "\"" + text.substring(s, i) } - } - } - return null - } - fun block(into: Node) { - while (true) { - val k = token() ?: return - if (k == "}") return - if (k == "{") continue - val key = k.drop(1) - val v = token() ?: return - if (v == "{") { val n = Node(); block(n); into.children[key] = n } else into.children[key] = v.drop(1) - } - } - return Node().also { block(it) } - } - } } 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 eaca8c603..7822ef44e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -318,7 +318,7 @@ private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fal * fill with an outline in the text colour - unmistakable in a column of identical buttons. */ @Composable -internal fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) { +private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } 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 523ce958a..e75946f1a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -120,11 +120,55 @@ private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActio } g.protonPrefix?.takeIf { it.isDirectory }?.let { dir -> SecondaryButton("Proton prefix", compact = true) { a.onBrowseFiles(dir) } + ManageSaves(g, dir, a) } BusyChip(s) } } +/** + * Manage saves: import a GameHub or Winlator save zip into this game's prefix, export its saves as + * either, or open the save folder found for it. The saves are looked for when the menu opens. + */ +@Composable +private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndActions) { + var open by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) } + val saves by androidx.compose.runtime.produceState?>(null, g.gameId, open) { + if (open) value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + runCatching { com.droiddeck.launcher.session.GameSaves.locate(prefix, g) }.getOrDefault(emptyList()) + } + } + val found = saves + val summary = when { + found == null -> "Looking for this game's saves…" + found.isEmpty() -> "No save folder found by name: an export takes the whole user folder." + else -> { + val mb = found.sumOf { it.bytes } / 1048576.0 + (if (found.size == 1) found[0].relPath else "${found.size} save folders") + " · ${found.sumOf { it.files }} files · " + + (if (mb < 1) "${(mb * 1024).toInt()} KB" else String.format("%.1f MB", mb)) + } + } + Box { + SecondaryButton("Manage saves", compact = true) { open = !open } + AnchoredMenu(open, onDismiss = { open = false }, title = "Game saves", note = summary) { first -> + MenuItem("Import saves…", checked = false, detail = "A GameHub or Winlator zip · the saves there now are backed up first", focusRequester = first) { + open = false; a.onSaveImport(g) + } + MenuItem("Export as GameHub zip…", checked = false, detail = "Saves under steamuser, for GameHub and BannerHub") { + open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.GAMEHUB) + } + MenuItem("Export as Winlator zip…", checked = false, detail = "Saves under xuser, for Winlator, WinNative and Bannerlator") { + open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.WINLATOR) + } + found?.firstOrNull()?.let { d -> + MenuItem("Open save folder", checked = false, detail = d.relPath) { + open = false; a.onBrowseFiles(java.io.File(prefix, "drive_c/users/steamuser/" + d.relPath)) + } + } + } + } +} + /** When it was last played (or where it is, if never) over its name, then room for Launch. */ @Composable private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) { 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 88d1f3b62..e701b1a9e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -133,8 +133,9 @@ class FrontEndActions( val onProtons: () -> Unit, /** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */ val onComponents: (focusContent: Boolean) -> Unit, - /** The Game saves page: import and export save zips per game (the floppy beside the Steam cog). */ - val onGameSaves: () -> Unit = {}, + /** A game page's Manage saves: import a save zip into this game, or export its saves in a layout. */ + val onSaveImport: (Library.SteamGame) -> Unit = {}, + val onSaveExport: (Library.SteamGame, com.droiddeck.launcher.session.GameSaves.Layout) -> Unit = { _, _ -> }, val onPerformance: () -> Unit, val onRoms: () -> Unit, val onFiles: () -> Unit, @@ -360,7 +361,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com val railSelection = when { s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup" - s.pageKey?.startsWith("settings:steam") == true || s.pageKey == "game-saves" -> "steam" + s.pageKey?.startsWith("settings:steam") == true -> "steam" s.pageKey?.startsWith("settings:") == true -> "desktop" selected.startsWith("app:") -> "games" selected.startsWith("emu:") || selected.startsWith("rom:") -> "desktop" 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 ac4491c80..7d034a138 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Save import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.ui.graphics.vector.ImageVector @@ -236,18 +235,3 @@ internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick), ) { Icon(Icons.Filled.Settings, "Settings", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) } } - -/** Game saves: a floppy disk beside the cog, 70% of its size, drawn the same way. */ -@Composable -internal fun SaveDisk(size: androidx.compose.ui.unit.Dp = 38.dp, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val pal = LocalPalette.current - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "saveEdge") - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.paneItem("saves").downToFirstTile().size(size).clip(Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, Shape12) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick), - ) { Icon(Icons.Outlined.Save, "Game saves", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(16.dp)) } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt deleted file mode 100644 index 2cbac137f..000000000 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt +++ /dev/null @@ -1,198 +0,0 @@ -package com.droiddeck.launcher.ui - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -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.session.GameSaves - -/** - * Game saves, opened from the floppy disk beside the Steam page's cog. A page like Components: - * the Proton at the top, the save zip type beside it (LB / RB turn it), and the games set to that - * Proton, each with Import (pick a zip in the app's file picker) and Export (pick a folder there). - * A zip of either type imports; the type only decides how an export is written. - */ -@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) -@Composable -fun GameSavesPage( - tools: List?, - games: List, - toolName: String?, - layout: GameSaves.Layout, - busy: String?, - sessionRunning: Boolean, - onTool: (String) -> Unit, - onLayout: (GameSaves.Layout) -> Unit, - onImport: (GameSaves.Game) -> Unit, - onExport: (GameSaves.Game) -> Unit, - onBack: () -> Unit, - requestInitialFocus: Boolean = true, -) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val narrow = LocalNarrowPane.current - BackHandler(onBack = onBack) - var toolMenu by remember { mutableStateOf(false) } - - val list = tools ?: emptyList() - val tool = list.firstOrNull { it.name == toolName } ?: list.firstOrNull() - val shown = games.filter { it.tool == tool?.name } - .sortedWith(compareByDescending { it.launched }.thenBy { it.game.name.lowercase() }) - val layouts = GameSaves.Layout.entries - - Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { - Text( - "Game saves", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, - maxLines = 1, modifier = Modifier.weight(1f), - ) - if (tools != null) Text( - if (shown.size == 1) "1 game on this Proton" else "${shown.size} games on this Proton", - fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, - ) - } - - // ---- which Proton, which zip type ----------------------------------------------------------- - androidx.compose.foundation.layout.FlowRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth().padding(top = 12.dp), - ) { - if (tool != null) Box { - ValueChip(tool.label, toolMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { toolMenu = !toolMenu } - AnchoredMenu(toolMenu, onDismiss = { toolMenu = false }, title = "Proton", - note = "Saves belong to the game, not the Proton: switch a game to another Proton and its saves go with it.") { first -> - list.forEachIndexed { i, t -> - val n = games.count { it.tool == t.name } - MenuItem( - t.label, checked = t.name == tool.name, - detail = t.name + " · " + if (n == 1) "1 game" else if (n == 0) "no games yet" else "$n games", - focusRequester = if (i == 0) first else null, - ) { onTool(t.name); toolMenu = false } - } - } - } - TabStrip(layouts.map { it.label }, layouts.indexOf(layout), { onLayout(layouts[it]) }) - } - - // ---- what the type means, or what is happening ----------------------------------------------- - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(RoundedCornerShape(12.dp)).background(colors.surface) - .border(1.dp, pal.line, RoundedCornerShape(12.dp)).padding(horizontal = 14.dp, vertical = 8.dp).heightIn(min = 28.dp), - ) { - if (busy != null) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(14.dp)) - else Box(Modifier.size(8.dp).background(if (sessionRunning) Color(0xFFFFB86B) else pal.good, CircleShape)) - Text( - when { - busy != null -> "$busy…" - sessionRunning -> "Steam is running: close the session before importing, or the game may save over it." - layout == GameSaves.Layout.GAMEHUB -> "Exports keep saves under steamuser, as GameHub does. Imports take either type and back up the saves already there first." - else -> "Exports keep saves under xuser, as Winlator does. Imports take either type and back up the saves already there first." - }, - fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), - ) - } - - // ---- the games ------------------------------------------------------------------------------ - when { - tools == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) { - CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(12.dp)) - Text("Looking for games and their saves…", fontSize = 14.sp, color = colors.onSurfaceVariant) - } - tool == null -> Text( - "No Proton is installed in the Linux runtime yet. Start the Steam client once so it sets one up.", - fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp), - ) - else -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 16.dp)) { - val first = remember { FocusRequester() } - LaunchedEffect(tool.name, requestInitialFocus) { if (requestInitialFocus) runCatching { first.requestFocus() } } - SettingsGroup("Games on this Proton · ${shown.size}") { - if (shown.isEmpty()) Text( - "No game is set to ${tool.label}. Pick it for a game in Steam (Properties › Compatibility) and launch the game once.", - fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), - ) - var firstSet = false - shown.forEach { g -> - val canImport = g.launched && busy == null && !sessionRunning - val canExport = g.launched && busy == null - val mod = if (!firstSet && canExport) { firstSet = true; Modifier.focusRequester(first) } else Modifier - GameSaveLine(g, canImport, canExport, mod, { onImport(g) }, { onExport(g) }) - } - } - } - } - } -} - -@Composable -private fun GameSaveLine( - g: GameSaves.Game, canImport: Boolean, canExport: Boolean, firstButton: Modifier, - onImport: () -> Unit, onExport: () -> Unit, -) { - val colors = MaterialTheme.colorScheme - val narrow = LocalNarrowPane.current - val detail = when { - !g.launched -> "Launch it once first: its save folder is made on the first launch" - g.saves.isEmpty() -> (if (g.game.library == "added") "Added game" else "Steam game") + " · no save folder found, Export takes the whole user folder" - else -> { - val files = g.saves.sumOf { it.files } - val mb = g.saves.sumOf { it.bytes } / 1048576.0 - (if (g.game.library == "added") "Added game" else "Steam game") + " · " + - (if (g.saves.size == 1) g.saves[0].relPath else "${g.saves.size} save folders") + - " · $files files · " + (if (mb < 1) "${(mb * 1024).toInt()} KB" else String.format("%.1f MB", mb)) - } - } - val text: @Composable (Modifier) -> Unit = { m -> - Column(m) { - Text(g.game.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(detail, fontSize = 12.sp, color = if (g.launched) colors.onSurfaceVariant else Color(0xFFFFB86B), maxLines = 2, overflow = TextOverflow.Ellipsis) - } - } - val buttons: @Composable () -> Unit = { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SmallButton("Import", enabled = canImport, modifier = if (canImport) firstButton else Modifier, onClick = onImport) - SmallButton("Export", enabled = canExport, modifier = if (!canImport) firstButton else Modifier, onClick = onExport) - } - } - if (narrow) Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - text(Modifier.fillMaxWidth()); buttons() - } else Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), - ) { text(Modifier.weight(1f)); buttons() } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt index 0cb88356e..7be01984c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt @@ -105,7 +105,6 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) // Enabled without a runtime: the session's loading screen installs it first. PrimaryButton("Play Steam", enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, onClick = a.onPlay) Cog(size = 54.dp, onClick = a.onSteamSettings) - SaveDisk(size = 38.dp, onClick = a.onGameSaves) } } }