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) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 12:30:21 -04:00
parent 85af93c63f
commit 1b243347cb
5 changed files with 78 additions and 37 deletions
@@ -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(),
)
@@ -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,
)
}
}
}
@@ -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,
)
}
}
@@ -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(
@@ -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,
)
}
}