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
3 changed files with 56 additions and 18 deletions
Showing only changes of commit 42a231e179 - Show all commits
@@ -31,14 +31,16 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import android.content.ClipData
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import com.fabledsword.minstrel.models.Invite
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -52,6 +54,7 @@ import com.fabledsword.minstrel.models.AdminUserRef
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -328,7 +331,8 @@ private fun GenerateInviteDialog(onDismiss: () -> Unit, onConfirm: (String?) ->
@Composable
private fun GeneratedInviteDialog(invite: Invite, onDismiss: () -> Unit) {
val clipboard = LocalClipboardManager.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Invite created") },
@@ -350,7 +354,8 @@ private fun GeneratedInviteDialog(invite: Invite, onDismiss: () -> Unit) {
confirmButton = {
Button(
onClick = {
clipboard.setText(AnnotatedString(invite.token))
val clip = ClipData.newPlainText("Minstrel invite", invite.token)
scope.launch { clipboard.setClipEntry(ClipEntry(clip)) }
onDismiss()
},
) {
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.player.ui
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -75,19 +76,23 @@ private fun MiniCover(coverUrl: String, contentDescription: String) {
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (coverUrl.isEmpty()) {
Icon(
imageVector = Lucide.Music,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
AsyncImage(
model = coverUrl,
contentDescription = contentDescription,
modifier = Modifier.size(COVER_SIZE_DP.dp),
contentScale = ContentScale.Crop,
)
// Cross-fade the artwork when the track changes so the cover
// dissolves into the next instead of hard-swapping.
Crossfade(targetState = coverUrl, label = "mini-cover") { url ->
if (url.isEmpty()) {
Icon(
imageVector = Lucide.Music,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
AsyncImage(
model = url,
contentDescription = contentDescription,
modifier = Modifier.size(COVER_SIZE_DP.dp),
contentScale = ContentScale.Crop,
)
}
}
}
}
@@ -76,6 +76,14 @@ fun PlaylistCard(
contentScale = ContentScale.Crop,
)
}
if (playlist.isSystem) {
VariantPill(
label = systemLabelFor(playlist.systemVariant),
modifier = Modifier
.align(Alignment.TopStart)
.padding(6.dp),
)
}
}
Spacer(Modifier.height(8.dp))
Text(
@@ -96,9 +104,29 @@ fun PlaylistCard(
}
}
/** Small label chip overlaid on a system playlist's cover. */
@Composable
private fun VariantPill(label: String, modifier: Modifier = Modifier) {
if (label.isEmpty()) return
Surface(
modifier = modifier,
shape = RoundedCornerShape(FabledSwordFlatTokens.radiusSm),
color = MaterialTheme.colorScheme.primary.copy(alpha = PILL_BG_ALPHA),
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
private const val PILL_BG_ALPHA = 0.85f
private fun subtitleFor(playlist: PlaylistRef): String = when {
playlist.isSystem -> systemLabelFor(playlist.systemVariant)
playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
playlist.isSystem -> systemLabelFor(playlist.systemVariant)
else -> " "
}