diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt index dfff7df6..7fcaf2c9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt @@ -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(), ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt index 4b14ce38..49ee5b2e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt @@ -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, - ) } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt index 0eeffa79..3837dd0f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt @@ -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, ) } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt index c46e3938..d44f5c69 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ServerImage.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ServerImage.kt new file mode 100644 index 00000000..80c09369 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ServerImage.kt @@ -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, + ) + } +}