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/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt index 2f06c7a90..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 @@ -17,9 +17,19 @@ 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 import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ViewList @@ -31,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 @@ -49,6 +60,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 +68,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.coil.CoilImage import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow @@ -116,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( @@ -125,6 +240,12 @@ fun ContainerConfigDialog( initialConfig: ContainerData = ContainerData(), onDismissRequest: () -> Unit, onSave: (ContainerData) -> Unit, + mediaHeroUrl: String? = null, + mediaLogoUrl: String? = null, + mediaCapsuleUrl: String? = null, + mediaHeaderUrl: String? = null, + mediaIconUrl: String? = null, + gameId: Int? = null, ) { if (visible) { val context = LocalContext.current @@ -688,7 +809,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 +1809,148 @@ 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.MediaUtils.mediaVersionFlow.collectAsState(initial = 0) + + + // LOGO --------------------------------------------- + val currentLogoModel: Any? = remember(mediaVersion, gameId) { + val gid = gameId + if (gid != null) { + val custom = app.gamenative.utils.MediaUtils.getCustomLogoUri(gid) + custom ?: mediaLogoUrl + } else mediaLogoUrl + } + MediaSection( + titleRes = R.string.media_logo_title, + descriptionRes = R.string.media_logo_hint, + noMediaTitleRes = R.string.media_no_logo, + gameId = gameId, + mediaVersion = mediaVersion, + currentModel = currentLogoModel, + placeholderRes = app.gamenative.R.drawable.testliblogo, + imageModifier = Modifier.widthIn(min = 150.dp, max = 300.dp), + 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, + ) + + // ICON --------------------------------------------- + val currentIconModel: Any? = remember(mediaVersion, gameId) { + val gid = gameId + if (gid != null) { + val custom = app.gamenative.utils.MediaUtils.getCustomIconUri(gid) + custom ?: mediaIconUrl + } else mediaIconUrl + } + MediaSection( + titleRes = R.string.media_icon_title, + descriptionRes = R.string.media_icon_hint, + noMediaTitleRes = R.string.media_no_icon, + gameId = gameId, + mediaVersion = mediaVersion, + currentModel = currentIconModel, + placeholderRes = app.gamenative.R.drawable.ic_logo_color, + imageModifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(10.dp)), + imageContentScale = ContentScale.Fit, + hasCustomMedia = app.gamenative.utils.MediaUtils::hasCustomIcon, + onPickMedia = { ctx, gid, uri -> app.gamenative.utils.MediaUtils.saveCustomIcon(ctx, gid, uri) }, + onResetMedia = app.gamenative.utils.MediaUtils::resetCustomIcon, + ) + + // HERO --------------------------------------------- + val currentHeroModel: Any? = remember(mediaVersion, gameId) { + val gid = gameId + if (gid != null) { + val custom = app.gamenative.utils.MediaUtils.getCustomHeroUri(gid) + custom ?: mediaHeroUrl + } else mediaHeroUrl + } + 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, + ) + + // CAPSULE --------------------------------------------- + val currentCapsuleModel: Any? = remember(mediaVersion, gameId) { + val gid = gameId + if (gid != null) { + val custom = app.gamenative.utils.MediaUtils.getCustomCapsuleUri(gid) + custom ?: mediaCapsuleUrl + } else mediaCapsuleUrl + } + 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, + ) + + // HEADER --------------------------------------------- + val currentHeaderModel: Any? = remember(mediaVersion, gameId) { + val gid = gameId + if (gid != null) { + val custom = app.gamenative.utils.MediaUtils.getCustomHeaderUri(gid) + custom ?: mediaHeaderUrl + } else mediaHeaderUrl + } + MediaSection( + titleRes = R.string.media_header_title, + descriptionRes = R.string.media_header_hint, + noMediaTitleRes = R.string.media_no_header, + gameId = gameId, + mediaVersion = mediaVersion, + currentModel = currentHeaderModel, + placeholderRes = app.gamenative.R.drawable.testhero, + imageModifier = Modifier + .widthIn(min = 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 (selectedTab == 9) SettingsGroup() { SettingsListDropdown( colors = settingsTileColors(), title = { Text(text = "Startup Selection") }, 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 49f876ee1..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 @@ -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,12 @@ fun AppScreen( showConfigDialog = false ContainerUtils.applyToContainer(context, appId, it) }, + mediaHeroUrl = appInfo.getHeroUrl(), + mediaLogoUrl = appInfo.getLogoUrl(), + mediaCapsuleUrl = appInfo.getCapsuleUrl(), + mediaHeaderUrl = appInfo.getHeaderImageUrl(), + mediaIconUrl = appInfo.clientIconUrl, + gameId = appInfo.id, ) LoadingDialog( @@ -943,9 +950,16 @@ private fun AppScreenContent( .height(250.dp) ) { // Hero background image + // Observe media change notifications to refresh hero immediately + val mediaVersion by app.gamenative.utils.MediaUtils.mediaVersionFlow.collectAsState(initial = 0) + CoilImage( modifier = Modifier.fillMaxSize(), - imageModel = { appInfo.getHeroUrl() }, + imageModel = { + val custom = app.gamenative.utils.MediaUtils.getCustomHeroUri(appInfo.id) + val base = custom ?: appInfo.getHeroUrl() + app.gamenative.utils.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..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 @@ -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 @@ -59,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( @@ -139,23 +140,37 @@ 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 } - 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.MediaUtils.mediaVersionFlow.collectAsState(initial = 0) + + + val baseModel: Any? = if (paneType == PaneType.GRID_CAPSULE) { + // Prefer custom capsule if present, otherwise Steam capsule + app.gamenative.utils.MediaUtils.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.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 = { imageUrl }, + 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..35991d560 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/MediaUtils.kt @@ -0,0 +1,285 @@ +package app.gamenative.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.RectF +import android.net.Uri +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.gamenative.R +import 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. + */ +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 } + + private fun mediaDirFor(appId: Int): File { + val root = File(app.gamenative.service.DownloadService.baseDataDirPath, "media") + val dir = File(root, appId.toString()) + if (!dir.exists()) dir.mkdirs() + return dir + } + + fun 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 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() } + 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() + } + 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. + */ + 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 } + + /** + * Save a custom icon image for list view. The image will be center-cropped to 512x512 and saved as PNG. + */ + fun saveCustomIcon(context: Context, appId: Int, sourceUri: Uri): Boolean = + try { + val bmp = decodeBitmap(context, sourceUri) ?: return false + val out = centerCropResize(bmp, 512, 512) + savePng(out, getCustomIconFile(appId)) + notifyMediaChanged() + true + } catch (t: Throwable) { Timber.w(t, "saveCustomIcon failed"); false } + + private fun decodeBitmap(context: Context, uri: Uri): Bitmap? { + return try { + context.contentResolver.openInputStream(uri).use { ins -> + if (ins == null) null else BitmapFactory.decodeStream(ins) + } + } catch (t: Throwable) { Timber.w(t, "decodeBitmap failed"); null } + } + + private fun centerCropResize(src: Bitmap, targetW: Int, targetH: Int): Bitmap { + val srcW = src.width + val srcH = src.height + val scale = maxOf(targetW.toFloat() / srcW, targetH.toFloat() / srcH) + val scaledW = (srcW * scale).toInt() + val scaledH = (srcH * scale).toInt() + val scaled = Bitmap.createScaledBitmap(src, scaledW, scaledH, true) + val x = (scaledW - targetW) / 2 + val y = (scaledH - targetH) / 2 + return Bitmap.createBitmap( + scaled, + x.coerceAtLeast(0), + y.coerceAtLeast(0), + targetW.coerceAtMost(scaled.width), + targetH.coerceAtMost(scaled.height) + ) + } + + private fun fitIntoCanvas(src: Bitmap, canvasW: Int, canvasH: Int): Bitmap { + val out = Bitmap.createBitmap(canvasW, canvasH, Bitmap.Config.ARGB_8888) + val canvas = Canvas(out) + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) + val scale = minOf(canvasW.toFloat() / src.width, canvasH.toFloat() / src.height) + val w = (src.width * scale).toInt() + val h = (src.height * scale).toInt() + val left = (canvasW - w) / 2f + val top = (canvasH - h) / 2f + val dst = RectF(left, top, left + w, top + h) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + canvas.drawBitmap(src, null, dst, paint) + return out + } + + private fun saveJpeg(bmp: Bitmap, file: File) { + if (!file.parentFile.exists()) file.parentFile.mkdirs() + FileOutputStream(file).use { fos -> + bmp.compress(Bitmap.CompressFormat.JPEG, 90, fos) + } + } + + private fun savePng(bmp: Bitmap, file: File) { + if (!file.parentFile.exists()) file.parentFile.mkdirs() + FileOutputStream(file).use { fos -> + bmp.compress(Bitmap.CompressFormat.PNG, 100, fos) + } + } + + fun 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) } + 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() + } +} + +/** + * Cache-busting helper: appends a version query to supported models so Coil invalidates its cache. + */ +fun bustCache(model: Any?, version: Int): Any? { + if (model == null) return null + return when (model) { + is String -> { + val s = model + val lower = s.lowercase() + if (lower.startsWith("http") || lower.startsWith("file:") || lower.startsWith("content:")) { + val sep = if (s.contains("?")) "&" else "?" + s + sep + "v=" + version + } else s + } + is Uri -> { + val scheme = model.scheme?.lowercase() + if (scheme == "http" || scheme == "https" || scheme == "file" || scheme == "content") { + val s = model.toString() + val sep = if (s.contains("?")) "&" else "?" + Uri.parse(s + sep + "v=" + version) + } else model + } + else -> model // Leave as-is for File or other models + } +} + +// ---------------------- 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), + ) +} 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