From b48d64d44240e02d2e8e850e2f83231fbb27561a Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Mon, 19 Jan 2026 12:02:42 -0500 Subject: [PATCH 1/2] Move Steam login to Settings and allow library access without mandatory login --- .../main/java/app/gamenative/MainActivity.kt | 5 + .../app/gamenative/service/SteamService.kt | 12 + .../main/java/app/gamenative/ui/PluviaMain.kt | 18 +- .../ui/component/dialog/SteamLoginDialog.kt | 379 ++++++++++++++++++ .../screen/settings/SettingsGroupInterface.kt | 58 ++- 5 files changed, 464 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/app/gamenative/ui/component/dialog/SteamLoginDialog.kt diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index 64b6e06fa..ee56ff836 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -177,6 +177,11 @@ class MainActivity : ComponentActivity() { // Initialize the controller management system ControllerManager.getInstance().init(getApplicationContext()) + // Start services + SteamService.start(this) + if (GOGService.hasStoredCredentials(this)) GOGService.start(this) + if (app.gamenative.service.epic.EpicService.hasStoredCredentials(this)) app.gamenative.service.epic.EpicService.start(this) + ContainerUtils.setContainerDefaults(applicationContext) handleLaunchIntent(intent) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 9b73542f3..c0dbb3628 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2036,6 +2036,18 @@ class SteamService : Service(), IChallengeUrlChanged { isWaitingForQRAuth = false } + fun start(context: Context) { + Timber.tag("Steam").i("[SteamService.start] Called. isRunning=$isRunning") + if (!isRunning) { + Timber.tag("Steam").i("[SteamService.start] Starting foreground service...") + val intent = Intent(context, SteamService::class.java) + context.startForegroundService(intent) + Timber.tag("Steam").i("[SteamService.start] startForegroundService called") + } else { + Timber.tag("Steam").i("[SteamService.start] Service already running, skipping") + } + } + fun stopService() { instance?.let { Timber.i("Stopping service.") diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 7f1623158..787ad2d42 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -266,8 +266,10 @@ fun PluviaMain( if (navController.currentDestination?.route != PluviaScreen.Home.route) { navController.navigate(PluviaScreen.Home.route) { - popUpTo(PluviaScreen.LoginUser.route) { - inclusive = true + navController.graph.findNode(PluviaScreen.LoginUser.route)?.let { + popUpTo(it.id) { + inclusive = true + } } } } @@ -284,11 +286,13 @@ fun PluviaMain( onSuccess = viewModel::launchApp, ) } - } else if (PluviaApp.xEnvironment == null) { + } else if (PluviaApp.xEnvironment == null && navController.currentDestination?.route == PluviaScreen.LoginUser.route) { Timber.i("Navigating to library") navController.navigate(PluviaScreen.Home.route) { - popUpTo(PluviaScreen.LoginUser.route) { - inclusive = true + navController.graph.findNode(PluviaScreen.LoginUser.route)?.let { + popUpTo(it.id) { + inclusive = true + } } } @@ -922,7 +926,7 @@ fun PluviaMain( NavHost( navController = navController, - startDestination = PluviaScreen.LoginUser.route, + startDestination = PluviaScreen.Home.route, ) { /** Login **/ /** Login **/ @@ -1004,7 +1008,7 @@ fun PluviaMain( SteamService.logOut() }, onGoOnline = { - navController.navigate(PluviaScreen.LoginUser.route) + navController.navigate(PluviaScreen.Settings.route) }, isOffline = isOffline, ) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/SteamLoginDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/SteamLoginDialog.kt new file mode 100644 index 000000000..047bbccff --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/SteamLoginDialog.kt @@ -0,0 +1,379 @@ +package app.gamenative.ui.component.dialog + +import android.content.Context +import android.content.res.Configuration +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.Login +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.gamenative.R +import app.gamenative.enums.LoginResult +import app.gamenative.enums.LoginScreen +import app.gamenative.ui.component.LoadingScreen +import app.gamenative.ui.screen.login.QrCodeImage +import app.gamenative.ui.data.UserLoginState +import app.gamenative.ui.model.UserLoginViewModel +import app.gamenative.ui.screen.login.TwoFactorAuthScreenContent +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import timber.log.Timber + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SteamLoginDialog( + visible: Boolean, + onDismissRequest: () -> Unit, + viewModel: UserLoginViewModel = hiltViewModel() +) { + if (!visible) return + + val userLoginState by viewModel.loginState.collectAsStateWithLifecycle() + val context = LocalContext.current + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + // Reset login state when opening + LaunchedEffect(visible) { + if (visible && userLoginState.loginResult == LoginResult.Success) { + // If already logged in, we might want to show logout or just close + // For now, let's just allow it to open + } + } + + // Automatically close dialog on successful login + LaunchedEffect(userLoginState.loginResult) { + if (userLoginState.loginResult == LoginResult.Success) { + onDismissRequest() + } + } + + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties( + usePlatformDefaultWidth = !isLandscape, + dismissOnBackPress = true, + dismissOnClickOutside = false + ) + ) { + Card( + modifier = Modifier + .fillMaxWidth(if (isLandscape) 0.9f else 1f) + .heightIn(max = configuration.screenHeightDp.dp * 0.9f) + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Default.Login, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = "Sign in to Steam", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Tab selection + var selectedTabIndex by remember { + mutableIntStateOf( + when (userLoginState.loginScreen) { + LoginScreen.QR -> 1 + else -> 0 + } + ) + } + + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)), + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + contentColor = MaterialTheme.colorScheme.primary, + indicator = { tabPositions -> + if (selectedTabIndex < tabPositions.size) { + TabRowDefaults.SecondaryIndicator( + modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]), + height = 3.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } + ) { + Tab( + selected = selectedTabIndex == 0, + onClick = { + selectedTabIndex = 0 + viewModel.onShowLoginScreen(LoginScreen.CREDENTIAL) + }, + text = { Text("Credentials") } + ) + Tab( + selected = selectedTabIndex == 1, + onClick = { + selectedTabIndex = 1 + viewModel.onShowLoginScreen(LoginScreen.QR) + }, + text = { Text("QR Code") } + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Content + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + ) { + if (userLoginState.isLoggingIn && userLoginState.loginResult != LoginResult.Success) { + Box( + modifier = Modifier.fillMaxWidth().height(200.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + Crossfade(targetState = userLoginState.loginScreen) { screen -> + when (screen) { + LoginScreen.CREDENTIAL -> { + UsernamePasswordForm( + userLoginState = userLoginState, + viewModel = viewModel, + context = context + ) + } + LoginScreen.TWO_FACTOR -> { + TwoFactorAuthScreenContent( + userLoginState = userLoginState, + message = when { + userLoginState.previousCodeIncorrect -> stringResource(R.string.steam_2fa_incorrect) + userLoginState.loginResult == LoginResult.DeviceAuth -> stringResource(R.string.steam_2fa_device) + userLoginState.loginResult == LoginResult.DeviceConfirm -> stringResource(R.string.steam_2fa_confirmation) + userLoginState.loginResult == LoginResult.EmailAuth -> stringResource(R.string.steam_2fa_email, userLoginState.email ?: "...") + else -> "" + }, + onSetTwoFactor = viewModel::setTwoFactorCode, + onLogin = viewModel::submit + ) + } + LoginScreen.QR -> { + QRCodeContent( + userLoginState = userLoginState, + onQrRetry = viewModel::onQrRetry + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Footer + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = onDismissRequest, + enabled = !userLoginState.isLoggingIn + ) { + Text("Cancel") + } + } + } + } + } +} + +@Composable +private fun UsernamePasswordForm( + userLoginState: UserLoginState, + viewModel: UserLoginViewModel, + context: Context +) { + var passwordVisible by remember { mutableStateOf(false) } + val keyboardController = LocalSoftwareKeyboardController.current + val passwordFocusRequester = remember { FocusRequester() } + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (!userLoginState.isSteamConnected) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.no_connection_to_steam), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + TextButton(onClick = { viewModel.retryConnection(context) }) { + Text(stringResource(R.string.retry_steam_connection)) + } + } + } + } + + OutlinedTextField( + value = userLoginState.username, + onValueChange = viewModel::setUsername, + label = { Text(stringResource(R.string.login_username)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { passwordFocusRequester.requestFocus() }) + ) + + OutlinedTextField( + value = userLoginState.password, + onValueChange = viewModel::setPassword, + label = { Text(stringResource(R.string.login_password)) }, + modifier = Modifier.fillMaxWidth().focusRequester(passwordFocusRequester), + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = null + ) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + keyboardController?.hide() + viewModel.onCredentialLogin() + }) + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = userLoginState.rememberSession, + onCheckedChange = viewModel::setRememberSession + ) + Text( + text = stringResource(R.string.login_remember_session), + style = MaterialTheme.typography.bodyMedium + ) + } + + Button( + onClick = { + keyboardController?.hide() + viewModel.onCredentialLogin() + }, + enabled = userLoginState.isSteamConnected && userLoginState.username.isNotEmpty() && userLoginState.password.isNotEmpty(), + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(8.dp) + ) { + Text(stringResource(R.string.login_sign_in)) + } + } +} + +@Composable +private fun QRCodeContent( + userLoginState: UserLoginState, + onQrRetry: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + if (userLoginState.isQrFailed) { + Text( + text = stringResource(R.string.login_qr_failed), + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onQrRetry) { + Text(stringResource(R.string.login_retry_qr)) + } + } else if (userLoginState.qrCode.isNullOrEmpty()) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + } else { + Box( + modifier = Modifier + .size(200.dp) + .border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(12.dp)) + .padding(8.dp) + ) { + QrCodeImage( + modifier = Modifier.fillMaxSize(), + content = userLoginState.qrCode!!, + size = 184.dp + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.login_qr_instructions), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt index 84fce112f..fe9c398f1 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt @@ -50,10 +50,12 @@ import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.enums.AppTheme import app.gamenative.events.AndroidEvent -import app.gamenative.service.epic.EpicService +import app.gamenative.service.SteamService import app.gamenative.service.gog.GOGService +import app.gamenative.service.epic.EpicService import app.gamenative.ui.component.dialog.EpicLoginDialog import app.gamenative.ui.component.dialog.GOGLoginDialog +import app.gamenative.ui.component.dialog.SteamLoginDialog import app.gamenative.ui.component.dialog.LoadingDialog import app.gamenative.ui.component.dialog.MessageDialog import app.gamenative.ui.component.dialog.SingleChoiceDialog @@ -253,6 +255,10 @@ fun SettingsGroupInterface( var showEpicLogoutDialog by rememberSaveable { mutableStateOf(false) } var epicLogoutLoading by rememberSaveable { mutableStateOf(false) } + // Steam login dialog state + var openSteamLoginDialog by rememberSaveable { mutableStateOf(false) } + var showSteamLogoutDialog by rememberSaveable { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() // Listen for GOG OAuth callback @@ -368,6 +374,30 @@ fun SettingsGroupInterface( var showGOGLogoutDialog by rememberSaveable { mutableStateOf(false) } var gogLogoutLoading by rememberSaveable { mutableStateOf(false) } + // Steam Integration + SettingsGroup(title = { Text(text = "Steam Integration") }) { + SettingsMenuLink( + colors = settingsTileColorsAlt(), + title = { Text(text = "Sign in to Steam") }, + subtitle = { Text(text = "Access your Steam library and cloud saves") }, + onClick = { + openSteamLoginDialog = true + }, + ) + + // Logout button - only show if logged in + if (SteamService.isLoggedIn) { + SettingsMenuLink( + colors = settingsTileColorsAlt(), + title = { Text(text = "Sign out of Steam") }, + subtitle = { Text(text = "Disconnect your account and clear local data") }, + onClick = { + showSteamLogoutDialog = true + }, + ) + } + } + // GOG Integration SettingsGroup(title = { Text(text = stringResource(R.string.gog_integration_title)) }) { SettingsMenuLink( @@ -895,6 +925,32 @@ fun SettingsGroupInterface( progress = -1f, message = "Signing out...", ) + + // Steam Login Dialog + SteamLoginDialog( + visible = openSteamLoginDialog, + onDismissRequest = { openSteamLoginDialog = false } + ) + + // Steam Logout Confirmation + MessageDialog( + visible = showSteamLogoutDialog, + title = "Sign Out of Steam?", + message = "Are you sure you want to sign out? This will remove your account credentials and clear the local Steam library cache. Installed games will remain on disk but may not be launchable without re-authenticating.", + confirmBtnText = "Sign Out", + dismissBtnText = stringResource(R.string.cancel), + onConfirmClick = { + showSteamLogoutDialog = false + SteamService.logOut() + coroutineScope.launch { + withContext(Dispatchers.Main) { + android.widget.Toast.makeText(context, "Signed out of Steam", android.widget.Toast.LENGTH_SHORT).show() + } + } + }, + onDismissRequest = { showSteamLogoutDialog = false }, + onDismissClick = { showSteamLogoutDialog = false } + ) } @Composable From f057ca7b68723f5e344b715a0a89c28f463232ed Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Mon, 19 Jan 2026 16:39:22 -0500 Subject: [PATCH 2/2] Fix custom game icons, stability issues, and UI refresh logic --- .../main/java/app/gamenative/PrefManager.kt | 2 +- .../gamenative/ui/model/LibraryViewModel.kt | 23 ++-- .../ui/screen/library/LibraryScreen.kt | 12 +++ .../library/appscreen/CustomGameAppScreen.kt | 20 ++-- .../library/components/LibraryAppItem.kt | 48 ++++++--- .../library/components/LibraryListPane.kt | 2 +- .../app/gamenative/utils/CustomGameCache.kt | 5 + .../app/gamenative/utils/CustomGameScanner.kt | 102 +++++++++++------- 8 files changed, 142 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index a9f1580b3..9d59f8029 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -551,7 +551,7 @@ object PrefManager { private val LIBRARY_FILTER = intPreferencesKey("library_filter") var libraryFilter: EnumSet get() { - val value = getPref(LIBRARY_FILTER, AppFilter.toFlags(EnumSet.of(AppFilter.GAME, AppFilter.SHARED))) + val value = getPref(LIBRARY_FILTER, AppFilter.toFlags(EnumSet.of(AppFilter.GAME, AppFilter.INSTALLED, AppFilter.SHARED))) return AppFilter.fromFlags(value) } set(value) { diff --git a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt index 127ef1a85..ef9e68280 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -142,6 +142,9 @@ class LibraryViewModel @Inject constructor( PluviaApp.events.on(onInstallStatusChanged) PluviaApp.events.on(onCustomGameImagesFetched) + + // Trigger initial filter to show custom games even if not signed into any store + onFilterApps(0) } override fun onCleared() { @@ -278,7 +281,7 @@ class LibraryViewModel @Inject constructor( } CustomGameScanner.invalidateCache() - onFilterApps(paginationCurrentPage) + onFilterApps(0) } } @@ -360,13 +363,14 @@ class LibraryViewModel @Inject constructor( } // Scan Custom Games roots and create UI items (filtered by search query inside scanner) - // Only include custom games if GAME filter is selected - val customGameItems = if (currentState.appInfoSortType.contains(AppFilter.GAME)) { - CustomGameScanner.scanAsLibraryItems( - query = currentState.searchQuery, - ) - } else { - emptyList() + val customGameItems = CustomGameScanner.scanAsLibraryItems( + query = currentState.searchQuery, + ).filter { _ -> + // Custom games are always considered "installed" + // So if INSTALLED filter is on, they should still be shown. + // If it's off, they should also be shown. + // Only filter by other criteria if needed. + true } val customEntries = customGameItems.map { LibraryEntry(it, true) } @@ -470,7 +474,8 @@ class LibraryViewModel @Inject constructor( if (includeOpen) addAll(customEntries) if (includeGOG) addAll(gogEntries) if (includeEpic) addAll(epicEntries) - }.sortedWith( + }.distinctBy { it.item.appId } // Safety deduplication by appId + .sortedWith( // Primary sort: installed status (0 = installed at top, 1 = not installed at bottom) // Secondary sort: alphabetically by name (case-insensitive) compareBy { entry -> diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt index 5f3f878f6..b28dd72f5 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt @@ -127,6 +127,18 @@ private fun LibraryScreenContent( var selectedAppId by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(null) } // Keep a stable reference to the selected item so detail view doesn't disappear during list refresh/pagination. var selectedLibraryItem by remember { mutableStateOf(null) } + + // Update selected item if it's found in the refreshed list (e.g. after image fetch) + LaunchedEffect(state.appInfoList) { + selectedAppId?.let { appId -> + state.appInfoList.find { it.appId == appId }?.let { updatedItem -> + if (selectedLibraryItem != updatedItem) { + selectedLibraryItem = updatedItem + } + } + } + } + val filterFabExpanded by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } // Dialog state for add custom game prompt diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/CustomGameAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/CustomGameAppScreen.kt index 373533435..f240ae463 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/CustomGameAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/CustomGameAppScreen.kt @@ -76,7 +76,7 @@ class CustomGameAppScreen : BaseAppScreen() { context: Context, libraryItem: LibraryItem, ): GameDisplayInfo { - val gameFolderPath = remember(libraryItem.appId) { + val gameFolderPath = remember(libraryItem) { CustomGameScanner.getFolderPathFromAppId(libraryItem.appId) } @@ -94,7 +94,7 @@ class CustomGameAppScreen : BaseAppScreen() { // Check for all SteamGridDB images in the game folder // Hero view uses horizontal grid (grid_hero) - val heroImageUrl = remember(gameFolderPath) { + val heroImageUrl = remember(libraryItem, gameFolderPath) { gameFolderPath?.let { path -> val folder = File(path) findSteamGridDBImage(folder, "grid_hero") @@ -102,7 +102,7 @@ class CustomGameAppScreen : BaseAppScreen() { } // Capsule view uses vertical grid (grid_capsule) - val capsuleUrl = remember(gameFolderPath) { + val capsuleUrl = remember(libraryItem, gameFolderPath) { gameFolderPath?.let { path -> val folder = File(path) findSteamGridDBImage(folder, "grid_capsule") @@ -110,7 +110,7 @@ class CustomGameAppScreen : BaseAppScreen() { } // Header view uses heroes endpoint (hero, but not grid_hero) - val headerUrl = remember(gameFolderPath) { + val headerUrl = remember(libraryItem, gameFolderPath) { gameFolderPath?.let { path -> val folder = File(path) // Find hero image but exclude grid_hero @@ -126,7 +126,7 @@ class CustomGameAppScreen : BaseAppScreen() { } } - val logoUrl = remember(gameFolderPath) { + val logoUrl = remember(libraryItem, gameFolderPath) { gameFolderPath?.let { path -> val folder = File(path) findSteamGridDBImage(folder, "logo") @@ -137,8 +137,8 @@ class CustomGameAppScreen : BaseAppScreen() { // and don't use SteamGridDB icons // Try to get release date from .gamenative metadata if available - var releaseDate by remember { mutableStateOf(0L) } - LaunchedEffect(gameFolderPath) { + var releaseDate by remember(libraryItem) { mutableStateOf(0L) } + LaunchedEffect(libraryItem, gameFolderPath) { gameFolderPath?.let { path -> val folder = File(path) // Get release date from metadata @@ -148,8 +148,8 @@ class CustomGameAppScreen : BaseAppScreen() { } // Calculate folder size on disk (async, will update via state) - var sizeOnDisk by remember { mutableStateOf(null) } - LaunchedEffect(gameFolderPath) { + var sizeOnDisk by remember(libraryItem) { mutableStateOf(null) } + LaunchedEffect(libraryItem, gameFolderPath) { gameFolderPath?.let { path -> withContext(Dispatchers.IO) { try { @@ -169,7 +169,7 @@ class CustomGameAppScreen : BaseAppScreen() { developer = context.getString(R.string.custom_game_unknown_developer), // Custom Games don't have developer info releaseDate = releaseDate, heroImageUrl = heroImageUrl, - iconUrl = null, // Icons are extracted from exe files, not from SteamGridDB + iconUrl = CustomGameScanner.findIconFileForCustomGame(context, libraryItem.appId), // Use local icon extraction gameId = libraryItem.gameId, appId = libraryItem.appId, installLocation = gameFolderPath, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt index bd21721b8..1de12d75c 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt @@ -165,7 +165,7 @@ internal fun AppItem( .clip(RoundedCornerShape(12.dp)), ) { if (paneType == PaneType.LIST) { - val iconUrl = remember(appInfo.appId) { + val iconUrl = remember(appInfo.appId, imageRefreshCounter) { if (appInfo.gameSource == GameSource.CUSTOM_GAME) { val path = CustomGameScanner.findIconFileForCustomGame(context, appInfo.appId) if (!path.isNullOrEmpty()) { @@ -212,26 +212,22 @@ internal fun AppItem( val imageUrl = remember(appInfo.appId, paneType, imageRefreshCounter) { val url = when (appInfo.gameSource) { GameSource.CUSTOM_GAME -> { - // For Custom Games, use SteamGridDB images + // For Custom Games, use SteamGridDB images only when (paneType) { PaneType.GRID_CAPSULE -> { // Vertical grid for capsule findSteamGridDBImage("grid_capsule") - ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + - "/library_600x900.jpg" } PaneType.GRID_HERO -> { // Horizontal grid for hero view findSteamGridDBImage("grid_hero") - ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + - "/header.jpg" } else -> { // For list view, use heroes endpoint (not grid_hero) val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appInfo.appId) - val heroUrl = gameFolderPath?.let { path -> + gameFolderPath?.let { path -> val folder = java.io.File(path) val heroFile = folder.listFiles()?.firstOrNull { file -> file.name.startsWith("steamgriddb_hero") && @@ -244,9 +240,6 @@ internal fun AppItem( } heroFile?.let { android.net.Uri.fromFile(it).toString() } } - heroUrl - ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + - "/header.jpg" } } } @@ -289,16 +282,45 @@ internal fun AppItem( } } + // Track fallback state + // Pre-resolve native icon for custom games + val nativeIconPath = if (appInfo.gameSource == GameSource.CUSTOM_GAME) { + CustomGameScanner.findIconFileForCustomGame(context, appInfo.appId) + } else null + + val formattedIconPath = nativeIconPath?.let { + if (it.startsWith("file://")) it else "file://$it" + } + + // If url is null (no SteamGridDB image), immediately use icon fallback for Custom Games + val initialImageUrl = imageUrl ?: formattedIconPath + + var currentImageUrl by remember(initialImageUrl) { mutableStateOf(initialImageUrl) } + var isUsingFallback by remember(initialImageUrl) { + mutableStateOf(appInfo.gameSource == GameSource.CUSTOM_GAME && imageUrl == null && initialImageUrl != null) + } + Box { ListItemImage( modifier = Modifier.aspectRatio(aspectRatio), imageModifier = Modifier .clip(RoundedCornerShape(3.dp)) .alpha(alpha), - image = { imageUrl }, + image = { currentImageUrl }, onFailure = { - hideText = false - alpha = 0.1f + if (appInfo.gameSource == GameSource.CUSTOM_GAME && !isUsingFallback) { + // Try using the icon as a fallback if SteamGridDB image failed to load + if (!formattedIconPath.isNullOrEmpty()) { + currentImageUrl = formattedIconPath + isUsingFallback = true + } else { + hideText = false + alpha = 0.1f + } + } else { + hideText = false + alpha = 0.1f + } }, ) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt index c9f5a0b91..5bde29beb 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt @@ -353,7 +353,7 @@ internal fun LibraryListPane( bottom = 72.dp, ), ) { - items(items = state.appInfoList, key = { it.index }) { item -> + items(items = state.appInfoList, key = { it.appId }) { item -> // Fade-in animation for items var isVisible by remember(item.index) { mutableStateOf(false) } val alpha by animateFloatAsState( diff --git a/app/src/main/java/app/gamenative/utils/CustomGameCache.kt b/app/src/main/java/app/gamenative/utils/CustomGameCache.kt index a4a579a1c..f009ac843 100644 --- a/app/src/main/java/app/gamenative/utils/CustomGameCache.kt +++ b/app/src/main/java/app/gamenative/utils/CustomGameCache.kt @@ -17,6 +17,7 @@ internal object CustomGameCache { * Builds the appId cache by scanning all Custom Game manual folders. * Returns a map of appId (Int) -> folder path (String). */ + @Synchronized fun buildCache( getManualFolders: () -> Set, readGameIdFromFile: (File) -> Int?, @@ -42,6 +43,7 @@ internal object CustomGameCache { * Gets or rebuilds the appId cache if needed. * Cache is invalidated when Custom Game manual folders change. */ + @Synchronized fun getOrRebuildCache( getManualFolders: () -> Set, readGameIdFromFile: (File) -> Int?, @@ -62,6 +64,7 @@ internal object CustomGameCache { * Invalidates the appId cache, forcing a rebuild on next access. * Call this when Custom Game paths change, after deletion, or after manual refresh. */ + @Synchronized fun invalidate() { appIdCache = null cacheManualFolders = null @@ -73,6 +76,7 @@ internal object CustomGameCache { * Removes any stale entries with the same path but different appId to maintain consistency. * Used for incremental updates when scanning new games. */ + @Synchronized fun addEntry(appId: Int, folderPath: String) { if (appIdCache != null) { appIdCache = appIdCache!!.toMutableMap().apply { @@ -90,5 +94,6 @@ internal object CustomGameCache { * Gets the current cache (without rebuilding). * Returns null if cache is not built yet. */ + @Synchronized fun getCache(): Map? = appIdCache } diff --git a/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt b/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt index 89886d478..9455e3d62 100644 --- a/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt +++ b/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt @@ -8,9 +8,11 @@ import android.os.Build import android.os.Environment import android.provider.Settings import androidx.core.content.ContextCompat +import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem +import app.gamenative.events.AndroidEvent import app.gamenative.service.DownloadService import com.winlator.container.ContainerManager import java.io.File @@ -275,49 +277,52 @@ object CustomGameScanner { } /** - * Scan a game folder and return the executable relative path if and only if - * there is exactly ONE candidate .exe within the folder root or exactly one - * across all immediate subfolders. Executables whose filenames start with - * "unins" (case-insensitive) are ignored. - * - * Examples of returned values: - * - "game.exe" - * - "Binaries/Win64/Game-Win64-Shipping.exe" + * Scan a game folder and return the executable relative path. + * Uses a scoring system to find the most likely primary executable. */ fun findUniqueExeRelativeToFolder(folderPath: String): String? = findUniqueExeRelativeToFolder(File(folderPath)) fun findUniqueExeRelativeToFolder(folder: File): String? { if (!folder.exists() || !folder.isDirectory) return null - fun File.isValidExe(): Boolean = this.isFile && - this.name.endsWith(".exe", ignoreCase = true) && - !this.name.startsWith("unins", ignoreCase = true) - - val candidates = mutableListOf() + val allExes = mutableListOf>() // Pair of relative path and score + + fun scoreExe(name: String, isRoot: Boolean): Int { + var score = 0 + val lower = name.lowercase() + + // Penalty for obvious non-game exes + val badKeywords = listOf("unins", "setup", "crash", "handler", "viewer", "compiler", "tool", "eac", "launcher", "steam", "unity", "dotnet") + if (badKeywords.any { lower.contains(it) }) score -= 100 + + // Bonus for common game keywords + val goodKeywords = listOf("shipping", "win64", "win32", "game") + if (goodKeywords.any { lower.contains(it) }) score += 50 + + // Bonus for root level exes + if (isRoot) score += 100 + + // Bonus for matching folder name + if (lower.contains(folder.name.lowercase())) score += 200 + + return score + } - folder.listFiles { f -> - f.isFile && - f.name.endsWith(".exe", ignoreCase = true) && - !f.name.startsWith("unins", ignoreCase = true) - }?.forEach { f -> - candidates.add(f.name) + // Search root + folder.listFiles { f -> f.isFile && f.name.endsWith(".exe", ignoreCase = true) }?.forEach { f -> + allExes.add(f.name to scoreExe(f.name, true)) } - val subDirs = folder.listFiles { f -> f.isDirectory } ?: emptyArray() - for (sd in subDirs) { - sd.listFiles { f -> - f.isFile && - f.name.endsWith(".exe", ignoreCase = true) && - !f.name.startsWith("unins", ignoreCase = true) - }?.forEach { f -> + // Search subdirs (1 level deep) + folder.listFiles { f -> f.isDirectory }?.forEach { sd -> + sd.listFiles { f -> f.isFile && f.name.endsWith(".exe", ignoreCase = true) }?.forEach { f -> val rel = sd.name + "/" + f.name - candidates.add(rel) + allExes.add(rel to scoreExe(f.name, false)) } } - // Keep only unique items - val unique = candidates.distinct() - return if (unique.size == 1) unique.first() else null + // Return the highest scoring exe + return allExes.maxByOrNull { it.second }?.first } /** @@ -433,18 +438,28 @@ object CustomGameScanner { val manualFolders = PrefManager.customGameManualFolders if (manualFolders.isNotEmpty()) { - val existingAppIds = mutableSetOf() - for (manualPath in manualFolders) { + val cache = getOrRebuildCache() + + // Map cache to LibraryItems + for ((idPart, path) in cache) { + val folder = File(path) + val folderName = folder.name + // Filter by query if provided if (q.isNotEmpty()) { - val folderName = File(manualPath).name if (!folderName.contains(q, ignoreCase = true)) continue } - val manualItem = createLibraryItemFromFolder(manualPath) - if (manualItem != null && existingAppIds.add(manualItem.appId)) { - items.add(manualItem.copy(index = indexCounter++)) - } + val appId = "${GameSource.CUSTOM_GAME.name}_$idPart" + + items.add(LibraryItem( + index = indexCounter++, + appId = appId, + name = folderName, + iconHash = "", + isShared = false, + gameSource = GameSource.CUSTOM_GAME, + )) } } @@ -456,6 +471,15 @@ object CustomGameScanner { kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { try { + // 1) Trigger SteamGridDB image fetch if not already done + if (PrefManager.fetchSteamGridDBImages && !GameMetadataManager.isSteamGridDBFetched(folder)) { + Timber.tag("CustomGameScanner").i("Triggering automatic SteamGridDB fetch for ${folder.name}") + SteamGridDB.fetchGameImages(folder.name, folder.absolutePath) + // Emit event to refresh UI + PluviaApp.events.emit(AndroidEvent.CustomGameImagesFetched(appId)) + } + + // 2) Icon extraction val hasExtractedIcon = folder.listFiles { file -> file.isFile && file.name.endsWith(".extracted.ico", ignoreCase = true) }?.isNotEmpty() == true @@ -469,13 +493,15 @@ object CustomGameScanner { if (!outIco.exists() || outIco.lastModified() < exeFile.lastModified()) { if (ExeIconExtractor.tryExtractMainIcon(exeFile, outIco)) { Timber.tag("CustomGameScanner").d("Extracted icon for ${folder.name} from ${exeFile.name}") + // Also emit event here as the icon might have changed + PluviaApp.events.emit(AndroidEvent.CustomGameImagesFetched(appId)) } } } } } } catch (e: Exception) { - Timber.tag("CustomGameScanner").d(e, "Icon extraction failed for ${folder.name}") + Timber.tag("CustomGameScanner").d(e, "Metadata detection/fetch failed for ${folder.name}") } } }