fix(clients): never leave cover tiles blank — shared web <Cover> + Android Coil placeholder/error
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:
@@ -1,15 +1,25 @@
|
|||||||
package com.fabledsword.minstrel.shared.widgets
|
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.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.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.compose.AsyncImagePainter
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a server-hosted image, resolving relative URLs centrally so
|
* Renders a server-hosted image, resolving relative URLs centrally so
|
||||||
* every cover surface loads consistently. Shows [fallback] when the URL
|
* 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
|
@Composable
|
||||||
fun ServerImage(
|
fun ServerImage(
|
||||||
@@ -22,12 +32,26 @@ fun ServerImage(
|
|||||||
val resolved = resolveServerUrl(url)
|
val resolved = resolveServerUrl(url)
|
||||||
if (resolved == null) {
|
if (resolved == null) {
|
||||||
fallback()
|
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(
|
AsyncImage(
|
||||||
model = resolved,
|
model = resolved,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
|
onState = { state = it },
|
||||||
)
|
)
|
||||||
|
if (state is AsyncImagePainter.State.Loading ||
|
||||||
|
state is AsyncImagePainter.State.Error
|
||||||
|
) {
|
||||||
|
fallback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
|
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
|
||||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
|
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
|
||||||
import { Play } from 'lucide-svelte';
|
import { Play } from 'lucide-svelte';
|
||||||
import AlbumMenu from './AlbumMenu.svelte';
|
import AlbumMenu from './AlbumMenu.svelte';
|
||||||
import CardActionCluster from './CardActionCluster.svelte';
|
import CardActionCluster from './CardActionCluster.svelte';
|
||||||
|
import Cover from './Cover.svelte';
|
||||||
|
|
||||||
let { album }: { album: AlbumRef } = $props();
|
let { album }: { album: AlbumRef } = $props();
|
||||||
|
|
||||||
function onImgError(e: Event) {
|
|
||||||
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onAddClick(e: MouseEvent) {
|
async function onAddClick(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -38,12 +34,9 @@
|
|||||||
shadow-sm transition-all duration-150
|
shadow-sm transition-all duration-150
|
||||||
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
|
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
|
||||||
>
|
>
|
||||||
<img
|
<Cover
|
||||||
src={album.cover_url}
|
src={album.cover_url}
|
||||||
alt=""
|
class="transition-transform group-hover:scale-[1.03]"
|
||||||
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
|
|
||||||
loading="lazy"
|
|
||||||
onerror={onImgError}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { playQueue, enqueueTracks } from '$lib/player/store.svelte';
|
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 ArtistMenu from './ArtistMenu.svelte';
|
||||||
import CardActionCluster from './CardActionCluster.svelte';
|
import CardActionCluster from './CardActionCluster.svelte';
|
||||||
|
import Cover from './Cover.svelte';
|
||||||
import { listArtistTracks } from '$lib/api/artists';
|
import { listArtistTracks } from '$lib/api/artists';
|
||||||
|
|
||||||
let { artist }: { artist: ArtistRef } = $props();
|
let { artist }: { artist: ArtistRef } = $props();
|
||||||
@@ -48,18 +49,12 @@
|
|||||||
class="block rounded focus-visible:ring-2 focus-visible:ring-accent"
|
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">
|
<div class="art-wrap relative mx-auto aspect-square w-full overflow-hidden rounded-full bg-surface-hover">
|
||||||
{#if artist.cover_url}
|
<Cover
|
||||||
<img
|
src={artist.cover_url}
|
||||||
src={artist.cover_url}
|
shape="round"
|
||||||
alt=""
|
fallback="artist"
|
||||||
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
|
class="transition-transform group-hover:scale-[1.03]"
|
||||||
loading="lazy"
|
/>
|
||||||
/>
|
|
||||||
{: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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Play ${artist.name}`}
|
aria-label={`Play ${artist.name}`}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import { Plus } from 'lucide-svelte';
|
import { Plus } from 'lucide-svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
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 TrackMenu from './TrackMenu.svelte';
|
||||||
import LikeButton from './LikeButton.svelte';
|
import LikeButton from './LikeButton.svelte';
|
||||||
|
import Cover from './Cover.svelte';
|
||||||
|
|
||||||
// Horizontal compact track row — cover thumb on the left, title +
|
// Horizontal compact track row — cover thumb on the left, title +
|
||||||
// artist on the right. Mirrors Android's CompactTrackTile so the
|
// artist on the right. Mirrors Android's CompactTrackTile so the
|
||||||
@@ -23,10 +24,6 @@
|
|||||||
|
|
||||||
const cover = $derived(coverUrl(track.album_id));
|
const cover = $derived(coverUrl(track.album_id));
|
||||||
|
|
||||||
function onImgError(e: Event) {
|
|
||||||
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClick() {
|
function onClick() {
|
||||||
playQueue(sectionTracks, index);
|
playQueue(sectionTracks, index);
|
||||||
}
|
}
|
||||||
@@ -47,13 +44,7 @@
|
|||||||
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
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">
|
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover">
|
||||||
<img
|
<Cover src={cover} />
|
||||||
src={cover}
|
|
||||||
alt=""
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
onerror={onImgError}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-sm font-medium text-text-primary">{track.title}</div>
|
<div class="truncate text-sm font-medium text-text-primary">{track.title}</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user