From 2079a6035004051a20cb56ff6d99bbcd9e044bf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Bj=C3=B8rnmose=20Bundgaard?= Date: Mon, 24 Nov 2025 23:48:22 +0100 Subject: [PATCH 1/2] Reintroduced custom media functionality, restructured reusable functions and made it work with steam and custom games --- .../component/dialog/ContainerConfigDialog.kt | 306 +++++++++++- .../ui/component/dialog/LoadingDialog.kt | 10 +- .../ui/component/dialog/LoadingToast.kt | 154 +++++++ .../ui/screen/library/LibraryAppScreen.kt | 9 +- .../screen/library/appscreen/BaseAppScreen.kt | 82 +++- .../library/appscreen/CustomGameAppScreen.kt | 189 ++------ .../library/appscreen/SteamAppScreen.kt | 50 +- .../library/components/LibraryAppItem.kt | 113 ++--- .../app/gamenative/utils/CustomGameScanner.kt | 435 +++++------------- .../app/gamenative/utils/CustomMediaUtils.kt | 232 ++++++++++ .../app/gamenative/utils/GameImageUtils.kt | 81 ++++ .../java/app/gamenative/utils/SteamGridDB.kt | 199 ++++---- app/src/main/res/values/strings.xml | 26 +- 13 files changed, 1217 insertions(+), 669 deletions(-) create mode 100644 app/src/main/java/app/gamenative/ui/component/dialog/LoadingToast.kt create mode 100644 app/src/main/java/app/gamenative/utils/CustomMediaUtils.kt create mode 100644 app/src/main/java/app/gamenative/utils/GameImageUtils.kt diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt index 547b9b40c..946b87c6b 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt @@ -17,9 +17,20 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.BorderStroke +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ViewList @@ -31,6 +42,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults @@ -49,6 +61,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -56,6 +69,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.coil.CoilImage import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow @@ -66,6 +85,8 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.tooling.preview.Preview import app.gamenative.R +import app.gamenative.data.GameSource +import app.gamenative.data.LibraryItem import app.gamenative.ui.component.dialog.state.MessageDialogState import app.gamenative.ui.component.settings.SettingsCPUList import app.gamenative.ui.component.settings.SettingsCenteredLabel @@ -97,6 +118,7 @@ import com.winlator.core.WineInfo import com.winlator.core.WineInfo.MAIN_WINE_VERSION import com.winlator.fexcore.FEXCoreManager import java.util.Locale +import app.gamenative.utils.GameImageUtils /** * Gets the component title for Win Components settings group. @@ -116,6 +138,108 @@ private fun winComponentsItemTitleRes(string: String): Int { } } +/** + * Reusable composable for media management sections (Logo, Icon, Hero, Capsule, Header). + * Handles displaying the media, pick/reset actions, and all UI elements. + */ +@Composable +private fun MediaSection( + titleRes: Int, + descriptionRes: Int, + noMediaTitleRes: Int, + gameId: Int?, + mediaVersion: Int, + currentModel: Any?, + placeholderRes: Int, + imageModifier: Modifier, + imageContentScale: ContentScale, + hasCustomMedia: (Int) -> Boolean, + onPickMedia: (android.content.Context, Int, android.net.Uri) -> Boolean, + onResetMedia: (Int) -> Unit, +) { + Text( + text = stringResource(titleRes), + color = Color.White, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + androidx.compose.foundation.layout.Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + val hasModel = when (currentModel) { + is String -> currentModel.isNotBlank() + is android.net.Uri -> true + else -> false + } + if (hasModel) { + CoilImage( + modifier = imageModifier, + imageModel = { app.gamenative.utils.bustCache(currentModel, mediaVersion) }, + imageOptions = ImageOptions(contentScale = imageContentScale), + previewPlaceholder = painterResource(placeholderRes), + ) + } else { + SettingsCenteredLabel( + colors = settingsTileColors(), + title = { Text(text = stringResource(noMediaTitleRes)) }, + subtitle = { Text(text = stringResource(R.string.media_choose_media)) }, + ) + } + } + } + + Text( + text = stringResource(descriptionRes), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + + if (gameId != null) { + val context = LocalContext.current + val isCustom = remember(mediaVersion, gameId) { hasCustomMedia(gameId) } + val picker = androidx.activity.compose.rememberLauncherForActivityResult( + contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + val ok = onPickMedia(context, gameId, uri) + Toast.makeText( + context, + if (ok) context.getString(R.string.media_updated, context.getString(titleRes)) + else context.getString(R.string.media_update_failed, context.getString(titleRes).lowercase()), + Toast.LENGTH_SHORT + ).show() + } + } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally) + ) { + androidx.compose.material3.Button(onClick = { picker.launch("image/*") }) { + Text(stringResource(R.string.media_choose_image)) + } + if (isCustom) { + androidx.compose.material3.OutlinedButton( + onClick = { + onResetMedia(gameId) + Toast.makeText(context, context.getString(R.string.media_reverted), Toast.LENGTH_SHORT).show() + }, + ) { + Text(stringResource(R.string.media_remove_custom_image)) + } + } + } + } + + Spacer(modifier = Modifier.padding(8.dp)) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ContainerConfigDialog( @@ -125,6 +249,13 @@ fun ContainerConfigDialog( initialConfig: ContainerData = ContainerData(), onDismissRequest: () -> Unit, onSave: (ContainerData) -> Unit, + mediaHeroUrl: String? = null, + mediaLogoUrl: String? = null, + mediaCapsuleUrl: String? = null, + mediaHeaderUrl: String? = null, + mediaIconUrl: String? = null, + gameId: Int? = null, + appId: String? = null, ) { if (visible) { val context = LocalContext.current @@ -693,11 +824,13 @@ fun ContainerConfigDialog( }, ) { paddingValues -> var selectedTab by rememberSaveable { mutableIntStateOf(0) } - val tabs = listOf("General", "Graphics", "Emulation", "Controller", "Wine", "Win Components", "Environment", "Drives", "Advanced") + val tabs = listOf("General", "Graphics", "Emulation", "Controller", "Wine", "Win Components", "Environment", "Drives", "Media", "Advanced") Column( modifier = Modifier .padding( - top = app.gamenative.utils.PaddingUtils.statusBarAwarePadding().calculateTopPadding() + paddingValues.calculateTopPadding(), + top = WindowInsets.statusBars + .asPaddingValues() + .calculateTopPadding() + paddingValues.calculateTopPadding(), bottom = 32.dp + paddingValues.calculateBottomPadding(), start = paddingValues.calculateStartPadding(LayoutDirection.Ltr), end = paddingValues.calculateEndPadding(LayoutDirection.Ltr), @@ -1697,7 +1830,174 @@ fun ContainerConfigDialog( }, ) } - if (selectedTab == 8) SettingsGroup() { + if (selectedTab == 8) SettingsGroup(title = { Text(text = "Media") }) { + // Observe global media change version to refresh previews instantly + val mediaVersion by app.gamenative.utils.CustomMediaUtils.mediaVersionFlow.collectAsState(initial = 0) + + // Determine game source from appId and create a minimal LibraryItem + val libraryItem = remember(appId) { + if (appId != null && appId.isNotEmpty()) { + val gameSource = if (appId.startsWith("CUSTOM_GAME_")) { + GameSource.CUSTOM_GAME + } else { + GameSource.STEAM + } + LibraryItem( + appId = appId, + gameSource = gameSource + ) + } else null + } + + // LOGO --------------------------------------------- + val currentLogoModel: Any? = remember(mediaVersion, libraryItem) { + if (libraryItem != null) { + GameImageUtils.getGameImage( + libraryItem = libraryItem, + imageType = "logo", + steamUrl = mediaLogoUrl + ) + } else mediaLogoUrl + } + MediaSection( + titleRes = R.string.media_logo_title, + descriptionRes = R.string.media_logo_hint, + noMediaTitleRes = R.string.media_no_logo, + gameId = gameId, + mediaVersion = mediaVersion, + currentModel = currentLogoModel, + placeholderRes = app.gamenative.R.drawable.testliblogo, + imageModifier = Modifier + .widthIn(min = 150.dp, max = 300.dp) + .heightIn(max = 100.dp), + imageContentScale = ContentScale.Fit, + hasCustomMedia = app.gamenative.utils.CustomMediaUtils::hasCustomLogo, + onPickMedia = { ctx, gid, uri -> app.gamenative.utils.CustomMediaUtils.saveCustomLogo(ctx, gid, uri) }, + onResetMedia = app.gamenative.utils.CustomMediaUtils::resetCustomLogo, + ) + + // ICON --------------------------------------------- + val currentIconModel: Any? = remember(mediaVersion, libraryItem) { + if (libraryItem != null) { + GameImageUtils.getGameImage( + libraryItem = libraryItem, + imageType = "icon", + steamUrl = mediaIconUrl + ) + } else mediaIconUrl + } + MediaSection( + titleRes = R.string.media_icon_title, + descriptionRes = R.string.media_icon_hint, + noMediaTitleRes = R.string.media_no_icon, + gameId = gameId, + mediaVersion = mediaVersion, + currentModel = currentIconModel, + placeholderRes = app.gamenative.R.drawable.ic_logo_color, + imageModifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(10.dp)), + imageContentScale = ContentScale.Fit, + hasCustomMedia = app.gamenative.utils.CustomMediaUtils::hasCustomIcon, + onPickMedia = { ctx, gid, uri -> app.gamenative.utils.CustomMediaUtils.saveCustomIcon(ctx, gid, uri) }, + onResetMedia = app.gamenative.utils.CustomMediaUtils::resetCustomIcon, + ) + + // HEADER --------------------------------------------- + val currentHeaderModel: Any? = remember(mediaVersion, libraryItem) { + if (libraryItem != null) { + GameImageUtils.getGameImage( + libraryItem = libraryItem, + imageType = "header", + steamUrl = mediaHeaderUrl + ) + } else mediaHeaderUrl + } + MediaSection( + titleRes = R.string.media_header_title, + descriptionRes = R.string.media_header_hint, + noMediaTitleRes = R.string.media_no_header, + gameId = gameId, + mediaVersion = mediaVersion, + currentModel = currentHeaderModel, + placeholderRes = app.gamenative.R.drawable.testhero, + imageModifier = Modifier + .widthIn(min = 200.dp, max = 400.dp) + .height(250.dp), + imageContentScale = ContentScale.Crop, + hasCustomMedia = app.gamenative.utils.CustomMediaUtils::hasCustomHeader, + onPickMedia = { ctx, gid, uri -> app.gamenative.utils.CustomMediaUtils.saveCustomHeader(ctx, gid, uri) }, + onResetMedia = app.gamenative.utils.CustomMediaUtils::resetCustomHeader, + ) + + // Grid CAPSULE --------------------------------------------- + val currentCapsuleModel: Any? = remember(mediaVersion, libraryItem) { + if (libraryItem != null) { + GameImageUtils.getGameImage( + libraryItem = libraryItem, + imageType = "grid_capsule", + steamUrl = mediaCapsuleUrl + ) + } else mediaCapsuleUrl + } + MediaSection( + titleRes = R.string.media_grid_capsule_title, + descriptionRes = R.string.media_grid_capsule_hint, + noMediaTitleRes = R.string.media_no_grid_capsule, + gameId = gameId, + mediaVersion = mediaVersion, + currentModel = currentCapsuleModel, + placeholderRes = app.gamenative.R.drawable.testhero, + imageModifier = Modifier + .widthIn(min = 150.dp, max = 250.dp) + .aspectRatio(2/3f) + .clip(RoundedCornerShape(3.dp)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + shape = RoundedCornerShape(3.dp) + ), + imageContentScale = ContentScale.Crop, + hasCustomMedia = app.gamenative.utils.CustomMediaUtils::hasCustomCapsule, + onPickMedia = { ctx, gid, uri -> app.gamenative.utils.CustomMediaUtils.saveCustomCapsule(ctx, gid, uri) }, + onResetMedia = app.gamenative.utils.CustomMediaUtils::resetCustomCapsule, + ) + + // Grid HERO --------------------------------------------- + val currentHeroModel: Any? = remember(mediaVersion, libraryItem) { + if (libraryItem != null) { + GameImageUtils.getGameImage( + libraryItem = libraryItem, + imageType = "grid_hero", + steamUrl = mediaHeroUrl + ) + } else mediaHeroUrl + } + MediaSection( + titleRes = R.string.media_grid_hero_title, + descriptionRes = R.string.media_grid_hero_hint, + noMediaTitleRes = R.string.media_no_grid_hero, + gameId = gameId, + mediaVersion = mediaVersion, + currentModel = currentHeroModel, + placeholderRes = app.gamenative.R.drawable.testhero, + imageModifier = Modifier + .widthIn(min = 150.dp, max = 250.dp) + .aspectRatio(460/215f) + .clip(RoundedCornerShape(3.dp)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + shape = RoundedCornerShape(3.dp) + ), + imageContentScale = ContentScale.Crop, + hasCustomMedia = app.gamenative.utils.CustomMediaUtils::hasCustomHero, + onPickMedia = { ctx, gid, uri -> app.gamenative.utils.CustomMediaUtils.saveCustomHero(ctx, gid, uri) }, + onResetMedia = app.gamenative.utils.CustomMediaUtils::resetCustomHero, + ) + + } + if (selectedTab == 9) SettingsGroup() { SettingsListDropdown( colors = settingsTileColors(), title = { Text(text = stringResource(R.string.startup_selection)) }, diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/LoadingDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/LoadingDialog.kt index ced54ce6d..97ac410a8 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/LoadingDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/LoadingDialog.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -42,15 +43,18 @@ fun LoadingDialog( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { + if (progress < 0) { + // Show spinner for indeterminate progress + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + } Text(message) - Spacer(modifier = Modifier.height(16.dp)) if (progress >= 0) { + Spacer(modifier = Modifier.height(16.dp)) Text(min(100, (progress * 100.0).roundToInt()).toString() + "%") LinearProgressIndicator( progress = { progress }, ) - } else { - LinearProgressIndicator() } } } diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/LoadingToast.kt b/app/src/main/java/app/gamenative/ui/component/dialog/LoadingToast.kt new file mode 100644 index 000000000..1a06ee0b9 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/LoadingToast.kt @@ -0,0 +1,154 @@ +package app.gamenative.ui.component.dialog + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlinx.coroutines.delay + +/** + * Toast-style loading indicator that appears at the bottom of the screen. + * + * @param visible Whether the toast is visible + * @param message The loading message to display + * @param doneMessage Optional message to show when done (triggers checkmark and fade out) + * @param onDismiss Called when the toast should be dismissed (after showing done message) + */ +@Composable +fun LoadingToast( + visible: Boolean, + message: String, + doneMessage: String? = null, + onDismiss: () -> Unit = {}, +) { + var showDone by remember { mutableStateOf(false) } + var shouldFadeOut by remember { mutableStateOf(false) } + + // When doneMessage is set, show checkmark and then fade out + LaunchedEffect(doneMessage) { + if (doneMessage != null) { + showDone = true + // Show done message for duration similar to Toast.LENGTH_SHORT (~2 seconds) + delay(2000) + shouldFadeOut = true + delay(300) // Wait for fade out animation + onDismiss() + } + } + + // Reset state when visibility changes + LaunchedEffect(visible) { + if (!visible) { + showDone = false + shouldFadeOut = false + } + } + + // Keep toast visible during fade out animation + val toastVisible = visible || shouldFadeOut + + if (toastVisible) { + // Custom toast overlay - no dialog, just a Box with high z-index + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(1000f) // High z-index to appear on top + ) { + AnimatedVisibility( + visible = visible && !shouldFadeOut, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 50.dp), // Match toast positioning (50dp from bottom) + contentAlignment = Alignment.BottomCenter + ) { + // Toast-style card at the bottom + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF323232).copy(alpha = 0.95f)) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Animated transition between spinner and checkmark + AnimatedContent( + targetState = showDone && doneMessage != null, + transitionSpec = { + (fadeIn() + scaleIn(initialScale = 0.8f)) togetherWith + (fadeOut() + scaleOut(targetScale = 0.8f)) + }, + label = "iconTransition" + ) { isDone -> + if (isDone) { + // Show checkmark when done + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = Color.White + ) + } else { + // Show spinner while loading + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = Color.White + ) + } + } + + // Animated transition between messages + Crossfade( + targetState = if (showDone && doneMessage != null) doneMessage else message, + label = "messageTransition" + ) { currentMessage -> + Text( + text = currentMessage ?: message, + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + } + } + } +} + diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 39e687cef..026123654 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -242,11 +242,12 @@ internal fun AppScreenContent( .fillMaxWidth() .height(250.dp) ) { - // Hero background image - if (displayInfo.heroImageUrl != null) { + // Header background image (use headerUrl, fallback to heroImageUrl if header not available) + val headerImageUrl = displayInfo.headerUrl ?: displayInfo.heroImageUrl + if (headerImageUrl != null) { CoilImage( modifier = Modifier.fillMaxSize(), - imageModel = { displayInfo.heroImageUrl }, + imageModel = { headerImageUrl }, imageOptions = ImageOptions(contentScale = ContentScale.Crop), loading = { LoadingScreen() }, failure = { @@ -264,7 +265,7 @@ internal fun AppScreenContent( previewPlaceholder = painterResource(R.drawable.testhero), ) } else { - // Fallback gradient background when no hero image + // Fallback gradient background when no header image Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.primary diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index 42cfd8385..fa9f80c99 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt @@ -8,6 +8,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -21,6 +22,7 @@ import app.gamenative.data.LibraryItem import app.gamenative.events.AndroidEvent import app.gamenative.PluviaApp import app.gamenative.ui.component.dialog.ContainerConfigDialog +import app.gamenative.ui.component.dialog.LoadingToast import app.gamenative.ui.data.AppMenuOption import app.gamenative.ui.data.GameDisplayInfo import app.gamenative.ui.enums.AppOptionMenuType @@ -39,6 +41,31 @@ import kotlinx.coroutines.withContext * This defines the contract that all game source-specific screens must implement. */ abstract class BaseAppScreen { + companion object { + // Track which appId is currently fetching images + private val fetchingImagesAppId = kotlinx.coroutines.flow.MutableStateFlow(null) + val fetchingImagesFlow: kotlinx.coroutines.flow.StateFlow = fetchingImagesAppId + + // Track done message for the currently fetching app + private val fetchingImagesDoneMessage = kotlinx.coroutines.flow.MutableStateFlow?>(null) + val fetchingImagesDoneMessageFlow: kotlinx.coroutines.flow.StateFlow?> = fetchingImagesDoneMessage + + fun isFetchingImages(appId: String): Boolean { + return fetchingImagesAppId.value == appId + } + + fun setFetchingImages(appId: String?, isFetching: Boolean) { + fetchingImagesAppId.value = if (isFetching) appId else null + if (!isFetching) { + // Clear done message when not fetching + fetchingImagesDoneMessage.value = null + } + } + + fun setFetchingImagesDone(appId: String?, doneMessage: String?) { + fetchingImagesDoneMessage.value = Pair(appId, doneMessage) + } + } /** * Get the game display information for rendering the UI. * This is called to get all the data needed for the common UI layout. @@ -267,6 +294,9 @@ abstract class BaseAppScreen { AppMenuOption( optionType = AppOptionMenuType.FetchSteamGridDBImages, onClick = { + // Set loading state + setFetchingImages(libraryItem.appId, true) + CoroutineScope(Dispatchers.IO).launch { try { // Use libraryItem.name directly (non-composable) @@ -286,21 +316,26 @@ abstract class BaseAppScreen { app.gamenative.utils.SteamGridDB.fetchGameImages(gameName, gameFolderPath) - // Emit event to notify UI that images have been fetched - PluviaApp.events.emit(AndroidEvent.CustomGameImagesFetched(libraryItem.appId)) - // Call hook for post-fetch processing (e.g., icon extraction) onAfterFetchImages(context, libraryItem, gameFolderPath) + // Notify UI that images have been refreshed + app.gamenative.utils.GameImageUtils.notifyImagesRefreshed() + + // Emit event to notify UI that images have been fetched + PluviaApp.events.emit(AndroidEvent.CustomGameImagesFetched(libraryItem.appId)) + + // Set done message to show checkmark and fade out (don't clear fetching state yet) withContext(Dispatchers.Main) { - Toast.makeText( - context, - context.getString(R.string.base_app_images_fetched), - Toast.LENGTH_SHORT - ).show() + setFetchingImagesDone( + libraryItem.appId, + context.getString(R.string.base_app_images_fetched) + ) } } else { withContext(Dispatchers.Main) { + // Clear loading state immediately for error case + setFetchingImages(libraryItem.appId, false) Toast.makeText( context, context.getString(R.string.base_app_game_folder_not_found), @@ -310,6 +345,8 @@ abstract class BaseAppScreen { } } catch (e: Exception) { withContext(Dispatchers.Main) { + // Clear loading state immediately for error case + setFetchingImages(libraryItem.appId, false) Toast.makeText( context, context.getString( @@ -320,6 +357,8 @@ abstract class BaseAppScreen { ).show() } } + // Note: Don't clear fetching state in finally block for success case + // It will be cleared by LoadingToast's onDismiss after fade animation } } ) @@ -431,6 +470,26 @@ abstract class BaseAppScreen { val context = LocalContext.current val displayInfo = getGameDisplayInfo(context, libraryItem) + // Observe fetching images state + val fetchingAppId by BaseAppScreen.fetchingImagesFlow.collectAsState() + val fetchingDoneMessage by BaseAppScreen.fetchingImagesDoneMessageFlow.collectAsState() + val isFetchingImages = fetchingAppId == libraryItem.appId + val doneMessage = if (fetchingDoneMessage?.first == libraryItem.appId) { + fetchingDoneMessage?.second + } else null + + // Show toast-style loading indicator while fetching images + LoadingToast( + visible = isFetchingImages || doneMessage != null, + message = context.getString(R.string.base_app_images_fetching), + doneMessage = doneMessage, + onDismiss = { + // Clear both fetching state and done message + BaseAppScreen.setFetchingImages(libraryItem.appId, false) + BaseAppScreen.setFetchingImagesDone(libraryItem.appId, null) + } + ) + // Use composable state for values that change over time var isInstalledState by remember(libraryItem.appId) { mutableStateOf(isInstalled(context, libraryItem)) @@ -590,6 +649,13 @@ abstract class BaseAppScreen { saveContainerConfig(context, libraryItem, it) showConfigDialog = false }, + mediaHeroUrl = displayInfo.heroImageUrl, + mediaLogoUrl = displayInfo.logoUrl, + mediaCapsuleUrl = displayInfo.capsuleUrl, + mediaHeaderUrl = displayInfo.headerUrl, + mediaIconUrl = displayInfo.iconUrl, + gameId = displayInfo.gameId, + appId = libraryItem.appId, ) } 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 61eaece11..1e3a870c5 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 @@ -19,6 +19,7 @@ import app.gamenative.ui.data.AppMenuOption import app.gamenative.ui.data.GameDisplayInfo import app.gamenative.utils.ContainerUtils import app.gamenative.utils.CustomGameScanner +import app.gamenative.utils.GameImageUtils import app.gamenative.utils.StorageUtils import com.winlator.container.ContainerData import com.winlator.container.ContainerManager @@ -78,57 +79,44 @@ class CustomGameAppScreen : BaseAppScreen() { CustomGameScanner.getFolderPathFromAppId(libraryItem.appId) } - // Helper function to find SteamGridDB images in the game folder - fun findSteamGridDBImage(folder: File, imageType: String): String? { - return folder.listFiles()?.firstOrNull { file -> - file.name.startsWith("steamgriddb_$imageType") && - (file.name.endsWith(".png", ignoreCase = true) || - file.name.endsWith(".jpg", ignoreCase = true) || - file.name.endsWith(".webp", ignoreCase = true)) - }?.let { Uri.fromFile(it).toString() } - } + // Observe media changes to refresh images when custom media is updated + val mediaVersion by app.gamenative.utils.CustomMediaUtils.mediaVersionFlow.collectAsState(initial = 0) - // Check for all SteamGridDB images in the game folder - // Hero view uses horizontal grid (grid_hero) - val heroImageUrl = remember(gameFolderPath) { - gameFolderPath?.let { path -> - val folder = File(path) - findSteamGridDBImage(folder, "grid_hero") - } + // Use centralized function to get images with proper priority: Custom media -> SteamGridDB -> Steam URLs + val heroImageUrl = remember(mediaVersion, libraryItem) { + GameImageUtils.getGameImage( + libraryItem = libraryItem, + imageType = "grid_hero" + ) } - // Capsule view uses vertical grid (grid_capsule) - val capsuleUrl = remember(gameFolderPath) { - gameFolderPath?.let { path -> - val folder = File(path) - findSteamGridDBImage(folder, "grid_capsule") - } + val capsuleUrl = remember(mediaVersion, libraryItem) { + GameImageUtils.getGameImage( + libraryItem = libraryItem, + imageType = "grid_capsule" + ) } - // Header view uses heroes endpoint (hero, but not grid_hero) - val headerUrl = remember(gameFolderPath) { - gameFolderPath?.let { path -> - val folder = File(path) - // Find hero image but exclude grid_hero - folder.listFiles()?.firstOrNull { file -> - file.name.startsWith("steamgriddb_hero") && - !file.name.contains("grid") && - (file.name.endsWith(".png", ignoreCase = true) || - file.name.endsWith(".jpg", ignoreCase = true) || - file.name.endsWith(".webp", ignoreCase = true)) - }?.let { Uri.fromFile(it).toString() } - } + val headerUrl = remember(mediaVersion, libraryItem) { + GameImageUtils.getGameImage( + libraryItem = libraryItem, + imageType = "header" + ) } - val logoUrl = remember(gameFolderPath) { - gameFolderPath?.let { path -> - val folder = File(path) - findSteamGridDBImage(folder, "logo") - } + val logoUrl = remember(mediaVersion, libraryItem) { + GameImageUtils.getGameImage( + libraryItem = libraryItem, + imageType = "logo" + ) } - // Note: iconUrl is intentionally null - we extract icons from exe files - // and don't use SteamGridDB icons + val iconUrl = remember(mediaVersion, libraryItem) { + GameImageUtils.getGameImage( + libraryItem = libraryItem, + imageType = "icon" + ) + } // Try to get release date from .gamenative metadata if available var releaseDate by remember { mutableStateOf(0L) } @@ -163,7 +151,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 = iconUrl, // Icons are extracted from exe files, not from SteamGridDB gameId = libraryItem.gameId, appId = libraryItem.appId, installLocation = gameFolderPath, @@ -243,118 +231,9 @@ class CustomGameAppScreen : BaseAppScreen() { override fun onAfterFetchImages(context: Context, libraryItem: LibraryItem, gameFolderPath: String) { // Extract icon from executable after fetching images Timber.tag("CustomGameAppScreen").d("onAfterFetchImages called - appId: ${libraryItem.appId}, gameFolderPath: $gameFolderPath") - - // Verify the path was resolved correctly, but use gameFolderPath as fallback - val resolvedPath = getInstallPath(context, libraryItem) - Timber.tag("CustomGameAppScreen").d("Resolved install path: ${resolvedPath ?: "null"}") - - // Use resolvedPath if available, otherwise fall back to gameFolderPath (which was validated by caller) - val actualGameFolderPath = resolvedPath ?: gameFolderPath - if (resolvedPath != gameFolderPath && resolvedPath != null) { - Timber.tag("CustomGameAppScreen").w("Path mismatch! gameFolderPath: $gameFolderPath, resolvedPath: $resolvedPath, using: $actualGameFolderPath") - } else if (resolvedPath == null) { - Timber.tag("CustomGameAppScreen").w("Could not resolve install path, using provided gameFolderPath: $gameFolderPath") - } - - val gameFolder = java.io.File(actualGameFolderPath) - if (gameFolder.exists() && gameFolder.isDirectory) { - Timber.tag("CustomGameAppScreen").i("Extracting icon from executable after fetching images") - try { - // Check if icon already exists by using the same method the UI uses - val existingIconPath = CustomGameScanner.findIconFileForCustomGame(context, libraryItem.appId) - val hasExtractedIcon = existingIconPath != null && existingIconPath.endsWith(".extracted.ico", ignoreCase = true) - - Timber.tag("CustomGameAppScreen").d("Icon check - existingIconPath: ${existingIconPath ?: "null"}, hasExtractedIcon: $hasExtractedIcon") - - // Also check if there's an extracted icon file anywhere in the folder (limited depth search) - // Limit search to 2 levels deep to avoid performance issues with large game folders - fun findExtractedIconLimited(dir: File, depth: Int = 0, maxDepth: Int = 2): File? { - if (depth > maxDepth) return null - dir.listFiles()?.forEach { file -> - if (file.isDirectory) { - val found = findExtractedIconLimited(file, depth + 1, maxDepth) - if (found != null) return found - } else if (file.name.endsWith(".extracted.ico", ignoreCase = true)) { - return file - } - } - return null - } - val extractedIconFile = findExtractedIconLimited(gameFolder) - Timber.tag("CustomGameAppScreen").d("Recursive search found extracted icon: ${extractedIconFile?.absolutePath ?: "null"}") - - // If findIconFileForCustomGame didn't find an extracted icon, but one exists, we should still try to extract - // (maybe the container path isn't set or the icon is in a different location) - val shouldExtract = !hasExtractedIcon || (extractedIconFile != null && existingIconPath != extractedIconFile.absolutePath) - - if (shouldExtract) { - // First, try using the container's selected executable if available - val containerManager = com.winlator.container.ContainerManager(context) - val hasContainer = containerManager.hasContainer(libraryItem.appId) - Timber.tag("CustomGameAppScreen").d("Container exists: $hasContainer") - - if (hasContainer) { - val container = containerManager.getContainerById(libraryItem.appId) - val relExe = container.executablePath - Timber.tag("CustomGameAppScreen").d("Container executable path: ${relExe ?: "null"}") - - if (!relExe.isNullOrEmpty()) { - val exeFile = java.io.File(gameFolder, relExe.replace('/', java.io.File.separatorChar)) - Timber.tag("CustomGameAppScreen").d("Checking executable file: ${exeFile.absolutePath}, exists: ${exeFile.exists()}") - - if (exeFile.exists()) { - val outIco = java.io.File(exeFile.parentFile, exeFile.nameWithoutExtension + ".extracted.ico") - Timber.tag("CustomGameAppScreen").d("Attempting to extract icon to: ${outIco.absolutePath}") - val extracted = app.gamenative.utils.ExeIconExtractor.tryExtractMainIcon(exeFile, outIco) - Timber.tag("CustomGameAppScreen").d("Icon extraction result: $extracted") - - if (extracted) { - Timber.tag("CustomGameAppScreen").d("Extracted icon from selected executable: ${exeFile.name}") - } else { - Timber.tag("CustomGameAppScreen").w("Failed to extract icon from selected executable: ${exeFile.name}") - } - } - } - } - - // If that didn't work, try finding a unique executable - val uniqueExeRel = CustomGameScanner.findUniqueExeRelativeToFolder(gameFolder) - Timber.tag("CustomGameAppScreen").d("Unique executable found: ${uniqueExeRel ?: "null"}") - - if (!uniqueExeRel.isNullOrEmpty()) { - val exeFile = java.io.File(gameFolder, uniqueExeRel.replace('/', java.io.File.separatorChar)) - Timber.tag("CustomGameAppScreen").d("Checking unique executable file: ${exeFile.absolutePath}, exists: ${exeFile.exists()}") - - if (exeFile.exists()) { - val outIco = java.io.File(exeFile.parentFile, exeFile.nameWithoutExtension + ".extracted.ico") - // Only extract if we haven't already extracted from the selected exe - if (!outIco.exists()) { - Timber.tag("CustomGameAppScreen").d("Attempting to extract icon to: ${outIco.absolutePath}") - val extracted = app.gamenative.utils.ExeIconExtractor.tryExtractMainIcon(exeFile, outIco) - Timber.tag("CustomGameAppScreen").d("Icon extraction result: $extracted") - - if (extracted) { - Timber.tag("CustomGameAppScreen").d("Extracted icon from unique executable: ${exeFile.name}") - } else { - Timber.tag("CustomGameAppScreen").w("Failed to extract icon from unique executable: ${exeFile.name}") - } - } else { - Timber.tag("CustomGameAppScreen").d("Icon file already exists: ${outIco.absolutePath}") - } - } - } else { - Timber.tag("CustomGameAppScreen").w("No unique executable found in folder: $gameFolderPath") - } - } else { - Timber.tag("CustomGameAppScreen").d("Icon already exists, skipping extraction") - } - } catch (e: Exception) { - Timber.tag("CustomGameAppScreen").e(e, "Failed to extract icon from executable") - // Silently continue - icon extraction is optional - } - } else { - Timber.tag("CustomGameAppScreen").e("Game folder does not exist: $gameFolderPath") - } + + // Use the centralized icon extraction function + CustomGameScanner.extractIconFromExecutable(context, libraryItem.appId) } @Composable diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt index 32e51b129..35b3b5992 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt @@ -32,6 +32,7 @@ import app.gamenative.ui.enums.AppOptionMenuType import app.gamenative.ui.enums.DialogType import app.gamenative.ui.screen.library.GameMigrationDialog import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.GameImageUtils import app.gamenative.utils.MarkerUtils import app.gamenative.utils.StorageUtils import app.gamenative.utils.SteamUtils @@ -177,14 +178,48 @@ class SteamAppScreen : BaseAppScreen() { } } - // Get hero image URL - val heroImageUrl = remember(appInfo.id) { - appInfo.getHeroUrl() + // Observe media changes to refresh images when custom media is updated + val mediaVersion by app.gamenative.utils.CustomMediaUtils.mediaVersionFlow.collectAsState(initial = 0) + + // Use centralized function to get images with proper priority: Custom media -> SteamGridDB -> Steam URLs + val heroImageUrl = remember(mediaVersion, libraryItem) { + GameImageUtils.getGameImage( + libraryItem = libraryItem, + imageType = "grid_hero", + steamUrl = appInfo.getHeroUrl() + ) + } + + val iconUrl = remember(mediaVersion, libraryItem) { + GameImageUtils.getGameImage( + libraryItem = libraryItem, + imageType = "icon", + steamUrl = appInfo.iconUrl + ) + } + + val logoUrl = remember(mediaVersion, libraryItem) { + GameImageUtils.getGameImage( + libraryItem = libraryItem, + imageType = "logo", + steamUrl = appInfo.getLogoUrl() ?: appInfo.logoUrl + ) + } + + val capsuleUrl = remember(mediaVersion, libraryItem) { + GameImageUtils.getGameImage( + libraryItem = libraryItem, + imageType = "grid_capsule", + steamUrl = appInfo.getCapsuleUrl() + ) } - // Get icon URL - val iconUrl = remember(appInfo.id) { - appInfo.iconUrl + val headerUrl = remember(mediaVersion, libraryItem) { + GameImageUtils.getGameImage( + libraryItem = libraryItem, + imageType = "header", + steamUrl = appInfo.getHeaderImageUrl() + ) } // Get install location @@ -261,6 +296,9 @@ class SteamAppScreen : BaseAppScreen() { sizeFromStore = sizeFromStore, lastPlayedText = lastPlayedText, playtimeText = playtimeText, + logoUrl = logoUrl, + capsuleUrl = capsuleUrl, + headerUrl = headerUrl, ) } 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 4f0273a62..00a463852 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 @@ -41,6 +41,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -63,8 +64,9 @@ import app.gamenative.ui.internal.fakeAppInfo import app.gamenative.ui.theme.PluviaTheme import app.gamenative.ui.util.ListItemImage import app.gamenative.utils.CustomGameScanner -import java.io.File -import android.net.Uri +import app.gamenative.utils.GameImageUtils + +var steamAssetsUrl = "https://shared.steamstatic.com/store_item_assets/steam/apps/" @Composable internal fun AppItem( @@ -83,7 +85,7 @@ internal fun AppItem( hideText = true alpha = 1f } - + // Reset alpha and hideText when image URL changes (e.g., when new images are fetched) LaunchedEffect(imageRefreshCounter) { if (paneType != PaneType.LIST) { @@ -154,85 +156,58 @@ internal fun AppItem( .clip(RoundedCornerShape(12.dp)), ) { if (paneType == PaneType.LIST) { - val iconUrl = remember(appInfo.appId) { - if (appInfo.gameSource == GameSource.CUSTOM_GAME) { - val path = CustomGameScanner.findIconFileForCustomGame(context, appInfo.appId) - if (!path.isNullOrEmpty()) { - if (path.startsWith("file://")) path else "file://$path" - } else { - appInfo.clientIconUrl - } - } else appInfo.clientIconUrl + // Observe media changes to refresh icon immediately when user updates or resets + val mediaVersion by app.gamenative.utils.CustomMediaUtils.mediaVersionFlow.collectAsState(initial = 0) + val iconModel: Any? = remember(mediaVersion, appInfo.gameId) { + app.gamenative.utils.CustomMediaUtils.getCustomIconUri(appInfo.gameId) + ?: if (appInfo.gameSource == GameSource.CUSTOM_GAME) { + val path = CustomGameScanner.findIconFileForCustomGame(context, appInfo.appId) + if (!path.isNullOrEmpty()) { + if (path.startsWith("file://")) path else "file://$path" + } else { + appInfo.clientIconUrl + } + } else appInfo.clientIconUrl } ListItemImage( modifier = Modifier.size(56.dp), imageModifier = Modifier.clip(RoundedCornerShape(10.dp)), - image = { iconUrl } + image = { app.gamenative.utils.bustCache(iconModel, mediaVersion) } ) } else { val aspectRatio = if (paneType == PaneType.GRID_CAPSULE) { 2/3f } else { 460/215f } + // Observe media changes to refresh images immediately when user updates or resets + val mediaVersion by app.gamenative.utils.CustomMediaUtils.mediaVersionFlow.collectAsState(initial = 0) - // Helper function to find SteamGridDB images for Custom Games - fun findSteamGridDBImage(imageType: String): String? { - if (appInfo.gameSource == GameSource.CUSTOM_GAME) { - val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appInfo.appId) - gameFolderPath?.let { path -> - val folder = java.io.File(path) - val imageFile = folder.listFiles()?.firstOrNull { file -> - file.name.startsWith("steamgriddb_$imageType") && - (file.name.endsWith(".png", ignoreCase = true) || - file.name.endsWith(".jpg", ignoreCase = true) || - file.name.endsWith(".webp", ignoreCase = true)) - } - return imageFile?.let { android.net.Uri.fromFile(it).toString() } + val baseModel: Any? = remember(mediaVersion, appInfo, paneType, imageRefreshCounter) { + when (paneType) { + PaneType.GRID_CAPSULE -> { + GameImageUtils.getGameImage( + libraryItem = appInfo, + imageType = "grid_capsule", + steamUrl = "$steamAssetsUrl${appInfo.gameId}/library_600x900.jpg" + ) } - } - return null - } - - val imageUrl = remember(appInfo.appId, paneType, imageRefreshCounter) { - if (appInfo.gameSource == GameSource.CUSTOM_GAME) { - // For Custom Games, use SteamGridDB images - 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 -> - val folder = java.io.File(path) - val heroFile = folder.listFiles()?.firstOrNull { file -> - file.name.startsWith("steamgriddb_hero") && - !file.name.contains("grid") && - (file.name.endsWith(".png", ignoreCase = true) || - file.name.endsWith(".jpg", ignoreCase = true) || - file.name.endsWith(".webp", ignoreCase = true)) - } - heroFile?.let { android.net.Uri.fromFile(it).toString() } - } - heroUrl ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" - } + PaneType.GRID_HERO -> { + GameImageUtils.getGameImage( + libraryItem = appInfo, + imageType = "grid_hero", + steamUrl = "$steamAssetsUrl${appInfo.gameId}/header.jpg" + ) } - } else { - // For Steam games, use standard Steam URLs - if (paneType == PaneType.GRID_CAPSULE) { - "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/library_600x900.jpg" - } else { - "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" + else -> { + // LIST view uses header images + GameImageUtils.getGameImage( + libraryItem = appInfo, + imageType = "header", + steamUrl = "$steamAssetsUrl${appInfo.gameId}/header.jpg" + ) } } } - + // Reset alpha and hideText when image URL changes (e.g., when new images are fetched) - LaunchedEffect(imageUrl) { + LaunchedEffect(baseModel) { if (paneType != PaneType.LIST) { hideText = true alpha = 1f @@ -242,7 +217,7 @@ internal fun AppItem( ListItemImage( modifier = Modifier.aspectRatio(aspectRatio), imageModifier = Modifier.clip(RoundedCornerShape(3.dp)).alpha(alpha), - image = { imageUrl }, + image = { app.gamenative.utils.bustCache(baseModel, mediaVersion) }, onFailure = { hideText = false alpha = 0.1f diff --git a/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt b/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt index 54a90a9d9..4ecc56e22 100644 --- a/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt +++ b/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt @@ -23,259 +23,13 @@ import org.json.JSONObject object CustomGameScanner { - // Default root path for Custom Games. Always use the app's external storage sandbox - // (Android/data//CustomGames) when available; fall back to internal only if external is unavailable. - // This ensures the folder is visible via MTP/file managers. - val defaultRootPath: String - get() { - // External app sandbox (e.g., /storage/emulated/0/Android/data/) - val externalBase = DownloadService.baseExternalAppDirPath - val externalDir = if (externalBase.isNotEmpty()) File(externalBase, "CustomGames") else null - val internalDir = File(DownloadService.baseDataDirPath, "CustomGames") - - // Always prefer external location (visible via MTP/file managers) when available - // Only fall back to internal if external is truly not available - val target = when { - externalDir != null -> { - // Always use external if available (it's visible to users via file managers) - // Create parent directory if needed - externalDir.parentFile?.mkdirs() - externalDir - } - else -> { - Timber.tag("CustomGameScanner").w("External storage not available, falling back to internal: ${internalDir.path}") - internalDir - } - } - if (!target.exists()) { - val created = target.mkdirs() - if (created) { - Timber.tag("CustomGameScanner").d("Created default CustomGames folder: ${target.path}") - } else { - Timber.tag("CustomGameScanner").w("Failed to create default CustomGames folder: ${target.path}") - } - } - Timber.tag("CustomGameScanner").d("Using default CustomGames path: ${target.path}") - return target.path - } - - /** - * Ensures the default CustomGames folder exists by creating it if it doesn't. - * This should be called when the library screen loads to guarantee the folder exists - * even if there are no custom games yet. - * - * This function explicitly creates the folder using the same logic as defaultRootPath - * to ensure it exists regardless of whether scanning happens. - */ - fun ensureDefaultFolderExists() { - Timber.tag("CustomGameScanner").d("Ensuring default CustomGames folder exists") - - try { - // Use the same logic as defaultRootPath to ensure consistency - val defaultPath = defaultRootPath - val folder = File(defaultPath) - - if (!folder.exists()) { - val created = folder.mkdirs() - if (created) { - Timber.tag("CustomGameScanner").d("Created default CustomGames folder: $defaultPath") - } else { - Timber.tag("CustomGameScanner").w("Failed to create default CustomGames folder: $defaultPath") - } - } else { - Timber.tag("CustomGameScanner").d("Default CustomGames folder already exists: $defaultPath") - } - } catch (e: Exception) { - Timber.tag("CustomGameScanner").e(e, "Error ensuring default CustomGames folder exists") - } - } - - /** - * Attempts to locate a suitable icon file for a Custom Game. - * Strategy (in priority order): - * 1) Check for SteamGridDB logo files (steamgriddb_logo.png/jpg/webp) - * 2) If we can uniquely identify an exe, try extracting embedded icon(s) - * 3) Otherwise, prefer an .ico whose filename contains "icon". - * 4) Otherwise, if there is exactly one .ico across the folder root or its immediate - * subfolders, use that. - * Returns the absolute file path to the icon when found; otherwise null. - */ - fun findIconFileForCustomGame(appId: String): String? { - val folderPath = getFolderPathFromAppId(appId) ?: return null - val folder = File(folderPath) - if (!folder.exists() || !folder.isDirectory) return null - - val steamGridLogo = folder.listFiles { file -> - file.isFile && file.name.startsWith("steamgriddb_logo", ignoreCase = true) && - (file.name.endsWith(".png", ignoreCase = true) || - file.name.endsWith(".jpg", ignoreCase = true) || - file.name.endsWith(".webp", ignoreCase = true)) - }?.firstOrNull() - if (steamGridLogo != null) { - Timber.tag("CustomGameScanner").d("Found SteamGridDB logo: ${steamGridLogo.absolutePath}") - return steamGridLogo.absolutePath - } - - // 2) If we can uniquely identify an exe, try extracting embedded icon(s) - val uniqueExeRel = findUniqueExeRelativeToFolder(folder) - if (!uniqueExeRel.isNullOrEmpty()) { - val exeFile = File(folder, uniqueExeRel.replace('/', File.separatorChar)) - if (exeFile.exists()) { - val outIco = File(exeFile.parentFile, exeFile.nameWithoutExtension + ".extracted.ico") - // Use cache if up to date, else (re)extract - val useCached = outIco.exists() && outIco.lastModified() >= exeFile.lastModified() - if (useCached) return outIco.absolutePath - try { - if (ExeIconExtractor.tryExtractMainIcon(exeFile, outIco)) { - return outIco.absolutePath - } - } catch (e: Exception) { - // swallow and fall back - } - } - } - - // Fallback to nearby images if extraction was not possible - return findNearbyImageIcon(folder, uniqueExeRel) - } - - // New: Context-aware variant that prefers the selected container executable's icon - fun findIconFileForCustomGame(context: Context, appId: String): String? { - val folderPath = getFolderPathFromAppId(appId) ?: return null - val folder = File(folderPath) - if (!folder.exists() || !folder.isDirectory) return null - - val steamGridLogo = folder.listFiles { file -> - file.isFile && file.name.startsWith("steamgriddb_logo", ignoreCase = true) && - (file.name.endsWith(".png", ignoreCase = true) || - file.name.endsWith(".jpg", ignoreCase = true) || - file.name.endsWith(".webp", ignoreCase = true)) - }?.firstOrNull() - if (steamGridLogo != null) { - Timber.tag("CustomGameScanner").d("Found SteamGridDB logo: ${steamGridLogo.absolutePath}") - return steamGridLogo.absolutePath - } - - // 2) Try extracting from the selected container executable - try { - val cm = ContainerManager(context) - if (cm.hasContainer(appId)) { - val container = cm.getContainerById(appId) - val relExe = container.executablePath - if (!relExe.isNullOrEmpty()) { - val exeFile = File(folder, relExe.replace('/', File.separatorChar)) - if (exeFile.exists()) { - val outIco = File(exeFile.parentFile, exeFile.nameWithoutExtension + ".extracted.ico") - val useCached = outIco.exists() && outIco.lastModified() >= exeFile.lastModified() - if (useCached) { - Timber.tag("CustomGameScanner").d("Found cached icon at ${outIco.absolutePath}") - return outIco.absolutePath - } - try { - if (ExeIconExtractor.tryExtractMainIcon(exeFile, outIco)) { - Timber.tag("CustomGameScanner").d("Extracted icon to ${outIco.absolutePath}") - return outIco.absolutePath - } - } catch (e: Exception) { - Timber.tag("CustomGameScanner").d(e, "Failed to extract icon from ${exeFile.name}") - } - } else { - Timber.tag("CustomGameScanner").d("Executable file does not exist: ${exeFile.absolutePath}") - } - } else { - Timber.tag("CustomGameScanner").d("Container executable path is empty") - } - } else { - Timber.tag("CustomGameScanner").d("No container found for $appId") - } - } catch (e: Exception) { - Timber.tag("CustomGameScanner").d(e, "Error checking container for $appId") - } - - // 3) If selected exe path failed or absent, try unique exe extraction - val fromUnique = findIconFileForCustomGame(appId) - if (!fromUnique.isNullOrEmpty()) { - Timber.tag("CustomGameScanner").d("Found icon from unique executable: $fromUnique") - return fromUnique - } - - // 4) As last resort, image heuristic - val fromHeuristic = findNearbyImageIcon(folder, null) - if (fromHeuristic != null) { - Timber.tag("CustomGameScanner").d("Found icon from heuristic: $fromHeuristic") - } else { - Timber.tag("CustomGameScanner").d("No icon found for $appId") - } - return fromHeuristic - } - - // Shared helper for .ico/.png heuristic - private fun findNearbyImageIcon(folder: File, uniqueExeRel: String?): String? { - fun File.icoFiles(): List = this.listFiles { f -> - f.isFile && (f.name.endsWith(".ico", ignoreCase = true) || f.name.endsWith(".png", ignoreCase = true)) - }?.toList() ?: emptyList() - - val rootIcons = folder.icoFiles() - val subdirIcons = folder.listFiles { f -> f.isDirectory }?.flatMap { it.icoFiles() } ?: emptyList() - val allIcons = (rootIcons + subdirIcons) - if (allIcons.isEmpty()) { - Timber.tag("CustomGameScanner").d("findNearbyImageIcon - No icon files found in $folder") - return null - } - - Timber.tag("CustomGameScanner").d("findNearbyImageIcon - Found ${allIcons.size} icon file(s): ${allIcons.map { it.name }}") - - // First priority: prefer .extracted.ico files (these are extracted from executables) - val extractedIcons = allIcons.filter { it.name.endsWith(".extracted.ico", ignoreCase = true) } - if (extractedIcons.isNotEmpty()) { - // If there's exactly one extracted icon, use it - if (extractedIcons.size == 1) { - Timber.tag("CustomGameScanner").d("findNearbyImageIcon - Using single extracted icon: ${extractedIcons.first().absolutePath}") - return extractedIcons.first().absolutePath - } - // If multiple extracted icons, prefer one matching exe name if available - val exeBase = uniqueExeRel?.substringAfterLast('/')?.substringBeforeLast('.') - if (!exeBase.isNullOrEmpty()) { - val matchingExtracted = extractedIcons.firstOrNull { - it.nameWithoutExtension.replace(".extracted", "").equals(exeBase, ignoreCase = true) - } - if (matchingExtracted != null) { - Timber.tag("CustomGameScanner").d("findNearbyImageIcon - Using extracted icon matching exe: ${matchingExtracted.absolutePath}") - return matchingExtracted.absolutePath - } - } - // Otherwise, use the first extracted icon - Timber.tag("CustomGameScanner").d("findNearbyImageIcon - Using first extracted icon: ${extractedIcons.first().absolutePath}") - return extractedIcons.first().absolutePath - } - - val exeBase = uniqueExeRel?.substringAfterLast('/')?.substringBeforeLast('.') - if (!exeBase.isNullOrEmpty()) { - val preferredByName = allIcons.firstOrNull { it.nameWithoutExtension.equals(exeBase, ignoreCase = true) } - if (preferredByName != null) { - Timber.tag("CustomGameScanner").d("findNearbyImageIcon - Using icon matching exe name: ${preferredByName.absolutePath}") - return preferredByName.absolutePath - } - } - val containsIcon = allIcons.firstOrNull { it.name.contains("icon", ignoreCase = true) } - if (containsIcon != null) { - Timber.tag("CustomGameScanner").d("findNearbyImageIcon - Using icon with 'icon' in name: ${containsIcon.absolutePath}") - return containsIcon.absolutePath - } - val distinct = allIcons.distinctBy { it.absolutePath } - if (distinct.size == 1) { - Timber.tag("CustomGameScanner").d("findNearbyImageIcon - Using single icon: ${distinct.first().absolutePath}") - return distinct.first().absolutePath - } - Timber.tag("CustomGameScanner").d("findNearbyImageIcon - Multiple icons found (${distinct.size}), cannot choose") - return null - } + var extractedGameIconFileName = "gameicon.extracted.ico" /** * 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. + * "unins" and "unitycrashhandler" (case-insensitive) are ignored. * * Examples of returned values: * - "game.exe" @@ -287,23 +41,18 @@ object CustomGameScanner { 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) + !this.name.startsWith("unins", ignoreCase = true) && + !this.name.startsWith("unitycrashhandler", ignoreCase = true) val candidates = mutableListOf() - folder.listFiles { f -> - f.isFile && f.name.endsWith(".exe", ignoreCase = true) && - !f.name.startsWith("unins", ignoreCase = true) - }?.forEach { f -> + folder.listFiles { it.isValidExe() }?.forEach { f -> candidates.add(f.name) } 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 -> + sd.listFiles { it.isValidExe() }?.forEach { f -> val rel = sd.name + "/" + f.name candidates.add(rel) } @@ -314,44 +63,6 @@ object CustomGameScanner { return if (unique.size == 1) unique.first() else null } - /** - * Find all valid executable files in a game folder. - * Returns a list of relative paths to all valid .exe files (excluding uninstallers). - * - * @param folderPath The path to the game folder - * @return List of relative executable paths, or empty list if folder doesn't exist - */ - fun findAllValidExeFiles(folderPath: String): List = findAllValidExeFiles(File(folderPath)) - - fun findAllValidExeFiles(folder: File): List { - if (!folder.exists() || !folder.isDirectory) return emptyList() - - fun File.isValidExe(): Boolean = this.isFile && this.name.endsWith(".exe", ignoreCase = true) && - !this.name.startsWith("unins", ignoreCase = true) - - val candidates = mutableListOf() - - folder.listFiles { f -> - f.isFile && f.name.endsWith(".exe", ignoreCase = true) && - !f.name.startsWith("unins", ignoreCase = true) - }?.forEach { f -> - candidates.add(f.name) - } - - 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 -> - val rel = sd.name + "/" + f.name - candidates.add(rel) - } - } - - return candidates.distinct() - } - /** * Checks if we have permission to access a given path. * On Android 11+ (API 30+), this checks for MANAGE_EXTERNAL_STORAGE permission. @@ -427,7 +138,7 @@ object CustomGameScanner { 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++)) @@ -440,31 +151,8 @@ object CustomGameScanner { private fun handleCustomGameDetection(folder: File, appId: String, idPart: Int) { CustomGameCache.addEntry(idPart, folder.absolutePath) - - kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { - try { - val hasExtractedIcon = folder.listFiles { file -> - file.isFile && file.name.endsWith(".extracted.ico", ignoreCase = true) - }?.isNotEmpty() == true - - if (!hasExtractedIcon) { - val uniqueExeRel = findUniqueExeRelativeToFolder(folder) - if (!uniqueExeRel.isNullOrEmpty()) { - val exeFile = File(folder, uniqueExeRel.replace('/', File.separatorChar)) - if (exeFile.exists()) { - val outIco = File(exeFile.parentFile, exeFile.nameWithoutExtension + ".extracted.ico") - if (!outIco.exists() || outIco.lastModified() < exeFile.lastModified()) { - if (ExeIconExtractor.tryExtractMainIcon(exeFile, outIco)) { - Timber.tag("CustomGameScanner").d("Extracted icon for ${folder.name} from ${exeFile.name}") - } - } - } - } - } - } catch (e: Exception) { - Timber.tag("CustomGameScanner").d(e, "Icon extraction failed for ${folder.name}") - } - } + // Note: Icon extraction is now only done when images are fetched from SteamGridDB, + // not during regular scanning. See CustomGameAppScreen.getGameDisplayInfo() } fun createLibraryItemFromFolder(folderPath: String): LibraryItem? { @@ -496,7 +184,7 @@ object CustomGameScanner { * Returns null if the file doesn't exist or doesn't contain a valid ID. */ private fun readGameIdFromFile(folder: File): Int? { - return app.gamenative.utils.GameMetadataManager.getAppId(folder) + return GameMetadataManager.getAppId(folder) } /** @@ -505,15 +193,15 @@ object CustomGameScanner { */ private fun writeGameIdToFile(folder: File, gameId: Int) { // Read existing metadata to preserve other fields - val existing = app.gamenative.utils.GameMetadataManager.read(folder) + val existing = GameMetadataManager.read(folder) val metadata = if (existing != null) { // Preserve existing metadata fields, only update appId existing.copy(appId = gameId) } else { // Create new metadata with just the appId - app.gamenative.utils.GameMetadata(appId = gameId) + GameMetadata(appId = gameId) } - app.gamenative.utils.GameMetadataManager.write(folder, metadata) + GameMetadataManager.write(folder, metadata) } /** @@ -620,7 +308,6 @@ object CustomGameScanner { fun getFolderPathFromAppId(appId: String): String? { // Extract the ID from appId (format: "CUSTOM_GAME_") if (!appId.startsWith("${GameSource.CUSTOM_GAME.name}_")) { - Timber.tag("CustomGameScanner").d("appId doesn't start with CUSTOM_GAME_: $appId") return null } @@ -634,4 +321,100 @@ object CustomGameScanner { return findCustomGameById(expectedId) } + + /** + * Extracts the icon from the executable file used for game launch. + * Uses existing logic to find the exe: first checks container's executablePath (if user selected one), + * otherwise tries to find a unique exe using findUniqueExeRelativeToFolder. + * + * First checks for extracted game icon in the game folder. + * If it doesn't exist, extracts the icon from the exe and creates extracted game icon. + * + * @param context The Android context + * @param appId The app ID of the custom game + * @return true if icon was extracted or already exists, false otherwise + */ + fun extractIconFromExecutable(context: Context, appId: String): Boolean { + try { + val gameFolderPath = getFolderPathFromAppId(appId) + if (gameFolderPath == null) { + Timber.tag("CustomGameScanner").w("Could not find game folder for appId: $appId") + return false + } + + val gameFolder = File(gameFolderPath) + if (!gameFolder.exists() || !gameFolder.isDirectory) { + Timber.tag("CustomGameScanner").w("Game folder does not exist: $gameFolderPath") + return false + } + + // Check if icon already exists + val iconFile = File(gameFolder, extractedGameIconFileName) + if (iconFile.exists()) { + Timber.tag("CustomGameScanner").d("Icon already exists: ${iconFile.absolutePath}") + return true + } + + // Get the executable that will be used for game launch using existing container logic + val container = ContainerUtils.getOrCreateContainer(context, appId) + var exeRelPath = container.executablePath + + // If container doesn't have an executable path, try finding a unique executable + if (exeRelPath.isEmpty()) { + exeRelPath = findUniqueExeRelativeToFolder(gameFolder) ?: run { + Timber.tag("CustomGameScanner").w("Could not find executable for game launch: $appId") + return false + } + } + + val exeFile = File(gameFolder, exeRelPath.replace('/', File.separatorChar)) + if (!exeFile.exists()) { + Timber.tag("CustomGameScanner").w("Executable file does not exist: ${exeFile.absolutePath}") + return false + } + + // Extract icon to gameicon.extracted.ico in the game folder + Timber.tag("CustomGameScanner").d("Extracting icon from: ${exeFile.absolutePath} to: ${iconFile.absolutePath}") + val extracted = ExeIconExtractor.tryExtractMainIcon(exeFile, iconFile) + + if (extracted) { + Timber.tag("CustomGameScanner").d("Successfully extracted icon from: ${exeFile.name}") + return true + } else { + Timber.tag("CustomGameScanner").w("Failed to extract icon from: ${exeFile.name}") + return false + } + } catch (e: Exception) { + Timber.tag("CustomGameScanner").e(e, "Failed to extract icon from executable for appId: $appId") + return false + } + } + + /** + * Finds the icon file for a custom game. + * Looks for extracted game icon in the game folder. + * + * @param appId The app ID of the custom game (can be called without context) + * @return The absolute path to the icon file, or null if not found + */ + fun findIconFileForCustomGame(appId: String): String? { + val gameFolderPath = getFolderPathFromAppId(appId) ?: return null + val gameFolder = File(gameFolderPath) + if (!gameFolder.exists() || !gameFolder.isDirectory) return null + + val iconFile = File(gameFolder, extractedGameIconFileName) + return if (iconFile.exists()) iconFile.absolutePath else null + } + + /** + * Finds the icon file for a custom game (with context parameter for compatibility). + * Looks for extracted game icon in the game folder. + * + * @param context The Android context (not used, kept for compatibility) + * @param appId The app ID of the custom game + * @return The absolute path to the icon file, or null if not found + */ + fun findIconFileForCustomGame(context: Context, appId: String): String? { + return findIconFileForCustomGame(appId) + } } diff --git a/app/src/main/java/app/gamenative/utils/CustomMediaUtils.kt b/app/src/main/java/app/gamenative/utils/CustomMediaUtils.kt new file mode 100644 index 000000000..aaa0d3262 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/CustomMediaUtils.kt @@ -0,0 +1,232 @@ +package app.gamenative.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.RectF +import android.net.Uri +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.gamenative.R +import app.gamenative.data.GameSource +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.coil.CoilImage +import java.io.File +import java.io.FileOutputStream +import timber.log.Timber + +/** + * Custom media/image utilities for managing user-selected game media. + */ +object CustomMediaUtils { + // Observable media version to trigger UI refresh when custom images change + private val _mediaVersion = kotlinx.coroutines.flow.MutableStateFlow(0) + val mediaVersionFlow: kotlinx.coroutines.flow.StateFlow = _mediaVersion + fun notifyMediaChanged() { _mediaVersion.value = _mediaVersion.value + 1 } + + private fun mediaDirFor(appId: Int): File { + val root = File(app.gamenative.service.DownloadService.baseDataDirPath, "media") + val dir = File(root, appId.toString()) + if (!dir.exists()) dir.mkdirs() + return dir + } + + fun getCustomHeaderFile(appId: Int): File = File(mediaDirFor(appId), "custom_header.jpg") + fun getCustomHeroFile(appId: Int): File = File(mediaDirFor(appId), "custom_hero.jpg") + fun getCustomLogoFile(appId: Int): File = File(mediaDirFor(appId), "custom_logo.png") + fun getCustomCapsuleFile(appId: Int): File = File(mediaDirFor(appId), "custom_capsule.jpg") + fun getCustomIconFile(appId: Int): File = File(mediaDirFor(appId), "custom_icon.png") + + fun hasCustomHero(appId: Int): Boolean = getCustomHeroFile(appId).exists() + fun hasCustomLogo(appId: Int): Boolean = getCustomLogoFile(appId).exists() + fun hasCustomCapsule(appId: Int): Boolean = getCustomCapsuleFile(appId).exists() + fun hasCustomHeader(appId: Int): Boolean = getCustomHeaderFile(appId).exists() + fun hasCustomIcon(appId: Int): Boolean = getCustomIconFile(appId).exists() + + fun resetCustomHeader(appId: Int) { + runCatching { getCustomHeaderFile(appId).delete() } + notifyMediaChanged() + } + fun resetCustomHero(appId: Int) { + runCatching { getCustomHeroFile(appId).delete() } + notifyMediaChanged() + } + fun resetCustomLogo(appId: Int) { + runCatching { getCustomLogoFile(appId).delete() } + notifyMediaChanged() + } + fun resetCustomCapsule(appId: Int) { + runCatching { getCustomCapsuleFile(appId).delete() } + notifyMediaChanged() + } + fun resetCustomIcon(appId: Int) { + runCatching { getCustomIconFile(appId).delete() } + notifyMediaChanged() + } + + /** + * Save a custom header image for list view. The image will be center-cropped to 920x430 and saved as JPEG. + */ + fun saveCustomHeader(context: Context, appId: Int, sourceUri: Uri): Boolean = + try { + val bmp = decodeBitmap(context, sourceUri) ?: return false + val out = centerCropResize(bmp, 920, 430) + saveJpeg(out, getCustomHeaderFile(appId)) + notifyMediaChanged() + true + } catch (t: Throwable) { Timber.w(t, "saveCustomHeader failed"); false } + + /** + * Save a custom hero image. Center-crop to 460x215 JPEG. + */ + fun saveCustomHero(context: Context, appId: Int, sourceUri: Uri): Boolean = + try { + val bmp = decodeBitmap(context, sourceUri) ?: return false + val out = centerCropResize(bmp, 460, 215) + saveJpeg(out, getCustomHeroFile(appId)) + notifyMediaChanged() + true + } catch (t: Throwable) { Timber.w(t, "saveCustomHero failed"); false } + + /** + * Save a custom logo image. It will be fitted inside 600x200 canvas preserving aspect, with transparent background. + */ + fun saveCustomLogo(context: Context, appId: Int, sourceUri: Uri): Boolean = + try { + val bmp = decodeBitmap(context, sourceUri) ?: return false + val out = fitIntoCanvas(bmp, 600, 200) + savePng(out, getCustomLogoFile(appId)) + notifyMediaChanged() + true + } catch (t: Throwable) { Timber.w(t, "saveCustomLogo failed"); false } + + /** + * Save a custom capsule image for grid capsule view. Center-crop to 600x900 (portrait) JPEG. + */ + fun saveCustomCapsule(context: Context, appId: Int, sourceUri: Uri): Boolean = + try { + val bmp = decodeBitmap(context, sourceUri) ?: return false + val out = centerCropResize(bmp, 600, 900) + saveJpeg(out, getCustomCapsuleFile(appId)) + notifyMediaChanged() + true + } catch (t: Throwable) { Timber.w(t, "saveCustomCapsule failed"); false } + + /** + * Save a custom icon image for list view. The image will be center-cropped to 512x512 and saved as PNG. + */ + fun saveCustomIcon(context: Context, appId: Int, sourceUri: Uri): Boolean = + try { + val bmp = decodeBitmap(context, sourceUri) ?: return false + val out = centerCropResize(bmp, 512, 512) + savePng(out, getCustomIconFile(appId)) + notifyMediaChanged() + true + } catch (t: Throwable) { Timber.w(t, "saveCustomIcon failed"); false } + + private fun decodeBitmap(context: Context, uri: Uri): Bitmap? { + return try { + context.contentResolver.openInputStream(uri).use { ins -> + if (ins == null) null else BitmapFactory.decodeStream(ins) + } + } catch (t: Throwable) { Timber.w(t, "decodeBitmap failed"); null } + } + + private fun centerCropResize(src: Bitmap, targetW: Int, targetH: Int): Bitmap { + val srcW = src.width + val srcH = src.height + val scale = maxOf(targetW.toFloat() / srcW, targetH.toFloat() / srcH) + val scaledW = (srcW * scale).toInt() + val scaledH = (srcH * scale).toInt() + val scaled = Bitmap.createScaledBitmap(src, scaledW, scaledH, true) + val x = (scaledW - targetW) / 2 + val y = (scaledH - targetH) / 2 + return Bitmap.createBitmap( + scaled, + x.coerceAtLeast(0), + y.coerceAtLeast(0), + targetW.coerceAtMost(scaled.width), + targetH.coerceAtMost(scaled.height) + ) + } + + private fun fitIntoCanvas(src: Bitmap, canvasW: Int, canvasH: Int): Bitmap { + val out = Bitmap.createBitmap(canvasW, canvasH, Bitmap.Config.ARGB_8888) + val canvas = Canvas(out) + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) + val scale = minOf(canvasW.toFloat() / src.width, canvasH.toFloat() / src.height) + val w = (src.width * scale).toInt() + val h = (src.height * scale).toInt() + val left = (canvasW - w) / 2f + val top = (canvasH - h) / 2f + val dst = RectF(left, top, left + w, top + h) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + canvas.drawBitmap(src, null, dst, paint) + return out + } + + private fun saveJpeg(bmp: Bitmap, file: File) { + if (!file.parentFile.exists()) file.parentFile.mkdirs() + FileOutputStream(file).use { fos -> + bmp.compress(Bitmap.CompressFormat.JPEG, 90, fos) + } + } + + private fun savePng(bmp: Bitmap, file: File) { + if (!file.parentFile.exists()) file.parentFile.mkdirs() + FileOutputStream(file).use { fos -> + bmp.compress(Bitmap.CompressFormat.PNG, 100, fos) + } + } + + fun getCustomHeaderUri(appId: Int): Uri? = getCustomHeaderFile(appId).takeIf { it.exists() }?.let { Uri.fromFile(it) } + fun getCustomHeroUri(appId: Int): Uri? = getCustomHeroFile(appId).takeIf { it.exists() }?.let { Uri.fromFile(it) } + fun getCustomLogoUri(appId: Int): Uri? = getCustomLogoFile(appId).takeIf { it.exists() }?.let { Uri.fromFile(it) } + fun getCustomCapsuleUri(appId: Int): Uri? = getCustomCapsuleFile(appId).takeIf { it.exists() }?.let { Uri.fromFile(it) } + fun getCustomIconUri(appId: Int): Uri? = getCustomIconFile(appId).takeIf { it.exists() }?.let { Uri.fromFile(it) } + +} + +/** + * Cache-busting helper: appends a version query to supported models so Coil invalidates its cache. + */ +fun bustCache(model: Any?, version: Int): Any? { + if (model == null) return null + return when (model) { + is String -> { + val s = model + val lower = s.lowercase() + if (lower.startsWith("http") || lower.startsWith("file:") || lower.startsWith("content:")) { + val sep = if (s.contains("?")) "&" else "?" + s + sep + "v=" + version + } else s + } + is Uri -> { + val scheme = model.scheme?.lowercase() + if (scheme == "http" || scheme == "https" || scheme == "file" || scheme == "content") { + val s = model.toString() + val sep = if (s.contains("?")) "&" else "?" + Uri.parse(s + sep + "v=" + version) + } else model + } + else -> model // Leave as-is for File or other models + } +} + diff --git a/app/src/main/java/app/gamenative/utils/GameImageUtils.kt b/app/src/main/java/app/gamenative/utils/GameImageUtils.kt new file mode 100644 index 000000000..cda76a58e --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/GameImageUtils.kt @@ -0,0 +1,81 @@ +package app.gamenative.utils + +import app.gamenative.data.GameSource +import app.gamenative.data.LibraryItem + +/** + * Utility functions for retrieving game images from various sources. + * Handles the priority: Custom media -> SteamGridDB -> Steam URLs + */ +object GameImageUtils { + /** + * Notifies the UI that game images have been refreshed (e.g., after fetching from SteamGridDB). + * This triggers a recomposition of all image-related UI components to display the newly fetched images. + */ + fun notifyImagesRefreshed() { + // Increment media version to trigger UI refresh + // This causes all remember(mediaVersion, ...) blocks to recompute + CustomMediaUtils.notifyMediaChanged() + } + /** + * Get game image URI/URL with proper priority: + * 1. Custom media (user-selected) + * 2. SteamGridDB images (for custom games, from game folder) + * 3. Steam URLs (for Steam games) + * + * @param libraryItem The library item containing appId and gameSource + * @param imageType The type of image: "hero", "capsule", "header", "logo", "icon", "grid_hero", "grid_capsule" + * @param steamUrl Optional Steam URL fallback (only used for Steam games) + * @return The image URI/URL string (can be file:// URI or http:// URL), or null if not found + */ + fun getGameImage( + libraryItem: LibraryItem, + imageType: String, + steamUrl: String? = null + ): String? { + // Get appId and gameId from libraryItem + val appId = libraryItem.appId + val gameId = libraryItem.gameId + + // 1. Check custom media first (only if we have a valid gameId) + if (gameId != null) { + val customUri = when (imageType) { + "hero", "grid_hero" -> CustomMediaUtils.getCustomHeroUri(gameId) + "capsule", "grid_capsule" -> CustomMediaUtils.getCustomCapsuleUri(gameId) + "header" -> CustomMediaUtils.getCustomHeaderUri(gameId) + "logo" -> CustomMediaUtils.getCustomLogoUri(gameId) + "icon" -> CustomMediaUtils.getCustomIconUri(gameId) + else -> null + } + if (customUri != null) return customUri.toString() + } + + // 2. Check SteamGridDB images + if (appId != null) { + // Find icon from SteamGridDB + var icon = SteamGridDB.findSteamGridDBImageByAppId(appId, "icon") + + // If no icon found, find icon from custom game scanner (extracted from exe file) + if (libraryItem.gameSource == GameSource.CUSTOM_GAME && icon == null) { + icon = CustomGameScanner.findIconFileForCustomGame(appId) + } + + // Check if the game has a custom icon + val steamGridUri = when (imageType) { + "hero", "grid_hero" -> SteamGridDB.findSteamGridDBImageByAppId(appId, "grid_hero") + "capsule", "grid_capsule" -> SteamGridDB.findSteamGridDBImageByAppId(appId, "grid_capsule") + "header" -> SteamGridDB.findSteamGridDBImageByAppId(appId, "header") + "logo" -> SteamGridDB.findSteamGridDBImageByAppId(appId, "logo") + "icon" -> icon + else -> null + } + + if (steamGridUri != null) return steamGridUri + } + + // 3. Fall back to Steam URL (for Steam games) + return steamUrl + + } +} + diff --git a/app/src/main/java/app/gamenative/utils/SteamGridDB.kt b/app/src/main/java/app/gamenative/utils/SteamGridDB.kt index 67587f3ef..344ed013e 100644 --- a/app/src/main/java/app/gamenative/utils/SteamGridDB.kt +++ b/app/src/main/java/app/gamenative/utils/SteamGridDB.kt @@ -2,6 +2,7 @@ package app.gamenative.utils import android.content.Context import android.graphics.BitmapFactory +import android.net.Uri import app.gamenative.PrefManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -138,65 +139,16 @@ object SteamGridDB { } /** - * Download and save an image from a URL - * @return Pair of (file path, isHorizontal) or null if failed - */ - private suspend fun downloadAndSaveImage( - imageUrl: String, - gameFolder: File, - fileName: String - ): Pair? = withContext(Dispatchers.IO) { - try { - // Download the image - val imageRequest = Request.Builder() - .url(imageUrl) - .build() - - val imageResponse = httpClient.newCall(imageRequest).execute() - - if (!imageResponse.isSuccessful) { - Timber.tag("SteamGridDB").w("Failed to download image from $imageUrl - HTTP ${imageResponse.code}") - return@withContext null - } - - val imageBytes = imageResponse.body?.bytes() ?: return@withContext null - - // Determine orientation - val isHorizontal = isImageHorizontal(imageBytes) - - // Determine file extension from URL - val extension = when { - imageUrl.contains(".png", ignoreCase = true) -> ".png" - imageUrl.contains(".jpg", ignoreCase = true) -> ".jpg" - imageUrl.contains(".jpeg", ignoreCase = true) -> ".jpg" - imageUrl.contains(".webp", ignoreCase = true) -> ".webp" - else -> ".png" // Default to PNG - } - - val outputFile = File(gameFolder, "$fileName$extension") - - // Save to file - FileOutputStream(outputFile).use { it.write(imageBytes) } - - Timber.tag("SteamGridDB").i("Saved image to ${outputFile.absolutePath} (horizontal: $isHorizontal)") - return@withContext Pair(outputFile.absolutePath, isHorizontal) - } catch (e: Exception) { - Timber.tag("SteamGridDB").e(e, "Error downloading image from $imageUrl") - return@withContext null - } - } - - /** - * Fetch grids for a game, find horizontal and vertical images, and save them separately. + * Fetch grids for a game, find horizontal, vertical, and header-sized images, and save them separately. * @param gameId The SteamGridDB game ID * @param gameFolder The folder where images should be saved - * @return Pair of (heroPath, capsulePath) where hero is horizontal grid and capsule is vertical grid + * @return Triple of (heroPath, capsulePath, headerPath) where hero is horizontal grid, capsule is vertical grid, and header is 460x215 image */ private suspend fun fetchGrids( gameId: Int, gameFolder: File - ): Pair = withContext(Dispatchers.IO) { - val apiKey = getApiKey() ?: return@withContext Pair(null, null) + ): Triple = withContext(Dispatchers.IO) { + val apiKey = getApiKey() ?: return@withContext Triple(null, null, null) try { val url = "$API_BASE_URL$GRIDS_ENDPOINT/$gameId" @@ -210,33 +162,38 @@ object SteamGridDB { if (!response.isSuccessful) { Timber.tag("SteamGridDB").w("Failed to fetch grids for game $gameId - HTTP ${response.code}") - return@withContext Pair(null, null) + return@withContext Triple(null, null, null) } - val body = response.body?.string() ?: return@withContext Pair(null, null) + val body = response.body?.string() ?: return@withContext Triple(null, null, null) val json = JSONObject(body) if (!json.optBoolean("success", false)) { Timber.tag("SteamGridDB").w("API returned success=false for grids (game $gameId)") - return@withContext Pair(null, null) + return@withContext Triple(null, null, null) } val dataArray = json.optJSONArray("data") if (dataArray == null || dataArray.length() == 0) { Timber.tag("SteamGridDB").d("No grid images found for game $gameId") - return@withContext Pair(null, null) + return@withContext Triple(null, null, null) } var heroPath: String? = null var capsulePath: String? = null + var headerPath: String? = null - // Loop through all grid images to find horizontal (hero) and vertical (capsule) + // Loop through all grid images to find horizontal (hero), vertical (capsule), and header-sized (460x215) for (i in 0 until dataArray.length()) { val imageObj = dataArray.getJSONObject(i) val imageUrl = imageObj.optString("url", "") if (imageUrl.isEmpty()) continue + // Check dimensions from JSON (more efficient than downloading first) + val width = imageObj.optInt("width", 0) + val height = imageObj.optInt("height", 0) + // Determine file extension from URL val extension = when { imageUrl.contains(".png", ignoreCase = true) -> ".png" @@ -246,7 +203,10 @@ object SteamGridDB { else -> ".png" // Default to PNG } - // Download the image to check orientation first + // Check if this is a hero-sized image (460x215) + val isHeroSize = (width == 460 && height == 215) || (width == 215 && height == 460) + + // Download the image val imageRequest = Request.Builder() .url(imageUrl) .build() @@ -260,19 +220,32 @@ object SteamGridDB { val imageBytes = imageResponse.body?.bytes() ?: continue - // Determine orientation + // Handle grid hero-sized images (460x215) - save as hero + if (isHeroSize && heroPath == null) { + val heroFile = File(gameFolder, "steamgriddb_grid_hero$extension") + try { + FileOutputStream(heroFile).use { it.write(imageBytes) } + heroPath = heroFile.absolutePath + Timber.tag("SteamGridDB").i("Found hero-sized image (${width}x${height}): ${heroFile.name}") + continue // Skip orientation check for header images + } catch (e: Exception) { + Timber.tag("SteamGridDB").e(e, "Failed to save header image") + } + } + + // Determine orientation for non-header images val isHorizontal = isImageHorizontal(imageBytes) // Save directly to the final filename based on orientation - if (isHorizontal == true && heroPath == null) { + if (isHorizontal == true && headerPath == null) { // This is horizontal - use for hero - val heroFile = File(gameFolder, "steamgriddb_grid_hero$extension") + val headerFile = File(gameFolder, "steamgriddb_header$extension") try { - FileOutputStream(heroFile).use { it.write(imageBytes) } - heroPath = heroFile.absolutePath - Timber.tag("SteamGridDB").i("Found horizontal grid for hero: ${heroFile.name}") + FileOutputStream(headerFile).use { it.write(imageBytes) } + headerPath = headerFile.absolutePath + Timber.tag("SteamGridDB").i("Found horizontal image for header: ${headerFile.name}") } catch (e: Exception) { - Timber.tag("SteamGridDB").e(e, "Failed to save hero image") + Timber.tag("SteamGridDB").e(e, "Failed to save header image") } } else if (isHorizontal == false && capsulePath == null) { // This is vertical - use for capsule @@ -285,26 +258,29 @@ object SteamGridDB { Timber.tag("SteamGridDB").e(e, "Failed to save capsule image") } } - // If we don't need this image, we just skip it (no temp file to delete) - // If we found both, we can stop - if (heroPath != null && capsulePath != null) { + + // If we found all needed images, we can stop + if (heroPath != null && capsulePath != null && headerPath != null) { break } } - // Log if we didn't find both images + // Log if we didn't find images if (heroPath == null) { - Timber.tag("SteamGridDB").w("No horizontal grid found for hero (game $gameId)") + Timber.tag("SteamGridDB").w("No horizontal grid-sized (460x215) found for hero (game $gameId)") } if (capsulePath == null) { Timber.tag("SteamGridDB").w("No vertical grid found for capsule (game $gameId)") } + if (headerPath == null) { + Timber.tag("SteamGridDB").d("No header image found in grids (game $gameId)") + } - return@withContext Pair(heroPath, capsulePath) + return@withContext Triple(heroPath, capsulePath, headerPath) } catch (e: Exception) { Timber.tag("SteamGridDB").e(e, "Error fetching grids for game $gameId") - return@withContext Pair(null, null) + return@withContext Triple(null, null, null) } } @@ -445,9 +421,8 @@ object SteamGridDB { file.isFile && file.name.startsWith("steamgriddb_grid_capsule", ignoreCase = true) && (file.name.endsWith(".png", ignoreCase = true) || file.name.endsWith(".jpg", ignoreCase = true) || file.name.endsWith(".webp", ignoreCase = true)) }?.isNotEmpty() == true - val existingHero = gameFolder.listFiles { file -> - file.isFile && file.name.startsWith("steamgriddb_hero", ignoreCase = true) && - !file.name.contains("grid", ignoreCase = true) && + val existingHeader = gameFolder.listFiles { file -> + file.isFile && file.name.startsWith("steamgriddb_header", ignoreCase = true) && (file.name.endsWith(".png", ignoreCase = true) || file.name.endsWith(".jpg", ignoreCase = true) || file.name.endsWith(".webp", ignoreCase = true)) }?.isNotEmpty() == true val existingLogo = gameFolder.listFiles { file -> @@ -455,7 +430,7 @@ object SteamGridDB { (file.name.endsWith(".png", ignoreCase = true) || file.name.endsWith(".jpg", ignoreCase = true) || file.name.endsWith(".webp", ignoreCase = true)) }?.isNotEmpty() == true - if (existingGridHero && existingGridCapsule && existingHero && existingLogo) { + if (existingGridHero && existingGridCapsule && existingHeader && existingLogo) { Timber.tag("SteamGridDB").d("All images already exist for '$gameName', skipping fetch") val gridHeroFile = gameFolder.listFiles { file -> file.isFile && file.name.startsWith("steamgriddb_grid_hero", ignoreCase = true) && @@ -466,8 +441,7 @@ object SteamGridDB { (file.name.endsWith(".png", ignoreCase = true) || file.name.endsWith(".jpg", ignoreCase = true) || file.name.endsWith(".webp", ignoreCase = true)) }?.firstOrNull() val heroFile = gameFolder.listFiles { file -> - file.isFile && file.name.startsWith("steamgriddb_hero", ignoreCase = true) && - !file.name.contains("grid", ignoreCase = true) && + file.isFile && file.name.startsWith("steamgriddb_header", ignoreCase = true) && (file.name.endsWith(".png", ignoreCase = true) || file.name.endsWith(".jpg", ignoreCase = true) || file.name.endsWith(".webp", ignoreCase = true)) }?.firstOrNull() val logoFile = gameFolder.listFiles { file -> @@ -475,8 +449,8 @@ object SteamGridDB { (file.name.endsWith(".png", ignoreCase = true) || file.name.endsWith(".jpg", ignoreCase = true) || file.name.endsWith(".webp", ignoreCase = true)) }?.firstOrNull() return@withContext ImageFetchResult( - gridPath = gridHeroFile?.absolutePath, // Hero path (horizontal grid) - heroPath = heroFile?.absolutePath, // Header path (heroes endpoint) + heroPath = gridHeroFile?.absolutePath, // Hero path (horizontal grid) + headerPath = heroFile?.absolutePath, // Header path logoPath = logoFile?.absolutePath, capsulePath = gridCapsuleFile?.absolutePath, // Capsule path (vertical grid) releaseDate = null // Don't update release date if images already exist @@ -486,8 +460,8 @@ object SteamGridDB { // Search for the game val searchResult = searchGame(gameName) ?: return@withContext ImageFetchResult(null, null, null, null, null) - // Fetch grids (returns hero and capsule paths) - val (gridHeroPath, gridCapsulePath) = if (!existingGridHero || !existingGridCapsule) { + // Fetch grids (returns hero, capsule, and header paths) + val (gridHeroPath, gridCapsulePath, headerPathFromGrids) = if (!existingGridHero || !existingGridCapsule || !existingHeader) { fetchGrids(searchResult.gameId, gameFolder) } else { val gridHeroFile = gameFolder.listFiles { file -> @@ -498,17 +472,21 @@ object SteamGridDB { file.isFile && file.name.startsWith("steamgriddb_grid_capsule", ignoreCase = true) && (file.name.endsWith(".png", ignoreCase = true) || file.name.endsWith(".jpg", ignoreCase = true) || file.name.endsWith(".webp", ignoreCase = true)) }?.firstOrNull() - Pair(gridHeroFile?.absolutePath, gridCapsuleFile?.absolutePath) + val headerFile = gameFolder.listFiles { file -> + file.isFile && file.name.startsWith("steamgriddb_header", ignoreCase = true) && + (file.name.endsWith(".png", ignoreCase = true) || file.name.endsWith(".jpg", ignoreCase = true) || file.name.endsWith(".webp", ignoreCase = true)) + }?.firstOrNull() + Triple(gridHeroFile?.absolutePath, gridCapsuleFile?.absolutePath, headerFile?.absolutePath) } - // Fetch heroes (for header) - val heroPath = if (!existingHero) { - fetchAndSaveImage(searchResult.gameId, gameFolder, "hero") + // Fetch header - only if not found in grids + val headerPath = headerPathFromGrids ?: if (!existingHeader) { + fetchAndSaveImage(searchResult.gameId, gameFolder, "header") } else { gameFolder.listFiles { file -> - file.isFile && file.name.startsWith("steamgriddb_hero", ignoreCase = true) && - !file.name.contains("grid", ignoreCase = true) && - (file.name.endsWith(".png", ignoreCase = true) || file.name.endsWith(".jpg", ignoreCase = true) || file.name.endsWith(".webp", ignoreCase = true)) + file.isFile && file.name.startsWith("steamgriddb_header", ignoreCase = true) && + !file.name.contains("grid", ignoreCase = true) && + (file.name.endsWith(".png", ignoreCase = true) || file.name.endsWith(".jpg", ignoreCase = true) || file.name.endsWith(".webp", ignoreCase = true)) }?.firstOrNull()?.absolutePath } @@ -539,8 +517,8 @@ object SteamGridDB { } return@withContext ImageFetchResult( - gridPath = gridHeroPath, // Horizontal grid for hero view - heroPath = heroPath, // Heroes endpoint for header view + heroPath = gridHeroPath, // Horizontal grid for hero view + headerPath = headerPath, // Heroes endpoint for header view logoPath = logoPath, capsulePath = gridCapsulePath, // Vertical grid for capsule view releaseDate = searchResult.releaseDate @@ -560,11 +538,44 @@ object SteamGridDB { * Data class for image fetch results */ data class ImageFetchResult( - val gridPath: String?, // Horizontal grid for hero view - val heroPath: String?, // Heroes endpoint for header view + val heroPath: String?, // Horizontal grid for hero view + val headerPath: String?, // Heroes endpoint for header view val logoPath: String?, val capsulePath: String? = null, // Vertical grid for capsule view val releaseDate: Long? = null ) + + /** + * Find a SteamGridDB image file in a game folder by type. + * @param folder The game folder to search in + * @param imageType The image type to find (e.g., "grid_hero", "grid_capsule", "logo", "hero") + * @param excludePattern Optional pattern to exclude from matches (e.g., "grid" to exclude "grid_hero" when searching for "hero") + * @return Uri string of the found image file, or null if not found + */ + fun findSteamGridDBImage(folder: File, imageType: String, excludePattern: String? = null): String? { + if (!folder.exists() || !folder.isDirectory) return null + + return folder.listFiles()?.firstOrNull { file -> + file.isFile && + file.name.startsWith("steamgriddb_$imageType", ignoreCase = true) && + (excludePattern == null || !file.name.contains(excludePattern, ignoreCase = true)) && + (file.name.endsWith(".png", ignoreCase = true) || + file.name.endsWith(".jpg", ignoreCase = true) || + file.name.endsWith(".webp", ignoreCase = true)) + }?.let { Uri.fromFile(it).toString() } + } + + /** + * Find a SteamGridDB image by appId (for Custom Games). + * @param appId The appId (e.g., "CUSTOM_GAME_123") + * @param imageType The image type to find (e.g., "grid_hero", "grid_capsule", "logo", "hero") + * @param excludePattern Optional pattern to exclude from matches (e.g., "grid" to exclude "grid_hero" when searching for "hero") + * @return Uri string of the found image file, or null if not found + */ + fun findSteamGridDBImageByAppId(appId: String, imageType: String, excludePattern: String? = null): String? { + val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appId) ?: return null + val folder = File(gameFolderPath) + return findSteamGridDBImage(folder, imageType, excludePattern) + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b602e908f..8f44c94c9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -217,10 +217,11 @@ Total Playtime: %s hrs Add custom game Add Custom Game - Please select the folder containing the game files you want to add as a custom game. + Please select the folder containing the game files you want to add as a custom game.\n\nPlease note that you can fetch images for this game later in the game settings. Don\'t show this dialog again Shortcut created Failed to create shortcut: %s + Fetching images... Images fetched successfully Game folder not found Failed to fetch images: %s @@ -345,4 +346,27 @@ Install Anyway Remove content? Are you sure you want to remove %1$s (%2$s)? + + + Logo + Recommended: up to 600×200 PNG with transparency. Will be scaled to fit. + Icon (List view) + Recommended: Square PNG with transparency. Will be center-cropped to fit. + Header Image + Recommended: 920×430 JPG/PNG. Will be center-cropped to fit. + Capsule (Grid view) + Recommended: 600×900 JPG/PNG. Will be center-cropped to fit. + Hero (Grid view) + Recommended: 460×215 JPG/PNG. Will be center-cropped to fit. + No logo available + No icon available + No header image available + No grid capsule image available + No grid hero image available + Select an image to use instead. + Choose image + Remove custom image + %s updated + Failed to update %s + Reverted to Steam default From 6fade35237d129946bfde21c8f2df07a655e2b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Bj=C3=B8rnmose=20Bundgaard?= Date: Tue, 25 Nov 2025 12:34:39 +0100 Subject: [PATCH 2/2] Fixed gamelist reload bug and translated strings --- .../screen/library/appscreen/CustomGameAppScreen.kt | 8 ++++---- .../ui/screen/library/appscreen/SteamAppScreen.kt | 8 ++++++++ app/src/main/java/app/gamenative/ui/util/Images.kt | 13 ++++++++++++- app/src/main/res/values/strings.xml | 2 ++ 4 files changed, 26 insertions(+), 5 deletions(-) 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 1e3a870c5..303738d85 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 @@ -362,7 +362,7 @@ class CustomGameAppScreen : BaseAppScreen() { withContext(Dispatchers.Main) { Toast.makeText( context, - "\"${libraryItem.name}\" has been deleted", + context.getString(R.string.custom_game_deleted, libraryItem.name), Toast.LENGTH_SHORT ).show() @@ -377,7 +377,7 @@ class CustomGameAppScreen : BaseAppScreen() { withContext(Dispatchers.Main) { Toast.makeText( context, - "Failed to delete game: ${e.message}", + context.getString(R.string.custom_game_delete_failed, e.message ?: ""), Toast.LENGTH_LONG ).show() } @@ -385,14 +385,14 @@ class CustomGameAppScreen : BaseAppScreen() { } } ) { - Text("Delete", color = androidx.compose.material3.MaterialTheme.colorScheme.error) + Text(stringResource(R.string.delete_app), color = androidx.compose.material3.MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = { hideDeleteDialog(libraryItem.appId) }) { - Text("Cancel") + Text(stringResource(R.string.cancel)) } } ) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt index 35b3b5992..bb6816e25 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt @@ -44,6 +44,7 @@ import com.winlator.fexcore.FEXCoreManager import com.winlator.xenvironment.ImageFsInstaller import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import androidx.compose.runtime.rememberCoroutineScope @@ -1070,6 +1071,13 @@ class SteamAppScreen : BaseAppScreen() { event = "game_uninstalled", properties = mapOf("game_name" to (appInfo?.name ?: "")) ) + + // Small delay to ensure file system updates are complete + // before navigating back (list will auto-refresh when displayed) + delay(250) + + // Navigate back to game list + onBack() } else { Toast.makeText( context, diff --git a/app/src/main/java/app/gamenative/ui/util/Images.kt b/app/src/main/java/app/gamenative/ui/util/Images.kt index 697f145e9..f5c806e69 100644 --- a/app/src/main/java/app/gamenative/ui/util/Images.kt +++ b/app/src/main/java/app/gamenative/ui/util/Images.kt @@ -1,6 +1,9 @@ package app.gamenative.ui.util +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -9,6 +12,7 @@ import androidx.compose.material.icons.filled.QuestionMark import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon 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.layout.ContentScale @@ -40,7 +44,14 @@ internal fun ListItemImage( contentDescription = contentDescription, ), loading = { - CircularProgressIndicator() + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.sizeIn(maxWidth = 64.dp, maxHeight = 64.dp) + ) + } }, failure = { onFailure() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f44c94c9..d318f319f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -87,6 +87,8 @@ Custom Game Settings Delete Game Are you sure you want to uninstall %1$s? + \"%1$s\" has been deleted + Failed to delete game: %1$s Unknown