feat(android): AlbumCard cover fallback for cached-only albums

The cached_albums → AlbumRef mapper drops coverUrl (only the
coverPath column is stored; the regular API's cover_url isn't
mirrored). Result: AlbumCards rendered from cache — Library Albums
tab, ArtistDetail album grid, Home Recently-added/Rediscover rows
on cold start — showed only the Lucide.Disc3 placeholder.

Same placeholder-URL trick as TrackRef.coverUrl:

  - models/AlbumRef.kt — adds `displayCoverUrl` computed property
    that returns the server-given coverUrl when populated, falls
    back to `http://placeholder.invalid/api/albums/{id}/cover`
    otherwise. BaseUrlInterceptor rewrites the host; Coil's shared
    OkHttp picks up the auth cookie. The original `coverUrl` field
    is preserved so callers that need to distinguish
    "server-provided" from "derived" can.

  - library/widgets/AlbumCard.kt — switches the branch from
    `album.coverUrl.isEmpty()` to `album.id.isEmpty()`. The cover
    Box gets a surfaceVariant background so failed image loads
    (e.g. album server-side without art) degrade to a tinted
    square rather than transparent. A proper error-slot fallback
    icon is a future refinement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 08:17:08 -04:00
parent 2c640b897a
commit 283706c4f8
2 changed files with 27 additions and 4 deletions
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.library.widgets
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -49,10 +50,11 @@ fun AlbumCard(
Box(
modifier = Modifier
.size(144.dp)
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)),
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (album.coverUrl.isEmpty()) {
if (album.id.isEmpty()) {
Icon(
imageVector = Lucide.Disc3,
contentDescription = null,
@@ -60,8 +62,15 @@ 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.coverUrl,
model = album.displayCoverUrl,
contentDescription = album.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
@@ -19,4 +19,18 @@ data class AlbumRef(
val trackCount: Int = 0,
val durationSec: Int = 0,
val coverUrl: String = "",
)
) {
/**
* Cover URL the UI should render. Returns the server-provided
* `coverUrl` when populated (regular API responses always carry it),
* otherwise derives `/api/albums/{id}/cover` via the placeholder
* host that `BaseUrlInterceptor` rewrites to the live server URL.
*
* The fallback path matters because the cached_albums entity →
* AlbumRef mapper drops coverUrl — without this property,
* AlbumCards rendered from cache (Library Albums tab, ArtistDetail
* grid, Home rows on cold-start) would show no artwork at all.
*/
val displayCoverUrl: String
get() = coverUrl.ifEmpty { "http://placeholder.invalid/api/albums/$id/cover" }
}