feat: M3 weighted shuffle v1 — real /api/radio with scoring #21

Merged
bvandeusen merged 262 commits from dev into main 2026-04-27 11:00:29 -04:00
5 changed files with 78 additions and 37 deletions
Showing only changes of commit 1b243347cb - Show all commits
@@ -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(),
)
@@ -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,
)
}
}
@@ -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,
)
}
}