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()
}
}
}
+3 -10
View File
@@ -1,18 +1,14 @@
<script lang="ts">
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
import { FALLBACK_COVER } from '$lib/media/covers';
import { api } from '$lib/api/client';
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
import { Play } from 'lucide-svelte';
import AlbumMenu from './AlbumMenu.svelte';
import CardActionCluster from './CardActionCluster.svelte';
import Cover from './Cover.svelte';
let { album }: { album: AlbumRef } = $props();
function onImgError(e: Event) {
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}
async function onAddClick(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
@@ -38,12 +34,9 @@
shadow-sm transition-all duration-150
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
>
<img
<Cover
src={album.cover_url}
alt=""
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
loading="lazy"
onerror={onImgError}
class="transition-transform group-hover:scale-[1.03]"
/>
<button
type="button"
+6 -11
View File
@@ -2,9 +2,10 @@
import type { ArtistRef, TrackRef } from '$lib/api/types';
import { api } from '$lib/api/client';
import { playQueue, enqueueTracks } from '$lib/player/store.svelte';
import { Disc3, Play } from 'lucide-svelte';
import { Play } from 'lucide-svelte';
import ArtistMenu from './ArtistMenu.svelte';
import CardActionCluster from './CardActionCluster.svelte';
import Cover from './Cover.svelte';
import { listArtistTracks } from '$lib/api/artists';
let { artist }: { artist: ArtistRef } = $props();
@@ -48,18 +49,12 @@
class="block rounded focus-visible:ring-2 focus-visible:ring-accent"
>
<div class="art-wrap relative mx-auto aspect-square w-full overflow-hidden rounded-full bg-surface-hover">
{#if artist.cover_url}
<img
<Cover
src={artist.cover_url}
alt=""
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
loading="lazy"
shape="round"
fallback="artist"
class="transition-transform group-hover:scale-[1.03]"
/>
{:else}
<div class="flex h-full w-full items-center justify-center">
<Disc3 strokeWidth={1} class="h-4/5 w-4/5 text-text-muted" />
</div>
{/if}
<button
type="button"
aria-label={`Play ${artist.name}`}
+3 -12
View File
@@ -2,9 +2,10 @@
import { Plus } from 'lucide-svelte';
import type { TrackRef } from '$lib/api/types';
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
import { coverUrl } from '$lib/media/covers';
import TrackMenu from './TrackMenu.svelte';
import LikeButton from './LikeButton.svelte';
import Cover from './Cover.svelte';
// Horizontal compact track row — cover thumb on the left, title +
// artist on the right. Mirrors Android's CompactTrackTile so the
@@ -23,10 +24,6 @@
const cover = $derived(coverUrl(track.album_id));
function onImgError(e: Event) {
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}
function onClick() {
playQueue(sectionTracks, index);
}
@@ -47,13 +44,7 @@
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
>
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover">
<img
src={cover}
alt=""
class="h-full w-full object-cover"
loading="lazy"
onerror={onImgError}
/>
<Cover src={cover} />
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-text-primary">{track.title}</div>
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts">
import { FALLBACK_COVER } from '$lib/media/covers';
import { Disc3 } from 'lucide-svelte';
// Shared cover-artwork renderer. The web counterpart of Android's
// CoverTile/ServerImage: one place that owns the loading placeholder and
// the error fallback so no card sits blank while art loads or 404s (the
// "You might like" row surfaces unplayed items whose art is often not yet
// backfilled). Replaces the three hand-rolled <img> tags that previously
// disagreed on fallback handling.
let {
src,
alt = '',
shape = 'square',
fallback = 'image',
class: klass = ''
}: {
src: string | undefined;
alt?: string;
// 'round' clips to a circle (artist avatars); 'square' leaves clipping
// to the parent so existing rounded-md / rounded corners still apply.
shape?: 'square' | 'round';
// 'artist' degrades to a Disc3 icon; 'image' to the static placeholder
// cover. A null/empty src is treated the same as a failed load.
fallback?: 'image' | 'artist';
class?: string;
} = $props();
// Per-image lifecycle. Reset when src changes so a reused card (virtualized
// list, client-side nav) never keeps a stale 'loaded'/'failed'.
let loaded = $state(false);
let failed = $state(false);
$effect(() => {
void src;
loaded = false;
failed = false;
});
const useIcon = $derived(fallback === 'artist' && (failed || !src));
// On failure for the image fallback, swap to the static placeholder asset.
// src doesn't change on the second error, so there's no reload loop.
const imgSrc = $derived(failed ? FALLBACK_COVER : src);
</script>
<div
class="relative h-full w-full overflow-hidden bg-surface-hover {shape === 'round'
? 'rounded-full'
: ''}"
>
{#if useIcon}
<div class="flex h-full w-full items-center justify-center">
<Disc3 strokeWidth={1} class="h-4/5 w-4/5 text-text-muted" />
</div>
{:else if imgSrc}
<img
src={imgSrc}
{alt}
class="h-full w-full object-cover transition-opacity duration-200 {loaded
? 'opacity-100'
: 'opacity-0'} {klass}"
loading="lazy"
onload={() => (loaded = true)}
onerror={() => (failed = true)}
/>
{/if}
</div>