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
Showing only changes of commit a606267c1e - Show all commits
@@ -15,6 +15,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -32,12 +34,18 @@ import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.composables.icons.lucide.Pause
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.SkipBack
import com.composables.icons.lucide.SkipForward
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
private const val MINI_BAR_HEIGHT_DP = 64
private const val MINI_BAR_HEIGHT_DP = 80
private const val MINI_BAR_TONAL_ELEVATION_DP = 4
private const val COVER_SIZE_DP = 48
private const val SCRUBBER_ROW_HEIGHT_DP = 4
@Composable
private fun MiniCover(coverUrl: String, contentDescription: String) {
@@ -71,9 +79,14 @@ private fun MiniCover(coverUrl: String, contentDescription: String) {
* fresh install. Tapping the body navigates to the full
* NowPlayingScreen via [onExpandClick].
*
* Cover comes from TrackRef.coverUrl (derived from albumId via the
* BaseUrlInterceptor placeholder); falls back to the Lucide.Music
* icon when the album binding is missing.
* Layout (Column):
* - Slim seek slider at the top (4dp track)
* - Row: cover | title/artist column | like | prev | play/pause | next | kebab
*
* The slider participates in pointer-events so tap/scrub works
* without the surface's onClick eating the gesture; the row's
* surface-level clickable still expands to NowPlaying on tap of
* the cover/title area.
*/
@Composable
fun MiniPlayer(
@@ -82,20 +95,92 @@ fun MiniPlayer(
onNavigateToArtist: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: PlayerViewModel = hiltViewModel(),
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val track = state.currentTrack ?: return
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
.collectAsStateWithLifecycle(initialValue = false)
Surface(
modifier = modifier
.fillMaxWidth()
.height(MINI_BAR_HEIGHT_DP.dp)
.clickable(onClick = onExpandClick),
.height(MINI_BAR_HEIGHT_DP.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = MINI_BAR_TONAL_ELEVATION_DP.dp,
) {
Column {
MiniSeekBar(
positionMs = state.positionMs,
durationMs = state.durationMs,
onSeek = viewModel::seekTo,
)
MiniRow(
track = track,
isPlaying = state.isPlaying,
isLiked = isLiked,
onExpandClick = onExpandClick,
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
onPrev = viewModel::skipToPrevious,
onNext = viewModel::skipToNext,
onToggleLike = {
trackActionsViewModel.toggleLike(track.id, isLiked)
},
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
}
}
}
@Composable
private fun MiniSeekBar(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) {
val fraction = if (durationMs > 0) {
(positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
Slider(
value = fraction,
onValueChange = { v -> if (durationMs > 0) onSeek((v * durationMs).toLong()) },
modifier = Modifier
.fillMaxWidth()
.height(SCRUBBER_ROW_HEIGHT_DP.dp),
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
),
)
}
@Composable
@Suppress("LongParameterList")
private fun MiniRow(
track: TrackRef,
isPlaying: Boolean,
isLiked: Boolean,
onExpandClick: () -> Unit,
onPlayPause: () -> Unit,
onPrev: () -> Unit,
onNext: () -> Unit,
onToggleLike: () -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Cover + title area expands to NowPlaying on tap, but the
// transport buttons + kebab don't (each IconButton handles
// its own clicks). Wrap only the cover-and-title region with
// the clickable modifier rather than the whole row.
Row(
modifier = Modifier.padding(horizontal = 12.dp),
modifier = Modifier
.weight(1f)
.clickable(onClick = onExpandClick),
verticalAlignment = Alignment.CenterVertically,
) {
MiniCover(coverUrl = track.coverUrl, contentDescription = track.title)
@@ -116,23 +201,36 @@ fun MiniPlayer(
overflow = TextOverflow.Ellipsis,
)
}
IconButton(
onClick = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
) {
Icon(
imageVector = if (state.isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (state.isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.onSurface,
)
}
// hideQueueActions=true: the playing track is itself the
// queue entry, so Play next / Add to queue would be silly.
TrackActionsButton(
track = track,
hideQueueActions = true,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
}
LikeButton(liked = isLiked, onToggle = onToggleLike)
IconButton(onClick = onPrev) {
Icon(
imageVector = Lucide.SkipBack,
contentDescription = "Previous",
tint = MaterialTheme.colorScheme.onSurface,
)
}
IconButton(onClick = onPlayPause) {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.onSurface,
)
}
IconButton(onClick = onNext) {
Icon(
imageVector = Lucide.SkipForward,
contentDescription = "Next",
tint = MaterialTheme.colorScheme.onSurface,
)
}
// hideQueueActions=true: the playing track is itself the
// queue entry, so Play next / Add to queue would be silly.
TrackActionsButton(
track = track,
hideQueueActions = true,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
}
}