fix(clients): never leave cover tiles blank — shared web <Cover> + Android Coil placeholder/error
test-web / test (push) Successful in 48s
android / Build + lint + test (push) Successful in 4m10s

Cover tiles (worst in the "You might like" home row, which surfaces
unplayed items whose art is often not yet backfilled) sat empty while
loading and stayed blank on a 404. The server returns a fast 404; the
gap was missing client-side loading/fallback states.

Web: new shared Cover.svelte owns the loading placeholder + onerror
fallback (static cover, or Disc3 for artists). AlbumCard, ArtistCard and
CompactTrackCard now reuse it instead of three hand-rolled <img> tags
that disagreed on fallback handling — notably ArtistCard had no onerror.

Android: ServerImage tracks Coil's load state so the per-caller fallback
doubles as a placeholder (loading) and an error state (404 / unreachable),
instead of only guarding the null-URL case. All five call sites pass an
explicit size modifier, so the new Box wrapper is layout-safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 12:29:05 -04:00
parent 97e0e88483
commit 096a3c0b15
5 changed files with 107 additions and 38 deletions
@@ -1,15 +1,25 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import com.fabledsword.minstrel.shared.resolveServerUrl
/**
* Renders a server-hosted image, resolving relative URLs centrally so
* every cover surface loads consistently. Shows [fallback] when the URL
* is blank or unresolvable.
* is blank/unresolvable, while the image is still loading, and when the
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
* which the "You might like" row hits often).
*/
@Composable
fun ServerImage(
@@ -22,12 +32,26 @@ fun ServerImage(
val resolved = resolveServerUrl(url)
if (resolved == null) {
fallback()
} else {
return
}
// Track Coil's load state so the fallback doubles as a placeholder
// (loading) and an error state (404 / unreachable) — not just a
// null-URL guard, which left present-but-failing URLs blank.
var state by remember(resolved) {
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
}
Box(modifier = modifier, contentAlignment = Alignment.Center) {
AsyncImage(
model = resolved,
contentDescription = contentDescription,
modifier = modifier,
modifier = Modifier.fillMaxSize(),
contentScale = contentScale,
onState = { state = it },
)
if (state is AsyncImagePainter.State.Loading ||
state is AsyncImagePainter.State.Error
) {
fallback()
}
}
}