feat: M3 weighted shuffle v1 — real /api/radio with scoring #21
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user