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 89 additions and 43 deletions
Showing only changes of commit 2c640b897a - Show all commits
@@ -29,6 +29,7 @@ import com.fabledsword.minstrel.history.data.HistoryRepository
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -131,10 +132,11 @@ private fun HistoryRow(entry: HistoryEntry, onClick: () -> Unit) {
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = entry.track.streamUrl.isNotEmpty(), onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
TrackCoverThumb(coverUrl = entry.track.coverUrl, contentDescription = entry.track.title)
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.track.title,
@@ -1,6 +1,5 @@
package com.fabledsword.minstrel.likes.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -12,26 +11,18 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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 coil3.compose.AsyncImage
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -47,6 +38,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -217,7 +209,7 @@ private fun LikedTrackRow(track: TrackRef) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
TrackCoverThumb(track)
TrackCoverThumb(coverUrl = track.coverUrl, contentDescription = track.title)
Column(modifier = Modifier.weight(1f)) {
Text(
text = track.title,
@@ -237,33 +229,6 @@ private fun LikedTrackRow(track: TrackRef) {
}
}
@Composable
private fun TrackCoverThumb(track: TrackRef) {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (track.coverUrl.isEmpty()) {
Icon(
imageVector = Lucide.Music,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
} else {
AsyncImage(
model = track.coverUrl,
contentDescription = track.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
}
}
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -44,4 +44,17 @@ data class PlaylistTrackRef(
val streamUrl: String? = null,
) {
val isAvailable: Boolean get() = trackId != null
/**
* Cover URL derived from the parent album's `/api/albums/{id}/cover`
* endpoint, using the placeholder host that `BaseUrlInterceptor`
* rewrites to the live server URL. Same mechanism as
* [TrackRef.coverUrl] — Coil shares the OkHttp client. Empty when
* the row has no album binding (rare; usually only for tracks
* whose upstream album was deleted).
*/
val coverUrl: String
get() = albumId?.takeIf { it.isNotEmpty() }
?.let { "http://placeholder.invalid/api/albums/$it/cover" }
.orEmpty()
}
@@ -61,6 +61,7 @@ import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -336,11 +337,10 @@ private fun TrackRow(
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${row.position}",
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = rowAlpha),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.width(28.dp),
TrackCoverThumb(
coverUrl = row.coverUrl,
contentDescription = row.title,
modifier = Modifier.padding(end = 12.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
@@ -0,0 +1,66 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
/**
* Shared track-row cover thumbnail. Pulls the cover from the track's
* derived `coverUrl` (which Coil resolves through the shared OkHttp
* + BaseUrlInterceptor); falls back to a Lucide.Music glyph on a
* surfaceVariant-tinted background when the URL is empty (album
* cover hasn't loaded into the cache yet OR the track has no album
* binding).
*
* Used by LikedTab, PlaylistDetail, HistoryTab, and any other dense
* track list. Sizes default to 48dp — the four-corner radius and
* fallback-icon size scale with the outer dimension so callers can
* pass e.g. 56dp for a more prominent row without re-implementing.
*/
@Composable
fun TrackCoverThumb(
coverUrl: String,
contentDescription: String?,
modifier: Modifier = Modifier,
size: Dp = DEFAULT_SIZE,
) {
Box(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (coverUrl.isEmpty()) {
Icon(
imageVector = Lucide.Music,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(size / 2),
)
} else {
AsyncImage(
model = coverUrl,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
}
}
private val DEFAULT_SIZE: Dp = 48.dp