From acaecfabcb4f4ac0089b542190246a7c327f890f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Bj=C3=B8rnmose=20Bundgaard?= Date: Mon, 3 Nov 2025 13:49:52 +0100 Subject: [PATCH 01/12] Custom media added, but with some flaws that needs to be fixed --- .../component/dialog/ContainerConfigDialog.kt | 428 +++++++++++++++++- .../ui/screen/library/LibraryAppScreen.kt | 34 +- .../library/components/LibraryAppItem.kt | 36 +- .../java/app/gamenative/utils/SteamUtils.kt | 142 ++++++ 4 files changed, 633 insertions(+), 7 deletions(-) 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 2f06c7a90..931ba6bad 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 @@ -20,6 +20,10 @@ import androidx.compose.foundation.layout.width 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.shape.RoundedCornerShape +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 @@ -49,6 +53,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 +61,11 @@ 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 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 @@ -125,6 +135,11 @@ fun ContainerConfigDialog( initialConfig: ContainerData = ContainerData(), onDismissRequest: () -> Unit, onSave: (ContainerData) -> Unit, + mediaHeroUrl: String? = null, + mediaLogoUrl: String? = null, + mediaCapsuleUrl: String? = null, + mediaHeaderUrl: String? = null, + gameId: Int? = null, ) { if (visible) { val context = LocalContext.current @@ -688,7 +703,7 @@ 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( @@ -1688,7 +1703,416 @@ 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.SteamUtils.mediaVersionFlow.collectAsState(initial = 0) + + fun bustCache(model: Any?, version: Int): Any? { + if (model == null) return null + return when (model) { + is String -> { + // Append version for http(s) and file: string models + if (model.startsWith("http", ignoreCase = true) || model.startsWith("file:", ignoreCase = true)) { + val sep = if (model.contains("?")) "&" else "?" + model + sep + "v=" + version + } else model + } + is android.net.Uri -> { + // Append for http(s), file, and content URIs + val scheme = model.scheme?.lowercase() + if (scheme == "http" || scheme == "https" || scheme == "file" || scheme == "content") { + val s = model.toString() + val sep = if (s.contains("?")) "&" else "?" + android.net.Uri.parse(s + sep + "v=" + version) + } else model + } + else -> model + } + } + + // HERO --------------------------------------------- + Text( + text = "Hero Image", + color = Color.White, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + + val currentHeroModel: Any? = run { + val gid = gameId + if (gid != null) { + val custom = app.gamenative.utils.SteamUtils.getCustomHeroUri(gid) + custom ?: mediaHeroUrl + } else mediaHeroUrl + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxWidth()) { + if (currentHeroModel != null && (currentHeroModel as? String)?.isNotBlank() != false) { + CoilImage( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + imageModel = { bustCache(currentHeroModel, mediaVersion) }, + imageOptions = ImageOptions(contentScale = ContentScale.Crop), + previewPlaceholder = painterResource(app.gamenative.R.drawable.testhero), + ) + } else { + SettingsCenteredLabel( + colors = settingsTileColors(), + title = { Text(text = "No hero image available") }, + subtitle = { Text(text = "Open from a specific game to preview and change its media.") }, + ) + } + + // Steam or Custom badge overlay in settings + val gid = gameId + if (gid != null) { + val isCustom = app.gamenative.utils.SteamUtils.hasCustomHero(gid) + val badgeText = if (isCustom) "Custom" else "Steam" + androidx.compose.foundation.layout.Box( + modifier = Modifier + .padding(16.dp) + .align(Alignment.TopStart) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text(badgeText, style = MaterialTheme.typography.labelSmall) + } + } + } + } + + // Recommended hint (below image) + Text( + text = "Recommended: 920×430 JPG/PNG. Will be center-cropped to fit.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + + // Pick/Reset actions for Hero + if (gameId != null) { + val context = LocalContext.current + val isCustom = app.gamenative.utils.SteamUtils.hasCustomHero(gameId) + val pickHero = androidx.activity.compose.rememberLauncherForActivityResult( + contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + val ok = app.gamenative.utils.SteamUtils.saveCustomHero(context, gameId, uri) + Toast.makeText(context, if (ok) "Hero image updated" else "Failed to update hero", 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 = { pickHero.launch("image/*") }) { Text("Choose image") } + if (isCustom) { + androidx.compose.material3.OutlinedButton( + onClick = { + app.gamenative.utils.SteamUtils.resetCustomHero(gameId) + Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() + }, + ) { Text("Reset to default") } + } + } + } + + Spacer(modifier = Modifier.padding(8.dp)) + + // Separator + androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 8.dp)) + + // LOGO --------------------------------------------- + Text( + text = "Logo", + color = Color.White, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + + val currentLogoModel: Any? = run { + val gid = gameId + if (gid != null) { + val custom = app.gamenative.utils.SteamUtils.getCustomLogoUri(gid) + custom ?: mediaLogoUrl + } else mediaLogoUrl + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + if (currentLogoModel != null && (currentLogoModel as? String)?.isNotBlank() != false) { + CoilImage( + modifier = Modifier + .width(200.dp) + .padding(8.dp), + imageModel = { bustCache(currentLogoModel, mediaVersion) }, + imageOptions = ImageOptions(contentScale = ContentScale.Fit), + previewPlaceholder = painterResource(app.gamenative.R.drawable.testliblogo), + ) + } else { + SettingsCenteredLabel( + colors = settingsTileColors(), + title = { Text(text = "No logo available") }, + subtitle = { Text(text = "Open from a specific game to preview and change its media.") }, + ) + } + + val gid = gameId + if (gid != null) { + val isCustom = app.gamenative.utils.SteamUtils.hasCustomLogo(gid) + val badgeText = if (isCustom) "Custom" else "Steam" + androidx.compose.foundation.layout.Box( + modifier = Modifier + .align(Alignment.TopStart) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text(badgeText, style = MaterialTheme.typography.labelSmall) + } + } + } + } + + // Recommended hint (below image) + Text( + text = "Recommended: up to 600×200 PNG with transparency. Will be scaled to fit.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + + // Pick/Reset actions for Logo + if (gameId != null) { + val context = LocalContext.current + val isCustom = app.gamenative.utils.SteamUtils.hasCustomLogo(gameId) + val pickLogo = androidx.activity.compose.rememberLauncherForActivityResult( + contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + val ok = app.gamenative.utils.SteamUtils.saveCustomLogo(context, gameId, uri) + Toast.makeText(context, if (ok) "Logo updated" else "Failed to update logo", 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 = { pickLogo.launch("image/*") }) { Text("Choose image") } + if (isCustom) { + androidx.compose.material3.OutlinedButton( + onClick = { + app.gamenative.utils.SteamUtils.resetCustomLogo(gameId) + Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() + }, + ) { Text("Reset to default") } + } + } + } + + Spacer(modifier = Modifier.padding(8.dp)) + + // Separator + androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 8.dp)) + + // CAPSULE --------------------------------------------- + Text( + text = "Capsule (Grid view)", + color = Color.White, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + + val currentCapsuleModel: Any? = run { + val gid = gameId + if (gid != null) { + val custom = app.gamenative.utils.SteamUtils.getCustomCapsuleUri(gid) + custom ?: mediaCapsuleUrl + } else mediaCapsuleUrl + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxWidth()) { + if (currentCapsuleModel != null && (currentCapsuleModel as? String)?.isNotBlank() != false) { + CoilImage( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + imageModel = { bustCache(currentCapsuleModel, mediaVersion) }, + imageOptions = ImageOptions(contentScale = ContentScale.Crop), + previewPlaceholder = painterResource(app.gamenative.R.drawable.testhero), + ) + } else { + SettingsCenteredLabel( + colors = settingsTileColors(), + title = { Text(text = "No capsule image available") }, + subtitle = { Text(text = "Open from a specific game to preview and change its media.") }, + ) + } + + val gid2 = gameId + if (gid2 != null) { + val isCustom = app.gamenative.utils.SteamUtils.hasCustomCapsule(gid2) + val badgeText = if (isCustom) "Custom" else "Steam" + androidx.compose.foundation.layout.Box( + modifier = Modifier + .padding(16.dp) + .align(Alignment.TopStart) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text(badgeText, style = MaterialTheme.typography.labelSmall) + } + } + } + } + + // Recommended hint (below image) + Text( + text = "Recommended: 600×900 JPG/PNG. Will be center-cropped to fit.", + 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 = app.gamenative.utils.SteamUtils.hasCustomCapsule(gameId) + val pickCapsule = androidx.activity.compose.rememberLauncherForActivityResult( + contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + val ok = app.gamenative.utils.SteamUtils.saveCustomCapsule(context, gameId, uri) + Toast.makeText(context, if (ok) "Capsule image updated" else "Failed to update capsule", 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 = { pickCapsule.launch("image/*") }) { Text("Choose image") } + if (isCustom) { + androidx.compose.material3.OutlinedButton( + onClick = { + app.gamenative.utils.SteamUtils.resetCustomCapsule(gameId) + Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() + }, + ) { Text("Reset to default") } + } + } + } + + Spacer(modifier = Modifier.padding(8.dp)) + + // HEADER --------------------------------------------- + Text( + text = "Header (List view)", + color = Color.White, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + + val currentHeaderModel: Any? = run { + val gid = gameId + if (gid != null) { + val custom = app.gamenative.utils.SteamUtils.getCustomHeaderUri(gid) + custom ?: mediaHeaderUrl + } else mediaHeaderUrl + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxWidth()) { + if (currentHeaderModel != null && (currentHeaderModel as? String)?.isNotBlank() != false) { + CoilImage( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + imageModel = { bustCache(currentHeaderModel, mediaVersion) }, + imageOptions = ImageOptions(contentScale = ContentScale.Crop), + previewPlaceholder = painterResource(app.gamenative.R.drawable.testhero), + ) + } else { + SettingsCenteredLabel( + colors = settingsTileColors(), + title = { Text(text = "No header image available") }, + subtitle = { Text(text = "Open from a specific game to preview and change its media.") }, + ) + } + + val gid3 = gameId + if (gid3 != null) { + val isCustom = app.gamenative.utils.SteamUtils.hasCustomHeader(gid3) + val badgeText = if (isCustom) "Custom" else "Steam" + androidx.compose.foundation.layout.Box( + modifier = Modifier + .padding(16.dp) + .align(Alignment.TopStart) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text(badgeText, style = MaterialTheme.typography.labelSmall) + } + } + } + } + + // Recommended hint (below image) + Text( + text = "Recommended: 460×215 JPG/PNG. Will be center-cropped to fit.", + 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 = app.gamenative.utils.SteamUtils.hasCustomHeader(gameId) + val pickHeader = androidx.activity.compose.rememberLauncherForActivityResult( + contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + val ok = app.gamenative.utils.SteamUtils.saveCustomHeader(context, gameId, uri) + Toast.makeText(context, if (ok) "Header image updated" else "Failed to update header", 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 = { pickHeader.launch("image/*") }) { Text("Choose image") } + if (isCustom) { + androidx.compose.material3.OutlinedButton( + onClick = { + app.gamenative.utils.SteamUtils.resetCustomHeader(gameId) + Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() + }, + ) { Text("Reset to default") } + } + } + } + } + if (selectedTab == 9) SettingsGroup() { SettingsListDropdown( colors = settingsTileColors(), title = { Text(text = "Startup Selection") }, 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 49f876ee1..8d64a88d0 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 @@ -52,6 +52,7 @@ import androidx.compose.runtime.mutableFloatStateOf 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 @@ -516,6 +517,11 @@ fun AppScreen( showConfigDialog = false ContainerUtils.applyToContainer(context, appId, it) }, + mediaHeroUrl = appInfo.getHeroUrl(), + mediaLogoUrl = appInfo.getLogoUrl(), + mediaCapsuleUrl = appInfo.getCapsuleUrl(), + mediaHeaderUrl = appInfo.getHeaderImageUrl(), + gameId = appInfo.id, ) LoadingDialog( @@ -943,9 +949,35 @@ private fun AppScreenContent( .height(250.dp) ) { // Hero background image + // Observe media change notifications to refresh hero immediately + val mediaVersion by SteamUtils.mediaVersionFlow.collectAsState(initial = 0) + fun bustCache(model: Any?, version: Int): Any? { + if (model == null) return null + return when (model) { + is String -> { + if (model.startsWith("http", ignoreCase = true) || model.startsWith("file:", ignoreCase = true)) { + val sep = if (model.contains("?")) "&" else "?" + model + sep + "v=" + version + } else model + } + is android.net.Uri -> { + val s = model.toString() + if (s.startsWith("http", ignoreCase = true) || s.startsWith("file:", ignoreCase = true)) { + val sep = if (s.contains("?")) "&" else "?" + android.net.Uri.parse(s + sep + "v=" + version) + } else model + } + else -> model + } + } + CoilImage( modifier = Modifier.fillMaxSize(), - imageModel = { appInfo.getHeroUrl() }, + imageModel = { + val custom = SteamUtils.getCustomHeroUri(appInfo.id) + val base = custom ?: appInfo.getHeroUrl() + bustCache(base, mediaVersion) + }, imageOptions = ImageOptions(contentScale = ContentScale.Crop), loading = { LoadingScreen() }, failure = { 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 415e26b9b..61fcc6095 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 @@ -40,6 +40,7 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -146,16 +147,43 @@ internal fun AppItem( ) } else { val aspectRatio = if (paneType == PaneType.GRID_CAPSULE) { 2/3f } else { 460/215f } - val imageUrl = if (paneType == PaneType.GRID_CAPSULE) { - "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/library_600x900.jpg" + // Observe media changes to refresh images immediately when user updates or resets + val mediaVersion by app.gamenative.utils.SteamUtils.mediaVersionFlow.collectAsState(initial = 0) + + fun bustCache(model: Any?, version: Int): Any? { + if (model == null) return null + return when (model) { + is String -> { + if (model.startsWith("http", ignoreCase = true) || model.startsWith("file:", ignoreCase = true)) { + val sep = if (model.contains("?")) "&" else "?" + model + sep + "v=" + version + } else model + } + is android.net.Uri -> { + val s = model.toString() + if (s.startsWith("http", ignoreCase = true) || s.startsWith("file:", ignoreCase = true)) { + val sep = if (s.contains("?")) "&" else "?" + android.net.Uri.parse(s + sep + "v=" + version) + } else model + } + else -> model + } + } + + val baseModel: Any? = if (paneType == PaneType.GRID_CAPSULE) { + // Prefer custom capsule if present, otherwise Steam capsule + app.gamenative.utils.SteamUtils.getCustomCapsuleUri(appInfo.gameId) + ?: ("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" + // Prefer custom header if present, otherwise Steam header + app.gamenative.utils.SteamUtils.getCustomHeaderUri(appInfo.gameId) + ?: ("https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg") } ListItemImage( modifier = Modifier.aspectRatio(aspectRatio), imageModifier = Modifier.clip(RoundedCornerShape(3.dp)).alpha(alpha), - image = { imageUrl }, + image = { bustCache(baseModel, mediaVersion) }, onFailure = { hideText = false alpha = 0.1f diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 6c251865b..a41ecc0c6 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -33,6 +33,148 @@ import java.util.concurrent.TimeUnit object SteamUtils { + // 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 } + + // --- Custom media (hero/logo/capsule/header) helpers --- + private fun mediaDirFor(appId: Int): File { + val base = File(SteamService.getAppDirPath(appId)) + val dir = File(base, "media") + if (!dir.exists()) dir.mkdirs() + return dir + } + + 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 getCustomHeaderFile(appId: Int): File = File(mediaDirFor(appId), "custom_header.jpg") + + 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 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 resetCustomHeader(appId: Int) { + runCatching { getCustomHeaderFile(appId).delete() } + notifyMediaChanged() + } + + /** + * Save a custom hero image. The image will be center-cropped to 920x430 and saved as JPEG. + */ + fun saveCustomHero(context: Context, appId: Int, sourceUri: android.net.Uri): Boolean = + try { + val bmp = decodeBitmap(context, sourceUri) ?: return false + val out = centerCropResize(bmp, 920, 430) + 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: android.net.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: android.net.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 header image for list view. Center-crop to 460x215 JPEG. + */ + fun saveCustomHeader(context: Context, appId: Int, sourceUri: android.net.Uri): Boolean = + try { + val bmp = decodeBitmap(context, sourceUri) ?: return false + val out = centerCropResize(bmp, 460, 215) + saveJpeg(out, getCustomHeaderFile(appId)) + notifyMediaChanged() + true + } catch (t: Throwable) { Timber.w(t, "saveCustomHeader failed"); false } + + private fun decodeBitmap(context: Context, uri: android.net.Uri): android.graphics.Bitmap? { + return try { + context.contentResolver.openInputStream(uri).use { ins -> + if (ins == null) null else android.graphics.BitmapFactory.decodeStream(ins) + } + } catch (t: Throwable) { Timber.w(t, "decodeBitmap failed"); null } + } + + private fun centerCropResize(src: android.graphics.Bitmap, targetW: Int, targetH: Int): android.graphics.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 = android.graphics.Bitmap.createScaledBitmap(src, scaledW, scaledH, true) + val x = (scaledW - targetW) / 2 + val y = (scaledH - targetH) / 2 + return android.graphics.Bitmap.createBitmap(scaled, x.coerceAtLeast(0), y.coerceAtLeast(0), targetW.coerceAtMost(scaled.width), targetH.coerceAtMost(scaled.height)) + } + + private fun fitIntoCanvas(src: android.graphics.Bitmap, canvasW: Int, canvasH: Int): android.graphics.Bitmap { + val out = android.graphics.Bitmap.createBitmap(canvasW, canvasH, android.graphics.Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(out) + canvas.drawColor(android.graphics.Color.TRANSPARENT, android.graphics.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 = android.graphics.RectF(left, top, left + w, top + h) + val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG) + canvas.drawBitmap(src, null, dst, paint) + return out + } + + private fun saveJpeg(bmp: android.graphics.Bitmap, file: File) { + if (!file.parentFile.exists()) file.parentFile.mkdirs() + java.io.FileOutputStream(file).use { fos -> + bmp.compress(android.graphics.Bitmap.CompressFormat.JPEG, 90, fos) + } + } + + private fun savePng(bmp: android.graphics.Bitmap, file: File) { + if (!file.parentFile.exists()) file.parentFile.mkdirs() + java.io.FileOutputStream(file).use { fos -> + bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, fos) + } + } + + fun getCustomHeroUri(appId: Int): android.net.Uri? = getCustomHeroFile(appId).takeIf { it.exists() }?.let { android.net.Uri.fromFile(it) } + fun getCustomLogoUri(appId: Int): android.net.Uri? = getCustomLogoFile(appId).takeIf { it.exists() }?.let { android.net.Uri.fromFile(it) } + fun getCustomCapsuleUri(appId: Int): android.net.Uri? = getCustomCapsuleFile(appId).takeIf { it.exists() }?.let { android.net.Uri.fromFile(it) } + fun getCustomHeaderUri(appId: Int): android.net.Uri? = getCustomHeaderFile(appId).takeIf { it.exists() }?.let { android.net.Uri.fromFile(it) } + internal val http = OkHttpClient.Builder() .readTimeout(5, TimeUnit.MINUTES) // from 2 min → 5 min .protocols(listOf(Protocol.HTTP_1_1)) // skip HTTP/2 stream stalls From 54a12fc809b30aa48f5be14573cb891733644e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Bj=C3=B8rnmose=20Bundgaard?= Date: Mon, 3 Nov 2025 21:51:53 +0100 Subject: [PATCH 02/12] Created MediaUtils.kt file --- .../component/dialog/ContainerConfigDialog.kt | 74 ++--- .../ui/component/dialog/GamesListDialog.kt | 2 +- .../ui/component/dialog/ProfileDialog.kt | 2 +- .../ui/component/topbar/AccountButton.kt | 2 +- .../gamenative/ui/screen/chat/ChatScreen.kt | 2 +- .../ui/screen/library/LibraryAppScreen.kt | 25 +- .../library/components/LibraryAppItem.kt | 29 +- .../java/app/gamenative/ui/util/Images.kt | 119 ------- .../java/app/gamenative/utils/MediaUtils.kt | 305 ++++++++++++++++++ 9 files changed, 343 insertions(+), 217 deletions(-) delete mode 100644 app/src/main/java/app/gamenative/ui/util/Images.kt create mode 100644 app/src/main/java/app/gamenative/utils/MediaUtils.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 931ba6bad..447f2e8b5 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 @@ -1705,30 +1705,8 @@ fun ContainerConfigDialog( } if (selectedTab == 8) SettingsGroup(title = { Text(text = "Media") }) { // Observe global media change version to refresh previews instantly - val mediaVersion by app.gamenative.utils.SteamUtils.mediaVersionFlow.collectAsState(initial = 0) - - fun bustCache(model: Any?, version: Int): Any? { - if (model == null) return null - return when (model) { - is String -> { - // Append version for http(s) and file: string models - if (model.startsWith("http", ignoreCase = true) || model.startsWith("file:", ignoreCase = true)) { - val sep = if (model.contains("?")) "&" else "?" - model + sep + "v=" + version - } else model - } - is android.net.Uri -> { - // Append for http(s), file, and content URIs - val scheme = model.scheme?.lowercase() - if (scheme == "http" || scheme == "https" || scheme == "file" || scheme == "content") { - val s = model.toString() - val sep = if (s.contains("?")) "&" else "?" - android.net.Uri.parse(s + sep + "v=" + version) - } else model - } - else -> model - } - } + val mediaVersion by app.gamenative.utils.MediaUtils.mediaVersionFlow.collectAsState(initial = 0) + // HERO --------------------------------------------- Text( @@ -1743,7 +1721,7 @@ fun ContainerConfigDialog( val currentHeroModel: Any? = run { val gid = gameId if (gid != null) { - val custom = app.gamenative.utils.SteamUtils.getCustomHeroUri(gid) + val custom = app.gamenative.utils.MediaUtils.getCustomHeroUri(gid) custom ?: mediaHeroUrl } else mediaHeroUrl } @@ -1755,7 +1733,7 @@ fun ContainerConfigDialog( modifier = Modifier .fillMaxWidth() .padding(8.dp), - imageModel = { bustCache(currentHeroModel, mediaVersion) }, + imageModel = { app.gamenative.utils.bustCache(currentHeroModel, mediaVersion) }, imageOptions = ImageOptions(contentScale = ContentScale.Crop), previewPlaceholder = painterResource(app.gamenative.R.drawable.testhero), ) @@ -1770,7 +1748,7 @@ fun ContainerConfigDialog( // Steam or Custom badge overlay in settings val gid = gameId if (gid != null) { - val isCustom = app.gamenative.utils.SteamUtils.hasCustomHero(gid) + val isCustom = app.gamenative.utils.MediaUtils.hasCustomHero(gid) val badgeText = if (isCustom) "Custom" else "Steam" androidx.compose.foundation.layout.Box( modifier = Modifier @@ -1799,12 +1777,12 @@ fun ContainerConfigDialog( // Pick/Reset actions for Hero if (gameId != null) { val context = LocalContext.current - val isCustom = app.gamenative.utils.SteamUtils.hasCustomHero(gameId) + val isCustom = app.gamenative.utils.MediaUtils.hasCustomHero(gameId) val pickHero = androidx.activity.compose.rememberLauncherForActivityResult( contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() ) { uri -> if (uri != null) { - val ok = app.gamenative.utils.SteamUtils.saveCustomHero(context, gameId, uri) + val ok = app.gamenative.utils.MediaUtils.saveCustomHero(context, gameId, uri) Toast.makeText(context, if (ok) "Hero image updated" else "Failed to update hero", Toast.LENGTH_SHORT).show() } } @@ -1816,7 +1794,7 @@ fun ContainerConfigDialog( if (isCustom) { androidx.compose.material3.OutlinedButton( onClick = { - app.gamenative.utils.SteamUtils.resetCustomHero(gameId) + app.gamenative.utils.MediaUtils.resetCustomHero(gameId) Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() }, ) { Text("Reset to default") } @@ -1842,7 +1820,7 @@ fun ContainerConfigDialog( val currentLogoModel: Any? = run { val gid = gameId if (gid != null) { - val custom = app.gamenative.utils.SteamUtils.getCustomLogoUri(gid) + val custom = app.gamenative.utils.MediaUtils.getCustomLogoUri(gid) custom ?: mediaLogoUrl } else mediaLogoUrl } @@ -1854,7 +1832,7 @@ fun ContainerConfigDialog( modifier = Modifier .width(200.dp) .padding(8.dp), - imageModel = { bustCache(currentLogoModel, mediaVersion) }, + imageModel = { app.gamenative.utils.bustCache(currentLogoModel, mediaVersion) }, imageOptions = ImageOptions(contentScale = ContentScale.Fit), previewPlaceholder = painterResource(app.gamenative.R.drawable.testliblogo), ) @@ -1868,7 +1846,7 @@ fun ContainerConfigDialog( val gid = gameId if (gid != null) { - val isCustom = app.gamenative.utils.SteamUtils.hasCustomLogo(gid) + val isCustom = app.gamenative.utils.MediaUtils.hasCustomLogo(gid) val badgeText = if (isCustom) "Custom" else "Steam" androidx.compose.foundation.layout.Box( modifier = Modifier @@ -1896,12 +1874,12 @@ fun ContainerConfigDialog( // Pick/Reset actions for Logo if (gameId != null) { val context = LocalContext.current - val isCustom = app.gamenative.utils.SteamUtils.hasCustomLogo(gameId) + val isCustom = app.gamenative.utils.MediaUtils.hasCustomLogo(gameId) val pickLogo = androidx.activity.compose.rememberLauncherForActivityResult( contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() ) { uri -> if (uri != null) { - val ok = app.gamenative.utils.SteamUtils.saveCustomLogo(context, gameId, uri) + val ok = app.gamenative.utils.MediaUtils.saveCustomLogo(context, gameId, uri) Toast.makeText(context, if (ok) "Logo updated" else "Failed to update logo", Toast.LENGTH_SHORT).show() } } @@ -1913,7 +1891,7 @@ fun ContainerConfigDialog( if (isCustom) { androidx.compose.material3.OutlinedButton( onClick = { - app.gamenative.utils.SteamUtils.resetCustomLogo(gameId) + app.gamenative.utils.MediaUtils.resetCustomLogo(gameId) Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() }, ) { Text("Reset to default") } @@ -1939,7 +1917,7 @@ fun ContainerConfigDialog( val currentCapsuleModel: Any? = run { val gid = gameId if (gid != null) { - val custom = app.gamenative.utils.SteamUtils.getCustomCapsuleUri(gid) + val custom = app.gamenative.utils.MediaUtils.getCustomCapsuleUri(gid) custom ?: mediaCapsuleUrl } else mediaCapsuleUrl } @@ -1951,7 +1929,7 @@ fun ContainerConfigDialog( modifier = Modifier .fillMaxWidth() .padding(8.dp), - imageModel = { bustCache(currentCapsuleModel, mediaVersion) }, + imageModel = { app.gamenative.utils.bustCache(currentCapsuleModel, mediaVersion) }, imageOptions = ImageOptions(contentScale = ContentScale.Crop), previewPlaceholder = painterResource(app.gamenative.R.drawable.testhero), ) @@ -1965,7 +1943,7 @@ fun ContainerConfigDialog( val gid2 = gameId if (gid2 != null) { - val isCustom = app.gamenative.utils.SteamUtils.hasCustomCapsule(gid2) + val isCustom = app.gamenative.utils.MediaUtils.hasCustomCapsule(gid2) val badgeText = if (isCustom) "Custom" else "Steam" androidx.compose.foundation.layout.Box( modifier = Modifier @@ -1993,12 +1971,12 @@ fun ContainerConfigDialog( if (gameId != null) { val context = LocalContext.current - val isCustom = app.gamenative.utils.SteamUtils.hasCustomCapsule(gameId) + val isCustom = app.gamenative.utils.MediaUtils.hasCustomCapsule(gameId) val pickCapsule = androidx.activity.compose.rememberLauncherForActivityResult( contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() ) { uri -> if (uri != null) { - val ok = app.gamenative.utils.SteamUtils.saveCustomCapsule(context, gameId, uri) + val ok = app.gamenative.utils.MediaUtils.saveCustomCapsule(context, gameId, uri) Toast.makeText(context, if (ok) "Capsule image updated" else "Failed to update capsule", Toast.LENGTH_SHORT).show() } } @@ -2010,7 +1988,7 @@ fun ContainerConfigDialog( if (isCustom) { androidx.compose.material3.OutlinedButton( onClick = { - app.gamenative.utils.SteamUtils.resetCustomCapsule(gameId) + app.gamenative.utils.MediaUtils.resetCustomCapsule(gameId) Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() }, ) { Text("Reset to default") } @@ -2033,7 +2011,7 @@ fun ContainerConfigDialog( val currentHeaderModel: Any? = run { val gid = gameId if (gid != null) { - val custom = app.gamenative.utils.SteamUtils.getCustomHeaderUri(gid) + val custom = app.gamenative.utils.MediaUtils.getCustomHeaderUri(gid) custom ?: mediaHeaderUrl } else mediaHeaderUrl } @@ -2045,7 +2023,7 @@ fun ContainerConfigDialog( modifier = Modifier .fillMaxWidth() .padding(8.dp), - imageModel = { bustCache(currentHeaderModel, mediaVersion) }, + imageModel = { app.gamenative.utils.bustCache(currentHeaderModel, mediaVersion) }, imageOptions = ImageOptions(contentScale = ContentScale.Crop), previewPlaceholder = painterResource(app.gamenative.R.drawable.testhero), ) @@ -2059,7 +2037,7 @@ fun ContainerConfigDialog( val gid3 = gameId if (gid3 != null) { - val isCustom = app.gamenative.utils.SteamUtils.hasCustomHeader(gid3) + val isCustom = app.gamenative.utils.MediaUtils.hasCustomHeader(gid3) val badgeText = if (isCustom) "Custom" else "Steam" androidx.compose.foundation.layout.Box( modifier = Modifier @@ -2087,12 +2065,12 @@ fun ContainerConfigDialog( if (gameId != null) { val context = LocalContext.current - val isCustom = app.gamenative.utils.SteamUtils.hasCustomHeader(gameId) + val isCustom = app.gamenative.utils.MediaUtils.hasCustomHeader(gameId) val pickHeader = androidx.activity.compose.rememberLauncherForActivityResult( contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() ) { uri -> if (uri != null) { - val ok = app.gamenative.utils.SteamUtils.saveCustomHeader(context, gameId, uri) + val ok = app.gamenative.utils.MediaUtils.saveCustomHeader(context, gameId, uri) Toast.makeText(context, if (ok) "Header image updated" else "Failed to update header", Toast.LENGTH_SHORT).show() } } @@ -2104,7 +2082,7 @@ fun ContainerConfigDialog( if (isCustom) { androidx.compose.material3.OutlinedButton( onClick = { - app.gamenative.utils.SteamUtils.resetCustomHeader(gameId) + app.gamenative.utils.MediaUtils.resetCustomHeader(gameId) Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() }, ) { Text("Reset to default") } diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GamesListDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GamesListDialog.kt index 9ee4a4b0b..df1d302bb 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GamesListDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GamesListDialog.kt @@ -39,7 +39,7 @@ import app.gamenative.Constants import app.gamenative.data.OwnedGames import app.gamenative.ui.component.LoadingScreen import app.gamenative.ui.theme.PluviaTheme -import app.gamenative.ui.util.ListItemImage +import app.gamenative.utils.ListItemImage import app.gamenative.utils.SteamUtils @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ProfileDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ProfileDialog.kt index 0b8176623..24e2c3764 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ProfileDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ProfileDialog.kt @@ -42,7 +42,7 @@ import androidx.compose.ui.unit.dp import app.gamenative.R import app.gamenative.ui.screen.PluviaScreen import app.gamenative.ui.theme.PluviaTheme -import app.gamenative.ui.util.SteamIconImage +import app.gamenative.utils.SteamIconImage import app.gamenative.utils.getAvatarURL import `in`.dragonbra.javasteam.enums.EPersonaState diff --git a/app/src/main/java/app/gamenative/ui/component/topbar/AccountButton.kt b/app/src/main/java/app/gamenative/ui/component/topbar/AccountButton.kt index f97992756..0fd94d821 100644 --- a/app/src/main/java/app/gamenative/ui/component/topbar/AccountButton.kt +++ b/app/src/main/java/app/gamenative/ui/component/topbar/AccountButton.kt @@ -20,7 +20,7 @@ import app.gamenative.events.SteamEvent import app.gamenative.service.SteamService import app.gamenative.ui.component.dialog.ProfileDialog import app.gamenative.ui.theme.PluviaTheme -import app.gamenative.ui.util.SteamIconImage +import app.gamenative.utils.SteamIconImage import app.gamenative.utils.getAvatarURL import `in`.dragonbra.javasteam.enums.EPersonaState import kotlinx.coroutines.launch diff --git a/app/src/main/java/app/gamenative/ui/screen/chat/ChatScreen.kt b/app/src/main/java/app/gamenative/ui/screen/chat/ChatScreen.kt index b6c37376a..b247342e8 100644 --- a/app/src/main/java/app/gamenative/ui/screen/chat/ChatScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/chat/ChatScreen.kt @@ -76,7 +76,7 @@ import app.gamenative.ui.data.ChatState import app.gamenative.ui.internal.fakeSteamFriends import app.gamenative.ui.model.ChatViewModel import app.gamenative.ui.theme.PluviaTheme -import app.gamenative.ui.util.ListItemImage +import app.gamenative.utils.ListItemImage import app.gamenative.utils.SteamUtils import app.gamenative.utils.getAvatarURL import kotlinx.coroutines.launch 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 8d64a88d0..cca23e0d7 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 @@ -950,33 +950,14 @@ private fun AppScreenContent( ) { // Hero background image // Observe media change notifications to refresh hero immediately - val mediaVersion by SteamUtils.mediaVersionFlow.collectAsState(initial = 0) - fun bustCache(model: Any?, version: Int): Any? { - if (model == null) return null - return when (model) { - is String -> { - if (model.startsWith("http", ignoreCase = true) || model.startsWith("file:", ignoreCase = true)) { - val sep = if (model.contains("?")) "&" else "?" - model + sep + "v=" + version - } else model - } - is android.net.Uri -> { - val s = model.toString() - if (s.startsWith("http", ignoreCase = true) || s.startsWith("file:", ignoreCase = true)) { - val sep = if (s.contains("?")) "&" else "?" - android.net.Uri.parse(s + sep + "v=" + version) - } else model - } - else -> model - } - } + val mediaVersion by app.gamenative.utils.MediaUtils.mediaVersionFlow.collectAsState(initial = 0) CoilImage( modifier = Modifier.fillMaxSize(), imageModel = { - val custom = SteamUtils.getCustomHeroUri(appInfo.id) + val custom = app.gamenative.utils.MediaUtils.getCustomHeroUri(appInfo.id) val base = custom ?: appInfo.getHeroUrl() - bustCache(base, mediaVersion) + app.gamenative.utils.bustCache(base, mediaVersion) }, imageOptions = ImageOptions(contentScale = ContentScale.Crop), loading = { LoadingScreen() }, 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 61fcc6095..cacba2705 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 @@ -60,7 +60,7 @@ import app.gamenative.service.SteamService import app.gamenative.ui.enums.PaneType import app.gamenative.ui.internal.fakeAppInfo import app.gamenative.ui.theme.PluviaTheme -import app.gamenative.ui.util.ListItemImage +import app.gamenative.utils.ListItemImage @Composable internal fun AppItem( @@ -148,42 +148,23 @@ internal fun AppItem( } 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.SteamUtils.mediaVersionFlow.collectAsState(initial = 0) + val mediaVersion by app.gamenative.utils.MediaUtils.mediaVersionFlow.collectAsState(initial = 0) - fun bustCache(model: Any?, version: Int): Any? { - if (model == null) return null - return when (model) { - is String -> { - if (model.startsWith("http", ignoreCase = true) || model.startsWith("file:", ignoreCase = true)) { - val sep = if (model.contains("?")) "&" else "?" - model + sep + "v=" + version - } else model - } - is android.net.Uri -> { - val s = model.toString() - if (s.startsWith("http", ignoreCase = true) || s.startsWith("file:", ignoreCase = true)) { - val sep = if (s.contains("?")) "&" else "?" - android.net.Uri.parse(s + sep + "v=" + version) - } else model - } - else -> model - } - } val baseModel: Any? = if (paneType == PaneType.GRID_CAPSULE) { // Prefer custom capsule if present, otherwise Steam capsule - app.gamenative.utils.SteamUtils.getCustomCapsuleUri(appInfo.gameId) + app.gamenative.utils.MediaUtils.getCustomCapsuleUri(appInfo.gameId) ?: ("https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/library_600x900.jpg") } else { // Prefer custom header if present, otherwise Steam header - app.gamenative.utils.SteamUtils.getCustomHeaderUri(appInfo.gameId) + app.gamenative.utils.MediaUtils.getCustomHeaderUri(appInfo.gameId) ?: ("https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg") } ListItemImage( modifier = Modifier.aspectRatio(aspectRatio), imageModifier = Modifier.clip(RoundedCornerShape(3.dp)).alpha(alpha), - image = { bustCache(baseModel, mediaVersion) }, + image = { app.gamenative.utils.bustCache(baseModel, mediaVersion) }, onFailure = { hideText = false alpha = 0.1f diff --git a/app/src/main/java/app/gamenative/ui/util/Images.kt b/app/src/main/java/app/gamenative/ui/util/Images.kt deleted file mode 100644 index 697f145e9..000000000 --- a/app/src/main/java/app/gamenative/ui/util/Images.kt +++ /dev/null @@ -1,119 +0,0 @@ -package app.gamenative.ui.util - -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.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import app.gamenative.R -import app.gamenative.ui.theme.PluviaTheme -import com.skydoves.landscapist.ImageOptions -import com.skydoves.landscapist.coil.CoilImage - -@Composable -internal fun ListItemImage( - modifier: Modifier = Modifier, - imageModifier: Modifier = Modifier.clip(CircleShape), - contentDescription: String? = null, - size: Dp = 40.dp, - image: () -> Any?, - onFailure: () -> Unit = {}, -) { - CoilImage( - modifier = modifier - .size(size) - .then(imageModifier), - imageModel = image, - imageOptions = ImageOptions( - contentScale = ContentScale.Fit, - contentDescription = contentDescription, - ), - loading = { - CircularProgressIndicator() - }, - failure = { - onFailure() - Icon(Icons.Filled.QuestionMark, null) - }, - previewPlaceholder = painterResource(R.drawable.ic_logo_color), - ) -} - -@Composable -internal fun SteamIconImage( - modifier: Modifier = Modifier, - contentDescription: String? = null, - size: Dp = 40.dp, - image: () -> Any?, -) { - CoilImage( - modifier = modifier - .size(size) - .clip(RoundedCornerShape(12.dp)), - imageModel = image, - imageOptions = ImageOptions( - contentScale = ContentScale.Crop, - contentDescription = contentDescription, - ), - loading = { - CircularProgressIndicator() - }, - failure = { - Icon(Icons.Default.AccountCircle, null) - }, - previewPlaceholder = painterResource(R.drawable.ic_logo_color), - ) -} - -@Composable -fun EmoticonImage( - size: Dp = 54.dp, - image: () -> Any?, -) { - CoilImage( - modifier = Modifier.size(size), - imageModel = image, - loading = { - CircularProgressIndicator() - }, - failure = { - Icon(Icons.Filled.QuestionMark, null) - }, - previewPlaceholder = painterResource(R.drawable.ic_logo_color), - ) -} - -@Composable -fun StickerImage( - size: Dp = 150.dp, - image: () -> Any?, -) { - EmoticonImage(size, image) -} - -@Preview -@Composable -private fun Preview_EmoticonImage() { - PluviaTheme { - EmoticonImage { "https://steamcommunity-a.akamaihd.net/economy/emoticonlarge/roar" } - } -} - -@Preview -@Composable -private fun Preview_StickerImage() { - PluviaTheme { - StickerImage { "https://steamcommunity-a.akamaihd.net/economy/sticker/Delivery%20Cat%20in%20a%20Blanket" } - } -} diff --git a/app/src/main/java/app/gamenative/utils/MediaUtils.kt b/app/src/main/java/app/gamenative/utils/MediaUtils.kt new file mode 100644 index 000000000..476998afb --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/MediaUtils.kt @@ -0,0 +1,305 @@ +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.annotation.DrawableRes +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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.gamenative.R +import app.gamenative.ui.theme.PluviaTheme +import app.gamenative.service.SteamService +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.coil.CoilImage +import java.io.File +import java.io.FileOutputStream +import timber.log.Timber + +/** + * Media/image utilities shared across the app. + * + * Coding style aligns with other files under app.gamenative.utils (top-level helpers + object for stateful ops). + */ +object MediaUtils { + // 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 } + + // --- Custom media (hero/logo/capsule/header) helpers --- + private fun mediaDirFor(appId: Int): File { + val base = File(SteamService.getAppDirPath(appId)) + val dir = File(base, "media") + if (!dir.exists()) dir.mkdirs() + return dir + } + + 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 getCustomHeaderFile(appId: Int): File = File(mediaDirFor(appId), "custom_header.jpg") + + 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 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 resetCustomHeader(appId: Int) { + runCatching { getCustomHeaderFile(appId).delete() } + notifyMediaChanged() + } + + /** + * Save a custom hero image. The image will be center-cropped to 920x430 and saved as JPEG. + */ + fun saveCustomHero(context: Context, appId: Int, sourceUri: Uri): Boolean = + try { + val bmp = decodeBitmap(context, sourceUri) ?: return false + val out = centerCropResize(bmp, 920, 430) + 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 header image for list view. Center-crop to 460x215 JPEG. + */ + fun saveCustomHeader(context: Context, appId: Int, sourceUri: Uri): Boolean = + try { + val bmp = decodeBitmap(context, sourceUri) ?: return false + val out = centerCropResize(bmp, 460, 215) + saveJpeg(out, getCustomHeaderFile(appId)) + notifyMediaChanged() + true + } catch (t: Throwable) { Timber.w(t, "saveCustomHeader 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 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 getCustomHeaderUri(appId: Int): Uri? = getCustomHeaderFile(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 // For File or other models we leave as-is. + } +} + +// ---------------------- UI helpers (reused across screens) ---------------------- +@Composable +internal fun ListItemImage( + modifier: Modifier = Modifier, + imageModifier: Modifier = Modifier.clip(CircleShape), + contentDescription: String? = null, + size: Dp = 40.dp, + image: () -> Any?, + onFailure: () -> Unit = {}, +) { + CoilImage( + modifier = modifier + .size(size) + .then(imageModifier), + imageModel = image, + imageOptions = ImageOptions( + contentScale = ContentScale.Fit, + contentDescription = contentDescription, + ), + loading = { CircularProgressIndicator() }, + failure = { + onFailure() + Icon(Icons.Filled.QuestionMark, null) + }, + previewPlaceholder = painterResource(R.drawable.ic_logo_color), + ) +} + +@Composable +internal fun SteamIconImage( + modifier: Modifier = Modifier, + contentDescription: String? = null, + size: Dp = 40.dp, + image: () -> Any?, +) { + CoilImage( + modifier = modifier + .size(size) + .clip(RoundedCornerShape(12.dp)), + imageModel = image, + imageOptions = ImageOptions( + contentScale = ContentScale.Crop, + contentDescription = contentDescription, + ), + loading = { CircularProgressIndicator() }, + failure = { Icon(Icons.Default.AccountCircle, null) }, + previewPlaceholder = painterResource(R.drawable.ic_logo_color), + ) +} + +@Composable +fun EmoticonImage( + size: Dp = 54.dp, + image: () -> Any?, +) { + CoilImage( + modifier = Modifier.size(size), + imageModel = image, + loading = { CircularProgressIndicator() }, + failure = { Icon(Icons.Filled.QuestionMark, null) }, + previewPlaceholder = painterResource(R.drawable.ic_logo_color), + ) +} + +@Composable +fun StickerImage( + size: Dp = 150.dp, + image: () -> Any?, +) { + EmoticonImage(size, image) +} + +@Preview +@Composable +private fun Preview_EmoticonImage() { + PluviaTheme { + EmoticonImage { "https://steamcommunity-a.akamaihd.net/economy/emoticonlarge/roar" } + } +} + +@Preview +@Composable +private fun Preview_StickerImage() { + PluviaTheme { + StickerImage { "https://steamcommunity-a.akamaihd.net/economy/sticker/Delivery%20Cat%20in%20a%20Blanket" } + } +} From 1328be67d02e25822d467854e4f1b6858efc1d2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Bj=C3=B8rnmose=20Bundgaard?= Date: Mon, 3 Nov 2025 21:58:52 +0100 Subject: [PATCH 03/12] Updating image on change --- .../component/dialog/ContainerConfigDialog.kt | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) 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 447f2e8b5..7f3ecc7ba 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 @@ -1718,7 +1718,7 @@ fun ContainerConfigDialog( .padding(horizontal = 16.dp, vertical = 8.dp) ) - val currentHeroModel: Any? = run { + val currentHeroModel: Any? = remember(mediaVersion, gameId) { val gid = gameId if (gid != null) { val custom = app.gamenative.utils.MediaUtils.getCustomHeroUri(gid) @@ -1748,7 +1748,7 @@ fun ContainerConfigDialog( // Steam or Custom badge overlay in settings val gid = gameId if (gid != null) { - val isCustom = app.gamenative.utils.MediaUtils.hasCustomHero(gid) + val isCustom = remember(mediaVersion, gid) { app.gamenative.utils.MediaUtils.hasCustomHero(gid) } val badgeText = if (isCustom) "Custom" else "Steam" androidx.compose.foundation.layout.Box( modifier = Modifier @@ -1777,7 +1777,7 @@ fun ContainerConfigDialog( // Pick/Reset actions for Hero if (gameId != null) { val context = LocalContext.current - val isCustom = app.gamenative.utils.MediaUtils.hasCustomHero(gameId) + val isCustom = remember(mediaVersion, gameId) { app.gamenative.utils.MediaUtils.hasCustomHero(gameId) } val pickHero = androidx.activity.compose.rememberLauncherForActivityResult( contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() ) { uri -> @@ -1817,7 +1817,7 @@ fun ContainerConfigDialog( .padding(horizontal = 16.dp, vertical = 8.dp) ) - val currentLogoModel: Any? = run { + val currentLogoModel: Any? = remember(mediaVersion, gameId) { val gid = gameId if (gid != null) { val custom = app.gamenative.utils.MediaUtils.getCustomLogoUri(gid) @@ -1846,7 +1846,7 @@ fun ContainerConfigDialog( val gid = gameId if (gid != null) { - val isCustom = app.gamenative.utils.MediaUtils.hasCustomLogo(gid) + val isCustom = remember(mediaVersion, gid) { app.gamenative.utils.MediaUtils.hasCustomLogo(gid) } val badgeText = if (isCustom) "Custom" else "Steam" androidx.compose.foundation.layout.Box( modifier = Modifier @@ -1874,7 +1874,7 @@ fun ContainerConfigDialog( // Pick/Reset actions for Logo if (gameId != null) { val context = LocalContext.current - val isCustom = app.gamenative.utils.MediaUtils.hasCustomLogo(gameId) + val isCustom = remember(mediaVersion, gameId) { app.gamenative.utils.MediaUtils.hasCustomLogo(gameId) } val pickLogo = androidx.activity.compose.rememberLauncherForActivityResult( contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() ) { uri -> @@ -1914,7 +1914,7 @@ fun ContainerConfigDialog( .padding(horizontal = 16.dp, vertical = 8.dp) ) - val currentCapsuleModel: Any? = run { + val currentCapsuleModel: Any? = remember(mediaVersion, gameId) { val gid = gameId if (gid != null) { val custom = app.gamenative.utils.MediaUtils.getCustomCapsuleUri(gid) @@ -1943,7 +1943,7 @@ fun ContainerConfigDialog( val gid2 = gameId if (gid2 != null) { - val isCustom = app.gamenative.utils.MediaUtils.hasCustomCapsule(gid2) + val isCustom = remember(mediaVersion, gid2) { app.gamenative.utils.MediaUtils.hasCustomCapsule(gid2) } val badgeText = if (isCustom) "Custom" else "Steam" androidx.compose.foundation.layout.Box( modifier = Modifier @@ -1971,7 +1971,7 @@ fun ContainerConfigDialog( if (gameId != null) { val context = LocalContext.current - val isCustom = app.gamenative.utils.MediaUtils.hasCustomCapsule(gameId) + val isCustom = remember(mediaVersion, gameId) { app.gamenative.utils.MediaUtils.hasCustomCapsule(gameId) } val pickCapsule = androidx.activity.compose.rememberLauncherForActivityResult( contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() ) { uri -> @@ -2008,7 +2008,7 @@ fun ContainerConfigDialog( .padding(horizontal = 16.dp, vertical = 8.dp) ) - val currentHeaderModel: Any? = run { + val currentHeaderModel: Any? = remember(mediaVersion, gameId) { val gid = gameId if (gid != null) { val custom = app.gamenative.utils.MediaUtils.getCustomHeaderUri(gid) @@ -2037,7 +2037,7 @@ fun ContainerConfigDialog( val gid3 = gameId if (gid3 != null) { - val isCustom = app.gamenative.utils.MediaUtils.hasCustomHeader(gid3) + val isCustom = remember(mediaVersion, gid3) { app.gamenative.utils.MediaUtils.hasCustomHeader(gid3) } val badgeText = if (isCustom) "Custom" else "Steam" androidx.compose.foundation.layout.Box( modifier = Modifier @@ -2065,7 +2065,7 @@ fun ContainerConfigDialog( if (gameId != null) { val context = LocalContext.current - val isCustom = app.gamenative.utils.MediaUtils.hasCustomHeader(gameId) + val isCustom = remember(mediaVersion, gameId) { app.gamenative.utils.MediaUtils.hasCustomHeader(gameId) } val pickHeader = androidx.activity.compose.rememberLauncherForActivityResult( contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() ) { uri -> From 9f39b8f0153dc031faabf5fe3ab697249af501ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Bj=C3=B8rnmose=20Bundgaard?= Date: Mon, 3 Nov 2025 22:28:09 +0100 Subject: [PATCH 04/12] Think its done --- .../component/dialog/ContainerConfigDialog.kt | 279 ++++++++++-------- .../ui/screen/library/LibraryAppScreen.kt | 1 + .../library/components/LibraryAppItem.kt | 8 +- .../java/app/gamenative/utils/MediaUtils.kt | 19 ++ 4 files changed, 187 insertions(+), 120 deletions(-) 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 7f3ecc7ba..fa4084785 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,11 +17,17 @@ 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.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 @@ -35,6 +41,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 @@ -64,6 +71,7 @@ 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 @@ -139,6 +147,7 @@ fun ContainerConfigDialog( mediaLogoUrl: String? = null, mediaCapsuleUrl: String? = null, mediaHeaderUrl: String? = null, + mediaIconUrl: String? = null, gameId: Int? = null, ) { if (visible) { @@ -1708,9 +1717,9 @@ fun ContainerConfigDialog( val mediaVersion by app.gamenative.utils.MediaUtils.mediaVersionFlow.collectAsState(initial = 0) - // HERO --------------------------------------------- + // LOGO --------------------------------------------- Text( - text = "Hero Image", + text = "Logo", color = Color.White, style = MaterialTheme.typography.titleMedium, modifier = Modifier @@ -1718,83 +1727,63 @@ fun ContainerConfigDialog( .padding(horizontal = 16.dp, vertical = 8.dp) ) - val currentHeroModel: Any? = remember(mediaVersion, gameId) { + val currentLogoModel: Any? = remember(mediaVersion, gameId) { val gid = gameId if (gid != null) { - val custom = app.gamenative.utils.MediaUtils.getCustomHeroUri(gid) - custom ?: mediaHeroUrl - } else mediaHeroUrl + val custom = app.gamenative.utils.MediaUtils.getCustomLogoUri(gid) + custom ?: mediaLogoUrl + } else mediaLogoUrl } Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxWidth()) { - if (currentHeroModel != null && (currentHeroModel as? String)?.isNotBlank() != false) { + androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + if (currentLogoModel != null && (currentLogoModel as? String)?.isNotBlank() != false) { CoilImage( modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - imageModel = { app.gamenative.utils.bustCache(currentHeroModel, mediaVersion) }, - imageOptions = ImageOptions(contentScale = ContentScale.Crop), - previewPlaceholder = painterResource(app.gamenative.R.drawable.testhero), + .widthIn(min = 150.dp, max = 300.dp), + imageModel = { app.gamenative.utils.bustCache(currentLogoModel, mediaVersion) }, + imageOptions = ImageOptions(contentScale = ContentScale.Fit), + previewPlaceholder = painterResource(app.gamenative.R.drawable.testliblogo), ) } else { SettingsCenteredLabel( colors = settingsTileColors(), - title = { Text(text = "No hero image available") }, + title = { Text(text = "No logo available") }, subtitle = { Text(text = "Open from a specific game to preview and change its media.") }, ) } - - // Steam or Custom badge overlay in settings - val gid = gameId - if (gid != null) { - val isCustom = remember(mediaVersion, gid) { app.gamenative.utils.MediaUtils.hasCustomHero(gid) } - val badgeText = if (isCustom) "Custom" else "Steam" - androidx.compose.foundation.layout.Box( - modifier = Modifier - .padding(16.dp) - .align(Alignment.TopStart) - .background( - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), - shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Text(badgeText, style = MaterialTheme.typography.labelSmall) - } - } } } // Recommended hint (below image) Text( - text = "Recommended: 920×430 JPG/PNG. Will be center-cropped to fit.", + text = "Recommended: up to 600×200 PNG with transparency. Will be scaled to fit.", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) ) - // Pick/Reset actions for Hero + // Pick/Reset actions for Logo if (gameId != null) { val context = LocalContext.current - val isCustom = remember(mediaVersion, gameId) { app.gamenative.utils.MediaUtils.hasCustomHero(gameId) } - val pickHero = androidx.activity.compose.rememberLauncherForActivityResult( + val isCustom = remember(mediaVersion, gameId) { app.gamenative.utils.MediaUtils.hasCustomLogo(gameId) } + val pickLogo = androidx.activity.compose.rememberLauncherForActivityResult( contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() ) { uri -> if (uri != null) { - val ok = app.gamenative.utils.MediaUtils.saveCustomHero(context, gameId, uri) - Toast.makeText(context, if (ok) "Hero image updated" else "Failed to update hero", Toast.LENGTH_SHORT).show() + val ok = app.gamenative.utils.MediaUtils.saveCustomLogo(context, gameId, uri) + Toast.makeText(context, if (ok) "Logo updated" else "Failed to update logo", 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 = { pickHero.launch("image/*") }) { Text("Choose image") } + androidx.compose.material3.Button(onClick = { pickLogo.launch("image/*") }) { Text("Choose image") } if (isCustom) { androidx.compose.material3.OutlinedButton( onClick = { - app.gamenative.utils.MediaUtils.resetCustomHero(gameId) + app.gamenative.utils.MediaUtils.resetCustomLogo(gameId) Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() }, ) { Text("Reset to default") } @@ -1805,11 +1794,11 @@ fun ContainerConfigDialog( Spacer(modifier = Modifier.padding(8.dp)) // Separator - androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 8.dp)) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - // LOGO --------------------------------------------- + // ICON --------------------------------------------- Text( - text = "Logo", + text = "Icon (List view)", color = Color.White, style = MaterialTheme.typography.titleMedium, modifier = Modifier @@ -1817,81 +1806,147 @@ fun ContainerConfigDialog( .padding(horizontal = 16.dp, vertical = 8.dp) ) - val currentLogoModel: Any? = remember(mediaVersion, gameId) { + val currentIconModel: Any? = remember(mediaVersion, gameId) { val gid = gameId if (gid != null) { - val custom = app.gamenative.utils.MediaUtils.getCustomLogoUri(gid) - custom ?: mediaLogoUrl - } else mediaLogoUrl + val custom = app.gamenative.utils.MediaUtils.getCustomIconUri(gid) + custom ?: mediaIconUrl + } else mediaIconUrl } Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - if (currentLogoModel != null && (currentLogoModel as? String)?.isNotBlank() != false) { + if (currentIconModel != null && (currentIconModel as? String)?.isNotBlank() != false) { CoilImage( modifier = Modifier - .width(200.dp) - .padding(8.dp), - imageModel = { app.gamenative.utils.bustCache(currentLogoModel, mediaVersion) }, + .size(56.dp) + .clip(RoundedCornerShape(10.dp)), + imageModel = { app.gamenative.utils.bustCache(currentIconModel, mediaVersion) }, imageOptions = ImageOptions(contentScale = ContentScale.Fit), - previewPlaceholder = painterResource(app.gamenative.R.drawable.testliblogo), + previewPlaceholder = painterResource(app.gamenative.R.drawable.ic_logo_color), ) } else { SettingsCenteredLabel( colors = settingsTileColors(), - title = { Text(text = "No logo available") }, + title = { Text(text = "No icon available") }, subtitle = { Text(text = "Open from a specific game to preview and change its media.") }, ) } + } + } + + // Recommended hint (below image) + Text( + text = "Recommended: Square PNG with transparency. Will be center-cropped to fit.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) - val gid = gameId - if (gid != null) { - val isCustom = remember(mediaVersion, gid) { app.gamenative.utils.MediaUtils.hasCustomLogo(gid) } - val badgeText = if (isCustom) "Custom" else "Steam" - androidx.compose.foundation.layout.Box( + // Pick/Reset actions for Icon + if (gameId != null) { + val context = LocalContext.current + val isCustom = remember(mediaVersion, gameId) { app.gamenative.utils.MediaUtils.hasCustomIcon(gameId) } + val pickIcon = androidx.activity.compose.rememberLauncherForActivityResult( + contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + val ok = app.gamenative.utils.MediaUtils.saveCustomIcon(context, gameId, uri) + Toast.makeText(context, if (ok) "Icon updated" else "Failed to update icon", 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 = { pickIcon.launch("image/*") }) { Text("Choose image") } + if (isCustom) { + androidx.compose.material3.OutlinedButton( + onClick = { + app.gamenative.utils.MediaUtils.resetCustomIcon(gameId) + Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() + }, + ) { Text("Reset to default") } + } + } + } + + Spacer(modifier = Modifier.padding(8.dp)) + + // Separator + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // HERO --------------------------------------------- + Text( + text = "Hero Image", + color = Color.White, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + + val currentHeroModel: Any? = remember(mediaVersion, gameId) { + val gid = gameId + if (gid != null) { + val custom = app.gamenative.utils.MediaUtils.getCustomHeroUri(gid) + custom ?: mediaHeroUrl + } else mediaHeroUrl + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + androidx.compose.foundation.layout.Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + if (currentHeroModel != null && (currentHeroModel as? String)?.isNotBlank() != false) { + CoilImage( modifier = Modifier - .align(Alignment.TopStart) - .background( - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), - shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Text(badgeText, style = MaterialTheme.typography.labelSmall) - } + .widthIn(min = 200.dp, max = 400.dp) + .height(250.dp), + imageModel = { app.gamenative.utils.bustCache(currentHeroModel, mediaVersion) }, + imageOptions = ImageOptions(contentScale = ContentScale.Crop), + previewPlaceholder = painterResource(app.gamenative.R.drawable.testhero), + ) + } else { + SettingsCenteredLabel( + colors = settingsTileColors(), + title = { Text(text = "No hero image available") }, + subtitle = { Text(text = "Open from a specific game to preview and change its media.") }, + ) } } } // Recommended hint (below image) Text( - text = "Recommended: up to 600×200 PNG with transparency. Will be scaled to fit.", + text = "Recommended: 920×430 JPG/PNG. Will be center-cropped to fit.", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) ) - // Pick/Reset actions for Logo + // Pick/Reset actions for Hero if (gameId != null) { val context = LocalContext.current - val isCustom = remember(mediaVersion, gameId) { app.gamenative.utils.MediaUtils.hasCustomLogo(gameId) } - val pickLogo = androidx.activity.compose.rememberLauncherForActivityResult( + val isCustom = remember(mediaVersion, gameId) { app.gamenative.utils.MediaUtils.hasCustomHero(gameId) } + val pickHero = androidx.activity.compose.rememberLauncherForActivityResult( contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() ) { uri -> if (uri != null) { - val ok = app.gamenative.utils.MediaUtils.saveCustomLogo(context, gameId, uri) - Toast.makeText(context, if (ok) "Logo updated" else "Failed to update logo", Toast.LENGTH_SHORT).show() + val ok = app.gamenative.utils.MediaUtils.saveCustomHero(context, gameId, uri) + Toast.makeText(context, if (ok) "Hero image updated" else "Failed to update hero", 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 = { pickLogo.launch("image/*") }) { Text("Choose image") } + androidx.compose.material3.Button(onClick = { pickHero.launch("image/*") }) { Text("Choose image") } if (isCustom) { androidx.compose.material3.OutlinedButton( onClick = { - app.gamenative.utils.MediaUtils.resetCustomLogo(gameId) + app.gamenative.utils.MediaUtils.resetCustomHero(gameId) Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() }, ) { Text("Reset to default") } @@ -1902,7 +1957,7 @@ fun ContainerConfigDialog( Spacer(modifier = Modifier.padding(8.dp)) // Separator - androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 8.dp)) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) // CAPSULE --------------------------------------------- Text( @@ -1923,12 +1978,21 @@ fun ContainerConfigDialog( } Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxWidth()) { + androidx.compose.foundation.layout.Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { if (currentCapsuleModel != null && (currentCapsuleModel as? String)?.isNotBlank() != false) { CoilImage( modifier = Modifier - .fillMaxWidth() - .padding(8.dp), + .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) + ), imageModel = { app.gamenative.utils.bustCache(currentCapsuleModel, mediaVersion) }, imageOptions = ImageOptions(contentScale = ContentScale.Crop), previewPlaceholder = painterResource(app.gamenative.R.drawable.testhero), @@ -1940,24 +2004,6 @@ fun ContainerConfigDialog( subtitle = { Text(text = "Open from a specific game to preview and change its media.") }, ) } - - val gid2 = gameId - if (gid2 != null) { - val isCustom = remember(mediaVersion, gid2) { app.gamenative.utils.MediaUtils.hasCustomCapsule(gid2) } - val badgeText = if (isCustom) "Custom" else "Steam" - androidx.compose.foundation.layout.Box( - modifier = Modifier - .padding(16.dp) - .align(Alignment.TopStart) - .background( - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), - shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Text(badgeText, style = MaterialTheme.typography.labelSmall) - } - } } } @@ -1998,6 +2044,9 @@ fun ContainerConfigDialog( Spacer(modifier = Modifier.padding(8.dp)) + // Separator + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + // HEADER --------------------------------------------- Text( text = "Header (List view)", @@ -2017,12 +2066,21 @@ fun ContainerConfigDialog( } Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxWidth()) { + androidx.compose.foundation.layout.Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { if (currentHeaderModel != null && (currentHeaderModel as? String)?.isNotBlank() != false) { CoilImage( modifier = Modifier - .fillMaxWidth() - .padding(8.dp), + .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) + ), imageModel = { app.gamenative.utils.bustCache(currentHeaderModel, mediaVersion) }, imageOptions = ImageOptions(contentScale = ContentScale.Crop), previewPlaceholder = painterResource(app.gamenative.R.drawable.testhero), @@ -2034,24 +2092,6 @@ fun ContainerConfigDialog( subtitle = { Text(text = "Open from a specific game to preview and change its media.") }, ) } - - val gid3 = gameId - if (gid3 != null) { - val isCustom = remember(mediaVersion, gid3) { app.gamenative.utils.MediaUtils.hasCustomHeader(gid3) } - val badgeText = if (isCustom) "Custom" else "Steam" - androidx.compose.foundation.layout.Box( - modifier = Modifier - .padding(16.dp) - .align(Alignment.TopStart) - .background( - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), - shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Text(badgeText, style = MaterialTheme.typography.labelSmall) - } - } } } @@ -2089,6 +2129,7 @@ fun ContainerConfigDialog( } } } + } if (selectedTab == 9) SettingsGroup() { SettingsListDropdown( 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 cca23e0d7..2f3c9dae3 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 @@ -521,6 +521,7 @@ fun AppScreen( mediaLogoUrl = appInfo.getLogoUrl(), mediaCapsuleUrl = appInfo.getCapsuleUrl(), mediaHeaderUrl = appInfo.getHeaderImageUrl(), + mediaIconUrl = appInfo.clientIconUrl, gameId = appInfo.id, ) 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 cacba2705..0f7a45f41 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 @@ -140,10 +140,16 @@ internal fun AppItem( .clip(RoundedCornerShape(12.dp)), ) { if (paneType == PaneType.LIST) { + // Observe media changes to refresh icon immediately when user updates or resets + val mediaVersion by app.gamenative.utils.MediaUtils.mediaVersionFlow.collectAsState(initial = 0) + val iconModel: Any? = remember(mediaVersion, appInfo.gameId) { + app.gamenative.utils.MediaUtils.getCustomIconUri(appInfo.gameId) + ?: appInfo.clientIconUrl + } ListItemImage( modifier = Modifier.size(56.dp), imageModifier = Modifier.clip(RoundedCornerShape(10.dp)), - image = { appInfo.clientIconUrl } + image = { app.gamenative.utils.bustCache(iconModel, mediaVersion) } ) } else { val aspectRatio = if (paneType == PaneType.GRID_CAPSULE) { 2/3f } else { 460/215f } diff --git a/app/src/main/java/app/gamenative/utils/MediaUtils.kt b/app/src/main/java/app/gamenative/utils/MediaUtils.kt index 476998afb..eac37cf1b 100644 --- a/app/src/main/java/app/gamenative/utils/MediaUtils.kt +++ b/app/src/main/java/app/gamenative/utils/MediaUtils.kt @@ -58,11 +58,13 @@ object MediaUtils { fun getCustomLogoFile(appId: Int): File = File(mediaDirFor(appId), "custom_logo.png") fun getCustomCapsuleFile(appId: Int): File = File(mediaDirFor(appId), "custom_capsule.jpg") fun getCustomHeaderFile(appId: Int): File = File(mediaDirFor(appId), "custom_header.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 resetCustomHero(appId: Int) { runCatching { getCustomHeroFile(appId).delete() } @@ -80,6 +82,10 @@ object MediaUtils { runCatching { getCustomHeaderFile(appId).delete() } notifyMediaChanged() } + fun resetCustomIcon(appId: Int) { + runCatching { getCustomIconFile(appId).delete() } + notifyMediaChanged() + } /** * Save a custom hero image. The image will be center-cropped to 920x430 and saved as JPEG. @@ -129,6 +135,18 @@ object MediaUtils { true } catch (t: Throwable) { Timber.w(t, "saveCustomHeader 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 -> @@ -188,6 +206,7 @@ object MediaUtils { 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 getCustomHeaderUri(appId: Int): Uri? = getCustomHeaderFile(appId).takeIf { it.exists() }?.let { Uri.fromFile(it) } + fun getCustomIconUri(appId: Int): Uri? = getCustomIconFile(appId).takeIf { it.exists() }?.let { Uri.fromFile(it) } } /** From 7f3b65a11fbf0b7f33115a2a39ab4e917d8c79f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Bj=C3=B8rnmose=20Bundgaard?= Date: Tue, 4 Nov 2025 09:56:43 +0100 Subject: [PATCH 05/12] Removed legacy from SteamUtils --- .../java/app/gamenative/utils/SteamUtils.kt | 144 ------------------ 1 file changed, 144 deletions(-) diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index a41ecc0c6..bf21c3fa2 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -5,7 +5,6 @@ import android.content.Context import android.provider.Settings import app.gamenative.PrefManager import app.gamenative.data.DepotInfo -import app.gamenative.data.LibraryItem import app.gamenative.enums.Marker import app.gamenative.service.SteamService import com.winlator.core.WineRegistryEditor @@ -32,149 +31,6 @@ import java.net.URLEncoder import java.util.concurrent.TimeUnit object SteamUtils { - - // 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 } - - // --- Custom media (hero/logo/capsule/header) helpers --- - private fun mediaDirFor(appId: Int): File { - val base = File(SteamService.getAppDirPath(appId)) - val dir = File(base, "media") - if (!dir.exists()) dir.mkdirs() - return dir - } - - 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 getCustomHeaderFile(appId: Int): File = File(mediaDirFor(appId), "custom_header.jpg") - - 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 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 resetCustomHeader(appId: Int) { - runCatching { getCustomHeaderFile(appId).delete() } - notifyMediaChanged() - } - - /** - * Save a custom hero image. The image will be center-cropped to 920x430 and saved as JPEG. - */ - fun saveCustomHero(context: Context, appId: Int, sourceUri: android.net.Uri): Boolean = - try { - val bmp = decodeBitmap(context, sourceUri) ?: return false - val out = centerCropResize(bmp, 920, 430) - 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: android.net.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: android.net.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 header image for list view. Center-crop to 460x215 JPEG. - */ - fun saveCustomHeader(context: Context, appId: Int, sourceUri: android.net.Uri): Boolean = - try { - val bmp = decodeBitmap(context, sourceUri) ?: return false - val out = centerCropResize(bmp, 460, 215) - saveJpeg(out, getCustomHeaderFile(appId)) - notifyMediaChanged() - true - } catch (t: Throwable) { Timber.w(t, "saveCustomHeader failed"); false } - - private fun decodeBitmap(context: Context, uri: android.net.Uri): android.graphics.Bitmap? { - return try { - context.contentResolver.openInputStream(uri).use { ins -> - if (ins == null) null else android.graphics.BitmapFactory.decodeStream(ins) - } - } catch (t: Throwable) { Timber.w(t, "decodeBitmap failed"); null } - } - - private fun centerCropResize(src: android.graphics.Bitmap, targetW: Int, targetH: Int): android.graphics.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 = android.graphics.Bitmap.createScaledBitmap(src, scaledW, scaledH, true) - val x = (scaledW - targetW) / 2 - val y = (scaledH - targetH) / 2 - return android.graphics.Bitmap.createBitmap(scaled, x.coerceAtLeast(0), y.coerceAtLeast(0), targetW.coerceAtMost(scaled.width), targetH.coerceAtMost(scaled.height)) - } - - private fun fitIntoCanvas(src: android.graphics.Bitmap, canvasW: Int, canvasH: Int): android.graphics.Bitmap { - val out = android.graphics.Bitmap.createBitmap(canvasW, canvasH, android.graphics.Bitmap.Config.ARGB_8888) - val canvas = android.graphics.Canvas(out) - canvas.drawColor(android.graphics.Color.TRANSPARENT, android.graphics.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 = android.graphics.RectF(left, top, left + w, top + h) - val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG) - canvas.drawBitmap(src, null, dst, paint) - return out - } - - private fun saveJpeg(bmp: android.graphics.Bitmap, file: File) { - if (!file.parentFile.exists()) file.parentFile.mkdirs() - java.io.FileOutputStream(file).use { fos -> - bmp.compress(android.graphics.Bitmap.CompressFormat.JPEG, 90, fos) - } - } - - private fun savePng(bmp: android.graphics.Bitmap, file: File) { - if (!file.parentFile.exists()) file.parentFile.mkdirs() - java.io.FileOutputStream(file).use { fos -> - bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, fos) - } - } - - fun getCustomHeroUri(appId: Int): android.net.Uri? = getCustomHeroFile(appId).takeIf { it.exists() }?.let { android.net.Uri.fromFile(it) } - fun getCustomLogoUri(appId: Int): android.net.Uri? = getCustomLogoFile(appId).takeIf { it.exists() }?.let { android.net.Uri.fromFile(it) } - fun getCustomCapsuleUri(appId: Int): android.net.Uri? = getCustomCapsuleFile(appId).takeIf { it.exists() }?.let { android.net.Uri.fromFile(it) } - fun getCustomHeaderUri(appId: Int): android.net.Uri? = getCustomHeaderFile(appId).takeIf { it.exists() }?.let { android.net.Uri.fromFile(it) } - internal val http = OkHttpClient.Builder() .readTimeout(5, TimeUnit.MINUTES) // from 2 min → 5 min .protocols(listOf(Protocol.HTTP_1_1)) // skip HTTP/2 stream stalls From bbd78973c1c8bfa2e77a126e67afd4b0394f6387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Bj=C3=B8rnmose=20Bundgaard?= Date: Tue, 4 Nov 2025 09:59:21 +0100 Subject: [PATCH 06/12] Removed legacy from SteamUtils --- app/src/main/java/app/gamenative/utils/SteamUtils.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index bf21c3fa2..49c489dbe 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -5,6 +5,7 @@ import android.content.Context import android.provider.Settings import app.gamenative.PrefManager import app.gamenative.data.DepotInfo +import app.gamenative.data.LibraryItem import app.gamenative.enums.Marker import app.gamenative.service.SteamService import com.winlator.core.WineRegistryEditor From 7bf3a40db928449697593f81502cfa18923f4439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Bj=C3=B8rnmose=20Bundgaard?= Date: Tue, 4 Nov 2025 09:59:49 +0100 Subject: [PATCH 07/12] Removed legacy from SteamUtils --- app/src/main/java/app/gamenative/utils/SteamUtils.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 49c489dbe..42e8ea6f5 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -32,6 +32,7 @@ import java.net.URLEncoder import java.util.concurrent.TimeUnit object SteamUtils { + internal val http = OkHttpClient.Builder() .readTimeout(5, TimeUnit.MINUTES) // from 2 min → 5 min .protocols(listOf(Protocol.HTTP_1_1)) // skip HTTP/2 stream stalls From 63e3226b18e5be056fd0964bce63c9c65ec6c8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Bj=C3=B8rnmose=20Bundgaard?= Date: Tue, 4 Nov 2025 10:00:32 +0100 Subject: [PATCH 08/12] Reset SteamUtils --- app/src/main/java/app/gamenative/utils/SteamUtils.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 42e8ea6f5..6c251865b 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -32,7 +32,7 @@ import java.net.URLEncoder import java.util.concurrent.TimeUnit object SteamUtils { - + internal val http = OkHttpClient.Builder() .readTimeout(5, TimeUnit.MINUTES) // from 2 min → 5 min .protocols(listOf(Protocol.HTTP_1_1)) // skip HTTP/2 stream stalls From 14bb8891ca445178caf0c6c7df260a95ef911032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Bj=C3=B8rnmose=20Bundgaard?= Date: Tue, 4 Nov 2025 10:11:31 +0100 Subject: [PATCH 09/12] Cleaned MediaUtils.kt --- .../java/app/gamenative/utils/MediaUtils.kt | 46 +------------------ 1 file changed, 1 insertion(+), 45 deletions(-) diff --git a/app/src/main/java/app/gamenative/utils/MediaUtils.kt b/app/src/main/java/app/gamenative/utils/MediaUtils.kt index eac37cf1b..e7a023bba 100644 --- a/app/src/main/java/app/gamenative/utils/MediaUtils.kt +++ b/app/src/main/java/app/gamenative/utils/MediaUtils.kt @@ -9,7 +9,6 @@ import android.graphics.Paint import android.graphics.PorterDuff import android.graphics.RectF import android.net.Uri -import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -23,11 +22,9 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import app.gamenative.R -import app.gamenative.ui.theme.PluviaTheme import app.gamenative.service.SteamService import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil.CoilImage @@ -37,8 +34,6 @@ import timber.log.Timber /** * Media/image utilities shared across the app. - * - * Coding style aligns with other files under app.gamenative.utils (top-level helpers + object for stateful ops). */ object MediaUtils { // Observable media version to trigger UI refresh when custom images change @@ -46,7 +41,6 @@ object MediaUtils { val mediaVersionFlow: kotlinx.coroutines.flow.StateFlow = _mediaVersion fun notifyMediaChanged() { _mediaVersion.value = _mediaVersion.value + 1 } - // --- Custom media (hero/logo/capsule/header) helpers --- private fun mediaDirFor(appId: Int): File { val base = File(SteamService.getAppDirPath(appId)) val dir = File(base, "media") @@ -231,7 +225,7 @@ fun bustCache(model: Any?, version: Int): Any? { Uri.parse(s + sep + "v=" + version) } else model } - else -> model // For File or other models we leave as-is. + else -> model // Leave as-is for File or other models } } @@ -284,41 +278,3 @@ internal fun SteamIconImage( previewPlaceholder = painterResource(R.drawable.ic_logo_color), ) } - -@Composable -fun EmoticonImage( - size: Dp = 54.dp, - image: () -> Any?, -) { - CoilImage( - modifier = Modifier.size(size), - imageModel = image, - loading = { CircularProgressIndicator() }, - failure = { Icon(Icons.Filled.QuestionMark, null) }, - previewPlaceholder = painterResource(R.drawable.ic_logo_color), - ) -} - -@Composable -fun StickerImage( - size: Dp = 150.dp, - image: () -> Any?, -) { - EmoticonImage(size, image) -} - -@Preview -@Composable -private fun Preview_EmoticonImage() { - PluviaTheme { - EmoticonImage { "https://steamcommunity-a.akamaihd.net/economy/emoticonlarge/roar" } - } -} - -@Preview -@Composable -private fun Preview_StickerImage() { - PluviaTheme { - StickerImage { "https://steamcommunity-a.akamaihd.net/economy/sticker/Delivery%20Cat%20in%20a%20Blanket" } - } -} From c38db647c33e740bfe5884e090d52c8cd63004d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Bj=C3=B8rnmose=20Bundgaard?= Date: Tue, 4 Nov 2025 14:03:20 +0100 Subject: [PATCH 10/12] This is now working as intended --- .../main/java/app/gamenative/service/SteamService.kt | 8 +++++++- app/src/main/java/app/gamenative/utils/MediaUtils.kt | 11 ++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index dddf6b854..2f25690fb 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -719,7 +719,13 @@ class SteamService : Service(), IChallengeUrlChanged { val appDirPath = getAppDirPath(appId) - return File(appDirPath).deleteRecursively() + val removed = File(appDirPath).deleteRecursively() + // Also remove any custom media for this game stored under app data + try { + app.gamenative.utils.MediaUtils.deleteAllMediaFor(appId) + } catch (_: Throwable) { } + + return removed } fun downloadApp(appId: Int): DownloadInfo? { diff --git a/app/src/main/java/app/gamenative/utils/MediaUtils.kt b/app/src/main/java/app/gamenative/utils/MediaUtils.kt index e7a023bba..35991d560 100644 --- a/app/src/main/java/app/gamenative/utils/MediaUtils.kt +++ b/app/src/main/java/app/gamenative/utils/MediaUtils.kt @@ -25,7 +25,6 @@ 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.service.SteamService import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil.CoilImage import java.io.File @@ -42,8 +41,8 @@ object MediaUtils { fun notifyMediaChanged() { _mediaVersion.value = _mediaVersion.value + 1 } private fun mediaDirFor(appId: Int): File { - val base = File(SteamService.getAppDirPath(appId)) - val dir = File(base, "media") + val root = File(app.gamenative.service.DownloadService.baseDataDirPath, "media") + val dir = File(root, appId.toString()) if (!dir.exists()) dir.mkdirs() return dir } @@ -201,6 +200,12 @@ object MediaUtils { fun getCustomCapsuleUri(appId: Int): Uri? = getCustomCapsuleFile(appId).takeIf { it.exists() }?.let { Uri.fromFile(it) } fun getCustomHeaderUri(appId: Int): Uri? = getCustomHeaderFile(appId).takeIf { it.exists() }?.let { Uri.fromFile(it) } fun getCustomIconUri(appId: Int): Uri? = getCustomIconFile(appId).takeIf { it.exists() }?.let { Uri.fromFile(it) } + + // Remove all custom media for a specific appId from app storage + fun deleteAllMediaFor(appId: Int) { + runCatching { mediaDirFor(appId).deleteRecursively() } + notifyMediaChanged() + } } /** From 06a96459bccaf8fbe38b46c6b507c657e1efeab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Bj=C3=B8rnmose=20Bundgaard?= Date: Tue, 4 Nov 2025 22:22:52 +0100 Subject: [PATCH 11/12] Moved strings to strings.xml --- .../component/dialog/ContainerConfigDialog.kt | 80 +++++++++---------- app/src/main/res/values/strings.xml | 22 +++++ 2 files changed, 62 insertions(+), 40 deletions(-) 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 fa4084785..ccaababe8 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 @@ -1719,7 +1719,7 @@ fun ContainerConfigDialog( // LOGO --------------------------------------------- Text( - text = "Logo", + text = stringResource(R.string.media_logo_title), color = Color.White, style = MaterialTheme.typography.titleMedium, modifier = Modifier @@ -1748,8 +1748,8 @@ fun ContainerConfigDialog( } else { SettingsCenteredLabel( colors = settingsTileColors(), - title = { Text(text = "No logo available") }, - subtitle = { Text(text = "Open from a specific game to preview and change its media.") }, + title = { Text(text = stringResource(R.string.media_no_logo)) }, + subtitle = { Text(text = stringResource(R.string.media_open_specific)) }, ) } } @@ -1757,7 +1757,7 @@ fun ContainerConfigDialog( // Recommended hint (below image) Text( - text = "Recommended: up to 600×200 PNG with transparency. Will be scaled to fit.", + text = stringResource(R.string.media_logo_hint), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) @@ -1772,21 +1772,21 @@ fun ContainerConfigDialog( ) { uri -> if (uri != null) { val ok = app.gamenative.utils.MediaUtils.saveCustomLogo(context, gameId, uri) - Toast.makeText(context, if (ok) "Logo updated" else "Failed to update logo", Toast.LENGTH_SHORT).show() + Toast.makeText(context, if (ok) context.getString(R.string.media_updated, context.getString(R.string.media_logo_title)) else context.getString(R.string.media_update_failed, context.getString(R.string.media_logo_title).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 = { pickLogo.launch("image/*") }) { Text("Choose image") } + androidx.compose.material3.Button(onClick = { pickLogo.launch("image/*") }) { Text(stringResource(R.string.media_choose_image)) } if (isCustom) { androidx.compose.material3.OutlinedButton( onClick = { app.gamenative.utils.MediaUtils.resetCustomLogo(gameId) - Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() + Toast.makeText(context, context.getString(R.string.media_reverted), Toast.LENGTH_SHORT).show() }, - ) { Text("Reset to default") } + ) { Text(stringResource(R.string.media_reset_to_default)) } } } } @@ -1798,7 +1798,7 @@ fun ContainerConfigDialog( // ICON --------------------------------------------- Text( - text = "Icon (List view)", + text = stringResource(R.string.media_icon_title), color = Color.White, style = MaterialTheme.typography.titleMedium, modifier = Modifier @@ -1828,8 +1828,8 @@ fun ContainerConfigDialog( } else { SettingsCenteredLabel( colors = settingsTileColors(), - title = { Text(text = "No icon available") }, - subtitle = { Text(text = "Open from a specific game to preview and change its media.") }, + title = { Text(text = stringResource(R.string.media_no_icon)) }, + subtitle = { Text(text = stringResource(R.string.media_open_specific)) }, ) } } @@ -1837,7 +1837,7 @@ fun ContainerConfigDialog( // Recommended hint (below image) Text( - text = "Recommended: Square PNG with transparency. Will be center-cropped to fit.", + text = stringResource(R.string.media_icon_hint), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) @@ -1852,21 +1852,21 @@ fun ContainerConfigDialog( ) { uri -> if (uri != null) { val ok = app.gamenative.utils.MediaUtils.saveCustomIcon(context, gameId, uri) - Toast.makeText(context, if (ok) "Icon updated" else "Failed to update icon", Toast.LENGTH_SHORT).show() + Toast.makeText(context, if (ok) context.getString(R.string.media_updated, context.getString(R.string.media_icon_title)) else context.getString(R.string.media_update_failed, context.getString(R.string.media_icon_title).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 = { pickIcon.launch("image/*") }) { Text("Choose image") } + androidx.compose.material3.Button(onClick = { pickIcon.launch("image/*") }) { Text(stringResource(R.string.media_choose_image)) } if (isCustom) { androidx.compose.material3.OutlinedButton( onClick = { app.gamenative.utils.MediaUtils.resetCustomIcon(gameId) - Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() + Toast.makeText(context, context.getString(R.string.media_reverted), Toast.LENGTH_SHORT).show() }, - ) { Text("Reset to default") } + ) { Text(stringResource(R.string.media_reset_to_default)) } } } } @@ -1878,7 +1878,7 @@ fun ContainerConfigDialog( // HERO --------------------------------------------- Text( - text = "Hero Image", + text = stringResource(R.string.media_hero_title), color = Color.White, style = MaterialTheme.typography.titleMedium, modifier = Modifier @@ -1911,8 +1911,8 @@ fun ContainerConfigDialog( } else { SettingsCenteredLabel( colors = settingsTileColors(), - title = { Text(text = "No hero image available") }, - subtitle = { Text(text = "Open from a specific game to preview and change its media.") }, + title = { Text(text = stringResource(R.string.media_no_hero)) }, + subtitle = { Text(text = stringResource(R.string.media_open_specific)) }, ) } } @@ -1920,7 +1920,7 @@ fun ContainerConfigDialog( // Recommended hint (below image) Text( - text = "Recommended: 920×430 JPG/PNG. Will be center-cropped to fit.", + text = stringResource(R.string.media_hero_hint), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) @@ -1935,21 +1935,21 @@ fun ContainerConfigDialog( ) { uri -> if (uri != null) { val ok = app.gamenative.utils.MediaUtils.saveCustomHero(context, gameId, uri) - Toast.makeText(context, if (ok) "Hero image updated" else "Failed to update hero", Toast.LENGTH_SHORT).show() + Toast.makeText(context, if (ok) context.getString(R.string.media_updated, context.getString(R.string.media_hero_title)) else context.getString(R.string.media_update_failed, context.getString(R.string.media_hero_title).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 = { pickHero.launch("image/*") }) { Text("Choose image") } + androidx.compose.material3.Button(onClick = { pickHero.launch("image/*") }) { Text(stringResource(R.string.media_choose_image)) } if (isCustom) { androidx.compose.material3.OutlinedButton( onClick = { app.gamenative.utils.MediaUtils.resetCustomHero(gameId) - Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() + Toast.makeText(context, context.getString(R.string.media_reverted), Toast.LENGTH_SHORT).show() }, - ) { Text("Reset to default") } + ) { Text(stringResource(R.string.media_reset_to_default)) } } } } @@ -1961,7 +1961,7 @@ fun ContainerConfigDialog( // CAPSULE --------------------------------------------- Text( - text = "Capsule (Grid view)", + text = stringResource(R.string.media_capsule_title), color = Color.White, style = MaterialTheme.typography.titleMedium, modifier = Modifier @@ -2000,8 +2000,8 @@ fun ContainerConfigDialog( } else { SettingsCenteredLabel( colors = settingsTileColors(), - title = { Text(text = "No capsule image available") }, - subtitle = { Text(text = "Open from a specific game to preview and change its media.") }, + title = { Text(text = stringResource(R.string.media_no_capsule)) }, + subtitle = { Text(text = stringResource(R.string.media_open_specific)) }, ) } } @@ -2009,7 +2009,7 @@ fun ContainerConfigDialog( // Recommended hint (below image) Text( - text = "Recommended: 600×900 JPG/PNG. Will be center-cropped to fit.", + text = stringResource(R.string.media_capsule_hint), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) @@ -2023,21 +2023,21 @@ fun ContainerConfigDialog( ) { uri -> if (uri != null) { val ok = app.gamenative.utils.MediaUtils.saveCustomCapsule(context, gameId, uri) - Toast.makeText(context, if (ok) "Capsule image updated" else "Failed to update capsule", Toast.LENGTH_SHORT).show() + Toast.makeText(context, if (ok) context.getString(R.string.media_updated, context.getString(R.string.media_capsule_title)) else context.getString(R.string.media_update_failed, context.getString(R.string.media_capsule_title).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 = { pickCapsule.launch("image/*") }) { Text("Choose image") } + androidx.compose.material3.Button(onClick = { pickCapsule.launch("image/*") }) { Text(stringResource(R.string.media_choose_image)) } if (isCustom) { androidx.compose.material3.OutlinedButton( onClick = { app.gamenative.utils.MediaUtils.resetCustomCapsule(gameId) - Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() + Toast.makeText(context, context.getString(R.string.media_reverted), Toast.LENGTH_SHORT).show() }, - ) { Text("Reset to default") } + ) { Text(stringResource(R.string.media_reset_to_default)) } } } } @@ -2049,7 +2049,7 @@ fun ContainerConfigDialog( // HEADER --------------------------------------------- Text( - text = "Header (List view)", + text = stringResource(R.string.media_header_title), color = Color.White, style = MaterialTheme.typography.titleMedium, modifier = Modifier @@ -2088,8 +2088,8 @@ fun ContainerConfigDialog( } else { SettingsCenteredLabel( colors = settingsTileColors(), - title = { Text(text = "No header image available") }, - subtitle = { Text(text = "Open from a specific game to preview and change its media.") }, + title = { Text(text = stringResource(R.string.media_no_header)) }, + subtitle = { Text(text = stringResource(R.string.media_open_specific)) }, ) } } @@ -2097,7 +2097,7 @@ fun ContainerConfigDialog( // Recommended hint (below image) Text( - text = "Recommended: 460×215 JPG/PNG. Will be center-cropped to fit.", + text = stringResource(R.string.media_header_hint), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) @@ -2111,21 +2111,21 @@ fun ContainerConfigDialog( ) { uri -> if (uri != null) { val ok = app.gamenative.utils.MediaUtils.saveCustomHeader(context, gameId, uri) - Toast.makeText(context, if (ok) "Header image updated" else "Failed to update header", Toast.LENGTH_SHORT).show() + Toast.makeText(context, if (ok) context.getString(R.string.media_updated, context.getString(R.string.media_header_title)) else context.getString(R.string.media_update_failed, context.getString(R.string.media_header_title).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 = { pickHeader.launch("image/*") }) { Text("Choose image") } + androidx.compose.material3.Button(onClick = { pickHeader.launch("image/*") }) { Text(stringResource(R.string.media_choose_image)) } if (isCustom) { androidx.compose.material3.OutlinedButton( onClick = { app.gamenative.utils.MediaUtils.resetCustomHeader(gameId) - Toast.makeText(context, "Reverted to Steam default", Toast.LENGTH_SHORT).show() + Toast.makeText(context, context.getString(R.string.media_reverted), Toast.LENGTH_SHORT).show() }, - ) { Text("Reset to default") } + ) { Text(stringResource(R.string.media_reset_to_default)) } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 25e7a1d71..aee7f49db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -121,4 +121,26 @@ Resume Pause + + 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. + Hero 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. + Header (List view) + Recommended: 460×215 JPG/PNG. Will be center-cropped to fit. + No logo available + No icon available + No hero image available + No capsule image available + No header image available + Open from a specific game to preview and change its media. + Choose image + Reset to default + %s updated + Failed to update %s + Reverted to Steam default From b3725fa512241d362c1c3d6b69b062a0d22416e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Bj=C3=B8rnmose=20Bundgaard?= Date: Tue, 4 Nov 2025 22:34:50 +0100 Subject: [PATCH 12/12] Made media section reusable --- .../component/dialog/ContainerConfigDialog.kt | 545 ++++++------------ 1 file changed, 182 insertions(+), 363 deletions(-) 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 ccaababe8..8653c02b4 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 @@ -134,6 +134,103 @@ 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 + ) { + if (currentModel != null && (currentModel as? String)?.isNotBlank() != false) { + 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_open_specific)) }, + ) + } + } + } + + 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_reset_to_default)) + } + } + } + } + + Spacer(modifier = Modifier.padding(8.dp)) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ContainerConfigDialog( @@ -1718,15 +1815,6 @@ fun ContainerConfigDialog( // LOGO --------------------------------------------- - Text( - text = stringResource(R.string.media_logo_title), - color = Color.White, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - ) - val currentLogoModel: Any? = remember(mediaVersion, gameId) { val gid = gameId if (gid != null) { @@ -1734,78 +1822,22 @@ fun ContainerConfigDialog( custom ?: mediaLogoUrl } else mediaLogoUrl } - - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - if (currentLogoModel != null && (currentLogoModel as? String)?.isNotBlank() != false) { - CoilImage( - modifier = Modifier - .widthIn(min = 150.dp, max = 300.dp), - imageModel = { app.gamenative.utils.bustCache(currentLogoModel, mediaVersion) }, - imageOptions = ImageOptions(contentScale = ContentScale.Fit), - previewPlaceholder = painterResource(app.gamenative.R.drawable.testliblogo), - ) - } else { - SettingsCenteredLabel( - colors = settingsTileColors(), - title = { Text(text = stringResource(R.string.media_no_logo)) }, - subtitle = { Text(text = stringResource(R.string.media_open_specific)) }, - ) - } - } - } - - // Recommended hint (below image) - Text( - text = stringResource(R.string.media_logo_hint), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + 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), + imageContentScale = ContentScale.Fit, + hasCustomMedia = app.gamenative.utils.MediaUtils::hasCustomLogo, + onPickMedia = { ctx, gid, uri -> app.gamenative.utils.MediaUtils.saveCustomLogo(ctx, gid, uri) }, + onResetMedia = app.gamenative.utils.MediaUtils::resetCustomLogo, ) - // Pick/Reset actions for Logo - if (gameId != null) { - val context = LocalContext.current - val isCustom = remember(mediaVersion, gameId) { app.gamenative.utils.MediaUtils.hasCustomLogo(gameId) } - val pickLogo = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() - ) { uri -> - if (uri != null) { - val ok = app.gamenative.utils.MediaUtils.saveCustomLogo(context, gameId, uri) - Toast.makeText(context, if (ok) context.getString(R.string.media_updated, context.getString(R.string.media_logo_title)) else context.getString(R.string.media_update_failed, context.getString(R.string.media_logo_title).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 = { pickLogo.launch("image/*") }) { Text(stringResource(R.string.media_choose_image)) } - if (isCustom) { - androidx.compose.material3.OutlinedButton( - onClick = { - app.gamenative.utils.MediaUtils.resetCustomLogo(gameId) - Toast.makeText(context, context.getString(R.string.media_reverted), Toast.LENGTH_SHORT).show() - }, - ) { Text(stringResource(R.string.media_reset_to_default)) } - } - } - } - - Spacer(modifier = Modifier.padding(8.dp)) - - // Separator - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - // ICON --------------------------------------------- - Text( - text = stringResource(R.string.media_icon_title), - color = Color.White, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - ) - val currentIconModel: Any? = remember(mediaVersion, gameId) { val gid = gameId if (gid != null) { @@ -1813,79 +1845,24 @@ fun ContainerConfigDialog( custom ?: mediaIconUrl } else mediaIconUrl } - - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - if (currentIconModel != null && (currentIconModel as? String)?.isNotBlank() != false) { - CoilImage( - modifier = Modifier - .size(56.dp) - .clip(RoundedCornerShape(10.dp)), - imageModel = { app.gamenative.utils.bustCache(currentIconModel, mediaVersion) }, - imageOptions = ImageOptions(contentScale = ContentScale.Fit), - previewPlaceholder = painterResource(app.gamenative.R.drawable.ic_logo_color), - ) - } else { - SettingsCenteredLabel( - colors = settingsTileColors(), - title = { Text(text = stringResource(R.string.media_no_icon)) }, - subtitle = { Text(text = stringResource(R.string.media_open_specific)) }, - ) - } - } - } - - // Recommended hint (below image) - Text( - text = stringResource(R.string.media_icon_hint), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + 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.MediaUtils::hasCustomIcon, + onPickMedia = { ctx, gid, uri -> app.gamenative.utils.MediaUtils.saveCustomIcon(ctx, gid, uri) }, + onResetMedia = app.gamenative.utils.MediaUtils::resetCustomIcon, ) - // Pick/Reset actions for Icon - if (gameId != null) { - val context = LocalContext.current - val isCustom = remember(mediaVersion, gameId) { app.gamenative.utils.MediaUtils.hasCustomIcon(gameId) } - val pickIcon = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() - ) { uri -> - if (uri != null) { - val ok = app.gamenative.utils.MediaUtils.saveCustomIcon(context, gameId, uri) - Toast.makeText(context, if (ok) context.getString(R.string.media_updated, context.getString(R.string.media_icon_title)) else context.getString(R.string.media_update_failed, context.getString(R.string.media_icon_title).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 = { pickIcon.launch("image/*") }) { Text(stringResource(R.string.media_choose_image)) } - if (isCustom) { - androidx.compose.material3.OutlinedButton( - onClick = { - app.gamenative.utils.MediaUtils.resetCustomIcon(gameId) - Toast.makeText(context, context.getString(R.string.media_reverted), Toast.LENGTH_SHORT).show() - }, - ) { Text(stringResource(R.string.media_reset_to_default)) } - } - } - } - - Spacer(modifier = Modifier.padding(8.dp)) - - // Separator - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - // HERO --------------------------------------------- - Text( - text = stringResource(R.string.media_hero_title), - color = Color.White, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - ) - val currentHeroModel: Any? = remember(mediaVersion, gameId) { val gid = gameId if (gid != null) { @@ -1893,82 +1870,24 @@ fun ContainerConfigDialog( custom ?: mediaHeroUrl } else mediaHeroUrl } - - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - androidx.compose.foundation.layout.Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - if (currentHeroModel != null && (currentHeroModel as? String)?.isNotBlank() != false) { - CoilImage( - modifier = Modifier - .widthIn(min = 200.dp, max = 400.dp) - .height(250.dp), - imageModel = { app.gamenative.utils.bustCache(currentHeroModel, mediaVersion) }, - imageOptions = ImageOptions(contentScale = ContentScale.Crop), - previewPlaceholder = painterResource(app.gamenative.R.drawable.testhero), - ) - } else { - SettingsCenteredLabel( - colors = settingsTileColors(), - title = { Text(text = stringResource(R.string.media_no_hero)) }, - subtitle = { Text(text = stringResource(R.string.media_open_specific)) }, - ) - } - } - } - - // Recommended hint (below image) - Text( - text = stringResource(R.string.media_hero_hint), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + MediaSection( + titleRes = R.string.media_hero_title, + descriptionRes = R.string.media_hero_hint, + noMediaTitleRes = R.string.media_no_hero, + gameId = gameId, + mediaVersion = mediaVersion, + currentModel = currentHeroModel, + 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.MediaUtils::hasCustomHero, + onPickMedia = { ctx, gid, uri -> app.gamenative.utils.MediaUtils.saveCustomHero(ctx, gid, uri) }, + onResetMedia = app.gamenative.utils.MediaUtils::resetCustomHero, ) - // Pick/Reset actions for Hero - if (gameId != null) { - val context = LocalContext.current - val isCustom = remember(mediaVersion, gameId) { app.gamenative.utils.MediaUtils.hasCustomHero(gameId) } - val pickHero = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() - ) { uri -> - if (uri != null) { - val ok = app.gamenative.utils.MediaUtils.saveCustomHero(context, gameId, uri) - Toast.makeText(context, if (ok) context.getString(R.string.media_updated, context.getString(R.string.media_hero_title)) else context.getString(R.string.media_update_failed, context.getString(R.string.media_hero_title).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 = { pickHero.launch("image/*") }) { Text(stringResource(R.string.media_choose_image)) } - if (isCustom) { - androidx.compose.material3.OutlinedButton( - onClick = { - app.gamenative.utils.MediaUtils.resetCustomHero(gameId) - Toast.makeText(context, context.getString(R.string.media_reverted), Toast.LENGTH_SHORT).show() - }, - ) { Text(stringResource(R.string.media_reset_to_default)) } - } - } - } - - Spacer(modifier = Modifier.padding(8.dp)) - - // Separator - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - // CAPSULE --------------------------------------------- - Text( - text = stringResource(R.string.media_capsule_title), - color = Color.White, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - ) - val currentCapsuleModel: Any? = remember(mediaVersion, gameId) { val gid = gameId if (gid != null) { @@ -1976,87 +1895,30 @@ fun ContainerConfigDialog( custom ?: mediaCapsuleUrl } else mediaCapsuleUrl } - - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - androidx.compose.foundation.layout.Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - if (currentCapsuleModel != null && (currentCapsuleModel as? String)?.isNotBlank() != false) { - CoilImage( - modifier = 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) - ), - imageModel = { app.gamenative.utils.bustCache(currentCapsuleModel, mediaVersion) }, - imageOptions = ImageOptions(contentScale = ContentScale.Crop), - previewPlaceholder = painterResource(app.gamenative.R.drawable.testhero), - ) - } else { - SettingsCenteredLabel( - colors = settingsTileColors(), - title = { Text(text = stringResource(R.string.media_no_capsule)) }, - subtitle = { Text(text = stringResource(R.string.media_open_specific)) }, - ) - } - } - } - - // Recommended hint (below image) - Text( - text = stringResource(R.string.media_capsule_hint), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + MediaSection( + titleRes = R.string.media_capsule_title, + descriptionRes = R.string.media_capsule_hint, + noMediaTitleRes = R.string.media_no_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.MediaUtils::hasCustomCapsule, + onPickMedia = { ctx, gid, uri -> app.gamenative.utils.MediaUtils.saveCustomCapsule(ctx, gid, uri) }, + onResetMedia = app.gamenative.utils.MediaUtils::resetCustomCapsule, ) - if (gameId != null) { - val context = LocalContext.current - val isCustom = remember(mediaVersion, gameId) { app.gamenative.utils.MediaUtils.hasCustomCapsule(gameId) } - val pickCapsule = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() - ) { uri -> - if (uri != null) { - val ok = app.gamenative.utils.MediaUtils.saveCustomCapsule(context, gameId, uri) - Toast.makeText(context, if (ok) context.getString(R.string.media_updated, context.getString(R.string.media_capsule_title)) else context.getString(R.string.media_update_failed, context.getString(R.string.media_capsule_title).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 = { pickCapsule.launch("image/*") }) { Text(stringResource(R.string.media_choose_image)) } - if (isCustom) { - androidx.compose.material3.OutlinedButton( - onClick = { - app.gamenative.utils.MediaUtils.resetCustomCapsule(gameId) - Toast.makeText(context, context.getString(R.string.media_reverted), Toast.LENGTH_SHORT).show() - }, - ) { Text(stringResource(R.string.media_reset_to_default)) } - } - } - } - - Spacer(modifier = Modifier.padding(8.dp)) - - // Separator - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - // HEADER --------------------------------------------- - Text( - text = stringResource(R.string.media_header_title), - color = Color.White, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - ) - val currentHeaderModel: Any? = remember(mediaVersion, gameId) { val gid = gameId if (gid != null) { @@ -2064,72 +1926,29 @@ fun ContainerConfigDialog( custom ?: mediaHeaderUrl } else mediaHeaderUrl } - - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - androidx.compose.foundation.layout.Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - if (currentHeaderModel != null && (currentHeaderModel as? String)?.isNotBlank() != false) { - CoilImage( - modifier = 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) - ), - imageModel = { app.gamenative.utils.bustCache(currentHeaderModel, mediaVersion) }, - imageOptions = ImageOptions(contentScale = ContentScale.Crop), - previewPlaceholder = painterResource(app.gamenative.R.drawable.testhero), - ) - } else { - SettingsCenteredLabel( - colors = settingsTileColors(), - title = { Text(text = stringResource(R.string.media_no_header)) }, - subtitle = { Text(text = stringResource(R.string.media_open_specific)) }, - ) - } - } - } - - // Recommended hint (below image) - Text( - text = stringResource(R.string.media_header_hint), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + 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 = 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.MediaUtils::hasCustomHeader, + onPickMedia = { ctx, gid, uri -> app.gamenative.utils.MediaUtils.saveCustomHeader(ctx, gid, uri) }, + onResetMedia = app.gamenative.utils.MediaUtils::resetCustomHeader, ) - if (gameId != null) { - val context = LocalContext.current - val isCustom = remember(mediaVersion, gameId) { app.gamenative.utils.MediaUtils.hasCustomHeader(gameId) } - val pickHeader = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() - ) { uri -> - if (uri != null) { - val ok = app.gamenative.utils.MediaUtils.saveCustomHeader(context, gameId, uri) - Toast.makeText(context, if (ok) context.getString(R.string.media_updated, context.getString(R.string.media_header_title)) else context.getString(R.string.media_update_failed, context.getString(R.string.media_header_title).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 = { pickHeader.launch("image/*") }) { Text(stringResource(R.string.media_choose_image)) } - if (isCustom) { - androidx.compose.material3.OutlinedButton( - onClick = { - app.gamenative.utils.MediaUtils.resetCustomHeader(gameId) - Toast.makeText(context, context.getString(R.string.media_reverted), Toast.LENGTH_SHORT).show() - }, - ) { Text(stringResource(R.string.media_reset_to_default)) } - } - } - } - } if (selectedTab == 9) SettingsGroup() { SettingsListDropdown(