Skip to content

Conversation

@Maaggel
Copy link

@Maaggel Maaggel commented Nov 4, 2025

Implemented “custom media support” for games

Adds full support for user-defined Logo, Icon, Hero Image, Capsule image and Header, allowing players to personalize how each game is visually represented throughout the app, and adding game media if it's missing.


The feature:

  • Allows users to place their own images for:
    • Logo
    • Icon
    • Hero image
    • Capsule image
    • Header
  • Instantly refreshes images across the app when media changes (no restart or cache clear required)
  • Falls back to Steam’s original artwork when no custom media exists

What’s included

  • New centralized MediaUtils.kt utility file:
    • Custom media path resolution
    • Image cache-busting via mediaVersionFlow
  • Updated UI components and dialogs (LibraryAppScreen, GamesListDialog, ProfileDialog, etc.) to use MediaUtils
  • Automatic UI refresh when a custom image is replaced or removed
  • Fully integrated with existing Coil image loading system

How it works

  • When a custom image is detected, the app loads this instead of the official art from Steam
  • If the image changes, a version signal is emitted through mediaVersionFlow, forcing all relevant Coil images to refresh.
  • If the user removes custom media, the app reverts to the default Steam CDN artwork immediately.

Highlights

  • No manual cache clearing or restarts needed
  • Lightweight reactive update system
  • Easy to extend for future media types (backgrounds, covers, etc.)

Screenshots etc.:
screen01
screen02
Watch the video

Summary by CodeRabbit

  • New Features

    • Media customization tab with per-item previews and pick/reset for logo, icon, hero, capsule, and header.
    • Upload, save, resize and reset-to-default workflows for custom media; immediate UI refresh when media changes.
    • Improved image handling and cache-busting to ensure updated images display.
    • New user-facing strings for media actions, hints and status messages.
  • Bug Fixes

    • Automatic cleanup of custom media when an app is removed.

@coderabbitai
Copy link

coderabbitai bot commented Nov 4, 2025

Walkthrough

Adds per-game custom media support: new MediaUtils for saving/resizing/serving media and cache-busting, moves image composables to utils, adds Media UI (new tab, previews, pick/reset flows), wires media into library screens/items, cleans up media on app deletion, updates imports and string resources.

Changes

Cohort / File(s) Summary
Media Management Core
app/src/main/java/app/gamenative/utils/MediaUtils.kt
New singleton providing mediaVersionFlow, per-app media file paths, save/reset/delete helpers, bitmap decode/resize utilities, URI getters, deleteAllMediaFor(), bustCache() cache-busting, and composable image helpers (ListItemImage, SteamIconImage).
Image Utilities Removal
app/src/main/java/app/gamenative/ui/util/Images.kt
Removed file that previously defined ListItemImage, SteamIconImage, EmoticonImage, and StickerImage; functionality relocated into MediaUtils.kt.
Dialog: Media Tab & Signature Change
app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt
Added optional media parameters (mediaHeroUrl, mediaLogoUrl, mediaCapsuleUrl, mediaHeaderUrl, mediaIconUrl, gameId) and a new "Media" tab with per-media preview, pick/reset flows, and mediaVersionFlow-driven previews.
Library Screen Integration
app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt
AppScreen/AppScreenContent now accept media parameters and observe MediaUtils.mediaVersionFlow; hero image prefers custom hero URI via MediaUtils.getCustomHeroUri() and applies bustCache(...) with mediaVersion.
List/Grid Item Refresh
app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt
Observes mediaVersionFlow (collectAsState), prefers custom media URIs with Steam fallbacks, and applies bustCache(model, mediaVersion) for immediate image refresh.
Import Path Updates
app/src/main/java/app/gamenative/ui/component/dialog/GamesListDialog.kt, app/src/main/java/app/gamenative/ui/component/dialog/ProfileDialog.kt, app/src/main/java/app/gamenative/ui/component/topbar/AccountButton.kt, app/src/main/java/app/gamenative/ui/screen/chat/ChatScreen.kt
Updated imports to app.gamenative.utils.ListItemImage / SteamIconImage (moved from ui.util). No logic changes.
App Deletion Cleanup
app/src/main/java/app/gamenative/service/SteamService.kt
deleteApp() now calls MediaUtils.deleteAllMediaFor(appId) after removing app directory (errors ignored) to remove custom media.
String Resources
app/src/main/res/values/strings.xml
Added media-related strings for titles, hints, "no ... available" messages, actions (open specific, choose image, reset), and status messages (updated, update_failed, reverted).

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as Dialog/Screen
    participant Picker as Content Picker
    participant Media as MediaUtils
    participant FS as FileSystem

    User->>UI: Open Media tab / pick media
    UI->>Picker: Launch GetContent
    Picker->>User: User selects image
    Picker->>UI: Return Uri
    UI->>Media: saveCustom[Type](context, appId, uri)
    Media->>FS: decodeBitmap(uri)
    Media->>Media: resize/crop to target dims
    Media->>FS: save JPEG/PNG file
    Media->>Media: notifyMediaChanged() (mediaVersionFlow++)
    Media-->>UI: success
    UI->>Media: collect mediaVersionFlow (collectAsState)
    UI->>UI: bustCache(model, mediaVersion) -> reload preview
    UI->>User: show updated preview
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Attention points:
    • Bitmap decoding/resizing/compression and file-IO error handling in MediaUtils.
    • bustCache() behavior for String/Uri/other models and URL query handling.
    • deleteApp() integration and silent-ignore error behavior.
    • Import migrations to ensure no duplicate/removed composables remain.

Possibly related PRs

  • Game shortcut utkarsh #198 — Overlaps on image helper changes (ListItemImage/SteamIconImage) and their relocation/reimplementation.
  • Controller b button #171 — Modifies SteamService.kt; related to app deletion and app lifecycle changes impacting deleteApp().
  • Dx12 games #131 — Also changes ContainerConfigDialog.kt; touches the same dialog where media parameters/UI were added.

Poem

🐰 I dug a tiny folder, snug and neat,

Saved hero, logo, icon — a media treat.
Cache bumped, previews hop into view,
Files tidy, dialogs brighter too.
Hop on — GameNative's media blooms anew! 🎨✨

Pre-merge checks and finishing touches

✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Custom Game media support' accurately and directly reflects the primary purpose of the PR—implementing user-defined custom media functionality (Logo, Icon, Hero, Capsule, Header) for games. It is concise, clear, and specific enough to convey the main change.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
app/src/main/java/app/gamenative/service/SteamService.kt (1)

722-728: Consider logging media deletion failures and narrowing the catch clause.

The custom media cleanup logic is wrapped in a broad catch that silently suppresses all failures. While this "best effort" approach is reasonable for cleanup operations, consider these refinements:

  1. Log failures to aid debugging orphaned media files
  2. Narrow catch (_: Throwable) to catch (e: Exception) to avoid catching serious errors like OutOfMemoryError
  3. Optionally, delete media before the directory to prevent orphaned media if directory deletion fails

Apply this diff:

 val appDirPath = getAppDirPath(appId)

+// Delete custom media first to avoid orphans if directory deletion fails
+try {
+    app.gamenative.utils.MediaUtils.deleteAllMediaFor(appId)
+} catch (e: Exception) {
+    Timber.w(e, "Failed to delete custom media for app $appId")
+}
+
 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
app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt (2)

143-173: Consider collecting mediaVersionFlow once to avoid redundant subscriptions.

The mediaVersionFlow is collected twice in this composable (lines 144 and 157) within the LIST and GRID branches. Each collectAsState creates a separate flow subscription, which is unnecessary since both branches are part of the same composition scope.

Hoist the collection to the composable scope:

+    // Observe media changes to refresh images immediately when user updates or resets
+    val mediaVersion by app.gamenative.utils.MediaUtils.mediaVersionFlow.collectAsState(initial = 0)
+
     // Game icon
     Box(
         modifier = Modifier
             .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 = { app.gamenative.utils.bustCache(iconModel, mediaVersion) }
             )
         } else {
             val aspectRatio = if (paneType == PaneType.GRID_CAPSULE) { 2/3f } else { 460/215f }
-            // Observe media changes to refresh images immediately when user updates or resets
-            val mediaVersion by app.gamenative.utils.MediaUtils.mediaVersionFlow.collectAsState(initial = 0)
-
-
             val baseModel: Any? = if (paneType == PaneType.GRID_CAPSULE) {

143-173: Prefer importing MediaUtils to reduce verbosity.

The fully qualified name app.gamenative.utils.MediaUtils.mediaVersionFlow is repeated multiple times. Importing MediaUtils would improve readability.

Add the import at the top of the file:

+import app.gamenative.utils.MediaUtils

Then simplify the references:

-    val mediaVersion by app.gamenative.utils.MediaUtils.mediaVersionFlow.collectAsState(initial = 0)
+    val mediaVersion by MediaUtils.mediaVersionFlow.collectAsState(initial = 0)
     val iconModel: Any? = remember(mediaVersion, appInfo.gameId) {
-        app.gamenative.utils.MediaUtils.getCustomIconUri(appInfo.gameId)
+        MediaUtils.getCustomIconUri(appInfo.gameId)
             ?: appInfo.clientIconUrl
     }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1a15652 and c38db64.

📒 Files selected for processing (10)
  • app/src/main/java/app/gamenative/service/SteamService.kt (1 hunks)
  • app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt (6 hunks)
  • app/src/main/java/app/gamenative/ui/component/dialog/GamesListDialog.kt (1 hunks)
  • app/src/main/java/app/gamenative/ui/component/dialog/ProfileDialog.kt (1 hunks)
  • app/src/main/java/app/gamenative/ui/component/topbar/AccountButton.kt (1 hunks)
  • app/src/main/java/app/gamenative/ui/screen/chat/ChatScreen.kt (1 hunks)
  • app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt (3 hunks)
  • app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt (3 hunks)
  • app/src/main/java/app/gamenative/ui/util/Images.kt (0 hunks)
  • app/src/main/java/app/gamenative/utils/MediaUtils.kt (1 hunks)
💤 Files with no reviewable changes (1)
  • app/src/main/java/app/gamenative/ui/util/Images.kt
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-09-19T17:13:01.017Z
Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 159
File: app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt:136-136
Timestamp: 2025-09-19T17:13:01.017Z
Learning: In LibraryAppScreen.kt, the user prefers to use runBlocking to maintain synchronous APIs when underlying methods have been converted to suspend functions, rather than refactoring all calling code to be async. This approach prevents UI breakage and maintains API compatibility. The user confirmed this is acceptable when performance is not a concern.

Applied to files:

  • app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt
📚 Learning: 2025-09-28T13:54:16.048Z
Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 176
File: app/src/main/java/app/gamenative/utils/SteamUtils.kt:651-655
Timestamp: 2025-09-28T13:54:16.048Z
Learning: In the GameNative project, when creating depots.txt files in SteamUtils.ensureSteamSettings, only create the file when SteamService.getInstalledDepotsOf() returns actual data (non-null). Don't create empty files or write empty strings when no data is available.

Applied to files:

  • app/src/main/java/app/gamenative/service/SteamService.kt
📚 Learning: 2025-09-28T13:56:06.888Z
Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 176
File: app/src/main/java/app/gamenative/service/SteamService.kt:179-180
Timestamp: 2025-09-28T13:56:06.888Z
Learning: In the GameNative project, the AppInfo table (with AppInfoDao) tracks local game installation state including which apps are downloaded and which depots were installed. This data should NOT be cleared during logout in clearDatabase() because games remain physically installed on the device and users should see their installed games when logging back in. Only user-specific Steam account data should be cleared on logout.

Applied to files:

  • app/src/main/java/app/gamenative/service/SteamService.kt
📚 Learning: 2025-09-19T17:07:27.941Z
Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 159
File: app/src/main/java/app/gamenative/service/DownloadService.kt:6-6
Timestamp: 2025-09-19T17:07:27.941Z
Learning: In app/src/main/java/app/gamenative/service/DownloadService.kt, the getSizeFromStoreDisplay() function uses runBlocking to call SteamService.getDownloadableDepots() because the latter was converted from synchronous to suspend in this PR. The user confirmed the previous synchronous implementation worked fine, and if performance is acceptable, keeping runBlocking is a valid approach.

Applied to files:

  • app/src/main/java/app/gamenative/service/SteamService.kt
🧬 Code graph analysis (1)
app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt (1)
app/src/main/java/app/gamenative/utils/MediaUtils.kt (1)
  • ListItemImage (238-263)
🔇 Additional comments (9)
app/src/main/java/app/gamenative/ui/component/topbar/AccountButton.kt (1)

23-23: Import migration verified and approved.

The import path change for SteamIconImage from app.gamenative.ui.util.SteamIconImage to app.gamenative.utils.SteamIconImage is correct and properly supported by the codebase. The definition exists in app/src/main/java/app/gamenative/utils/MediaUtils.kt, confirming the refactoring is consistent with the utility reorganization.

app/src/main/java/app/gamenative/ui/component/dialog/GamesListDialog.kt (1)

42-42: LGTM! Clean import path update.

The import path change for ListItemImage from ui.util to utils aligns with the broader package reorganization to centralize image utilities.

app/src/main/java/app/gamenative/ui/component/dialog/ProfileDialog.kt (1)

45-45: LGTM! Clean import refactoring.

The import path update aligns with the PR's reorganization of image utilities from ui.util to utils package. The SteamIconImage usage remains unchanged and consistent.

app/src/main/java/app/gamenative/ui/screen/chat/ChatScreen.kt (1)

79-79: Import migration verified as complete and consistent.

The import path change is correct. All files importing ListItemImage have been updated to use the new app.gamenative.utils location, with no remaining imports from the old ui.util package. The component definition exists at MediaUtils.kt:239 and is properly utilized in GamesListDialog, ChatScreen, and LibraryAppItem.

app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt (2)

63-63: Import path migration aligns with PR objectives.

The change from app.gamenative.ui.util.ListItemImage to app.gamenative.utils.ListItemImage correctly reflects the migration of rendering helpers from ui.util to utils as described in the PR summary.


145-168: Custom media resolution with Steam CDN fallback is well-implemented.

The logic correctly prefers custom media URIs (icon, capsule, header) when available and falls back to Steam-hosted assets. The use of remember with mediaVersion as a key ensures recomputation when media changes, and bustCache forces Coil to reload images.

app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt (3)

953-962: Hero image refresh implementation is correct and consistent.

The hero image loading correctly observes mediaVersionFlow, prefers custom hero URIs from MediaUtils.getCustomHeroUri, falls back to appInfo.getHeroUrl(), and applies bustCache to force immediate refresh when media changes. This pattern is consistent with the implementation in LibraryAppItem.kt.


520-526: Media parameters correctly passed to ContainerConfigDialog.

Passing mediaHeroUrl, mediaLogoUrl, mediaCapsuleUrl, mediaHeaderUrl, mediaIconUrl, and gameId enables the media management features within the container configuration dialog, as described in the PR objectives.


55-55: Import addition supports media version flow observation.

The collectAsState import is correctly added to support observing mediaVersionFlow for reactive image updates.

Comment on lines 1741 to 1747
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),
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing accessibility content descriptions for images.

The CoilImage components in the media previews lack content descriptions, making them inaccessible to screen reader users.

Add content descriptions to the ImageOptions:

 CoilImage(
     modifier = Modifier.widthIn(min = 150.dp, max = 300.dp),
     imageModel = { app.gamenative.utils.bustCache(currentLogoModel, mediaVersion) },
-    imageOptions = ImageOptions(contentScale = ContentScale.Fit),
+    imageOptions = ImageOptions(
+        contentScale = ContentScale.Fit,
+        contentDescription = "Game logo preview"
+    ),
     previewPlaceholder = painterResource(app.gamenative.R.drawable.testliblogo),
 )

Also applies to: 1820-1827, 1903-1910, 1986-1999, 2074-2087

🤖 Prompt for AI Agents
In app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt
around lines 1741-1747 (and similarly update the other occurrences at 1820-1827,
1903-1910, 1986-1999, 2074-2087), the CoilImage calls are missing accessibility
content descriptions; add a contentDescription to the ImageOptions for each
image (or pass a contentDescription prop accepted by CoilImage) — use a
meaningful, localized string resource (e.g., string.resource name like
R.string.logo_preview_description) or use contentDescription = null only for
purely decorative images; update the ImageOptions constructors to include
contentDescription = "..." (or reference to stringResource) so screen readers
can announce the images.

Comment on lines +39 to +41
private val _mediaVersion = kotlinx.coroutines.flow.MutableStateFlow(0)
val mediaVersionFlow: kotlinx.coroutines.flow.StateFlow<Int> = _mediaVersion
fun notifyMediaChanged() { _mediaVersion.value = _mediaVersion.value + 1 }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the media version increment atomic.

notifyMediaChanged does a read–modify–write on _mediaVersion.value. When two coroutines call this at nearly the same time, one increment can be lost, leaving observers stuck on the old version and skipping a needed cache bust. MutableStateFlow.update { it + 1 } performs the increment atomically and avoids this race. Please move to update (or getAndUpdate) so every media change bumps the version reliably. (zenn.dev)

🤖 Prompt for AI Agents
In app/src/main/java/app/gamenative/utils/MediaUtils.kt around lines 39 to 41,
notifyMediaChanged currently performs a non-atomic read–modify–write on
_mediaVersion.value which can lose increments under concurrency; replace that
with an atomic update call on the MutableStateFlow (e.g., _mediaVersion.update {
it + 1 } or use getAndUpdate) so the increment is performed atomically and no
version bumps are lost, keeping mediaVersionFlow observers reliable.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt (1)

1741-1747: Add accessibility content descriptions for screen readers.

The CoilImage for the logo preview lacks a contentDescription, making it inaccessible to screen reader users. This issue also applies to the Icon (lines 1820-1827), Hero (lines 1903-1910), Capsule (lines 1986-1999), and Header (lines 2074-2087) images.

Add contentDescription to each ImageOptions:

 CoilImage(
     modifier = Modifier.widthIn(min = 150.dp, max = 300.dp),
     imageModel = { app.gamenative.utils.bustCache(currentLogoModel, mediaVersion) },
-    imageOptions = ImageOptions(contentScale = ContentScale.Fit),
+    imageOptions = ImageOptions(
+        contentScale = ContentScale.Fit,
+        contentDescription = context.getString(R.string.media_logo_preview_description)
+    ),
     previewPlaceholder = painterResource(app.gamenative.R.drawable.testliblogo),
 )

Apply similar fixes to the other four media sections (Icon, Hero, Capsule, Header) using appropriate string resources for each (e.g., R.string.media_icon_preview_description, R.string.media_hero_preview_description, etc.).

🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt (1)

1720-2131: Consider extracting a reusable composable to reduce ~385 lines of duplication.

The five media sections (Logo, Icon, Hero, Capsule, Header) follow an identical pattern with only minor variations in titles, dimensions, and method names. This duplication makes the code harder to maintain—any bug fix or enhancement requires changes in five places.

Extract a reusable MediaSection composable:

@Composable
private fun MediaSection(
    titleRes: Int,
    hintRes: Int,
    noMediaTitleRes: Int,
    gameId: Int?,
    mediaVersion: Int,
    currentModel: Any?,
    placeholderRes: Int,
    imageModifier: Modifier,
    imageOptions: ImageOptions,
    hasCustomMedia: (Int) -> Boolean,
    saveCustomMedia: (Context, Int, Uri) -> Boolean,
    resetCustomMedia: (Int) -> Unit,
) {
    val context = LocalContext.current
    
    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) {
        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,
                    previewPlaceholder = painterResource(placeholderRes),
                )
            } else {
                SettingsCenteredLabel(
                    colors = settingsTileColors(),
                    title = { Text(text = stringResource(noMediaTitleRes)) },
                    subtitle = { Text(text = stringResource(R.string.media_open_specific)) },
                )
            }
        }
    }
    
    Text(
        text = stringResource(hintRes),
        color = MaterialTheme.colorScheme.onSurfaceVariant,
        style = MaterialTheme.typography.bodySmall,
        modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
    )
    
    if (gameId != null) {
        val isCustom = remember(mediaVersion, gameId) { hasCustomMedia(gameId) }
        val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
            if (uri != null) {
                val ok = saveCustomMedia(context, gameId, uri)
                val mediaName = stringResource(titleRes)
                Toast.makeText(
                    context,
                    if (ok) context.getString(R.string.media_updated, mediaName)
                    else context.getString(R.string.media_update_failed, mediaName.lowercase()),
                    Toast.LENGTH_SHORT
                ).show()
            }
        }
        Row(
            modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally)
        ) {
            Button(onClick = { picker.launch("image/*") }) { Text(stringResource(R.string.media_choose_image)) }
            if (isCustom) {
                OutlinedButton(
                    onClick = {
                        resetCustomMedia(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))
}

Then replace each section with a single call:

MediaSection(
    titleRes = R.string.media_logo_title,
    hintRes = R.string.media_logo_hint,
    noMediaTitleRes = R.string.media_no_logo,
    gameId = gameId,
    mediaVersion = mediaVersion,
    currentModel = currentLogoModel,
    placeholderRes = R.drawable.testliblogo,
    imageModifier = Modifier.widthIn(min = 150.dp, max = 300.dp),
    imageOptions = ImageOptions(contentScale = ContentScale.Fit),
    hasCustomMedia = MediaUtils::hasCustomLogo,
    saveCustomMedia = MediaUtils::saveCustomLogo,
    resetCustomMedia = MediaUtils::resetCustomLogo,
)

This reduces ~385 lines to ~80 lines (5 calls × ~16 lines each) plus one ~60-line helper, improving maintainability significantly.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c38db64 and 06a9645.

📒 Files selected for processing (2)
  • app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt (6 hunks)
  • app/src/main/res/values/strings.xml (1 hunks)
🔇 Additional comments (5)
app/src/main/res/values/strings.xml (2)

124-145: Well-structured media string additions.

The new media-related strings are well-organized with a clear section comment. All titles, hints, action labels, and status messages follow consistent naming conventions and provide helpful context (e.g., specific dimension recommendations in hints). The use of %s placeholders in media_updated and media_update_failed aligns with Android localization best practices for dynamic content.


143-145: Verified: Status message placeholders are correctly formatted in calling code.

The search results confirm that media_updated and media_update_failed are properly used with context.getString() formatting across all five call sites in ContainerConfigDialog.kt. Each usage passes the appropriate media type string (Logo, Icon, Hero, Capsule, Header) as the format argument, ensuring users see messages like "Logo updated" rather than raw %s placeholders.

app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt (3)

146-151: LGTM: Function signature extensions maintain backward compatibility.

The six new nullable parameters with default values ensure existing callers continue to work while enabling the new media management features.


715-715: Media tab placement is logical.

Inserting "Media" before "Advanced" groups related settings appropriately.


1715-1717: Excellent reactive media refresh pattern.

Observing mediaVersionFlow with collectAsState ensures previews update immediately when media changes, providing a smooth user experience without requiring manual cache clearing.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt (1)

171-176: Missing accessibility content description for images.

The CoilImage lacks a contentDescription in ImageOptions, making it inaccessible to screen reader users. This issue was flagged in a previous review and remains unresolved.

Add a contentDescription parameter to ImageOptions:

 CoilImage(
     modifier = imageModifier,
     imageModel = { app.gamenative.utils.bustCache(currentModel, mediaVersion) },
-    imageOptions = ImageOptions(contentScale = imageContentScale),
+    imageOptions = ImageOptions(
+        contentScale = imageContentScale,
+        contentDescription = stringResource(titleRes) + " preview"
+    ),
     previewPlaceholder = painterResource(placeholderRes),
 )

Alternatively, if you prefer localized descriptions, add dedicated string resources (e.g., R.string.media_logo_preview_description) and pass them via a new contentDescriptionRes: Int parameter to MediaSection.

🧹 Nitpick comments (2)
app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt (2)

170-170: Simplify the null/blank check for clarity.

The expression (currentModel as? String)?.isNotBlank() != false is correct but convoluted. It works because null != false evaluates to true, but this is not immediately obvious to readers.

Consider a more explicit check:

-if (currentModel != null && (currentModel as? String)?.isNotBlank() != false) {
+val hasValidModel = currentModel != null && (currentModel as? String)?.isNotBlank() != false
+if (hasValidModel) {

Or even clearer:

-if (currentModel != null && (currentModel as? String)?.isNotBlank() != false) {
+val isValidModel = when (currentModel) {
+    null -> false
+    is String -> currentModel.isNotBlank()
+    else -> true
+}
+if (isValidModel) {

1818-1824: Extract repeated currentModel computation into a helper function.

The pattern for computing currentModel is repeated identically for all five media types (Logo, Icon, Hero, Capsule, Header). This creates unnecessary duplication and makes the code harder to maintain.

Extract a helper function at the top of the Media tab block:

@Composable
fun rememberMediaModel(
    mediaVersion: Int,
    gameId: Int?,
    getCustomUri: (Int) -> android.net.Uri?,
    fallbackUrl: String?
): Any? = remember(mediaVersion, gameId) {
    gameId?.let { getCustomUri(it) ?: fallbackUrl } ?: fallbackUrl
}

Then simplify each media section:

-val currentLogoModel: Any? = remember(mediaVersion, gameId) {
-    val gid = gameId
-    if (gid != null) {
-        val custom = app.gamenative.utils.MediaUtils.getCustomLogoUri(gid)
-        custom ?: mediaLogoUrl
-    } else mediaLogoUrl
-}
+val currentLogoModel = rememberMediaModel(
+    mediaVersion, gameId,
+    app.gamenative.utils.MediaUtils::getCustomLogoUri,
+    mediaLogoUrl
+)

Apply the same pattern to Icon, Hero, Capsule, and Header.

Also applies to: 1841-1847, 1866-1872, 1891-1897, 1922-1928

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 06a9645 and b3725fa.

📒 Files selected for processing (1)
  • app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt (7 hunks)
🔇 Additional comments (2)
app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt (2)

137-232: Good refactor: MediaSection eliminates code duplication.

The MediaSection composable successfully addresses the previous review's concern about ~400 lines of duplicated media UI. This reusable component consolidates the common pattern for all five media types (Logo, Icon, Hero, Capsule, Header) and makes the codebase more maintainable.

The use of string resources for localization and the parameterization of media-specific details (image modifiers, content scales, MediaUtils functions) demonstrate good design.


1812-1952: Media tab integration looks solid.

The Media tab is well-integrated:

  • Correctly observes mediaVersionFlow for reactive updates
  • Falls back gracefully to Steam CDN URLs when custom media is absent
  • Provides appropriate per-media controls (pick/reset) conditioned on gameId
  • Uses media-specific modifiers and content scales for proper presentation

The implementation aligns with the PR objectives to support custom media without requiring app restarts.

@phobos665
Copy link
Contributor

Tested this locally on my own device (Oneplus 12).

Worked well with multiple images tested per category, also it worked with the correct dimensions in each of the listing styles too.

@phobos665
Copy link
Contributor

@utkarshdalal

I'm pretty happy with this, although it'd be good to get your take.

I've tested it on my OP12 device and worked well.

@utkarshdalal
Copy link
Owner

I'll take a look! Looks like it has a conflict, maybe string was added. I can take a look at that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants