feat(android): variant pill + mini-cover crossfade + clipboard API (audit v3 §4.10/§4.16 tail)

§4.10/§4.18 — PlaylistCard now overlays a small primary-tinted pill
on system-playlist covers showing the variant ("For You" / "Discover"
/ "Today's mix" / …) instead of relying on the subtitle line. The
subtitle now prefers track count, so system tiles show variant pill
+ track count rather than just the label.

§4.16 — MiniPlayer cover wraps its content in a Crossfade keyed on
coverUrl so artwork dissolves into the next track instead of hard-
swapping. Pairs with the CoverPrefetcher cache-warming so the next
cover is usually ready to fade in.

Cleanup — migrated the AdminUsers invite Copy-token button off the
deprecated LocalClipboardManager.setText to LocalClipboard.setClipEntry
(suspend, via rememberCoroutineScope + ClipData/ClipEntry), clearing
the build warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 00:47:49 -04:00
parent ef85f5fd0b
commit 42a231e179
3 changed files with 56 additions and 18 deletions
@@ -31,14 +31,16 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.ui.platform.LocalClipboardManager import android.content.ClipData
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import com.fabledsword.minstrel.models.Invite import com.fabledsword.minstrel.models.Invite
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -328,7 +331,8 @@ private fun GenerateInviteDialog(onDismiss: () -> Unit, onConfirm: (String?) ->
@Composable @Composable
private fun GeneratedInviteDialog(invite: Invite, onDismiss: () -> Unit) { private fun GeneratedInviteDialog(invite: Invite, onDismiss: () -> Unit) {
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Invite created") }, title = { Text("Invite created") },
@@ -350,7 +354,8 @@ private fun GeneratedInviteDialog(invite: Invite, onDismiss: () -> Unit) {
confirmButton = { confirmButton = {
Button( Button(
onClick = { onClick = {
clipboard.setText(AnnotatedString(invite.token)) val clip = ClipData.newPlainText("Minstrel invite", invite.token)
scope.launch { clipboard.setClipEntry(ClipEntry(clip)) }
onDismiss() onDismiss()
}, },
) { ) {
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.player.ui package com.fabledsword.minstrel.player.ui
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -75,19 +76,23 @@ private fun MiniCover(coverUrl: String, contentDescription: String) {
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (coverUrl.isEmpty()) { // Cross-fade the artwork when the track changes so the cover
Icon( // dissolves into the next instead of hard-swapping.
imageVector = Lucide.Music, Crossfade(targetState = coverUrl, label = "mini-cover") { url ->
contentDescription = null, if (url.isEmpty()) {
tint = MaterialTheme.colorScheme.onSurfaceVariant, Icon(
) imageVector = Lucide.Music,
} else { contentDescription = null,
AsyncImage( tint = MaterialTheme.colorScheme.onSurfaceVariant,
model = coverUrl, )
contentDescription = contentDescription, } else {
modifier = Modifier.size(COVER_SIZE_DP.dp), AsyncImage(
contentScale = ContentScale.Crop, model = url,
) contentDescription = contentDescription,
modifier = Modifier.size(COVER_SIZE_DP.dp),
contentScale = ContentScale.Crop,
)
}
} }
} }
} }
@@ -76,6 +76,14 @@ fun PlaylistCard(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} }
if (playlist.isSystem) {
VariantPill(
label = systemLabelFor(playlist.systemVariant),
modifier = Modifier
.align(Alignment.TopStart)
.padding(6.dp),
)
}
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( 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 { private fun subtitleFor(playlist: PlaylistRef): String = when {
playlist.isSystem -> systemLabelFor(playlist.systemVariant)
playlist.trackCount > 0 -> "${playlist.trackCount} tracks" playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
playlist.isSystem -> systemLabelFor(playlist.systemVariant)
else -> " " else -> " "
} }