diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index eecd5fb2d..66ebde211 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -31,6 +31,7 @@
+
@@ -125,6 +126,18 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
+
+
+
+
,
+ selectedBranch: StoreBetaBranchItem?,
+ onSelect: (StoreBetaBranchItem) -> Unit,
+ onClose: () -> Unit,
+) {
+ BoxWithConstraints(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(BbScrim.copy(alpha = 0.6f))
+ .windowInsetsPadding(WindowInsets.navigationBars),
+ contentAlignment = Alignment.Center,
+ ) {
+ val dialogWidth = (maxWidth - 32.dp).coerceAtMost(560.dp)
+ val dialogMaxHeight = (maxHeight - 48.dp).coerceIn(220.dp, 640.dp)
+ Surface(
+ modifier =
+ Modifier
+ .widthIn(min = 320.dp, max = dialogWidth)
+ .fillMaxWidth()
+ .heightIn(max = dialogMaxHeight),
+ shape = RoundedCornerShape(14.dp),
+ color = BbBg,
+ border = BorderStroke(1.dp, BbBorder),
+ tonalElevation = 8.dp,
+ ) {
+ Column(Modifier.fillMaxWidth()) {
+ BetaBranchHeader(
+ gameTitle = gameTitle,
+ branchCount = branches.size,
+ onClose = onClose,
+ )
+ HorizontalDivider(color = BbBorder, thickness = 0.5.dp)
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth().weight(1f, fill = false),
+ contentPadding = PaddingValues(vertical = 4.dp),
+ ) {
+ itemsIndexed(branches) { index, branch ->
+ BetaBranchPickerRow(
+ branch = branch,
+ selected = branch == selectedBranch,
+ onClick = { onSelect(branch) },
+ )
+ if (index < branches.lastIndex) {
+ HorizontalDivider(
+ color = Color.White.copy(alpha = 0.06f),
+ thickness = 1.dp,
+ modifier = Modifier.padding(horizontal = 14.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun BetaBranchHeader(
+ gameTitle: String,
+ branchCount: Int,
+ onClose: () -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Box(
+ Modifier
+ .size(34.dp)
+ .clip(RoundedCornerShape(9.dp))
+ .background(BbAccent.copy(alpha = 0.16f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Outlined.AltRoute,
+ contentDescription = null,
+ tint = BbAccentGlow,
+ modifier = Modifier.size(19.dp),
+ )
+ }
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
+ Text(
+ stringResource(R.string.store_game_beta_branch).uppercase(),
+ color = BbTextSecondary,
+ fontSize = 9.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 0.9.sp,
+ )
+ Text(
+ gameTitle,
+ style = MaterialTheme.typography.titleSmall,
+ color = BbTextPrimary,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Surface(
+ modifier =
+ Modifier.semantics {
+ contentDescription = "$branchCount branches"
+ },
+ color = BbAccent.copy(alpha = 0.14f),
+ shape = RoundedCornerShape(7.dp),
+ ) {
+ Text(
+ branchCount.toString(),
+ color = BbAccentGlow,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp),
+ )
+ }
+ IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
+ Icon(
+ Icons.Outlined.Close,
+ contentDescription = "Close",
+ tint = BbTextSecondary,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun BetaBranchPickerRow(
+ branch: StoreBetaBranchItem,
+ selected: Boolean,
+ onClick: () -> Unit,
+) {
+ val rowAlpha = if (branch.pwdRequired) 0.45f else 1f
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .then(if (!branch.pwdRequired) Modifier.clickable(onClick = onClick) else Modifier)
+ .alpha(rowAlpha)
+ .padding(horizontal = 14.dp, vertical = 11.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(11.dp),
+ ) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ val displayName =
+ if (branch.name.equals("public", ignoreCase = true)) {
+ "${branch.name} (default)"
+ } else {
+ branch.name
+ }
+ Text(
+ displayName,
+ color = if (selected) BbAccentGlow else BbTextPrimary,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ val dateStr =
+ remember(branch.timeUpdated) {
+ branch.timeUpdated
+ ?.let { SimpleDateFormat("MMM d, yyyy", Locale.US).format(it) }
+ ?: "—"
+ }
+ Text(
+ "build ${branch.buildId} · $dateStr",
+ color = BbTextSecondary,
+ fontSize = 11.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ when {
+ branch.pwdRequired -> Icon(
+ Icons.Outlined.Lock,
+ contentDescription = null,
+ tint = BbLocked,
+ modifier = Modifier.size(17.dp),
+ )
+ selected -> Icon(
+ Icons.Outlined.Check,
+ contentDescription = null,
+ tint = BbAccentGlow,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+}
diff --git a/app/src/main/app/shell/LaunchOptionsScreen.kt b/app/src/main/app/shell/LaunchOptionsScreen.kt
new file mode 100644
index 000000000..8309a381b
--- /dev/null
+++ b/app/src/main/app/shell/LaunchOptionsScreen.kt
@@ -0,0 +1,251 @@
+package com.winlator.cmod.app.shell
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Check
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.RocketLaunch
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+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.winlator.cmod.R
+
+/** A single Steam launch option (appinfo `config.launch` entry). */
+internal data class StoreLaunchOptionItem(
+ // Relative path, '/'-separated.
+ val executable: String,
+ val arguments: String,
+ val label: String,
+)
+
+// Palette — mirrors the Workshop window so the modal feels native.
+private val LoBg = Color(0xFF12121B)
+private val LoBorder = Color(0xFF2A2A3A)
+private val LoAccent = Color(0xFF1A9FFF)
+private val LoAccentGlow = Color(0xFF58A6FF)
+private val LoTextPrimary = Color(0xFFF0F4FF)
+private val LoTextSecondary = Color(0xFF93A6BC)
+private val LoScrim = Color(0xFF000000)
+
+/**
+ * Steam launch-option picker — a Workshop-shaped modal window listing the
+ * game's appinfo `config.launch` entries. Tapping a row persists it as the
+ * game's default; the check mark moves to confirm and the window stays open.
+ *
+ * Stateless: data and callbacks are hoisted to the LaunchOptionsDialog wrapper.
+ */
+@Composable
+internal fun StoreLaunchOptionsScreen(
+ gameTitle: String,
+ options: List,
+ selectedOption: StoreLaunchOptionItem?,
+ onSelect: (StoreLaunchOptionItem) -> Unit,
+ onClose: () -> Unit,
+) {
+ BoxWithConstraints(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ // Dim the game-detail screen behind so the modal reads as foreground.
+ .background(LoScrim.copy(alpha = 0.6f))
+ .windowInsetsPadding(WindowInsets.navigationBars),
+ contentAlignment = Alignment.Center,
+ ) {
+ val dialogWidth = (maxWidth - 32.dp).coerceAtMost(560.dp)
+ val dialogMaxHeight = (maxHeight - 48.dp).coerceIn(220.dp, 640.dp)
+ Surface(
+ modifier =
+ Modifier
+ .widthIn(min = 320.dp, max = dialogWidth)
+ .fillMaxWidth()
+ .heightIn(max = dialogMaxHeight),
+ shape = RoundedCornerShape(14.dp),
+ color = LoBg,
+ border = BorderStroke(1.dp, LoBorder),
+ tonalElevation = 8.dp,
+ ) {
+ Column(Modifier.fillMaxWidth()) {
+ LaunchOptionsHeader(
+ gameTitle = gameTitle,
+ optionCount = options.size,
+ onClose = onClose,
+ )
+ HorizontalDivider(color = LoBorder, thickness = 0.5.dp)
+ LazyColumn(
+ // fill = false: the window wraps short lists instead of
+ // stretching to the max height.
+ modifier = Modifier.fillMaxWidth().weight(1f, fill = false),
+ contentPadding = PaddingValues(vertical = 4.dp),
+ ) {
+ itemsIndexed(options) { index, option ->
+ LaunchOptionPickerRow(
+ option = option,
+ selected = option == selectedOption,
+ onClick = { onSelect(option) },
+ )
+ if (index < options.lastIndex) {
+ HorizontalDivider(
+ color = Color.White.copy(alpha = 0.06f),
+ thickness = 1.dp,
+ modifier = Modifier.padding(horizontal = 14.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LaunchOptionsHeader(
+ gameTitle: String,
+ optionCount: Int,
+ onClose: () -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Box(
+ Modifier
+ .size(34.dp)
+ .clip(RoundedCornerShape(9.dp))
+ .background(LoAccent.copy(alpha = 0.16f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Outlined.RocketLaunch,
+ contentDescription = null,
+ tint = LoAccentGlow,
+ modifier = Modifier.size(19.dp),
+ )
+ }
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
+ Text(
+ stringResource(R.string.store_game_launch_options).uppercase(),
+ color = LoTextSecondary,
+ fontSize = 9.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 0.9.sp,
+ )
+ Text(
+ gameTitle,
+ style = MaterialTheme.typography.titleSmall,
+ color = LoTextPrimary,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Surface(
+ modifier =
+ Modifier.semantics {
+ contentDescription = "$optionCount launch options"
+ },
+ color = LoAccent.copy(alpha = 0.14f),
+ shape = RoundedCornerShape(7.dp),
+ ) {
+ Text(
+ optionCount.toString(),
+ color = LoAccentGlow,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp),
+ )
+ }
+ IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
+ Icon(
+ Icons.Outlined.Close,
+ contentDescription = "Close",
+ tint = LoTextSecondary,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun LaunchOptionPickerRow(
+ option: StoreLaunchOptionItem,
+ selected: Boolean,
+ onClick: () -> Unit,
+) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 14.dp, vertical = 11.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(11.dp),
+ ) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ option.label,
+ color = if (selected) LoAccentGlow else LoTextPrimary,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ buildString {
+ append(option.executable)
+ if (option.arguments.isNotBlank()) {
+ append(" · ")
+ append(option.arguments)
+ }
+ },
+ color = LoTextSecondary,
+ fontSize = 11.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (selected) {
+ Icon(
+ Icons.Outlined.Check,
+ contentDescription = null,
+ tint = LoAccentGlow,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+}
diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt
index 6a55ffa25..229309553 100644
--- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt
+++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt
@@ -46,8 +46,11 @@ import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.CloudSync
import androidx.compose.material.icons.outlined.Construction
import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Refresh
+import androidx.compose.material.icons.outlined.AltRoute
+import androidx.compose.material.icons.outlined.RocketLaunch
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.Save
@@ -132,11 +135,15 @@ internal fun LibraryGameLaunchScreen(
showVerifyFiles: Boolean = true,
showCheckForUpdate: Boolean = true,
showWorkshop: Boolean = true,
+ // Nullable on purpose: null hides the menu item.
+ onLaunchOptions: (() -> Unit)? = null,
+ onBetaBranches: (() -> Unit)? = null,
playEnabled: Boolean = true,
playDisabledLabel: String? = null,
onBack: () -> Unit,
onPlay: () -> Unit,
onSettings: () -> Unit,
+ onAchievements: (() -> Unit)? = null,
onShortcut: () -> Unit,
onCloudSaves: () -> Unit,
onUninstall: () -> Unit,
@@ -272,6 +279,8 @@ internal fun LibraryGameLaunchScreen(
onVerifyFiles = onVerifyFiles,
onCheckForUpdate = onCheckForUpdate,
onWorkshop = onWorkshop,
+ onLaunchOptions = onLaunchOptions,
+ onBetaBranches = onBetaBranches,
)
}
@@ -383,6 +392,14 @@ internal fun LibraryGameLaunchScreen(
size = actionIconSize,
onClick = onSettings,
)
+ if (onAchievements != null) {
+ LaunchIconActionButton(
+ icon = Icons.Outlined.EmojiEvents,
+ contentDescription = stringResource(R.string.steam_achievements_title),
+ size = actionIconSize,
+ onClick = onAchievements,
+ )
+ }
LaunchIconActionButton(
icon = Icons.Outlined.Home,
contentDescription =
@@ -799,6 +816,9 @@ private fun SourceTag(
onVerifyFiles: () -> Unit = {},
onCheckForUpdate: () -> Unit = {},
onWorkshop: () -> Unit = {},
+ // Nullable on purpose: null hides the menu item.
+ onLaunchOptions: (() -> Unit)? = null,
+ onBetaBranches: (() -> Unit)? = null,
) {
var menuOpen by remember { mutableStateOf(false) }
var anchorHeightPx by remember { mutableStateOf(0) }
@@ -869,6 +889,20 @@ private fun SourceTag(
enabled = areSteamActionsEnabled,
) { menuOpen = false; onWorkshop() }
}
+ if (onLaunchOptions != null) {
+ LaunchSourceMenuItem(
+ icon = Icons.Outlined.RocketLaunch,
+ label = stringResource(R.string.store_game_launch_options),
+ enabled = areSteamActionsEnabled,
+ ) { menuOpen = false; onLaunchOptions() }
+ }
+ if (onBetaBranches != null) {
+ LaunchSourceMenuItem(
+ icon = Icons.Outlined.AltRoute,
+ label = stringResource(R.string.store_game_beta_branch),
+ enabled = areSteamActionsEnabled,
+ ) { menuOpen = false; onBetaBranches() }
+ }
}
}
}
diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt
index 64b4fbd8e..8836970da 100644
--- a/app/src/main/app/shell/StoreGameDetailScreen.kt
+++ b/app/src/main/app/shell/StoreGameDetailScreen.kt
@@ -57,6 +57,8 @@ import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.Extension
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.Refresh
+import androidx.compose.material.icons.outlined.AltRoute
+import androidx.compose.material.icons.outlined.RocketLaunch
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material.icons.outlined.SystemUpdate
@@ -121,6 +123,59 @@ private val StoreTextPrimary = Color(0xFFF0F4FF)
private val StoreTextSecondary = Color(0xFF93A6BC)
private val StoreDanger = Color(0xFFFF6B6B)
+/**
+ * All StoreGameDetailScreen inputs in one holder. The split exists for ART's
+ * bytecode verifier: with the 40+-param signature and the screen's body in a
+ * single method, the frame needed 260+ registers and D8's prologue emitted
+ * copies this device class rejects (VerifyError: "copy-cat1 v0<-v260" /
+ * "wide register has type Low-half"). The wrapper below keeps the public
+ * defaulted signature in a tiny frame; the body lives in a one-param method.
+ */
+internal class StoreGameDetailParams(
+ val title: String,
+ val subtitle: String,
+ val sourceLabel: String,
+ val heroImageUrl: Any?,
+ val isLoading: Boolean,
+ val isInstalled: Boolean,
+ val installPathDisplay: String,
+ val downloadSize: Long,
+ val installSize: Long,
+ val availableBytes: Long,
+ val isInstallEnabled: Boolean,
+ val isDownloadActionEnabled: Boolean,
+ val customPathLabel: String,
+ val showCustomPath: Boolean,
+ val showCloudSync: Boolean,
+ val showUninstall: Boolean,
+ val showUpdateCheck: Boolean,
+ val isCheckingForUpdate: Boolean,
+ val isUpdateAvailable: Boolean,
+ val updateDownloadSize: Long,
+ val updateStatusText: String?,
+ val isUpdateActionEnabled: Boolean,
+ val isUpdateCheckCoolingDown: Boolean,
+ val showWorkshop: Boolean,
+ val showVerifyFiles: Boolean,
+ val areSteamActionsEnabled: Boolean,
+ val onLaunchOptions: (() -> Unit)?,
+ val onBetaBranches: (() -> Unit)?,
+ val dlcs: List,
+ val selectedDlcIds: Set,
+ val isDlcSelectionEnabled: Boolean,
+ val onBack: () -> Unit,
+ val onInstall: () -> Unit,
+ val onCheckForUpdate: () -> Unit,
+ val onWorkshop: () -> Unit,
+ val onVerifyFiles: () -> Unit,
+ val onDownloadUpdate: () -> Unit,
+ val onUninstall: () -> Unit,
+ val onCloudSync: () -> Unit,
+ val onCustomPath: () -> Unit,
+ val onToggleDlc: (Int) -> Unit,
+ val onToggleSelectAllDlcs: () -> Unit,
+)
+
@Composable
internal fun StoreGameDetailScreen(
title: String,
@@ -149,6 +204,9 @@ internal fun StoreGameDetailScreen(
showWorkshop: Boolean = false,
showVerifyFiles: Boolean = false,
areSteamActionsEnabled: Boolean = true,
+ // Nullable on purpose: null hides the menu item.
+ onLaunchOptions: (() -> Unit)? = null,
+ onBetaBranches: (() -> Unit)? = null,
dlcs: List = emptyList(),
selectedDlcIds: Set = emptySet(),
isDlcSelectionEnabled: Boolean = true,
@@ -164,6 +222,100 @@ internal fun StoreGameDetailScreen(
onToggleDlc: (Int) -> Unit = {},
onToggleSelectAllDlcs: () -> Unit = {},
) {
+ StoreGameDetailContent(
+ StoreGameDetailParams(
+ title = title,
+ subtitle = subtitle,
+ sourceLabel = sourceLabel,
+ heroImageUrl = heroImageUrl,
+ isLoading = isLoading,
+ isInstalled = isInstalled,
+ installPathDisplay = installPathDisplay,
+ downloadSize = downloadSize,
+ installSize = installSize,
+ availableBytes = availableBytes,
+ isInstallEnabled = isInstallEnabled,
+ isDownloadActionEnabled = isDownloadActionEnabled,
+ customPathLabel = customPathLabel,
+ showCustomPath = showCustomPath,
+ showCloudSync = showCloudSync,
+ showUninstall = showUninstall,
+ showUpdateCheck = showUpdateCheck,
+ isCheckingForUpdate = isCheckingForUpdate,
+ isUpdateAvailable = isUpdateAvailable,
+ updateDownloadSize = updateDownloadSize,
+ updateStatusText = updateStatusText,
+ isUpdateActionEnabled = isUpdateActionEnabled,
+ isUpdateCheckCoolingDown = isUpdateCheckCoolingDown,
+ showWorkshop = showWorkshop,
+ showVerifyFiles = showVerifyFiles,
+ areSteamActionsEnabled = areSteamActionsEnabled,
+ onLaunchOptions = onLaunchOptions,
+ onBetaBranches = onBetaBranches,
+ dlcs = dlcs,
+ selectedDlcIds = selectedDlcIds,
+ isDlcSelectionEnabled = isDlcSelectionEnabled,
+ onBack = onBack,
+ onInstall = onInstall,
+ onCheckForUpdate = onCheckForUpdate,
+ onWorkshop = onWorkshop,
+ onVerifyFiles = onVerifyFiles,
+ onDownloadUpdate = onDownloadUpdate,
+ onUninstall = onUninstall,
+ onCloudSync = onCloudSync,
+ onCustomPath = onCustomPath,
+ onToggleDlc = onToggleDlc,
+ onToggleSelectAllDlcs = onToggleSelectAllDlcs,
+ ),
+ )
+}
+
+@Composable
+private fun StoreGameDetailContent(p: StoreGameDetailParams) {
+ // Local rebinds keep the body identical to the pre-split parameter names.
+ val title = p.title
+ val subtitle = p.subtitle
+ val sourceLabel = p.sourceLabel
+ val heroImageUrl = p.heroImageUrl
+ val isLoading = p.isLoading
+ val isInstalled = p.isInstalled
+ val installPathDisplay = p.installPathDisplay
+ val downloadSize = p.downloadSize
+ val installSize = p.installSize
+ val availableBytes = p.availableBytes
+ val isInstallEnabled = p.isInstallEnabled
+ val isDownloadActionEnabled = p.isDownloadActionEnabled
+ val customPathLabel = p.customPathLabel
+ val showCustomPath = p.showCustomPath
+ val showCloudSync = p.showCloudSync
+ val showUninstall = p.showUninstall
+ val showUpdateCheck = p.showUpdateCheck
+ val isCheckingForUpdate = p.isCheckingForUpdate
+ val isUpdateAvailable = p.isUpdateAvailable
+ val updateDownloadSize = p.updateDownloadSize
+ val updateStatusText = p.updateStatusText
+ val isUpdateActionEnabled = p.isUpdateActionEnabled
+ val isUpdateCheckCoolingDown = p.isUpdateCheckCoolingDown
+ val showWorkshop = p.showWorkshop
+ val showVerifyFiles = p.showVerifyFiles
+ val areSteamActionsEnabled = p.areSteamActionsEnabled
+ val onLaunchOptions = p.onLaunchOptions
+ val onBetaBranches = p.onBetaBranches
+ val dlcs = p.dlcs
+ val selectedDlcIds = p.selectedDlcIds
+ val isDlcSelectionEnabled = p.isDlcSelectionEnabled
+ val onBack = p.onBack
+ val onInstall = p.onInstall
+ val onCheckForUpdate = p.onCheckForUpdate
+ val onWorkshop = p.onWorkshop
+ val onVerifyFiles = p.onVerifyFiles
+ val onDownloadUpdate = p.onDownloadUpdate
+ val onUninstall = p.onUninstall
+ val onCloudSync = p.onCloudSync
+ val onCustomPath = p.onCustomPath
+ val onToggleDlc = p.onToggleDlc
+ val onToggleSelectAllDlcs = p.onToggleSelectAllDlcs
+
val context = LocalContext.current
val density = LocalDensity.current
var dlcExpanded by remember { mutableStateOf(false) }
@@ -186,7 +338,11 @@ internal fun StoreGameDetailScreen(
val showUpdateCta = updateCheckAvailable && isUpdateAvailable
val verifyFilesAvailable = showVerifyFiles && isInstalled
val workshopAvailable = showWorkshop && isInstalled
- val sourceMenuEnabled = updateCheckAvailable || verifyFilesAvailable || workshopAvailable
+ val launchOptionsAvailable = onLaunchOptions != null && isInstalled
+ val betaBranchesAvailable = onBetaBranches != null && isInstalled
+ val sourceMenuEnabled =
+ updateCheckAvailable || verifyFilesAvailable || workshopAvailable ||
+ launchOptionsAvailable || betaBranchesAvailable
val showDlcCard = dlcs.isNotEmpty()
val showActionColumn =
showDownloadCta || showUpdateCta ||
@@ -303,6 +459,8 @@ internal fun StoreGameDetailScreen(
onVerifyFiles = onVerifyFiles,
onCheckForUpdate = onCheckForUpdate,
onWorkshop = onWorkshop,
+ onLaunchOptions = if (launchOptionsAvailable) onLaunchOptions else null,
+ onBetaBranches = if (betaBranchesAvailable) onBetaBranches else null,
)
}
@@ -500,14 +658,72 @@ internal fun StoreGameDetailScreen(
}
if (showDownloadCta) {
- StoreCtaButton(
- height = ctaHeight,
- icon = Icons.Outlined.Download,
- label = stringResource(R.string.common_ui_download),
- enabled = !isLoading && isDownloadActionEnabled,
- loading = isLoading,
- onClick = onInstall,
- )
+ if (onBetaBranches != null && !isInstalled) {
+ // Pre-install: a beta-branch segment "sliced off" the
+ // Download pill — pick a branch before committing.
+ // Each segment paints only its slice of one gradient
+ // spanning the whole row, so the pair reads as the
+ // single Download pill.
+ var splitCtaWidthPx by remember { mutableIntStateOf(0) }
+ val splitCtaLeadPx =
+ with(LocalDensity.current) {
+ (actionIconSize + actionIconSpacing).toPx()
+ }
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .onSizeChanged { splitCtaWidthPx = it.width },
+ horizontalArrangement = Arrangement.spacedBy(actionIconSpacing),
+ ) {
+ StoreCtaCutoutButton(
+ height = ctaHeight,
+ icon = Icons.Outlined.AltRoute,
+ contentDescription = stringResource(R.string.store_game_beta_branch),
+ // Same gate as Download: don't stage picks the
+ // blocked download can't honor.
+ enabled = !isLoading && isDownloadActionEnabled,
+ shape =
+ RoundedCornerShape(
+ topStart = 14.dp,
+ bottomStart = 14.dp,
+ topEnd = 4.dp,
+ bottomEnd = 4.dp,
+ ),
+ onClick = onBetaBranches,
+ modifier = Modifier.width(actionIconSize),
+ fillEndX =
+ splitCtaWidthPx.takeIf { it > 0 }?.toFloat()
+ ?: Float.POSITIVE_INFINITY,
+ )
+ StoreCtaButton(
+ height = ctaHeight,
+ icon = Icons.Outlined.Download,
+ label = stringResource(R.string.common_ui_download),
+ enabled = !isLoading && isDownloadActionEnabled,
+ loading = isLoading,
+ onClick = onInstall,
+ modifier = Modifier.weight(1f),
+ shape =
+ RoundedCornerShape(
+ topStart = 4.dp,
+ bottomStart = 4.dp,
+ topEnd = 14.dp,
+ bottomEnd = 14.dp,
+ ),
+ fillStartX = -splitCtaLeadPx,
+ )
+ }
+ } else {
+ StoreCtaButton(
+ height = ctaHeight,
+ icon = Icons.Outlined.Download,
+ label = stringResource(R.string.common_ui_download),
+ enabled = !isLoading && isDownloadActionEnabled,
+ loading = isLoading,
+ onClick = onInstall,
+ )
+ }
}
}
}
@@ -786,6 +1002,9 @@ private fun StoreSourceTag(
onVerifyFiles: () -> Unit = {},
onCheckForUpdate: () -> Unit = {},
onWorkshop: () -> Unit = {},
+ // Nullable on purpose: null hides the menu item.
+ onLaunchOptions: (() -> Unit)? = null,
+ onBetaBranches: (() -> Unit)? = null,
) {
var menuOpen by remember { mutableStateOf(false) }
var anchorHeightPx by remember { mutableIntStateOf(0) }
@@ -861,6 +1080,20 @@ private fun StoreSourceTag(
enabled = areSteamActionsEnabled,
) { menuOpen = false; onWorkshop() }
}
+ if (onLaunchOptions != null) {
+ StoreSourceMenuItem(
+ icon = Icons.Outlined.RocketLaunch,
+ label = stringResource(R.string.store_game_launch_options),
+ enabled = areSteamActionsEnabled,
+ ) { menuOpen = false; onLaunchOptions() }
+ }
+ if (onBetaBranches != null) {
+ StoreSourceMenuItem(
+ icon = Icons.Outlined.AltRoute,
+ label = stringResource(R.string.store_game_beta_branch),
+ enabled = areSteamActionsEnabled,
+ ) { menuOpen = false; onBetaBranches() }
+ }
}
}
}
@@ -1018,6 +1251,73 @@ private fun StoreActionChip(
}
}
+/** Glass-look brushes shared by StoreCtaButton and StoreCtaCutoutButton. */
+private class StoreCtaGlass(
+ val fill: Brush,
+ val sheen: Brush,
+ val rim: Brush,
+)
+
+private fun storeCtaGlassBrushes(
+ enabled: Boolean,
+ flare: Float,
+ fillStartX: Float = 0f,
+ fillEndX: Float = Float.POSITIVE_INFINITY,
+): StoreCtaGlass =
+ StoreCtaGlass(
+ fill =
+ if (enabled) {
+ Brush.horizontalGradient(
+ colors =
+ listOf(
+ Color(0xFF00B4D8).copy(alpha = 0.38f),
+ StoreAccent.copy(alpha = 0.38f),
+ Color(0xFF7B2FF7).copy(alpha = 0.38f),
+ ),
+ startX = fillStartX,
+ endX = fillEndX,
+ )
+ } else {
+ Brush.horizontalGradient(
+ colors =
+ listOf(
+ Color(0xFF3A3A4A).copy(alpha = 0.35f),
+ Color(0xFF2A2A36).copy(alpha = 0.35f),
+ ),
+ startX = fillStartX,
+ endX = fillEndX,
+ )
+ },
+ sheen =
+ if (enabled) {
+ Brush.verticalGradient(
+ 0.00f to Color.White.copy(alpha = 0.28f),
+ 0.35f to Color.White.copy(alpha = 0.06f),
+ 0.55f to Color.Transparent,
+ 1.00f to Color.Black.copy(alpha = 0.12f),
+ )
+ } else {
+ Brush.verticalGradient(
+ 0.0f to Color.White.copy(alpha = 0.10f),
+ 0.6f to Color.Transparent,
+ 1.0f to Color.Black.copy(alpha = 0.08f),
+ )
+ },
+ rim =
+ if (enabled) {
+ Brush.verticalGradient(
+ 0.0f to Color.White.copy(alpha = 0.55f + 0.35f * flare),
+ 0.5f to Color.White.copy(alpha = 0.08f + 0.18f * flare),
+ 1.0f to Color.White.copy(alpha = 0.22f + 0.22f * flare),
+ )
+ } else {
+ Brush.verticalGradient(
+ 0.0f to Color.White.copy(alpha = 0.16f),
+ 1.0f to Color.White.copy(alpha = 0.04f),
+ )
+ },
+ )
+
@Composable
private fun StoreCtaButton(
height: Dp,
@@ -1026,6 +1326,10 @@ private fun StoreCtaButton(
enabled: Boolean,
loading: Boolean,
onClick: () -> Unit,
+ modifier: Modifier = Modifier.fillMaxWidth(),
+ shape: RoundedCornerShape = RoundedCornerShape(14.dp),
+ fillStartX: Float = 0f,
+ fillEndX: Float = Float.POSITIVE_INFINITY,
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
@@ -1039,64 +1343,18 @@ private fun StoreCtaButton(
animationSpec = spring(dampingRatio = 0.6f, stiffness = 500f),
label = "storeCtaFlare",
)
- val shape = remember { RoundedCornerShape(14.dp) }
- val activeBrush =
- Brush.horizontalGradient(
- colors =
- listOf(
- Color(0xFF00B4D8).copy(alpha = 0.38f),
- StoreAccent.copy(alpha = 0.38f),
- Color(0xFF7B2FF7).copy(alpha = 0.38f),
- ),
- )
- val disabledBrush =
- Brush.horizontalGradient(
- colors =
- listOf(
- Color(0xFF3A3A4A).copy(alpha = 0.35f),
- Color(0xFF2A2A36).copy(alpha = 0.35f),
- ),
- )
- val glassSheenBrush =
- if (enabled) {
- Brush.verticalGradient(
- 0.00f to Color.White.copy(alpha = 0.28f),
- 0.35f to Color.White.copy(alpha = 0.06f),
- 0.55f to Color.Transparent,
- 1.00f to Color.Black.copy(alpha = 0.12f),
- )
- } else {
- Brush.verticalGradient(
- 0.0f to Color.White.copy(alpha = 0.10f),
- 0.6f to Color.Transparent,
- 1.0f to Color.Black.copy(alpha = 0.08f),
- )
- }
- val glassRimBrush =
- if (enabled) {
- Brush.verticalGradient(
- 0.0f to Color.White.copy(alpha = 0.55f + 0.35f * flare),
- 0.5f to Color.White.copy(alpha = 0.08f + 0.18f * flare),
- 1.0f to Color.White.copy(alpha = 0.22f + 0.22f * flare),
- )
- } else {
- Brush.verticalGradient(
- 0.0f to Color.White.copy(alpha = 0.16f),
- 1.0f to Color.White.copy(alpha = 0.04f),
- )
- }
+ val glass = storeCtaGlassBrushes(enabled, flare, fillStartX, fillEndX)
Box(
modifier =
- Modifier
- .fillMaxWidth()
+ modifier
.height(height)
.graphicsLayer {
scaleX = scale
scaleY = scale
}.clip(shape)
- .background(if (enabled) activeBrush else disabledBrush)
- .background(glassSheenBrush)
- .border(1.dp, glassRimBrush, shape)
+ .background(glass.fill)
+ .background(glass.sheen)
+ .border(1.dp, glass.rim, shape)
.clickable(
interactionSource = interactionSource,
indication = null,
@@ -1134,6 +1392,62 @@ private fun StoreCtaButton(
}
}
+/**
+ * The small segment "sliced off" the Download pill: same glass look, icon only.
+ * Opens the beta-branch picker before a download is committed.
+ */
+@Composable
+private fun StoreCtaCutoutButton(
+ height: Dp,
+ icon: ImageVector,
+ contentDescription: String,
+ enabled: Boolean,
+ shape: RoundedCornerShape,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ fillStartX: Float = 0f,
+ fillEndX: Float = Float.POSITIVE_INFINITY,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isPressed by interactionSource.collectIsPressedAsState()
+ val scale by animateFloatAsState(
+ targetValue = if (isPressed && enabled) 0.96f else 1f,
+ animationSpec = spring(dampingRatio = 0.5f, stiffness = 600f),
+ label = "storeCtaCutoutScale",
+ )
+ val flare by animateFloatAsState(
+ targetValue = if (isPressed && enabled) 1f else 0f,
+ animationSpec = spring(dampingRatio = 0.6f, stiffness = 500f),
+ label = "storeCtaCutoutFlare",
+ )
+ val glass = storeCtaGlassBrushes(enabled, flare, fillStartX, fillEndX)
+ Box(
+ modifier =
+ modifier
+ .height(height)
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ }.clip(shape)
+ .background(glass.fill)
+ .background(glass.sheen)
+ .border(1.dp, glass.rim, shape)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = { if (enabled) onClick() },
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ icon,
+ contentDescription = contentDescription,
+ modifier = Modifier.size(24.dp),
+ tint = Color.White,
+ )
+ }
+}
+
@Composable
private fun StoreIconActionButton(
icon: ImageVector,
diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt
index 13e32d4e9..f7d056b96 100644
--- a/app/src/main/app/shell/UnifiedActivity.kt
+++ b/app/src/main/app/shell/UnifiedActivity.kt
@@ -216,6 +216,7 @@ import com.winlator.cmod.shared.theme.WinNativeTheme
import dagger.hilt.android.AndroidEntryPoint
import dagger.Lazy
import com.winlator.cmod.feature.stores.steam.enums.EPersonaState
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -370,6 +371,7 @@ class UnifiedActivity :
lifecycleScope.launch {
val result =
runCatching {
+ // checkForAppUpdate defaults to the game's selected beta branch.
withContext(Dispatchers.IO) { SteamService.checkForAppUpdate(appId) }
}.getOrNull()
try {
@@ -381,8 +383,16 @@ class UnifiedActivity :
}
result.hasUpdate -> {
val started =
- withContext(Dispatchers.IO) {
- SteamService.downloadAppForUpdate(appId, result.depotIds)
+ runCatching {
+ withContext(Dispatchers.IO) {
+ SteamService.downloadAppForUpdate(appId, result.depotIds)
+ }
+ }.getOrElse { e ->
+ Log.w("UnifiedActivity", "Steam update download failed to start for appId=$appId", e)
+ taskCheckingShown = false
+ taskDoneFailed = true
+ taskDoneMessage = getString(R.string.store_game_update_failed_notice)
+ return@launch
}
if (started != null) {
showTaskProgressPopup(
@@ -1414,6 +1424,32 @@ class UnifiedActivity :
val persona by SteamService.instance?.localPersona?.collectAsState()
?: remember { mutableStateOf(null) }
val scope = rememberCoroutineScope()
+ val rightDrawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
+ val friends by SteamService.instance?.friendsList?.collectAsState()
+ ?: remember { mutableStateOf(emptyList()) }
+ var chatFriend by remember { mutableStateOf(null) }
+ val friendsDrawerOpen = rightDrawerState.isOpen
+ LaunchedEffect(isLoggedIn) {
+ if (isLoggedIn) {
+ while (true) {
+ runCatching { SteamService.instance?.refreshFriends() }
+ kotlinx.coroutines.delay(30_000L)
+ }
+ }
+ }
+ LaunchedEffect(isLoggedIn, friendsDrawerOpen) {
+ if (isLoggedIn && friendsDrawerOpen) {
+ while (true) {
+ runCatching { SteamService.instance?.syncFriendsPresence() }
+ kotlinx.coroutines.delay(5_000L)
+ }
+ }
+ }
+ LaunchedEffect(isLoggedIn) {
+ if (isLoggedIn) {
+ runCatching { com.winlator.cmod.feature.stores.steam.chat.ChatOverlayService.start(context) }
+ }
+ }
val epicApps by db.epicGameDao().getAll().collectAsState(initial = emptyList())
val gogApps by db.gogGameDao().getAll().collectAsState(initial = emptyList())
@@ -1641,6 +1677,50 @@ class UnifiedActivity :
}
}
+ androidx.compose.runtime.CompositionLocalProvider(
+ androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Rtl,
+ ) {
+ ModalNavigationDrawer(
+ drawerState = rightDrawerState,
+ drawerContent = {
+ androidx.compose.runtime.CompositionLocalProvider(
+ androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Ltr,
+ ) {
+ com.winlator.cmod.feature.stores.steam.friends.FriendsDrawerContent(
+ self = persona ?: com.winlator.cmod.feature.stores.steam.data.SteamFriend(),
+ friends = friends,
+ onSetState = { st -> scope.launch { SteamService.setPersonaState(st) } },
+ onOpenChat = { f -> chatFriend = f; scope.launch { rightDrawerState.close() } },
+ onJoinGame = { f ->
+ scope.launch { rightDrawerState.close() }
+ scope.launch {
+ val app = withContext(Dispatchers.IO) { SteamService.getAppInfoOf(f.gameAppId) }
+ val installed = withContext(Dispatchers.IO) { SteamService.getInstalledApp(f.gameAppId) }
+ val label = f.gameName.ifBlank { context.getString(R.string.steam_join_the_game) }
+ if (app != null && installed != null) {
+ android.widget.Toast.makeText(
+ context, context.getString(R.string.steam_join_joining, f.name, label), android.widget.Toast.LENGTH_SHORT,
+ ).show()
+ launchSteamGame(context, ContainerManager(context), app, f.connectString)
+ } else {
+ android.widget.Toast.makeText(
+ context,
+ if (app != null) context.getString(R.string.steam_join_install, label, f.name)
+ else context.getString(R.string.steam_join_not_owned, label),
+ android.widget.Toast.LENGTH_LONG,
+ ).show()
+ }
+ }
+ },
+ )
+ }
+ },
+ scrimColor = Color.Black.copy(alpha = 0.5f),
+ gesturesEnabled = rightDrawerState.isOpen,
+ ) {
+ androidx.compose.runtime.CompositionLocalProvider(
+ androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Ltr,
+ ) {
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
@@ -1739,7 +1819,7 @@ class UnifiedActivity :
}, persona, context, scope, isControllerConnected, isPS, isLibraryTab, searchQueryTfv, {
searchQueryTfv =
it
- }, onFilterClicked = { scope.launch { drawerState.open() } }) {
+ }, onFilterClicked = { scope.launch { drawerState.open() } }, onFriendsClicked = { scope.launch { rightDrawerState.open() } }) {
if (selectedLibrarySource == "GOG") {
globalSettingsGogGame = gogApps.find { it.id == selectedGogGameId }
} else {
@@ -1850,6 +1930,21 @@ class UnifiedActivity :
val addGameFabMargin = (libraryFabBase * 0.035f).dp.coerceIn(12.dp, 20.dp)
val addGameFabIconSize = (libraryFabBase * 0.055f).dp.coerceIn(24.dp, 28.dp)
+ if (drawerState.isClosed) {
+ DrawerSwipeHotZone(
+ modifier = Modifier.align(Alignment.CenterStart),
+ onOpenDrawer = { scope.launch { drawerState.open() } },
+ )
+ }
+ if (rightDrawerState.isClosed) {
+ DrawerSwipeHotZone(
+ modifier = Modifier.align(Alignment.CenterEnd).padding(end = 22.dp),
+ isRightSide = true,
+ onOpenDrawer = { scope.launch { rightDrawerState.open() } },
+ )
+ }
+
+ // Composed after the hot zones so the FAB stays on top for hit-testing.
if (key == "library") {
Box(
modifier =
@@ -1876,17 +1971,13 @@ class UnifiedActivity :
)
}
}
-
- if (drawerState.isClosed) {
- DrawerSwipeHotZone(
- modifier = Modifier.align(Alignment.CenterStart),
- onOpenDrawer = { scope.launch { drawerState.open() } },
- )
- }
}
}
}
} // end ModalNavigationDrawer
+ } // end inner LTR
+ } // end right friends ModalNavigationDrawer
+ } // end RTL provider
if (globalSettingsApp != null) {
GameSettingsDialog(
@@ -1908,8 +1999,19 @@ class UnifiedActivity :
})
}
+ chatFriend?.let { cf ->
+ com.winlator.cmod.feature.stores.steam.friends.SteamChatScreen(
+ friend = friends.firstOrNull { it.steamId == cf.steamId } ?: cf,
+ onClose = { chatFriend = null },
+ )
+ }
+
BackHandler(enabled = true) {
- if (drawerState.isOpen) {
+ if (chatFriend != null) {
+ chatFriend = null
+ } else if (rightDrawerState.isOpen) {
+ scope.launch { rightDrawerState.close() }
+ } else if (drawerState.isOpen) {
scope.launch { drawerState.close() }
} else if (globalSettingsApp != null) {
globalSettingsApp = null
@@ -1977,6 +2079,7 @@ class UnifiedActivity :
@Composable
private fun DrawerSwipeHotZone(
modifier: Modifier = Modifier,
+ isRightSide: Boolean = false,
onOpenDrawer: () -> Unit,
) {
val density = LocalDensity.current
@@ -1986,8 +2089,8 @@ class UnifiedActivity :
modifier =
modifier
.fillMaxHeight()
- .width(40.dp)
- .pointerInput(openThresholdPx) {
+ .width(if (isRightSide) 30.dp else 40.dp)
+ .pointerInput(openThresholdPx, isRightSide) {
var accumulatedDrag = 0f
var opened = false
@@ -1997,9 +2100,10 @@ class UnifiedActivity :
opened = false
},
onHorizontalDrag = { change, dragAmount ->
- if (dragAmount <= 0f || opened) return@detectHorizontalDragGestures
+ val delta = if (isRightSide) -dragAmount else dragAmount
+ if (delta <= 0f || opened) return@detectHorizontalDragGestures
- accumulatedDrag += dragAmount
+ accumulatedDrag += delta
change.consume()
if (accumulatedDrag >= openThresholdPx) {
@@ -2026,6 +2130,7 @@ class UnifiedActivity :
searchQuery: TextFieldValue,
onSearchQueryChange: (TextFieldValue) -> Unit,
onFilterClicked: () -> Unit,
+ onFriendsClicked: () -> Unit = {},
onGameSettingsClicked: () -> Unit,
) {
var isSearchExpanded by remember { mutableStateOf(false) }
@@ -2258,6 +2363,20 @@ class UnifiedActivity :
Spacer(Modifier.width(8.dp))
+ Box(
+ modifier =
+ Modifier
+ .size(44.dp)
+ .shadow(6.dp, CircleShape, spotColor = Color.Black.copy(alpha = 0.5f))
+ .clip(CircleShape)
+ .background(SurfaceDark)
+ .clickable { onFriendsClicked() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.People, contentDescription = "Friends", tint = TextPrimary, modifier = Modifier.size(24.dp))
+ }
+ Spacer(Modifier.width(8.dp))
+
Box(
modifier =
Modifier
@@ -4325,6 +4444,7 @@ class UnifiedActivity :
val scope = rememberCoroutineScope()
var currentScreen by remember { mutableStateOf(LibraryDetailScreen.Main) }
var activePopup by remember { mutableStateOf(null) }
+ var showAchievements by remember(app.id) { mutableStateOf(false) }
var shortcutRefreshKey by remember(app.id, gogGame?.id) { mutableStateOf(0) }
var pinnedShortcutOverride by remember(app.id, gogGame?.id) { mutableStateOf(null) }
var showWorkshopDialog by remember(app.id) { mutableStateOf(false) }
@@ -4333,6 +4453,39 @@ class UnifiedActivity :
val isEpic = app.id >= 2000000000
val isGog = gogGame != null
val epicId = if (isEpic) app.id - 2000000000 else 0
+ val isSteamLibraryGame = !isCustom && !isEpic && !isGog
+
+ var showLaunchOptionsDialog by remember(app.id) { mutableStateOf(false) }
+ var launchOptions by remember(app.id) { mutableStateOf>(emptyList()) }
+ var selectedLaunchOption by remember(app.id) { mutableStateOf(null) }
+ var showBetaBranchesDialog by remember(app.id) { mutableStateOf(false) }
+ var betaBranches by remember(app.id) { mutableStateOf>(emptyList()) }
+ var selectedBetaBranch by remember(app.id) { mutableStateOf(null) }
+ LaunchedEffect(app.id, isSteamLibraryGame) {
+ val steamInstalled =
+ isSteamLibraryGame && withContext(Dispatchers.IO) { SteamService.isAppInstalled(app.id) }
+ if (!steamInstalled) {
+ launchOptions = emptyList()
+ selectedLaunchOption = null
+ betaBranches = emptyList()
+ selectedBetaBranch = null
+ return@LaunchedEffect
+ }
+ // Degrade to hidden menu items (logged) rather than crash the dialog.
+ runCatching {
+ loadSteamLaunchOptionsRefreshing(app.id) { options, selected ->
+ launchOptions = options
+ selectedLaunchOption = selected
+ }
+ loadSteamBetaBranchesRefreshing(app.id) { branches, selected ->
+ betaBranches = branches
+ selectedBetaBranch = selected
+ }
+ }.onFailure { e ->
+ if (e is kotlinx.coroutines.CancellationException) throw e
+ Log.e("UnifiedActivity", "Steam game-detail extras load failed for appId=${app.id}", e)
+ }
+ }
val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState(
initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(),
@@ -5002,6 +5155,9 @@ class UnifiedActivity :
ShortcutSettingsComposeDialog(this@UnifiedActivity, shortcut).show()
}
},
+ onAchievements = if (!isCustom && !isEpic && !isGog) {
+ { showAchievements = true }
+ } else null,
onShortcut = {
if (hasPinnedShortcut) {
currentScreen = LibraryDetailScreen.Shortcut
@@ -5053,6 +5209,18 @@ class UnifiedActivity :
(!isEpic || epicGame?.isInstalled == true) &&
(!isGog || gogGame?.isInstalled == true),
showWorkshop = !isEpic && !isGog,
+ onLaunchOptions =
+ if (launchOptions.size >= 2) {
+ { showLaunchOptionsDialog = true }
+ } else {
+ null
+ },
+ onBetaBranches =
+ if (betaBranches.size >= 2) {
+ { showBetaBranchesDialog = true }
+ } else {
+ null
+ },
areSteamActionsEnabled =
when {
isEpic -> !hasBlockingEpicDownloadForLibrary
@@ -5310,6 +5478,22 @@ class UnifiedActivity :
}
}
+ if (showAchievements) {
+ Dialog(
+ onDismissRequest = { showAchievements = false },
+ properties = DialogProperties(
+ usePlatformDefaultWidth = false,
+ dismissOnClickOutside = false,
+ ),
+ ) {
+ com.winlator.cmod.feature.stores.steam.achievements.SteamAchievementsScreen(
+ appId = app.id,
+ appName = app.name,
+ onClose = { showAchievements = false },
+ )
+ }
+ }
+
activePopup?.let { popup ->
LibraryDetailPopupFrame(
title =
@@ -5478,6 +5662,40 @@ class UnifiedActivity :
onDismissRequest = { showWorkshopDialog = false },
)
}
+
+ if (showLaunchOptionsDialog) {
+ LaunchOptionsDialog(
+ appId = app.id,
+ gameTitle = app.name,
+ options = launchOptions,
+ selectedOption = selectedLaunchOption,
+ onSelectionSaved = { selectedLaunchOption = it },
+ onDismissRequest = { showLaunchOptionsDialog = false },
+ )
+ }
+
+ if (showBetaBranchesDialog) {
+ BetaBranchesDialog(
+ gameTitle = app.name,
+ branches = betaBranches,
+ selectedBranch = selectedBetaBranch,
+ onSelect = { item ->
+ persistSteamBetaBranchSelection(
+ appId = app.id,
+ item = item,
+ scope = scope,
+ onSaved = { saved ->
+ selectedBetaBranch = saved
+ // Close the picker so the update-check flow it
+ // triggers is what the user sees next.
+ showBetaBranchesDialog = false
+ },
+ startUpdate = { startUpdateCheck(app.id, app.name) },
+ )
+ },
+ onDismissRequest = { showBetaBranchesDialog = false },
+ )
+ }
}
}
}
@@ -8467,6 +8685,191 @@ class UnifiedActivity :
}
}
+ // Apps whose appinfo was already re-fetched this process (see
+ // refreshAppInfoFromPicsOnce) — avoids a PICS round-trip on every
+ // game-detail open.
+ private val appinfoRefreshedApps = java.util.Collections.synchronizedSet(mutableSetOf())
+
+ /**
+ * Builds the Steam launch-option list (appinfo config.launch) for the STEAM
+ * dropdown on both game detail screens, plus the currently effective selection.
+ */
+ private suspend fun loadSteamLaunchOptions(appId: Int): Pair, StoreLaunchOptionItem?> =
+ withContext(Dispatchers.IO) {
+ val appDir = java.io.File(SteamService.getAppDirPath(appId))
+ val allOptions =
+ SteamService
+ .getWindowsLaunchInfos(appId)
+ .map { info ->
+ StoreLaunchOptionItem(
+ executable = info.executable,
+ arguments = info.arguments,
+ label = info.description.ifBlank { info.executable.substringAfterLast('/') },
+ )
+ }
+ // Label is part of the key: cached appinfo rows predating
+ // LaunchInfo.arguments have "" args, and exe+args alone would
+ // collapse distinct options like "Play (DX11)" / "Play (DX12)".
+ .distinctBy { Triple(it.executable.lowercase(), it.arguments, it.label) }
+ // Hide options whose exe is missing on disk, but if that empties a
+ // non-empty list (case-sensitivity quirks) keep the unfiltered set.
+ val onDisk = allOptions.filter { java.io.File(appDir, it.executable).isFile }
+ val options = onDisk.ifEmpty { allOptions }
+ val (selectedExe, selectedArgs) = SteamService.getSelectedLaunchOption(applicationContext, appId)
+ val selected =
+ options.firstOrNull {
+ it.executable.equals(selectedExe, ignoreCase = true) && it.arguments == selectedArgs
+ } ?: options.firstOrNull { it.executable.equals(selectedExe, ignoreCase = true) }
+ ?: options.firstOrNull()
+ options to selected
+ }
+
+ /**
+ * Re-fetches [appId]'s PICS appinfo at most once per process — shared by
+ * the launch-options and beta-branch loaders so one round-trip feeds both.
+ * Returns true when this call performed the refresh; a failed fetch
+ * (offline) releases the slot so the next game-detail open retries.
+ */
+ private suspend fun refreshAppInfoFromPicsOnce(appId: Int): Boolean {
+ if (!appinfoRefreshedApps.add(appId)) return false
+ if (SteamService.refreshAppInfoFromPics(appId)) return true
+ appinfoRefreshedApps.remove(appId)
+ return false
+ }
+
+ /**
+ * Loads launch options, then — once per app per process — re-fetches the
+ * app's PICS appinfo to heal cached rows that predate LaunchInfo.arguments
+ * and re-applies the fresh list. [apply] runs on the caller's context.
+ */
+ private suspend fun loadSteamLaunchOptionsRefreshing(
+ appId: Int,
+ apply: (List, StoreLaunchOptionItem?) -> Unit,
+ ) {
+ val (options, selected) = loadSteamLaunchOptions(appId)
+ apply(options, selected)
+ if (refreshAppInfoFromPicsOnce(appId)) {
+ val (fresh, freshSelected) = loadSteamLaunchOptions(appId)
+ apply(fresh, freshSelected)
+ }
+ }
+
+ private fun persistSteamLaunchOptionSelection(
+ appId: Int,
+ option: StoreLaunchOptionItem,
+ scope: CoroutineScope,
+ onSaved: (StoreLaunchOptionItem) -> Unit,
+ ) {
+ scope.launch(Dispatchers.IO) {
+ val saved =
+ SteamService.setSelectedLaunchOption(
+ applicationContext,
+ appId,
+ option.executable,
+ option.arguments,
+ )
+ withContext(Dispatchers.Main) {
+ if (saved) {
+ onSaved(option)
+ } else {
+ com.winlator.cmod.shared.ui.toast.WinToast.show(
+ this@UnifiedActivity,
+ getString(R.string.store_game_launch_option_failed),
+ android.widget.Toast.LENGTH_SHORT,
+ )
+ }
+ }
+ }
+ }
+
+ private suspend fun loadSteamBetaBranches(appId: Int): Pair, StoreBetaBranchItem?> =
+ withContext(Dispatchers.IO) {
+ val branches =
+ SteamService.getAppInfoOf(appId)
+ ?.branches
+ ?.values
+ ?.map { b -> StoreBetaBranchItem(b.name, b.buildId, b.timeUpdated, b.pwdRequired) }
+ ?.sortedWith(compareBy({ !it.name.equals("public", ignoreCase = true) }, { it.name }))
+ .orEmpty()
+ // recoverSelectedBetaName also heals selections lost to an app
+ // reinstall (shortcuts are app-private; game files survive).
+ val selectedName = SteamService.recoverSelectedBetaName(appId).ifBlank { "public" }
+ val selected =
+ branches.firstOrNull { it.name.equals(selectedName, ignoreCase = true) }
+ // Selected beta may have been retired from PICS — fall back to public.
+ ?: branches.firstOrNull { it.name.equals("public", ignoreCase = true) }
+ branches to selected
+ }
+
+ /**
+ * Loads beta branches, then — once per app per process — re-fetches the
+ * app's PICS appinfo for current branch build ids and re-applies the
+ * fresh list. [apply] runs on the caller's context.
+ */
+ private suspend fun loadSteamBetaBranchesRefreshing(
+ appId: Int,
+ apply: (List, StoreBetaBranchItem?) -> Unit,
+ ) {
+ val (branches, selected) = loadSteamBetaBranches(appId)
+ apply(branches, selected)
+ if (refreshAppInfoFromPicsOnce(appId)) {
+ val (fresh, freshSelected) = loadSteamBetaBranches(appId)
+ apply(fresh, freshSelected)
+ }
+ }
+
+ /**
+ * Commits a staged pre-install branch pick right before the download starts,
+ * so downloadApp (and any later resume) resolves it from the shortcut.
+ * Custom path is persisted first: the shortcut snapshots game_install_path
+ * at creation and is never rewritten. On failure (no usable container yet)
+ * the install proceeds on the public branch rather than blocking.
+ */
+ private suspend fun commitPendingBetaBranch(
+ appId: Int,
+ item: StoreBetaBranchItem,
+ customPath: String?,
+ ) {
+ customPath?.let { SteamService.setCustomInstallPath(appId, it) }
+ val branchName = if (item.name.equals("public", ignoreCase = true)) "" else item.name
+ if (!SteamService.setSelectedBetaBranch(applicationContext, appId, branchName)) {
+ withContext(Dispatchers.Main) {
+ com.winlator.cmod.shared.ui.toast.WinToast.show(
+ this@UnifiedActivity,
+ getString(R.string.store_game_beta_branch_failed),
+ android.widget.Toast.LENGTH_SHORT,
+ )
+ }
+ }
+ }
+
+ private fun persistSteamBetaBranchSelection(
+ appId: Int,
+ item: StoreBetaBranchItem,
+ scope: CoroutineScope,
+ onSaved: (StoreBetaBranchItem) -> Unit,
+ startUpdate: () -> Unit,
+ ) {
+ scope.launch(Dispatchers.IO) {
+ // "public" is the implicit default — store blank so resolveSelectedBetaName
+ // keeps returning "" for games the user never switched.
+ val branchName = if (item.name.equals("public", ignoreCase = true)) "" else item.name
+ val saved = SteamService.setSelectedBetaBranch(applicationContext, appId, branchName)
+ withContext(Dispatchers.Main) {
+ if (saved) {
+ onSaved(item)
+ startUpdate()
+ } else {
+ com.winlator.cmod.shared.ui.toast.WinToast.show(
+ this@UnifiedActivity,
+ getString(R.string.store_game_beta_branch_failed),
+ android.widget.Toast.LENGTH_SHORT,
+ )
+ }
+ }
+ }
+ }
+
// Game Manager Dialog
@Composable
fun GameManagerDialog(
@@ -8486,6 +8889,14 @@ class UnifiedActivity :
var isCheckingForUpdate by remember(app.id) { mutableStateOf(false) }
var isUpdateCheckCoolingDown by remember(app.id) { mutableStateOf(false) }
var showWorkshopDialog by remember(app.id) { mutableStateOf(false) }
+ var showLaunchOptionsDialog by remember(app.id) { mutableStateOf(false) }
+ var launchOptions by remember(app.id) { mutableStateOf>(emptyList()) }
+ var selectedLaunchOption by remember(app.id) { mutableStateOf(null) }
+ var showBetaBranchesDialog by remember(app.id) { mutableStateOf(false) }
+ var betaBranches by remember(app.id) { mutableStateOf>(emptyList()) }
+ var selectedBetaBranch by remember(app.id) { mutableStateOf(null) }
+ // Pre-install branch choice, staged until the Download button commits it.
+ var pendingBetaBranch by remember(app.id) { mutableStateOf(null) }
var updateInfo by remember(app.id) { mutableStateOf(null) }
var updateStatusText by remember(app.id) { mutableStateOf(null) }
val downloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState(
@@ -8515,13 +8926,18 @@ class UnifiedActivity :
val installed: Boolean,
)
- LaunchedEffect(app.id, downloadRecords) {
+ LaunchedEffect(app.id, downloadRecords, pendingBetaBranch) {
val loadData =
withContext(Dispatchers.IO) {
+ // Size the same branch the download will actually fetch — the
+ // staged pre-install pick wins over the persisted selection.
+ val branch =
+ pendingBetaBranch?.name
+ ?: SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" }
val selectableDlcApps = SteamService.getSelectableDlcAppsOf(app.id)
val perDlcSizes =
selectableDlcApps.associate { dlc ->
- dlc.id to SteamService.getDlcOnlyManifestSizes(app.id, dlc.id)
+ dlc.id to SteamService.getDlcOnlyManifestSizes(app.id, dlc.id, branch = branch)
}
val installedDlcIds =
SteamService.getInstalledDlcDepotsOf(app.id)
@@ -8531,7 +8947,7 @@ class UnifiedActivity :
dlcApps = selectableDlcApps,
dlcSizes = perDlcSizes,
installedDlcIds = installedDlcIds,
- baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id),
+ baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id, branch = branch),
installed = SteamService.isAppInstalled(app.id),
)
}
@@ -8544,13 +8960,48 @@ class UnifiedActivity :
isLoading = false
}
- LaunchedEffect(app.id, selectedDlcIds.toList()) {
+ LaunchedEffect(app.id, selectedDlcIds.toList(), pendingBetaBranch) {
selectedManifestSizes =
withContext(Dispatchers.IO) {
- SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList())
+ val branch =
+ pendingBetaBranch?.name
+ ?: SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" }
+ SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList(), branch = branch)
}
}
+ LaunchedEffect(app.id, installed) {
+ // Wait for the install state so the pre-install branch cutout can't
+ // flash on a game that then resolves to installed.
+ if (installed == null) return@LaunchedEffect
+ if (installed == true) {
+ // A staged pre-install pick is meaningless once the game is
+ // installed (it was either committed by Download or abandoned).
+ pendingBetaBranch = null
+ } else {
+ launchOptions = emptyList()
+ selectedLaunchOption = null
+ }
+ // Degrade to hidden menu items (logged) rather than crash the dialog.
+ runCatching {
+ if (installed == true) {
+ loadSteamLaunchOptionsRefreshing(app.id) { options, selected ->
+ launchOptions = options
+ selectedLaunchOption = selected
+ }
+ }
+ // Branches load for both states: installed games switch via the
+ // STEAM menu, uninstalled games via the Download-button cutout.
+ loadSteamBetaBranchesRefreshing(app.id) { branches, selected ->
+ betaBranches = branches
+ selectedBetaBranch = selected
+ }
+ }.onFailure { e ->
+ if (e is kotlinx.coroutines.CancellationException) throw e
+ Log.e("UnifiedActivity", "Steam game-detail extras load failed for appId=${app.id}", e)
+ }
+ }
+
val totalDownloadSize = selectedManifestSizes.downloadSize
val totalInstallSize = selectedManifestSizes.installSize
val defaultPathSet =
@@ -8671,6 +9122,18 @@ class UnifiedActivity :
showWorkshop = isReallyInstalled,
showVerifyFiles = isReallyInstalled,
areSteamActionsEnabled = !hasBlockingSteamDownload,
+ onLaunchOptions =
+ if (launchOptions.size >= 2) {
+ { showLaunchOptionsDialog = true }
+ } else {
+ null
+ },
+ onBetaBranches =
+ if (betaBranches.size >= 2) {
+ { showBetaBranchesDialog = true }
+ } else {
+ null
+ },
dlcs = dlcItems,
selectedDlcIds = selectedDlcIds.toSet(),
isDlcSelectionEnabled = steamDownloadRecord == null,
@@ -8689,6 +9152,9 @@ class UnifiedActivity :
val installableDlcIds = dlcItems
.filter { !it.isInstalled && it.id in selectedDlcIds }
.map { it.id }
+ if (installed != true) {
+ pendingBetaBranch?.let { commitPendingBetaBranch(app.id, it, customPath) }
+ }
SteamService.downloadApp(app.id, installableDlcIds, false, customPath)
withContext(Dispatchers.Main) { onDismissRequest() }
}
@@ -8809,6 +9275,114 @@ class UnifiedActivity :
onDismissRequest = { showWorkshopDialog = false },
)
}
+
+ if (showLaunchOptionsDialog) {
+ LaunchOptionsDialog(
+ appId = app.id,
+ gameTitle = app.name,
+ options = launchOptions,
+ selectedOption = selectedLaunchOption,
+ onSelectionSaved = { selectedLaunchOption = it },
+ onDismissRequest = { showLaunchOptionsDialog = false },
+ )
+ }
+
+ if (showBetaBranchesDialog) {
+ BetaBranchesDialog(
+ gameTitle = app.name,
+ branches = betaBranches,
+ // pendingBetaBranch is null for installed games (cleared on install).
+ selectedBranch = pendingBetaBranch ?: selectedBetaBranch,
+ onSelect = { item ->
+ if (installed == true) {
+ persistSteamBetaBranchSelection(
+ appId = app.id,
+ item = item,
+ scope = scope,
+ onSaved = { saved ->
+ selectedBetaBranch = saved
+ // Close the picker so the update-check flow it
+ // triggers is what the user sees next.
+ showBetaBranchesDialog = false
+ },
+ startUpdate = { startUpdateCheck(app.id, app.name) },
+ )
+ } else {
+ // Pre-install: stage only — the Download button commits.
+ pendingBetaBranch = item
+ showBetaBranchesDialog = false
+ }
+ },
+ onDismissRequest = { showBetaBranchesDialog = false },
+ )
+ }
+ }
+
+ /**
+ * Hosts the Workshop-styled launch-option picker window over a game detail
+ * dialog. Selecting a row persists it as the game's default and reports the
+ * saved option through [onSelectionSaved].
+ */
+ @Composable
+ private fun LaunchOptionsDialog(
+ appId: Int,
+ gameTitle: String,
+ options: List,
+ selectedOption: StoreLaunchOptionItem?,
+ onSelectionSaved: (StoreLaunchOptionItem) -> Unit,
+ onDismissRequest: () -> Unit,
+ ) {
+ val scope = rememberCoroutineScope()
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ properties =
+ DialogProperties(
+ usePlatformDefaultWidth = false,
+ decorFitsSystemWindows = false,
+ ),
+ ) {
+ StoreLaunchOptionsScreen(
+ gameTitle = gameTitle,
+ options = options,
+ selectedOption = selectedOption,
+ onSelect = { option ->
+ persistSteamLaunchOptionSelection(appId, option, scope, onSelectionSaved)
+ },
+ onClose = onDismissRequest,
+ )
+ }
+ }
+
+ /**
+ * Hosts the Workshop-styled beta-branch picker window over a game detail
+ * dialog. What a row tap means is the host's call via [onSelect]: installed
+ * games persist + start the update flow, pre-install games just stage the
+ * choice until the Download button commits it.
+ */
+ @Composable
+ private fun BetaBranchesDialog(
+ gameTitle: String,
+ branches: List,
+ selectedBranch: StoreBetaBranchItem?,
+ onSelect: (StoreBetaBranchItem) -> Unit,
+ onDismissRequest: () -> Unit,
+ ) {
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ properties =
+ DialogProperties(
+ usePlatformDefaultWidth = false,
+ decorFitsSystemWindows = false,
+ ),
+ ) {
+ StoreBetaBranchScreen(
+ gameTitle = gameTitle,
+ branches = branches,
+ selectedBranch = selectedBranch,
+ onSelect = onSelect,
+ onClose = onDismissRequest,
+ )
+ }
}
@Composable
@@ -9355,6 +9929,7 @@ class UnifiedActivity :
context: android.content.Context,
containerManager: ContainerManager,
app: SteamApp,
+ joinConnect: String? = null,
) {
lifecycleScope.launch(Dispatchers.IO) {
val gameInstallPath = SteamService.getAppDirPath(app.id)
@@ -9417,6 +9992,7 @@ class UnifiedActivity :
intent.putExtra("container_id", shortcut.container.id)
intent.putExtra("shortcut_path", shortcut.file.path)
intent.putExtra("shortcut_name", shortcut.name)
+ if (!joinConnect.isNullOrBlank()) intent.putExtra("steam_join_connect", joinConnect)
withContext(Dispatchers.Main) {
launchGame(context, intent)
}
@@ -9461,6 +10037,7 @@ class UnifiedActivity :
intent.putExtra("container_id", container.id)
intent.putExtra("shortcut_path", shortcutFile.path)
intent.putExtra("shortcut_name", app.name)
+ if (!joinConnect.isNullOrBlank()) intent.putExtra("steam_join_connect", joinConnect)
withContext(Dispatchers.Main) {
launchGame(context, intent)
}
@@ -10242,8 +10819,6 @@ class UnifiedActivity :
onImmersiveBlurChanged: (Boolean) -> Unit,
onExitApp: () -> Unit,
) {
- val currentState = persona?.state ?: EPersonaState.Online
- var statusExpanded by remember { mutableStateOf(false) }
ModalDrawerSheet(
drawerShape = RectangleShape,
@@ -10259,176 +10834,6 @@ class UnifiedActivity :
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
- // ── Avatar Card ──
- Surface(
- shape = RoundedCornerShape(16.dp),
- color = SurfaceDark,
- border = BorderStroke(1.dp, CardBorder),
- modifier =
- Modifier
- .fillMaxWidth()
- .clickable(
- interactionSource = remember { MutableInteractionSource() },
- indication = null,
- ) { statusExpanded = !statusExpanded },
- ) {
- Column(Modifier.padding(16.dp)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- val avatarUrl =
- persona?.avatarHash?.getAvatarURL()
- ?: "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg"
-
- Box(
- modifier =
- Modifier
- .size(48.dp)
- .clip(CircleShape),
- ) {
- AsyncImage(
- model =
- ImageRequest
- .Builder(context)
- .data(avatarUrl)
- .crossfade(true)
- .build(),
- contentDescription = "Profile",
- modifier = Modifier.fillMaxSize(),
- contentScale = ContentScale.Crop,
- )
- }
-
- Spacer(Modifier.width(12.dp))
-
- Column(Modifier.weight(1f)) {
- Text(
- text = persona?.name ?: stringResource(R.string.stores_accounts_not_signed_in),
- style = MaterialTheme.typography.titleSmall,
- fontWeight = FontWeight.Bold,
- color = TextPrimary,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- val statusLabel =
- when (currentState) {
- EPersonaState.Online -> stringResource(R.string.stores_accounts_status_online)
- EPersonaState.Away -> stringResource(R.string.stores_accounts_status_away)
- else -> stringResource(R.string.stores_accounts_status_offline)
- }
- val statusColor =
- when (currentState) {
- EPersonaState.Online -> StatusOnline
- EPersonaState.Away -> StatusAway
- else -> StatusOffline
- }
- Row(verticalAlignment = Alignment.CenterVertically) {
- Box(Modifier.size(8.dp).background(statusColor, CircleShape))
- Spacer(Modifier.width(6.dp))
- Text(statusLabel, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
- }
- }
-
- val chevronRotation by animateFloatAsState(
- targetValue = if (statusExpanded) 90f else 0f,
- animationSpec = tween(250),
- label = "chevronRotation",
- )
- Icon(
- Icons.Outlined.ChevronRight,
- contentDescription = "Toggle status",
- tint = TextSecondary,
- modifier =
- Modifier
- .size(20.dp)
- .graphicsLayer { rotationZ = chevronRotation },
- )
- }
-
- // Expandable status options
- AnimatedVisibility(visible = statusExpanded) {
- Column(Modifier.padding(top = 12.dp)) {
- HorizontalDivider(color = TextSecondary.copy(alpha = 0.2f))
- Spacer(Modifier.height(8.dp))
- Text(
- stringResource(R.string.stores_accounts_status_header),
- style = MaterialTheme.typography.labelSmall,
- color = TextSecondary,
- )
- Spacer(Modifier.height(8.dp))
-
- listOf(
- Triple(EPersonaState.Online, stringResource(R.string.stores_accounts_status_online), StatusOnline),
- Triple(EPersonaState.Away, stringResource(R.string.stores_accounts_status_away), StatusAway),
- Triple(
- EPersonaState.Invisible,
- stringResource(R.string.stores_accounts_status_invisible),
- StatusOffline,
- ),
- ).forEach { (state, label, color) ->
- val isSelected = currentState == state
- val rowBg by animateColorAsState(
- targetValue = if (isSelected) Accent.copy(alpha = 0.12f) else Color.Transparent,
- animationSpec = tween(250),
- label = "statusRowBg",
- )
- val borderAlpha by animateFloatAsState(
- targetValue = if (isSelected) 1f else 0f,
- animationSpec = tween(250),
- label = "statusBorder",
- )
- val checkScale by animateFloatAsState(
- targetValue = if (isSelected) 1f else 0f,
- animationSpec =
- spring(
- dampingRatio = Spring.DampingRatioMediumBouncy,
- stiffness = Spring.StiffnessMedium,
- ),
- label = "checkScale",
- )
- Row(
- modifier =
- Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(8.dp))
- .background(rowBg)
- .border(1.dp, Accent.copy(alpha = 0.4f * borderAlpha), RoundedCornerShape(8.dp))
- .clickable(
- interactionSource = remember { MutableInteractionSource() },
- indication = null,
- ) {
- scope.launch {
- SteamService.setPersonaState(state)
- statusExpanded = false
- }
- }.padding(horizontal = 12.dp, vertical = 10.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- Box(Modifier.size(10.dp).background(color, CircleShape))
- Text(label, color = TextPrimary, style = MaterialTheme.typography.bodyMedium)
- Spacer(Modifier.weight(1f))
- Icon(
- Icons.Outlined.Check,
- contentDescription = null,
- tint = Accent,
- modifier =
- Modifier
- .size(16.dp)
- .graphicsLayer {
- scaleX = checkScale
- scaleY = checkScale
- alpha = checkScale
- },
- )
- }
- }
- }
- }
- }
- }
-
- Spacer(Modifier.height(20.dp))
- HorizontalDivider(color = TextSecondary.copy(alpha = 0.15f))
- Spacer(Modifier.height(20.dp))
// ── Layouts ──
Text(
diff --git a/app/src/main/cpp/wn-steam-client/rust/src/chat_image.rs b/app/src/main/cpp/wn-steam-client/rust/src/chat_image.rs
new file mode 100644
index 000000000..8ecd155a4
--- /dev/null
+++ b/app/src/main/cpp/wn-steam-client/rust/src/chat_image.rs
@@ -0,0 +1,239 @@
+use sha1::{Digest, Sha1};
+use std::time::Duration;
+
+const COMMUNITY: &str = "https://steamcommunity.com";
+
+fn sha1_hex(bytes: &[u8]) -> String {
+ let mut hasher = Sha1::new();
+ hasher.update(bytes);
+ hasher
+ .finalize()
+ .iter()
+ .map(|b| format!("{:02x}", b))
+ .collect()
+}
+
+fn random_sessionid() -> String {
+ let mut bytes = [0u8; 12];
+ rand::Rng::fill(&mut rand::thread_rng(), &mut bytes[..]);
+ bytes.iter().map(|b| format!("{:02x}", b)).collect()
+}
+
+/// Best-effort image dimensions for PNG/JPEG/GIF without an image crate.
+fn image_dimensions(bytes: &[u8]) -> (u32, u32) {
+ if bytes.len() > 24 && &bytes[0..8] == b"\x89PNG\r\n\x1a\n" {
+ let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
+ let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
+ return (w, h);
+ }
+ if bytes.len() > 10 && (&bytes[0..6] == b"GIF89a" || &bytes[0..6] == b"GIF87a") {
+ let w = u16::from_le_bytes([bytes[6], bytes[7]]) as u32;
+ let h = u16::from_le_bytes([bytes[8], bytes[9]]) as u32;
+ return (w, h);
+ }
+ if bytes.len() > 4 && bytes[0] == 0xFF && bytes[1] == 0xD8 {
+ let mut i = 2usize;
+ while i + 9 < bytes.len() {
+ if bytes[i] != 0xFF {
+ i += 1;
+ continue;
+ }
+ let marker = bytes[i + 1];
+ if (0xC0..=0xCF).contains(&marker)
+ && marker != 0xC4
+ && marker != 0xC8
+ && marker != 0xCC
+ {
+ let h = u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]) as u32;
+ let w = u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32;
+ return (w, h);
+ }
+ let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
+ if len < 2 {
+ break;
+ }
+ i += 2 + len;
+ }
+ }
+ (0, 0)
+}
+
+fn content_type(bytes: &[u8]) -> &'static str {
+ if bytes.len() > 8 && &bytes[0..8] == b"\x89PNG\r\n\x1a\n" {
+ "image/png"
+ } else if bytes.len() > 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 {
+ "image/jpeg"
+ } else if bytes.len() > 6 && (&bytes[0..6] == b"GIF89a" || &bytes[0..6] == b"GIF87a") {
+ "image/gif"
+ } else if bytes.len() > 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
+ "image/webp"
+ } else {
+ "image/png"
+ }
+}
+
+fn build_client(ca_bundle_path: &str) -> Result {
+ let mut builder = reqwest::blocking::Client::builder()
+ .user_agent("Mozilla/5.0")
+ .connect_timeout(Duration::from_secs(15));
+ if !ca_bundle_path.is_empty() {
+ if let Ok(pem) = std::fs::read(ca_bundle_path) {
+ if let Ok(certs) = reqwest::Certificate::from_pem_bundle(&pem) {
+ for cert in certs {
+ builder = builder.add_root_certificate(cert);
+ }
+ }
+ }
+ }
+ builder
+ .build()
+ .map_err(|err| format!("http client: {err}"))
+}
+
+fn json_get_str(value: &serde_json::Value, key: &str) -> String {
+ value
+ .get(key)
+ .and_then(|v| {
+ if v.is_string() {
+ v.as_str().map(|s| s.to_string())
+ } else if v.is_number() {
+ Some(v.to_string())
+ } else {
+ None
+ }
+ })
+ .unwrap_or_default()
+}
+
+/// Uploads an image to Steam's chat UGC and returns the resulting image URL.
+pub fn upload(
+ ca_bundle_path: &str,
+ self_steamid: u64,
+ friend_steamid: u64,
+ access_token: &str,
+ image: &[u8],
+ file_name: &str,
+) -> Result {
+ if image.is_empty() {
+ return Err("image is empty".into());
+ }
+ let client = build_client(ca_bundle_path)?;
+ let sessionid = random_sessionid();
+ let cookie = format!(
+ "sessionid={sessionid}; steamLoginSecure={self_steamid}%7C%7C{access_token}"
+ );
+ let sha = sha1_hex(image);
+ let (width, height) = image_dimensions(image);
+ let size = image.len().to_string();
+ let width_s = width.to_string();
+ let height_s = height.to_string();
+
+ let begin = client
+ .post(format!("{COMMUNITY}/chat/beginfileupload/?l=english"))
+ .header("Cookie", cookie.as_str())
+ .header("Referer", format!("{COMMUNITY}/chat/"))
+ .header("Origin", COMMUNITY)
+ .form(&[
+ ("sessionid", sessionid.as_str()),
+ ("l", "english"),
+ ("file_size", size.as_str()),
+ ("file_name", file_name),
+ ("file_sha", sha.as_str()),
+ ("file_image_width", width_s.as_str()),
+ ("file_image_height", height_s.as_str()),
+ ("file_type", content_type(image)),
+ ])
+ .timeout(Duration::from_secs(30))
+ .send()
+ .map_err(|err| format!("begin send: {err}"))?;
+ let begin_status = begin.status().as_u16();
+ let begin_body = begin.text().map_err(|err| format!("begin body: {err}"))?;
+ if begin_status != 200 {
+ return Err(format!("begin http {begin_status}: {begin_body}"));
+ }
+ let begin_json: serde_json::Value =
+ serde_json::from_str(&begin_body).map_err(|err| format!("begin json: {err}"))?;
+ let payload = begin_json.get("result").unwrap_or(&begin_json);
+ let ugcid = json_get_str(payload, "ugcid");
+ let hmac = json_get_str(&begin_json, "hmac");
+ let timestamp = {
+ let top = json_get_str(&begin_json, "timestamp");
+ if top.is_empty() { json_get_str(payload, "timestamp") } else { top }
+ };
+ let url_host = json_get_str(payload, "url_host");
+ let url_path = json_get_str(payload, "url_path");
+ let use_https = payload
+ .get("use_https")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(true);
+ if ugcid.is_empty() || url_host.is_empty() {
+ return Err(format!("begin missing ugcid/url_host: {begin_body}"));
+ }
+
+ let scheme = if use_https { "https" } else { "http" };
+ let put_url = format!("{scheme}://{url_host}{url_path}");
+ let mut put = client
+ .put(&put_url)
+ .header("Content-Type", content_type(image))
+ .body(image.to_vec())
+ .timeout(Duration::from_secs(45));
+ if let Some(headers) = payload.get("request_headers").and_then(|v| v.as_array()) {
+ for h in headers {
+ let name = json_get_str(h, "name");
+ let value = json_get_str(h, "value");
+ if !name.is_empty() {
+ put = put.header(name, value);
+ }
+ }
+ }
+ let put_resp = put.send().map_err(|err| format!("ugc put: {err}"))?;
+ let put_status = put_resp.status().as_u16();
+ if !(200..300).contains(&put_status) {
+ return Err(format!("ugc put http {put_status}"));
+ }
+
+ let commit = client
+ .post(format!("{COMMUNITY}/chat/commitfileupload/"))
+ .header("Cookie", cookie.as_str())
+ .header("Referer", format!("{COMMUNITY}/chat/"))
+ .header("Origin", COMMUNITY)
+ .form(&[
+ ("sessionid", sessionid.as_str()),
+ ("l", "english"),
+ ("file_name", file_name),
+ ("file_sha", sha.as_str()),
+ ("file_size", size.as_str()),
+ ("file_image_width", width_s.as_str()),
+ ("file_image_height", height_s.as_str()),
+ ("file_type", content_type(image)),
+ ("success", "1"),
+ ("ugcid", ugcid.as_str()),
+ ("timestamp", timestamp.as_str()),
+ ("hmac", hmac.as_str()),
+ ("friend_steamid", &friend_steamid.to_string()),
+ ("spoiler", "0"),
+ ])
+ .timeout(Duration::from_secs(30))
+ .send()
+ .map_err(|err| format!("commit send: {err}"))?;
+ let commit_status = commit.status().as_u16();
+ let commit_body = commit.text().map_err(|err| format!("commit body: {err}"))?;
+ if commit_status != 200 {
+ return Err(format!("commit http {commit_status}: {commit_body}"));
+ }
+ let commit_json: serde_json::Value =
+ serde_json::from_str(&commit_body).map_err(|err| format!("commit json: {err}"))?;
+ let details = commit_json
+ .get("result")
+ .and_then(|r| r.get("details"))
+ .ok_or_else(|| format!("commit no details: {commit_body}"))?;
+ let file_sha = json_get_str(details, "file_sha");
+ let sha_upper = if file_sha.is_empty() {
+ sha.to_uppercase()
+ } else {
+ file_sha.to_uppercase()
+ };
+ Ok(format!(
+ "https://images.steamusercontent.com/ugc/{ugcid}/{sha_upper}/"
+ ))
+}
diff --git a/app/src/main/cpp/wn-steam-client/rust/src/cm_client.rs b/app/src/main/cpp/wn-steam-client/rust/src/cm_client.rs
index 7d88e5658..b4e8d3fe5 100644
--- a/app/src/main/cpp/wn-steam-client/rust/src/cm_client.rs
+++ b/app/src/main/cpp/wn-steam-client/rust/src/cm_client.rs
@@ -73,6 +73,8 @@ pub struct FriendPersonaSnapshot {
pub game_played_app_id: u32,
pub avatar_hash: Vec,
pub rich_presence: Vec<(String, String)>,
+ pub game_name: String,
+ pub gameid: u64,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
@@ -145,11 +147,21 @@ pub struct CMClientCore {
friends: Mutex>,
self_persona: Mutex