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 49a5324be8 - Show all commits
@@ -9,19 +9,26 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
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.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
@@ -30,10 +37,16 @@ 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.Disc3
import com.composables.icons.lucide.LibraryBig
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.X
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.models.RequestStatus
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
@@ -83,6 +96,7 @@ fun RequestsScreen(
is RequestsUiState.Success -> RequestList(
rows = s.requests,
onCancel = viewModel::cancel,
onListen = { id -> navController.navigate(id) },
)
}
}
@@ -90,17 +104,30 @@ fun RequestsScreen(
}
@Composable
private fun RequestList(rows: List<RequestRef>, onCancel: (String) -> Unit) {
private fun RequestList(
rows: List<RequestRef>,
onCancel: (String) -> Unit,
onListen: (Any) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.id }) { req ->
RequestRow(req = req, onCancel = { onCancel(req.id) })
RequestRow(
req = req,
onCancel = { onCancel(req.id) },
onListen = req.listenRoute()?.let { route -> { onListen(route) } },
)
HorizontalDivider()
}
}
}
@Composable
private fun RequestRow(req: RequestRef, onCancel: () -> Unit) {
private fun RequestRow(
req: RequestRef,
onCancel: () -> Unit,
onListen: (() -> Unit)?,
) {
var showCancelConfirm by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
@@ -108,6 +135,7 @@ private fun RequestRow(req: RequestRef, onCancel: () -> Unit) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
KindAvatar(kind = req.kind)
Column(modifier = Modifier.weight(1f)) {
Text(
text = req.displayName,
@@ -123,6 +151,15 @@ private fun RequestRow(req: RequestRef, onCancel: () -> Unit) {
StatusPill(status = req.status)
KindPill(label = req.kind)
}
val progress = req.ingestProgressText()
if (progress != null) {
Text(
text = progress,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
if (req.status == RequestStatus.REJECTED && !req.notes.isNullOrEmpty()) {
Text(
text = req.notes,
@@ -134,13 +171,91 @@ private fun RequestRow(req: RequestRef, onCancel: () -> Unit) {
)
}
}
if (req.status == RequestStatus.PENDING) {
TextButton(onClick = onCancel) {
when {
onListen != null -> OutlinedButton(onClick = onListen) {
Icon(
Lucide.Play,
contentDescription = null,
modifier = Modifier.size(16.dp).padding(end = 4.dp),
)
Text("Listen")
}
req.status == RequestStatus.PENDING -> TextButton(onClick = { showCancelConfirm = true }) {
Icon(Lucide.X, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
Text("Cancel")
}
}
}
if (showCancelConfirm) {
AlertDialog(
onDismissRequest = { showCancelConfirm = false },
title = { Text("Cancel request?") },
text = { Text("Cancel “${req.displayName}”? Lidarr will stop searching for it.") },
confirmButton = {
Button(
onClick = {
showCancelConfirm = false
onCancel()
},
) { Text("Cancel request") }
},
dismissButton = {
TextButton(onClick = { showCancelConfirm = false }) { Text("Keep") }
},
)
}
}
@Composable
private fun KindAvatar(kind: String) {
// Flutter mapping: disc-3 for artist, library-big for album,
// music for track. Mirrors lib/requests/requests_screen.dart.
val icon = when (kind) {
"artist" -> Lucide.Disc3
"album" -> Lucide.LibraryBig
"track" -> Lucide.Music
else -> Lucide.Music
}
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(28.dp),
)
}
/**
* Most-specific listenable detail-screen route for a completed
* request, or null when nothing's been matched / the status isn't
* completed. Track-level matches resolve to AlbumDetail since the
* client has no TrackDetail screen.
*/
private fun RequestRef.listenRoute(): Any? {
if (status != RequestStatus.COMPLETED) return null
matchedAlbumId?.takeIf { it.isNotEmpty() }?.let { return AlbumDetail(it) }
matchedArtistId?.takeIf { it.isNotEmpty() }?.let { return ArtistDetail(it) }
return null
}
/**
* "2 albums · 14 tracks ingested" — null when both counts are zero.
* Singularization matches Flutter ("1 album · 1 track ingested").
*/
private fun RequestRef.ingestProgressText(): String? {
if (importedAlbumCount == 0 && importedTrackCount == 0) return null
val parts = buildList {
if (importedAlbumCount > 0) {
add(
"$importedAlbumCount ${if (importedAlbumCount == 1) "album" else "albums"}",
)
}
if (importedTrackCount > 0) {
add(
"$importedTrackCount ${if (importedTrackCount == 1) "track" else "tracks"}",
)
}
}
return "${parts.joinToString(" · ")} ingested"
}
@Composable