feat: M3 weighted shuffle v1 — real /api/radio with scoring #21
@@ -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(),
|
||||
)
|
||||
|
||||
+7
-12
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-10
@@ -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(
|
||||
|
||||
@@ -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