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.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 -> " "
}