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:
@@ -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(),
|
||||
)
|
||||
|
||||
+7
-12
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-10
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user