ui: move content, games, GPU drivers and loading copy to string resources

This commit is contained in:
xXJSONDeruloXx committed 2026-10-01 22:14:13 -04:00
1 parent b4a86cb5bb
commit 3ba0d287e9
5 files changed
+217 -103

No files matched your search

@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.runtime.CompositionLocalProvider
import android.os.Build
import androidx.compose.animation.AnimatedContent
@@ -185,8 +186,8 @@ private fun Content(
Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = padH, vertical = padV)) {
when {
selected == "android-apps" && s.isHomeApp -> {
Rise(0) { PageHeader("Android apps") { Chip("${s.androidApps.size} apps", ok = false) } }
if (s.androidApps.isEmpty()) Rise(3) { Note("No launchable Android apps found.") }
Rise(0) { PageHeader(stringResource(R.string.content_android_apps)) { Chip(stringResource(R.string.content_apps_count, s.androidApps.size), ok = false) } }
if (s.androidApps.isEmpty()) Rise(3) { Note(stringResource(R.string.content_no_android_apps)) }
else Rise(3, Modifier.fillMaxWidth()) {
ArtGrid(s.androidApps.map { app ->
Tile(
@@ -208,8 +209,8 @@ private fun Content(
val apps = UserAppsState.items
var adding by rememberSaveable { mutableStateOf(false) }
Rise(0) {
PageHeader("Desktop") {
if (s.desktopInstalled) Chip("● Desktop installed", ok = true) else Chip("Installs on first open", ok = false)
PageHeader(stringResource(R.string.drawer_desktop)) {
if (s.desktopInstalled) Chip(stringResource(R.string.content_desktop_installed), ok = true) else Chip(stringResource(R.string.content_desktop_first_open), ok = false)
}
}
Rise(2) { DesktopCard(s, a) }
@@ -224,7 +225,7 @@ private fun Content(
}
if (UserAppsState.working != null || UserAppsState.lastError != null) Rise(5) { UserAppsProgress() }
if (!addAfterReady) {
Rise(5) { SectionTitle("Available to install", available.size.toString()) }
Rise(5) { SectionTitle(stringResource(R.string.content_available), available.size.toString()) }
Rise(6) {
LauncherGrid(available.map { GridItem.Emu(it) } + GridItem.Add, first = ready.isEmpty(), onSelect = onSelect, onAdd = { adding = true })
}
@@ -237,11 +238,11 @@ private fun Content(
}
selected.startsWith("emu:") -> {
val e = s.emulators.firstOrNull { "emu:${it.id}" == selected }
if (e == null) Note("Not installed.") else {
if (e == null) Note(stringResource(R.string.content_not_installed)) else {
val pkgId = Library.packageId(e.id)
val pkg = pkgId?.let { id -> s.packages?.firstOrNull { it.id == id } }
Rise(0) {
BackLink("Desktop") { onSelect("desktop") }
BackLink(stringResource(R.string.drawer_desktop)) { onSelect("desktop") }
}
Rise(1) {
Row(
@@ -252,7 +253,7 @@ private fun Content(
Column {
Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(
e.system.replaceFirstChar { it.uppercase() } + if (e.installed) "" else " · not installed",
e.system.replaceFirstChar { it.uppercase() }.let { if (e.installed) it else stringResource(R.string.content_not_installed_suffix, it) },
fontSize = 14.sp, color = colors.onSurfaceVariant,
)
}
@@ -261,21 +262,21 @@ private fun Content(
if (e.installed) {
Rise(3) {
Actions {
PrimaryButton("Open ${e.name}", enabled = !s.busy, main = true) { a.onEmulator(e) }
PrimaryButton(stringResource(R.string.content_open_named, e.name), enabled = !s.busy, main = true) { a.onEmulator(e) }
BusyChip(s)
SecondaryButton("ROMs folder", onClick = a.onRoms)
SecondaryButton(stringResource(R.string.setup_tool_roms), onClick = a.onRoms)
if (pkg != null) SecondaryButton(
if (pkg.kind == "appimage") "Remove" else "Hide",
if (pkg.kind == "appimage") stringResource(R.string.store_remove) else stringResource(R.string.common_hide),
enabled = s.packageBusyId == null && !s.sessionRunning,
) { a.onRemovePackage(pkg.id) }
}
}
Rise(4) { SectionTitle("Games", e.games.size.toString()) }
Rise(4) { SectionTitle(stringResource(R.string.content_games), e.games.size.toString()) }
if (e.games.isEmpty()) Rise(5) {
Note(
if (s.romsDir == null) "Choose a ROMs folder."
else if (e.id == "retroarch") "Browse to /root/ROMs in RetroArch."
else "Add ${e.system} games to ROMs/${e.system.substringBefore(' ')}.",
if (s.romsDir == null) stringResource(R.string.content_choose_roms)
else if (e.id == "retroarch") stringResource(R.string.content_retroarch_roms)
else stringResource(R.string.content_add_roms, e.system, e.system.substringBefore(' ')),
)
}
else Rise(5, Modifier.fillMaxWidth()) {
@@ -285,46 +286,46 @@ private fun Content(
if (pkg != null) Rise(2) {
Actions {
PrimaryButton(
if (s.packageBusyId == pkg.id) "Installing…" else "Install ${e.name}",
if (s.packageBusyId == pkg.id) stringResource(R.string.store_installing) else stringResource(R.string.content_install_named, e.name),
enabled = s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning,
) { a.onInstallPackage(pkg.id) }
if (s.sessionRunning) ActionChip("Stop session to install", ok = false)
else if (!s.ready) ActionChip("Runtime required", ok = false)
if (s.sessionRunning) ActionChip(stringResource(R.string.content_stop_to_install), ok = false)
else if (!s.ready) ActionChip(stringResource(R.string.content_runtime_required), ok = false)
}
}
Rise(3) {
Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
Note(when {
s.packageCatalogLoading -> "Loading install details…"
pkg == null -> "Install details are unavailable right now. Try again when the package catalog is reachable."
!s.ready -> "Install the Linux runtime from Setup before installing desktop apps."
s.sessionRunning -> "Stop the active session before installing desktop apps."
s.packageCatalogLoading -> stringResource(R.string.content_loading_details)
pkg == null -> stringResource(R.string.content_details_unavailable)
!s.ready -> stringResource(R.string.content_needs_runtime)
s.sessionRunning -> stringResource(R.string.content_stop_first)
pkg.notes.isNotBlank() -> pkg.notes
else -> "Install ${e.name} into the Linux desktop runtime."
else -> stringResource(R.string.content_install_into, e.name)
})
}
}
if (s.packageBusyId == pkg?.id) Rise(4) {
val stage = s.packageStage
Text(
if (stage != null && s.packagePercent >= 0) "$stage · ${s.packagePercent}%" else stage ?: "Starting…",
if (stage != null && s.packagePercent >= 0) stringResource(R.string.store_busy_percent_short, stage, s.packagePercent) else stage ?: stringResource(R.string.store_starting),
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp),
)
if (s.packagePercent >= 0) LinearProgressIndicator(progress = { s.packagePercent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
if (pkg?.kind == "tar") Rise(5) { Note("Hiding this package takes it off Desktop; its files stay in the Linux runtime.") }
if (pkg?.kind == "tar") Rise(5) { Note(stringResource(R.string.content_hide_note)) }
}
}
}
selected.startsWith("rom:") -> {
val pair = romFor(s, selected)
if (pair == null) Note("That game is gone from the ROMs folder.") else {
if (pair == null) Note(stringResource(R.string.content_rom_gone)) else {
val (e, g) = pair
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
BackLink(e.name) { onSelect("emu:${e.id}") }
Eyebrow("Desktop · ${e.system}")
Eyebrow(stringResource(R.string.content_desktop_system, e.system))
}
}
Rise(1) { Title(g.name) }
@@ -335,7 +336,7 @@ private fun Content(
Spacer(Modifier.height(14.dp))
Actions {
Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp))
PrimaryButton("Launch in ${e.name}", enabled = !s.busy, main = true) { a.onRom(g) }
PrimaryButton(stringResource(R.string.content_launch_in, e.name), enabled = !s.busy, main = true) { a.onRom(g) }
if (s.busy) BusyChip(s) else ActionChip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false)
}
}
@@ -344,7 +345,7 @@ private fun Content(
}
val others = e.games.filter { it !== g }
if (others.isNotEmpty()) {
Rise(3) { SectionTitle("Also in ${e.name}", null) }
Rise(3) { SectionTitle(stringResource(R.string.content_also_in, e.name), null) }
Rise(4, Modifier.fillMaxWidth()) {
ArtGrid(others.map { x ->
val index = e.games.indexOf(x)
@@ -354,7 +355,7 @@ private fun Content(
}
}
}
else -> Note("That page is gone. Pick a section on the left.")
else -> Note(stringResource(R.string.content_page_gone))
}
}
}
@@ -368,7 +369,7 @@ private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
val actions: @Composable () -> Unit = {
Actions {
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
PrimaryButton(if (s.desktopInstalled) "Open desktop" else "Install & open desktop", enabled = !s.busy, main = true, onClick = a.onDesktop)
PrimaryButton(if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop), enabled = !s.busy, main = true, onClick = a.onDesktop)
Cog(onClick = a.onDesktopSettings)
BusyChip(s)
}
@@ -383,8 +384,8 @@ private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14),
) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) }
Column(modifier = Modifier.weight(1f)) {
Text("Linux desktop", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text("LXQt, Firefox and your emulators", fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(stringResource(R.string.content_linux_desktop), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(stringResource(R.string.content_linux_desktop_hint), fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (!narrow) actions()
}
@@ -599,7 +600,7 @@ private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boole
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val system = e.system.replaceFirstChar { it.uppercase() }
val detail = if (e.installed && e.id != "retroarch") "$system · ${e.games.size} game${if (e.games.size == 1) "" else "s"}" else system
val detail = if (e.installed && e.id != "retroarch") "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size) else system
TileFrame("emu:${e.id}", modifier, isFirst, filled = e.installed, onClick = onClick) {
Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp))
Column(modifier = Modifier.weight(1f)) {
@@ -607,7 +608,7 @@ private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boole
Text(detail, fontSize = if (e.installed) 13.sp else 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (!e.installed) Text(
"Install", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
stringResource(R.string.setup_install), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
modifier = Modifier.clip(RoundedCornerShape(8.dp)).border(1.dp, pal.line2, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp),
)
}
@@ -1,5 +1,7 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -66,10 +68,10 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull()
if (current == null) {
Column(modifier = modifier.padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
Rise(0) { PageHeader("Games") }
Rise(1) { Note("Games you install in Steam show up here. Open Steam, install one, and it appears after the session.") }
Rise(0) { PageHeader(stringResource(R.string.content_games)) }
Rise(1) { Note(stringResource(R.string.games_empty)) }
Rise(2) {
Actions { PrimaryButton("Play Steam", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp), onClick = a.onPlay) }
Actions { PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp), onClick = a.onPlay) }
}
}
return
@@ -86,7 +88,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp))
}
}
Rise(1) { SectionTitle("Launch settings", null) }
Rise(1) { SectionTitle(stringResource(R.string.games_launch_settings), null) }
Rise(2) { LaunchSettings(s, a, host) }
}
return
@@ -104,7 +106,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
GameHeroCopy(current, if (narrow) 24.sp else 32.sp)
GameActions(current, s, a)
}
SectionTitle("Launch settings", null)
SectionTitle(stringResource(R.string.games_launch_settings), null)
LaunchSettings(s, a, host)
}
}
@@ -114,12 +116,12 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
@Composable
private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions) {
Actions {
PrimaryButton("Launch", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(g) }
PrimaryButton(stringResource(R.string.games_launch), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(g) }
g.gameFiles?.takeIf { it.isDirectory }?.let { dir ->
SecondaryButton("Game files", compact = true) { a.onBrowseFiles(dir) }
SecondaryButton(stringResource(R.string.games_files), compact = true) { a.onBrowseFiles(dir) }
}
g.protonPrefix?.takeIf { it.isDirectory }?.let { dir ->
SecondaryButton("Proton prefix", compact = true) { a.onBrowseFiles(dir) }
SecondaryButton(stringResource(R.string.games_prefix), compact = true) { a.onBrowseFiles(dir) }
// Only games added to the library; Steam titles keep their saves with Steam Cloud.
if (g.library == Library.ADDED) ManageSaves(g, dir, a)
}
@@ -141,28 +143,32 @@ private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndA
}
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."
found == null -> stringResource(R.string.games_saves_looking)
found.isEmpty() -> stringResource(R.string.games_saves_none)
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))
stringResource(
R.string.games_saves_summary,
if (found.size == 1) found[0].relPath else stringResource(R.string.games_saves_folders, found.size),
found.sumOf { it.files },
if (mb < 1) stringResource(R.string.games_kb, (mb * 1024).toInt()) else stringResource(R.string.common_size_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) {
SecondaryButton(stringResource(R.string.games_manage_saves), compact = true) { open = !open }
AnchoredMenu(open, onDismiss = { open = false }, title = stringResource(R.string.games_saves), note = summary) { first ->
MenuItem(stringResource(R.string.games_import_saves), checked = false, detail = stringResource(R.string.games_import_saves_hint), focusRequester = first) {
open = false; a.onSaveImport(g)
}
MenuItem("Export as GameHub zip…", checked = false, detail = "Saves under steamuser, for GameHub and BannerHub") {
MenuItem(stringResource(R.string.games_export_gamehub), checked = false, detail = stringResource(R.string.games_export_gamehub_hint)) {
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") {
MenuItem(stringResource(R.string.games_export_winlator), checked = false, detail = stringResource(R.string.games_export_winlator_hint)) {
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) {
MenuItem(stringResource(R.string.games_open_save_folder), checked = false, detail = d.relPath) {
open = false; a.onBrowseFiles(java.io.File(prefix, "drive_c/users/steamuser/" + d.relPath))
}
}
@@ -197,7 +203,7 @@ private fun GameList(
modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp),
) {
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 6.dp, bottom = 10.dp)) {
Text("Games", fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
Text(stringResource(R.string.content_games), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
Text(games.size.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 3.dp))
}
for (g in games) key(g.appId) {
@@ -231,7 +237,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni
Column(modifier = Modifier.weight(1f)) {
Text(g.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
lastPlayedText(g.lastPlayed)?.removePrefix("Last played ")?.replaceFirstChar { it.uppercase() } ?: "Never played",
playedSpan(g.lastPlayed)?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.games_never_played),
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
@@ -256,30 +262,35 @@ internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit
/** Why a launch button is greyed out: the runtime is being worked on. Nothing when it is not. */
@Composable
internal fun BusyChip(s: FrontEndState) {
if (s.busy) ActionChip(if (s.percent >= 0) "Runtime busy · ${s.percent}%" else "Runtime busy", ok = false)
if (s.busy) ActionChip(if (s.percent >= 0) stringResource(R.string.games_runtime_busy_percent, s.percent) else stringResource(R.string.games_runtime_busy), ok = false)
}
/** Whether Steam can start, said where Play is rather than only in Setup. */
@Composable
internal fun RuntimeChip(s: FrontEndState) = when {
s.busy -> Chip(if (s.percent >= 0) "${s.stage} · ${s.percent}%" else s.stage, ok = false)
!s.ready -> Chip("Runtime installs on first Play", ok = false)
s.available != null && s.available != s.installed -> Chip("Runtime update available", ok = false)
else -> Chip("● Runtime ready", ok = true)
s.busy -> Chip(if (s.percent >= 0) stringResource(R.string.setup_runtime_progress, s.stage, s.percent) else s.stage, ok = false)
!s.ready -> Chip(stringResource(R.string.games_runtime_first_play), ok = false)
s.available != null && s.available != s.installed -> Chip(stringResource(R.string.games_runtime_update), ok = false)
else -> Chip(stringResource(R.string.games_runtime_ready), ok = true)
}
/** "Last played 3 days ago" from Steam's unix seconds; null for a game never played. */
private fun lastPlayedText(lastPlayed: Long): String? {
@Composable
private fun lastPlayedText(lastPlayed: Long): String? =
playedSpan(lastPlayed)?.let { stringResource(R.string.games_last_played, it.replaceFirstChar { c -> c.lowercase() }) }
/** "3 days ago" from Steam's unix seconds; null for a game never played. */
private fun playedSpan(lastPlayed: Long): String? {
if (lastPlayed <= 0L) return null
val span = android.text.format.DateUtils.getRelativeTimeSpanString(
return android.text.format.DateUtils.getRelativeTimeSpanString(
lastPlayed * 1000L, System.currentTimeMillis(), android.text.format.DateUtils.MINUTE_IN_MILLIS,
).toString()
return "Last played " + span.replaceFirstChar { it.lowercase() }
}
@Composable
private fun libraryLabel(library: String): String = when (library) {
"internal" -> "Internal storage"
Library.ADDED -> "Added game"
"internal" -> stringResource(R.string.games_internal)
Library.ADDED -> stringResource(R.string.games_added)
else -> library
}
@@ -1,5 +1,7 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -120,29 +122,28 @@ internal fun GpuDriversPanel(s: GpuDriversState, a: GpuDriversActions, onAdvance
DeviceCard(s)
if (!s.unsupported) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ModeCard(Icons.Outlined.AutoAwesome, "Auto", "The best pair for this GPU, kept up to date", s.auto, Modifier.weight(1f)) { a.onAuto(true) }
ModeCard(Icons.Outlined.Tune, "Manual", "Pick a pair yourself", !s.auto, Modifier.weight(1f)) { a.onAuto(false) }
ModeCard(Icons.Outlined.AutoAwesome, stringResource(R.string.common_auto), stringResource(R.string.gpu_auto_hint), s.auto, Modifier.weight(1f)) { a.onAuto(true) }
ModeCard(Icons.Outlined.Tune, stringResource(R.string.mode_suspend_manual), stringResource(R.string.gpu_manual_hint), !s.auto, Modifier.weight(1f)) { a.onAuto(false) }
}
if (s.auto) AutoCard(s)
else {
val shown = s.pairs.filter { it.suits || showAll }
if (shown.isEmpty()) Text(
if (s.checking) "Checking for drivers…" else "No drivers listed yet. Tap refresh to look for them.",
if (s.checking) stringResource(R.string.gpu_checking) else stringResource(R.string.gpu_none_listed),
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp),
)
for (p in shown) PairLine(p, s.busy == p.key, s.percent, enabled = s.busy == null) { a.onPair(p.key) }
if (s.pairs.any { !it.suits }) FocusText(
if (showAll) "Hide drivers for other GPUs" else "Show drivers for other GPUs", pal.signal,
if (showAll) stringResource(R.string.gpu_hide_other) else stringResource(R.string.gpu_show_other), pal.signal,
) { showAll = !showAll }
}
}
SettingsGroup("Advanced") {
val together = s.activeBundle?.let { "Set together with the %s driver: $it carries both." }
SettingsRow("Runtime driver", together?.format("display") ?: "Games and the desktop draw with it.") {
DriverDropdown(s.linuxRows, s.linuxSelected, "Runtime default", a.onSelectLinux) { onAdvanced("rt") }
SettingsGroup(stringResource(R.string.gpu_advanced)) {
SettingsRow(stringResource(R.string.comp_runtime_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_display, it) } ?: stringResource(R.string.gpu_runtime_hint)) {
DriverDropdown(s.linuxRows, s.linuxSelected, stringResource(R.string.gpu_runtime_default), a.onSelectLinux) { onAdvanced("rt") }
}
SettingsRow("Display driver", together?.format("runtime") ?: "Puts frames on the screen. Changes apply after DroidDeck restarts.") {
DriverDropdown(s.androidRows, s.androidSelected, "Auto - picked by GPU", a.onSelectAndroid) { onAdvanced("panel") }
SettingsRow(stringResource(R.string.comp_display_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_runtime, it) } ?: stringResource(R.string.gpu_display_hint)) {
DriverDropdown(s.androidRows, s.androidSelected, stringResource(R.string.gpu_auto_by_gpu), a.onSelectAndroid) { onAdvanced("panel") }
}
}
}
@@ -174,7 +175,7 @@ private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: St
onClick = { open = false; if (row.id != selected) onSelect(row.id) },
)
HorizontalDivider(color = pal.line)
DropdownMenuItem(text = { Text("Download, import or delete…", fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() })
DropdownMenuItem(text = { Text(stringResource(R.string.gpu_manage), fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() })
}
}
}
@@ -217,14 +218,14 @@ private fun AutoCard(s: GpuDriversState) {
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
) {
Text("IN USE", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
Text(stringResource(R.string.gpu_in_use_caps), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
Text(
active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: "The drivers built into DroidDeck",
active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: stringResource(R.string.gpu_builtin),
fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
)
Text(
if (active != null || s.activeBundle != null) "Runtime and display drivers set together as a matched pair."
else recommended?.let { "Recommended for this GPU: ${it.name}." } ?: "Tap refresh to look for drivers for this GPU.",
if (active != null || s.activeBundle != null) stringResource(R.string.gpu_matched_pair)
else recommended?.let { stringResource(R.string.gpu_recommended_for, it.name) } ?: stringResource(R.string.gpu_refresh_hint),
fontSize = 13.sp, color = colors.onSurfaceVariant,
)
if (s.busy != null) {
@@ -284,7 +285,7 @@ private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean,
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(p.name, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (p.recommended) Text(
"RECOMMENDED", fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good,
stringResource(R.string.gpu_recommended_caps), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good,
modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(pal.good.copy(alpha = 0.12f)).padding(horizontal = 6.dp, vertical = 2.dp),
)
}
@@ -295,10 +296,10 @@ private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean,
Text(if (percent >= 0) "$percent%" else "…", fontSize = 12.sp, color = colors.onSurfaceVariant)
LinearProgressIndicator(progress = { percent.coerceAtLeast(0) / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
}
p.active -> Text("In use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
!p.complete -> Text("Unavailable", fontSize = 13.sp, color = colors.onSurfaceVariant)
p.installed -> Text("Use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
else -> Text("Download", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
p.active -> Text(stringResource(R.string.comp_tag_in_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
!p.complete -> Text(stringResource(R.string.gpu_unavailable), fontSize = 13.sp, color = colors.onSurfaceVariant)
p.installed -> Text(stringResource(R.string.common_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
else -> Text(stringResource(R.string.common_download), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
}
}
}
@@ -1,5 +1,7 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
@@ -89,17 +91,17 @@ fun HudText(text: String) {
}
/** One line of the loading screen's checklist; [detail] is what it says while it is the one running. */
private class LoadStage(val label: String, val detail: String)
private class LoadStage(@androidx.annotation.StringRes val label: Int, @androidx.annotation.StringRes val detail: Int)
private fun loadStages(steam: Boolean): List<LoadStage> = if (steam) listOf(
LoadStage("Linux runtime", "Getting the Linux runtime ready"),
LoadStage("Session", "Starting the display and audio"),
LoadStage("Steam client", "Checking the Steam client and its compatibility tools"),
LoadStage("Opening Steam", "Waiting for Steam's first frame"),
LoadStage(R.string.load_runtime, R.string.load_runtime_detail),
LoadStage(R.string.load_session, R.string.load_session_detail),
LoadStage(R.string.load_steam, R.string.load_steam_detail),
LoadStage(R.string.load_opening_steam, R.string.load_opening_steam_detail),
) else listOf(
LoadStage("Linux runtime", "Getting the Linux runtime ready"),
LoadStage("Desktop", "Getting the desktop ready"),
LoadStage("Opening", "Waiting for the first frame"),
LoadStage(R.string.load_runtime, R.string.load_runtime_detail),
LoadStage(R.string.load_desktop, R.string.load_desktop_detail),
LoadStage(R.string.load_opening, R.string.load_opening_detail),
)
/**
@@ -128,7 +130,7 @@ private fun readableStep(step: String): Boolean {
@Composable
fun LoadingOverlay(
step: String, percent: Int, elapsed: String, hint: String, ended: Boolean,
title: String = "Starting Steam", steam: Boolean = true, onCancel: (() -> Unit)? = null,
title: String = stringResource(R.string.load_starting_steam), steam: Boolean = true, onCancel: (() -> Unit)? = null,
/** An ended session's exit status and log path, shown small under the advice. */
endedDetail: String? = null,
onRetry: (() -> Unit)? = null,
@@ -147,7 +149,7 @@ fun LoadingOverlay(
if (at > reached) reached = at
}
val active = reached.coerceAtMost(stages.lastIndex)
val detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stages[active].detail
val detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stringResource(stages[active].detail)
val context = LocalContext.current
// The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in.
val games by produceState(emptyList<Library.SteamGame>()) {
@@ -186,7 +188,7 @@ fun LoadingOverlay(
}
Spacer(Modifier.height(30.dp))
Text(
if (ended) "The session ended" else title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold,
if (ended) stringResource(R.string.load_ended) else title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold,
color = colors.onBackground, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
@@ -223,9 +225,9 @@ private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onC
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 22.dp)) {
var firstUsed = false
fun claim(): Modifier = if (firstUsed) Modifier else { firstUsed = true; Modifier.focusRequester(first) }
if (onRetry != null) PrimaryButton("Try again", modifier = claim(), onClick = onRetry)
if (onShareLogs != null) SecondaryButton("Share logs", modifier = claim(), onClick = onShareLogs)
if (onClose != null) SecondaryButton("Back", modifier = claim(), onClick = onClose)
if (onRetry != null) PrimaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry)
if (onShareLogs != null) SecondaryButton(stringResource(R.string.drawer_share_logs), modifier = claim(), onClick = onShareLogs)
if (onClose != null) SecondaryButton(stringResource(R.string.mode_back), modifier = claim(), onClick = onClose)
}
}
@@ -237,9 +239,10 @@ private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifi
val run = rememberInfiniteTransition(label = "segment")
.animateFloat(-0.4f, 1f, infiniteRepeatable(tween(1300, easing = FastOutSlowInEasing)), label = "run")
val still = Motion.scale == 0f
val stepDescription = stringResource(R.string.load_step, active + 1, count)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier.semantics { contentDescription = "Step ${active + 1} of $count" },
modifier = modifier.semantics { contentDescription = stepDescription },
) {
repeat(count) { i ->
Box(
@@ -272,13 +275,13 @@ private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) {
Text("B", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background)
}
Text("Cancel", fontSize = 13.sp, color = colors.onSurfaceVariant)
Text(stringResource(R.string.common_cancel), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
}
/** A session paused in the background, ready to pick up where it left off. */
@Composable
fun SessionPausedOverlay(title: String = "Steam is paused", onResume: () -> Unit) {
fun SessionPausedOverlay(title: String = stringResource(R.string.load_steam_paused), onResume: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val interactionSource = remember { MutableInteractionSource() }
@@ -304,7 +307,7 @@ fun SessionPausedOverlay(title: String = "Steam is paused", onResume: () -> Unit
}
Text(title, fontSize = 28.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, textAlign = TextAlign.Center)
PrimaryButton(
"Resume",
stringResource(R.string.resume_session),
modifier = Modifier.padding(top = 14.dp).focusRequester(resumeFocus).controllerConfirm(onClick = onResume),
onClick = onResume,
)
+98
View File
@@ -740,4 +740,102 @@
<string name="ctrl_onscreen_buttons">On-screen buttons</string>
<string name="ctrl_defaults">Defaults</string>
<string name="ctrl_reset_mapping">Reset mapping</string>
<string name="content_android_apps">Android apps</string>
<string name="content_apps_count">%1$d apps</string>
<string name="content_no_android_apps">No launchable Android apps found.</string>
<string name="content_desktop_installed">● Desktop installed</string>
<string name="content_desktop_first_open">Installs on first open</string>
<string name="content_available">Available to install</string>
<string name="content_not_installed">Not installed.</string>
<string name="content_not_installed_suffix">%1$s · not installed</string>
<string name="content_open_named">Open %1$s</string>
<string name="content_games">Games</string>
<string name="content_choose_roms">Choose a ROMs folder.</string>
<string name="content_retroarch_roms">Browse to /root/ROMs in RetroArch.</string>
<string name="content_add_roms">Add %1$s games to ROMs/%2$s.</string>
<string name="content_install_named">Install %1$s</string>
<string name="content_stop_to_install">Stop session to install</string>
<string name="content_runtime_required">Runtime required</string>
<string name="content_loading_details">Loading install details…</string>
<string name="content_details_unavailable">Install details are unavailable. Try again later.</string>
<string name="content_needs_runtime">Install the Linux runtime from Setup first.</string>
<string name="content_stop_first">Stop the running session to install desktop apps.</string>
<string name="content_install_into">Installs %1$s into the Linux desktop.</string>
<string name="store_busy_percent_short">%1$s · %2$d%%</string>
<string name="content_hide_note">Hiding removes it from Desktop. Its files stay in the Linux runtime.</string>
<string name="content_rom_gone">That game is no longer in the ROMs folder.</string>
<string name="content_desktop_system">Desktop · %1$s</string>
<string name="content_launch_in">Launch in %1$s</string>
<string name="content_also_in">Also in %1$s</string>
<string name="content_page_gone">That page is gone. Pick a section on the left.</string>
<string name="content_open_desktop">Open desktop</string>
<string name="content_install_open_desktop">Install &amp; open desktop</string>
<string name="content_linux_desktop">Linux desktop</string>
<string name="content_linux_desktop_hint">LXQt, Firefox and your emulators</string>
<string name="games_empty">Games you install in Steam show up here after the session.</string>
<string name="games_play_steam">Play Steam</string>
<string name="games_launch_settings">Launch settings</string>
<string name="games_launch">Launch</string>
<string name="games_files">Game files</string>
<string name="games_prefix">Proton prefix</string>
<string name="games_saves_looking">Looking for saves…</string>
<string name="games_saves_none">No save folder found by name. Export takes the whole user folder.</string>
<string name="games_saves_folders">%1$d save folders</string>
<string name="games_saves_summary">%1$s · %2$d files · %3$s</string>
<string name="games_kb">%1$d KB</string>
<string name="games_manage_saves">Manage saves</string>
<string name="games_saves">Game saves</string>
<string name="games_import_saves">Import saves…</string>
<string name="games_import_saves_hint">GameHub or Winlator zip. Current saves are backed up first.</string>
<string name="games_export_gamehub">Export as GameHub zip…</string>
<string name="games_export_gamehub_hint">For GameHub and BannerHub</string>
<string name="games_export_winlator">Export as Winlator zip…</string>
<string name="games_export_winlator_hint">For Winlator, WinNative and Bannerlator</string>
<string name="games_open_save_folder">Open save folder</string>
<string name="games_never_played">Never played</string>
<string name="games_runtime_busy_percent">Runtime busy · %1$d%%</string>
<string name="games_runtime_busy">Runtime busy</string>
<string name="games_runtime_first_play">Runtime installs on first Play</string>
<string name="games_runtime_update">Runtime update available</string>
<string name="games_runtime_ready">● Runtime ready</string>
<string name="games_last_played">Last played %1$s</string>
<string name="games_internal">Internal storage</string>
<string name="games_added">Added game</string>
<string name="gpu_auto_hint">The best pair for this GPU, kept up to date</string>
<string name="gpu_manual_hint">Pick a pair yourself</string>
<string name="gpu_checking">Checking for drivers…</string>
<string name="gpu_none_listed">No drivers listed yet. Refresh to look for them.</string>
<string name="gpu_hide_other">Hide drivers for other GPUs</string>
<string name="gpu_show_other">Show drivers for other GPUs</string>
<string name="gpu_advanced">Advanced</string>
<string name="gpu_set_with_display">Set together with the display driver: %1$s carries both.</string>
<string name="gpu_set_with_runtime">Set together with the runtime driver: %1$s carries both.</string>
<string name="gpu_runtime_hint">Used by games and the desktop.</string>
<string name="gpu_runtime_default">Runtime default</string>
<string name="gpu_display_hint">Draws frames to the screen. Applies after DroidDeck restarts.</string>
<string name="gpu_auto_by_gpu">Auto - picked by GPU</string>
<string name="gpu_manage">Download, import or delete…</string>
<string name="gpu_in_use_caps">IN USE</string>
<string name="gpu_builtin">The drivers built into DroidDeck</string>
<string name="gpu_matched_pair">Runtime and display drivers set together as a matched pair.</string>
<string name="gpu_recommended_for">Recommended for this GPU: %1$s.</string>
<string name="gpu_refresh_hint">Refresh to look for drivers for this GPU.</string>
<string name="gpu_recommended_caps">RECOMMENDED</string>
<string name="gpu_unavailable">Unavailable</string>
<string name="load_runtime">Linux runtime</string>
<string name="load_runtime_detail">Getting the Linux runtime ready</string>
<string name="load_session">Session</string>
<string name="load_session_detail">Starting display and audio</string>
<string name="load_steam">Steam client</string>
<string name="load_steam_detail">Checking Steam and its compatibility tools</string>
<string name="load_opening_steam">Opening Steam</string>
<string name="load_opening_steam_detail">Waiting for Steam\'s first frame</string>
<string name="load_desktop">Desktop</string>
<string name="load_desktop_detail">Getting the desktop ready</string>
<string name="load_opening">Opening</string>
<string name="load_opening_detail">Waiting for the first frame</string>
<string name="load_starting_steam">Starting Steam</string>
<string name="load_ended">The session ended</string>
<string name="load_step">Step %1$d of %2$d</string>
<string name="load_steam_paused">Steam is paused</string>
</resources>