fix(library): drop out-of-range release years instead of failing the album #12
@@ -45,7 +45,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import com.composables.icons.lucide.ArrowLeft
|
import com.composables.icons.lucide.ArrowLeft
|
||||||
import com.composables.icons.lucide.Disc3
|
import com.composables.icons.lucide.Disc3
|
||||||
import com.composables.icons.lucide.Lucide
|
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.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
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.SkeletonTrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
|
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
|
||||||
@@ -290,8 +290,8 @@ private fun AlbumCover(album: AlbumRef) {
|
|||||||
modifier = Modifier.size(56.dp),
|
modifier = Modifier.size(56.dp),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
AsyncImage(
|
ServerImage(
|
||||||
model = album.displayCoverUrl,
|
url = album.displayCoverUrl,
|
||||||
contentDescription = album.title,
|
contentDescription = album.title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
|||||||
+7
-12
@@ -36,13 +36,11 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import com.composables.icons.lucide.ArrowLeft
|
import com.composables.icons.lucide.ArrowLeft
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Play
|
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.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -206,8 +205,7 @@ private fun ArtistAvatar(artist: ArtistRef, fallbackAlbumId: String? = null) {
|
|||||||
// _ArtistAvatar). Bare Lucide.User only when neither exists.
|
// _ArtistAvatar). Bare Lucide.User only when neither exists.
|
||||||
val model = when {
|
val model = when {
|
||||||
artist.coverUrl.isNotEmpty() -> artist.coverUrl
|
artist.coverUrl.isNotEmpty() -> artist.coverUrl
|
||||||
fallbackAlbumId != null ->
|
fallbackAlbumId != null -> "/api/albums/$fallbackAlbumId/cover"
|
||||||
"http://placeholder.invalid/api/albums/$fallbackAlbumId/cover"
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
@@ -217,20 +215,17 @@ private fun ArtistAvatar(artist: ArtistRef, fallbackAlbumId: String? = null) {
|
|||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (model == null) {
|
ServerImage(
|
||||||
|
url = model,
|
||||||
|
contentDescription = artist.name,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Lucide.User,
|
imageVector = Lucide.User,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(40.dp),
|
modifier = Modifier.size(40.dp),
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
AsyncImage(
|
|
||||||
model = model,
|
|
||||||
contentDescription = artist.name,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,14 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import com.composables.icons.lucide.Disc3
|
import com.composables.icons.lucide.Disc3
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import com.fabledsword.minstrel.models.AlbumRef
|
import com.fabledsword.minstrel.models.AlbumRef
|
||||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,18 +68,13 @@ fun AlbumCard(
|
|||||||
modifier = Modifier.size(56.dp),
|
modifier = Modifier.size(56.dp),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Use displayCoverUrl so cached-only albums (which
|
// displayCoverUrl yields /api/albums/{id}/cover —
|
||||||
// drop coverUrl in the entity→domain mapper) still
|
// relative on fresh fetches, placeholder-absolute from
|
||||||
// hit /api/albums/{id}/cover via the BaseUrlInterceptor
|
// cache. ServerImage resolves both to a loadable URL.
|
||||||
// placeholder rewrite. On load failure (album with
|
ServerImage(
|
||||||
// no cover server-side) the Box's surfaceVariant
|
url = album.displayCoverUrl,
|
||||||
// background shows through — a Disc3 error-slot
|
|
||||||
// fallback is a future refinement.
|
|
||||||
AsyncImage(
|
|
||||||
model = album.displayCoverUrl,
|
|
||||||
contentDescription = album.title,
|
contentDescription = album.title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-10
@@ -19,14 +19,13 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import com.composables.icons.lucide.ListMusic
|
import com.composables.icons.lucide.ListMusic
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,20 +60,17 @@ fun PlaylistCard(
|
|||||||
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)),
|
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (playlist.coverUrl.isEmpty()) {
|
ServerImage(
|
||||||
|
url = playlist.coverUrl,
|
||||||
|
contentDescription = playlist.name,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Lucide.ListMusic,
|
imageVector = Lucide.ListMusic,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(56.dp),
|
modifier = Modifier.size(56.dp),
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
AsyncImage(
|
|
||||||
model = playlist.coverUrl,
|
|
||||||
contentDescription = playlist.name,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (playlist.isSystem) {
|
if (playlist.isSystem) {
|
||||||
VariantPill(
|
VariantPill(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user