mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 19:11:08 +00:00
Game saves: Manage saves on each game's page instead of a separate page
The floppy disk beside the Steam cog and its Game saves page are gone. A game's page now has Manage saves beside Proton prefix, a menu with Import saves (a GameHub or Winlator zip, picked in the app's file picker; the saves there now are backed up first), Export as GameHub zip, Export as Winlator zip (a folder picked the same way) and Open save folder. The game's saves are looked for when the menu opens. The Proton list and config.vdf reader only that page needed are removed.
This commit is contained in:
1 parent
174321c0ec
commit
f22b8253fd
8 files changed
+97
-413
No files matched your search
@@ -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<List<GameSaves.Tool>?>(null)
|
||||
private var saveGames by mutableStateOf<List<GameSaves.Game>>(emptyList())
|
||||
private var saveTool by mutableStateOf<String?>(null)
|
||||
private var saveLayout by mutableStateOf(GameSaves.Layout.GAMEHUB)
|
||||
private var saveBusy by mutableStateOf<String?>(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<ControllerPrefs.Settings?>(null)
|
||||
private var catalog by mutableStateOf<List<DesktopCatalog.Entry>?>(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)
|
||||
|
||||
@@ -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/<appid>/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/<appid>/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<SaveDir>) {
|
||||
/** A game, its prefix, and the save folders found in it. */
|
||||
data class Game(val game: Library.SteamGame, val prefix: File?, val saves: List<SaveDir>) {
|
||||
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<Tool> {
|
||||
val out = LinkedHashMap<String, Tool>()
|
||||
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<String, String> {
|
||||
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<String, String>()
|
||||
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<Library.SteamGame>): List<Game> {
|
||||
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<String, Any> = 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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<List<com.droiddeck.launcher.session.GameSaves.SaveDir>?>(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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
@@ -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<GameSaves.Tool>?,
|
||||
games: List<GameSaves.Game>,
|
||||
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<GameSaves.Game> { 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() }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user