From 1b243347cb0b58d54c1c0b5714094ee57b4beb41 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 29 May 2026 12:30:21 -0400 Subject: [PATCH] fix(android): resolve relative server cover URLs via shared ServerImage The server returns relative cover_url (/api/albums/{id}/cover, /api/playlists/{id}/cover). Android only resolved the empty-fallback placeholder trick, so any non-empty relative URL from a fresh fetch went to Coil host-less and silently failed (album detail, artist-detail album grid, playlist cards, artist avatars). Add a shared ServerImage composable + resolveServerImageUrl that prefixes the placeholder host (rewritten by BaseUrlInterceptor) for relative paths, passes http(s) through, and falls back when blank. Route AlbumCard, PlaylistCard, AlbumDetail header, and ArtistAvatar through it. Mirrors Flutter's ServerImage._resolve. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/library/ui/AlbumDetailScreen.kt | 6 +- .../minstrel/library/ui/ArtistDetailScreen.kt | 19 +++---- .../minstrel/library/widgets/AlbumCard.kt | 18 ++---- .../playlists/widgets/PlaylistCard.kt | 16 ++---- .../minstrel/shared/widgets/ServerImage.kt | 56 +++++++++++++++++++ 5 files changed, 78 insertions(+), 37 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ServerImage.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt index dfff7df6..7fcaf2c9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt @@ -45,7 +45,6 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController -import coil3.compose.AsyncImage import com.composables.icons.lucide.ArrowLeft import com.composables.icons.lucide.Disc3 import com.composables.icons.lucide.Lucide @@ -60,6 +59,7 @@ import com.fabledsword.minstrel.shared.widgets.CachedDot import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold +import com.fabledsword.minstrel.shared.widgets.ServerImage import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel @@ -290,8 +290,8 @@ private fun AlbumCover(album: AlbumRef) { modifier = Modifier.size(56.dp), ) } else { - AsyncImage( - model = album.displayCoverUrl, + ServerImage( + url = album.displayCoverUrl, contentDescription = album.title, modifier = Modifier.fillMaxSize(), ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt index 4b14ce38..49ee5b2e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt @@ -36,13 +36,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController -import coil3.compose.AsyncImage import com.composables.icons.lucide.ArrowLeft import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Play @@ -54,6 +52,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold +import com.fabledsword.minstrel.shared.widgets.ServerImage import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile @OptIn(ExperimentalMaterial3Api::class) @@ -206,8 +205,7 @@ private fun ArtistAvatar(artist: ArtistRef, fallbackAlbumId: String? = null) { // _ArtistAvatar). Bare Lucide.User only when neither exists. val model = when { artist.coverUrl.isNotEmpty() -> artist.coverUrl - fallbackAlbumId != null -> - "http://placeholder.invalid/api/albums/$fallbackAlbumId/cover" + fallbackAlbumId != null -> "/api/albums/$fallbackAlbumId/cover" else -> null } Box( @@ -217,20 +215,17 @@ private fun ArtistAvatar(artist: ArtistRef, fallbackAlbumId: String? = null) { .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center, ) { - if (model == null) { + ServerImage( + url = model, + contentDescription = artist.name, + modifier = Modifier.fillMaxSize(), + ) { Icon( imageVector = Lucide.User, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(40.dp), ) - } else { - AsyncImage( - model = model, - contentDescription = artist.name, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt index 0eeffa79..3837dd0f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt @@ -19,15 +19,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.composables.icons.lucide.Disc3 import com.composables.icons.lucide.Lucide import androidx.compose.material3.Icon import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.nav.LocalDetailSeedCache +import com.fabledsword.minstrel.shared.widgets.ServerImage import com.fabledsword.minstrel.theme.FabledSwordFlatTokens /** @@ -69,18 +68,13 @@ fun AlbumCard( modifier = Modifier.size(56.dp), ) } else { - // Use displayCoverUrl so cached-only albums (which - // drop coverUrl in the entity→domain mapper) still - // hit /api/albums/{id}/cover via the BaseUrlInterceptor - // placeholder rewrite. On load failure (album with - // no cover server-side) the Box's surfaceVariant - // background shows through — a Disc3 error-slot - // fallback is a future refinement. - AsyncImage( - model = album.displayCoverUrl, + // displayCoverUrl yields /api/albums/{id}/cover — + // relative on fresh fetches, placeholder-absolute from + // cache. ServerImage resolves both to a loadable URL. + ServerImage( + url = album.displayCoverUrl, contentDescription = album.title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, ) } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt index c46e3938..d44f5c69 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt @@ -19,14 +19,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.composables.icons.lucide.ListMusic import com.composables.icons.lucide.Lucide import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.nav.LocalDetailSeedCache +import com.fabledsword.minstrel.shared.widgets.ServerImage import com.fabledsword.minstrel.theme.FabledSwordFlatTokens /** @@ -61,20 +60,17 @@ fun PlaylistCard( .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)), contentAlignment = Alignment.Center, ) { - if (playlist.coverUrl.isEmpty()) { + ServerImage( + url = playlist.coverUrl, + contentDescription = playlist.name, + modifier = Modifier.fillMaxSize(), + ) { Icon( imageVector = Lucide.ListMusic, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(56.dp), ) - } else { - AsyncImage( - model = playlist.coverUrl, - contentDescription = playlist.name, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) } if (playlist.isSystem) { VariantPill( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ServerImage.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ServerImage.kt new file mode 100644 index 00000000..80c09369 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ServerImage.kt @@ -0,0 +1,56 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage + +/** + * Resolves a server image/cover URL into something Coil can load. + * + * The server returns **relative** cover URLs (e.g. + * `/api/albums/{id}/cover`, `/api/playlists/{id}/cover`). Coil can't + * load a host-less path, so we prefix the `placeholder.invalid` host + * that `BaseUrlInterceptor` rewrites to the live server (carrying the + * session cookie). Absolute `http(s)` URLs — including ones already + * built against the placeholder host — pass through unchanged; blank + * input returns null so the caller renders its own fallback. + * + * This is the single place a server URL becomes loadable, mirroring + * Flutter's `ServerImage._resolve` (which prepends the base URL). + * Cover surfaces must route through [ServerImage] rather than handing + * a raw `coverUrl` to `AsyncImage` — that was the bug where relative + * URLs from fresh fetches silently failed to load. + */ +fun resolveServerImageUrl(raw: String?): String? = when { + raw.isNullOrBlank() -> null + raw.startsWith("http://") || raw.startsWith("https://") -> raw + raw.startsWith("/") -> "http://placeholder.invalid$raw" + else -> "http://placeholder.invalid/$raw" +} + +/** + * Renders a server-hosted image, resolving relative URLs centrally so + * every cover surface loads consistently. Shows [fallback] when the URL + * is blank or unresolvable. + */ +@Composable +fun ServerImage( + url: String?, + contentDescription: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop, + fallback: @Composable () -> Unit = {}, +) { + val resolved = resolveServerImageUrl(url) + if (resolved == null) { + fallback() + } else { + AsyncImage( + model = resolved, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } +}