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 168 additions and 7 deletions
Showing only changes of commit f0278ed2bf - Show all commits
@@ -21,12 +21,12 @@ import com.fabledsword.minstrel.library.ui.AlbumDetailScreen
import com.fabledsword.minstrel.library.ui.ArtistDetailScreen
import com.fabledsword.minstrel.library.ui.LibraryScreen
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
import com.fabledsword.minstrel.player.ui.QueueScreen
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen
import com.fabledsword.minstrel.requests.ui.RequestsScreen
import com.fabledsword.minstrel.search.ui.SearchScreen
import com.fabledsword.minstrel.settings.ui.SettingsScreen
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ShellScaffold
/**
@@ -146,14 +146,10 @@ private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
popEnterTransition = { slideInVertically(initialOffsetY = { it }) },
popExitTransition = { slideOutVertically(targetOffsetY = { it }) },
) {
NowPlayingScreen()
NowPlayingScreen(navController = navController)
}
composable<Queue> { ComingSoon("Queue", "Lands in a later 6.x slice.") }
composable<Queue> { QueueScreen(navController = navController) }
composable<ServerUrl> { ServerUrlScreen(navController = navController) }
composable<Login> { LoginScreen(navController = navController) }
}
@Composable
private fun ComingSoon(title: String, body: String) {
EmptyState(title = title, body = body, modifier = Modifier.fillMaxSize())
}
@@ -70,6 +70,14 @@ class PlayerController @Inject constructor(
fun skipToNext() { mediaController?.seekToNextMediaItem() }
fun skipToPrevious() { mediaController?.seekToPreviousMediaItem() }
/** Jump to [index] in the current queue. No-op if index is out of range. */
fun seekToIndex(index: Int) {
val controller = mediaController ?: return
if (index !in queueRefs.indices) return
controller.seekTo(index, /* positionMs = */ 0L)
controller.play()
}
/**
* Replace the queue with [tracks] and start playing from [initialIndex].
* [source] tags the queue with its origin (e.g. "for_you") so the
@@ -29,12 +29,15 @@ 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 com.composables.icons.lucide.ListMusic
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.nav.Queue
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
import com.fabledsword.minstrel.theme.LocalActionColors
@@ -53,6 +56,7 @@ private const val SECONDS_PER_MINUTE = 60L
*/
@Composable
fun NowPlayingScreen(
navController: NavHostController,
viewModel: PlayerViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -91,6 +95,19 @@ fun NowPlayingScreen(
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
onNext = viewModel::skipToNext,
)
Spacer(Modifier.height(16.dp))
ViewQueueButton(onClick = { navController.navigate(Queue) })
}
}
@Composable
private fun ViewQueueButton(onClick: () -> Unit) {
IconButton(onClick = onClick) {
Icon(
imageVector = Lucide.ListMusic,
contentDescription = "View queue",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -30,4 +30,5 @@ class PlayerViewModel @Inject constructor(
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
fun skipToNext() = controller.skipToNext()
fun skipToPrevious() = controller.skipToPrevious()
fun seekToIndex(index: Int) = controller.seekToIndex(index)
}
@@ -0,0 +1,139 @@
package com.fabledsword.minstrel.player.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
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 com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Volume2
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.widgets.EmptyState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun QueueScreen(
navController: NavHostController,
viewModel: PlayerViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Queue") },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
)
},
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
if (state.queue.isEmpty()) {
EmptyState(
title = "Queue is empty",
body = "Pick a track from the library and it'll show up here.",
)
} else {
QueueList(
tracks = state.queue,
currentIndex = state.queueIndex,
onJumpTo = viewModel::seekToIndex,
)
}
}
}
}
@Composable
private fun QueueList(
tracks: List<TrackRef>,
currentIndex: Int,
onJumpTo: (Int) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
QueueRow(
track = track,
isCurrent = index == currentIndex,
onClick = { onJumpTo(index) },
)
HorizontalDivider()
}
}
}
@Composable
private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
val highlight = if (isCurrent) {
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
} else {
Color.Transparent
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(highlight)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (isCurrent) {
Icon(
Lucide.Volume2,
contentDescription = "Now playing",
tint = MaterialTheme.colorScheme.primary,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (track.artistName.isNotEmpty()) {
Text(
text = track.artistName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
private const val HIGHLIGHT_ALPHA = 0.12f