Apple Music (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Apple Music’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, Modifiers, motion, and haptics.
Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (Apple Music’s true-black/true-white canvas, single Apple-Music-Red accent, 12dp softer album corners, album-art-derived Now Playing gradient, time-synced lyrics) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar, AndroidX Palette instead of Core ImageCIAreaAverage, a translucentSurfaceinstead of.regularMaterial(Android has no live blur),sp/dpinstead ofpt. Apple Music supports both light and dark, so we wire a full Material 3 light + dark scheme; dark canvas is true#000000(not#121212).
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for artwork, and androidx.palette:palette-ktx for the Now Playing album-color extraction.
1. Color Tokens
// ui/theme/AppleMusicColors.kt
import androidx.compose.ui.graphics.Color
object AMColors {
// Brand
val Red = Color(0xFFFA2D48) // Play, heart liked, shuffle active, scrubber fill, tab
val Coral = Color(0xFFFC3C44) // warmer marketing variant
val RedPressed = Color(0xFFD4213B)
// Canvas & Surfaces (Light)
val CanvasLight = Color(0xFFFFFFFF)
val Surface1Light = Color(0xFFF2F2F7) // grouped list / section cards
val Surface3Light = Color(0xFFE5E5EA) // pressed rows, input fill
val DividerLight = Color(0xFFC6C6C8)
// Canvas & Surfaces (Dark) — full-contrast true black
val CanvasDark = Color(0xFF000000) // unusual for media apps — NOT #121212
val Surface1Dark = Color(0xFF1C1C1E)
val Surface2Dark = Color(0xFF2C2C2E)
val Surface3Dark = Color(0xFF3A3A3C)
val DividerDark = Color(0xFF38383A)
// Text base colors (apply alpha for secondary/tertiary tiers)
val LabelLight = Color(0xFF000000) // primary, light
val LabelDark = Color(0xFFFFFFFF) // primary, dark
val SecLight = Color(0xFF3C3C43) // .copy(alpha = 0.60f) = secondary
val SecDark = Color(0xFFEBEBF5) // .copy(alpha = 0.60f) = secondary (dark)
// Badges
val AtmosGold = Color(0xFFD4A857)
val LosslessSilver = Color(0xFF8E8E93)
// iOS system colors (prefer for links / status, not the brand red)
val SystemBlue = Color(0xFF007AFF) // inline links (artist in description)
val SystemGreen = Color(0xFF34C759) // download complete
val SystemRed = Color(0xFFFF3B30) // playback errors (NOT Apple Music red)
}
Apple Music supports light and dark equally — wire both Material 3 schemes. primary is Apple Music Red; dark background is true black.
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.isSystemInDarkTheme
private val AMLight = lightColorScheme(
primary = AMColors.Red,
onPrimary = Color.White,
background = AMColors.CanvasLight,
onBackground = AMColors.LabelLight,
surface = AMColors.CanvasLight,
onSurface = AMColors.LabelLight,
surfaceVariant = AMColors.Surface1Light,
outline = AMColors.DividerLight,
error = AMColors.SystemRed,
)
private val AMDark = darkColorScheme(
primary = AMColors.Red,
onPrimary = Color.White,
background = AMColors.CanvasDark, // true #000000
onBackground = AMColors.LabelDark,
surface = AMColors.Surface1Dark,
onSurface = AMColors.LabelDark,
surfaceVariant = AMColors.Surface2Dark,
outline = AMColors.DividerDark,
error = AMColors.SystemRed,
)
@Composable
fun AppleMusicTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (darkTheme) AMDark else AMLight,
typography = AMTypography,
content = content,
)
2. Typography
Apple Music is 100% SF Pro with automatic Display/Text optical switching. SF Pro is unavailable on Android — use the system font (Roboto) and reserve a bundled Inter for a tighter metric match. SF Pro Rounded (Listen Now hero cards) maps to a rounded family if you bundle one; otherwise keep Roboto. Honor font scaling everywhere (Apple Music is a flagship Dynamic Type app).
// ui/theme/AppleMusicType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
private val SF = FontFamily.Default // swap to FontFamily(Font(R.font.inter_…))
private val SFRounded = FontFamily.Default // swap to a rounded family for Listen Now heroes
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object AMText {
val LargeTitle = TextStyle(SF, fontWeight = FontWeight.Bold, fontSize = 34.sp, lineHeight = 37.sp, letterSpacing = 0.37.sp)
val Title1 = TextStyle(SF, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = 0.36.sp)
val HeroListen = TextStyle(SFRounded, fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 29.sp, letterSpacing = (-0.2).sp)
val Title2 = TextStyle(SF, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.26).sp)
val Title3 = TextStyle(SF, fontWeight = FontWeight.SemiBold, fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.15).sp)
val NowPlaying = TextStyle(SF, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
val Headline = TextStyle(SF, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 21.sp, letterSpacing = (-0.43).sp)
val Body = TextStyle(SF, fontWeight = FontWeight.Normal, fontSize = 17.sp, lineHeight = 25.sp, letterSpacing = (-0.43).sp)
val Callout = TextStyle(SF, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 20.sp, letterSpacing = (-0.32).sp)
val Subheadline = TextStyle(SF, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 20.sp, letterSpacing = (-0.24).sp)
val Footnote = TextStyle(SF, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp, letterSpacing = (-0.08).sp)
val Caption1 = TextStyle(SF, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
val Caption2 = TextStyle(SF, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.07.sp)
val Button = TextStyle(SF, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 17.sp, letterSpacing = (-0.43).sp)
val Tab = TextStyle(SF, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = (-0.24).sp)
val LyricsCurrent = TextStyle(SF, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp, letterSpacing = (-0.3).sp)
val LyricsOther = TextStyle(SF, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 29.sp, letterSpacing = (-0.2).sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val AMTypography = Typography(
headlineLarge = AMText.LargeTitle,
headlineMedium = AMText.Title1,
headlineSmall = AMText.Title3,
titleMedium = AMText.Headline,
bodyLarge = AMText.Body,
bodyMedium = AMText.Subheadline,
labelLarge = AMText.Button,
labelSmall = AMText.Tab,
)
3. Signature Components
Primary Red Play Button
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun ApplePlayButton(
isPlaying: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: Dp = 64.dp,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(
if (pressed) 0.92f else 1f,
spring(dampingRatio = 0.75f, stiffness = 600f),
label = "playScale",
)
val haptics = LocalHapticFeedback.current
Box(
modifier = modifier
.size(size)
.scale(scale)
.clip(CircleShape)
.background(AMColors.Red)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ~iOS .soft impact
onClick()
},
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = Color.White,
modifier = Modifier.size(size * 0.44f),
)
}
}
Pill Button (Play / Shuffle / Join Apple Music)
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun AMPillButton(
text: String,
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
tint: Color = AMColors.Red,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, label = "pillScale")
val haptics = LocalHapticFeedback.current
Row(
modifier = modifier
.scale(scale)
.clip(RoundedCornerShape(8.dp)) // soft rectangle, NOT a full pill — iOS Music signature
.background(if (pressed) AMColors.RedPressed else tint)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onClick()
}
.padding(horizontal = 24.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(15.dp))
Text(text, style = AMText.Button, color = Color.White)
}
}
Album Tile (signature 12dp corner)
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
@Composable
fun AlbumTile(
artworkUrl: String,
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
width: Dp = 160.dp,
isDark: Boolean = false,
) {
val haptics = LocalHapticFeedback.current
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.96f else 1f, label = "tileScale")
Column(
modifier = modifier
.width(width)
.scale(scale)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ~iOS .selection
onClick()
},
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
AsyncImage(
model = artworkUrl,
contentDescription = "$title album art",
modifier = Modifier
.size(width)
// generous shadow in light only — dark mode uses surface steps, no shadow
.then(if (!isDark)
Modifier.shadow(16.dp, RoundedCornerShape(12.dp),
spotColor = Color.Black.copy(alpha = 0.08f)) else Modifier)
.clip(RoundedCornerShape(12.dp)), // signature softer-than-Spotify corner
contentScale = ContentScale.Crop,
)
Text(
title,
style = AMText.Subheadline.copy(fontWeight = FontWeight.SemiBold, fontSize = 15.sp),
color = if (isDark) AMColors.LabelDark else AMColors.LabelLight,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
subtitle,
style = AMText.Footnote,
color = (if (isDark) AMColors.SecDark else AMColors.SecLight).copy(alpha = 0.60f),
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
Track Row (with red equalizer + Atmos badge)
import androidx.compose.material.icons.filled.MoreHoriz
@Composable
fun TrackRow(
title: String,
artist: String,
artworkUrl: String,
isPlaying: Boolean,
hasAtmos: Boolean,
explicit: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Row(
modifier = modifier
.fillMaxWidth()
.height(56.dp)
.background(if (pressed) Color.Black.copy(alpha = 0.05f) else Color.Transparent)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box {
AsyncImage(
model = artworkUrl,
contentDescription = null,
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop,
)
if (isPlaying) {
Box(
Modifier
.size(18.dp)
.align(Alignment.Center)
.clip(RoundedCornerShape(4.dp))
.background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center,
) { Equalizer(color = AMColors.Red) } // red, not Spotify green
}
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (explicit) {
Box(
Modifier
.clip(RoundedCornerShape(3.dp))
.background(Color.Gray)
.padding(horizontal = 4.dp),
) { Text("E", style = AMText.Caption2.copy(fontWeight = FontWeight.Bold),
color = Color.White) }
}
Text(title, style = AMText.Body.copy(fontSize = 17.sp),
color = AMColors.LabelLight, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (hasAtmos) AtmosBadge()
}
Text(artist, style = AMText.Subheadline,
color = AMColors.SecLight.copy(alpha = 0.60f),
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Icon(Icons.Filled.MoreHoriz, contentDescription = "More",
tint = AMColors.SecLight.copy(alpha = 0.60f), modifier = Modifier.size(20.dp))
}
}
@Composable
fun AtmosBadge() {
Box(
Modifier
.clip(CircleShape) // full pill
.background(androidx.compose.ui.graphics.Brush.verticalGradient(
listOf(AMColors.AtmosGold, AMColors.AtmosGold.copy(alpha = 0.85f))))
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text("Dolby Atmos",
style = AMText.Caption2.copy(fontWeight = FontWeight.SemiBold), color = Color.White)
}
}
Now Playing Hero (album-art-extracted gradient)
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.ui.graphics.Brush
@Composable
fun NowPlayingScreen(
trackTitle: String,
artist: String,
artworkUrl: String,
dominantColor: Color, // from Palette — see §4
complementaryColor: Color, // darker derived — see §4
isPlaying: Boolean,
progress: Float,
onPlayPause: () -> Unit,
) {
Box(
Modifier
.fillMaxSize()
.background(Brush.verticalGradient(listOf(dominantColor, complementaryColor))),
) {
Column(
Modifier.fillMaxSize().padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
AsyncImage(
model = artworkUrl,
contentDescription = "$trackTitle album art",
modifier = Modifier
.size(340.dp)
.shadow(40.dp, RoundedCornerShape(16.dp),
spotColor = Color.Black.copy(alpha = 0.3f))
.clip(RoundedCornerShape(16.dp)),
contentScale = ContentScale.Crop,
)
Spacer(Modifier.height(24.dp))
Text(trackTitle, style = AMText.NowPlaying, color = Color.White, maxLines = 1)
Text(artist, style = AMText.Subheadline,
color = Color.White.copy(alpha = 0.7f), maxLines = 1)
Spacer(Modifier.height(24.dp))
AMScrubber(progress = progress)
Spacer(Modifier.height(24.dp))
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(32.dp)) {
Icon(Icons.Filled.SkipPrevious, "Previous",
tint = Color.White, modifier = Modifier.size(32.dp))
ApplePlayButton(isPlaying = isPlaying, onClick = onPlayPause, size = 64.dp)
Icon(Icons.Filled.SkipNext, "Next",
tint = Color.White, modifier = Modifier.size(32.dp))
}
}
}
}
@Composable
fun AMScrubber(progress: Float) {
Box(Modifier.fillMaxWidth().height(16.dp), contentAlignment = Alignment.CenterStart) {
Box(Modifier.fillMaxWidth().height(4.dp).clip(CircleShape)
.background(Color.White.copy(alpha = 0.2f)))
Box(Modifier.fillMaxWidth(progress).height(4.dp).clip(CircleShape)
.background(AMColors.Red)) // Apple Music Red fill
Box(
Modifier
.fillMaxWidth(progress)
.height(16.dp),
contentAlignment = Alignment.CenterEnd,
) {
Box(Modifier.size(16.dp).clip(CircleShape).background(Color.White)
.shadow(2.dp, CircleShape, spotColor = Color.Black.copy(alpha = 0.2f)))
}
}
}
Mini-Player (translucent surface — no live blur on Android)
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material3.Surface
@Composable
fun MiniPlayer(
trackTitle: String,
artist: String,
artworkUrl: String,
isPlaying: Boolean,
onPlayPause: () -> Unit,
onExpand: () -> Unit,
isDark: Boolean = false,
) {
Surface(
color = (if (isDark) AMColors.Surface1Dark else AMColors.CanvasLight).copy(alpha = 0.80f),
tonalElevation = 0.dp,
modifier = Modifier.fillMaxWidth().height(64.dp).clickable(onClick = onExpand),
) {
Row(
Modifier.padding(start = 12.dp, end = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage(
model = artworkUrl, contentDescription = null,
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop,
)
Column(Modifier.weight(1f)) {
Text(trackTitle,
style = AMText.Subheadline.copy(fontWeight = FontWeight.Medium, fontSize = 15.sp),
color = if (isDark) AMColors.LabelDark else AMColors.LabelLight, maxLines = 1)
Text(artist, style = AMText.Footnote,
color = (if (isDark) AMColors.SecDark else AMColors.SecLight).copy(alpha = 0.60f),
maxLines = 1)
}
Icon(
if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = if (isDark) AMColors.LabelDark else AMColors.LabelLight,
modifier = Modifier.size(22.dp).clickable(onClick = onPlayPause),
)
Icon(Icons.Filled.SkipNext, contentDescription = "Next",
tint = if (isDark) AMColors.LabelDark else AMColors.LabelLight,
modifier = Modifier.size(22.dp))
}
}
}
4. Time-Synced Lyrics + Album-Art Color Extraction
Apple Music’s defining feature is the time-synced lyrics view (current line scales up + brightens, auto-scrolls to playback). iOS uses ScrollViewReader; Compose uses LazyColumn + LaunchedEffect(currentTimeMs) + animateScrollToItem. Line size/opacity animate via animateFloatAsState.
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
data class LyricLine(val startMs: Long, val endMs: Long, val text: String)
@Composable
fun LyricsView(lines: List<LyricLine>, currentTimeMs: Long, gradient: Brush) {
val listState = rememberLazyListState()
val currentIndex = lines.indexOfLast { currentTimeMs >= it.startMs }
LaunchedEffect(currentIndex) {
if (currentIndex >= 0) listState.animateScrollToItem(
index = currentIndex.coerceAtLeast(0),
scrollOffset = -300, // keep the active line centered
)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().background(gradient),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 80.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
itemsIndexed(lines) { i, line ->
val isCurrent = i == currentIndex
val isPast = i < currentIndex
val targetAlpha = when {
isCurrent -> 1f
isPast -> 0.3f // faded above
else -> 0.6f // upcoming below
}
val alpha by animateFloatAsState(targetAlpha,
spring(dampingRatio = 0.85f), label = "lyricAlpha")
Text(
line.text,
style = if (isCurrent) AMText.LyricsCurrent else AMText.LyricsOther,
color = Color.White.copy(alpha = alpha),
)
}
}
}
Album-art color extraction (the Now Playing / Lyrics gradient): Android’s analog to iOS Core Image CIAreaAverage is AndroidX Palette. Load with Coil (software bitmap), pull a vibrant swatch as the dominant, and derive a darker complementary by scaling RGB — exactly Apple Music’s “primary at top, darker complementary at bottom”.
// build.gradle: implementation("androidx.palette:palette-ktx:1.0.0")
import android.content.Context
import android.graphics.drawable.BitmapDrawable
import androidx.palette.graphics.Palette
import coil.imageLoader
import coil.request.ImageRequest
suspend fun extractGradient(context: Context, url: String): Pair<Color, Color> {
val request = ImageRequest.Builder(context)
.data(url)
.allowHardware(false) // Palette needs a software bitmap
.build()
val bitmap = (context.imageLoader.execute(request).drawable as? BitmapDrawable)?.bitmap
?: return AMColors.CanvasDark to AMColors.CanvasDark
val palette = Palette.from(bitmap).generate()
val rgb = (palette.vibrantSwatch ?: palette.dominantSwatch)?.rgb
?: return AMColors.CanvasDark to AMColors.CanvasDark
val dominant = Color(rgb)
val complementary = Color( // darker derived variant
red = dominant.red * 0.4f,
green = dominant.green * 0.4f,
blue = dominant.blue * 0.4f,
)
return dominant to complementary
}
Hoist into a ViewModel and cache per track so scrubbing never re-computes:
val gradient by produceState(AMColors.CanvasDark to AMColors.CanvasDark, artworkUrl) {
value = extractGradient(context, artworkUrl)
}
The mini-player → Now Playing artwork morph is Compose SharedTransitionLayout + Modifier.sharedElement() (Compose 1.7+) on the 44dp ↔ 340dp artwork; fall back to AnimatedVisibility + scaleIn over a 0.35s spring(dampingRatio = 0.8f).
5. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. Apple Music’s iOS tab bar is .regularMaterial blur; Android has no first-class live blur, so use an 80%-opaque canvas surface. Active tint is Apple Music Red (distinctive — most apps use the system accent for the tab indicator). Five tabs, always labeled.
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
@Composable
fun AppleMusicBottomBar(selected: Int, onSelect: (Int) -> Unit, isDark: Boolean = false) {
NavigationBar(
containerColor = (if (isDark) AMColors.CanvasDark else AMColors.CanvasLight)
.copy(alpha = 0.80f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Home" to Icons.Filled.Home,
"New" to Icons.Filled.LibraryMusic,
"Radio" to Icons.Filled.Podcasts,
"Library" to Icons.Filled.LibraryBooks,
"Search" to Icons.Filled.Search,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(25.dp)) },
label = { Text(label, style = AMText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = AMColors.Red, // Apple Music Red, not system accent
selectedTextColor = AMColors.Red,
unselectedIconColor = (if (isDark) AMColors.SecDark else AMColors.SecLight)
.copy(alpha = 0.60f),
unselectedTextColor = (if (isDark) AMColors.SecDark else AMColors.SecLight)
.copy(alpha = 0.60f),
indicatorColor = Color.Transparent, // no Material pill — Apple Music has none
),
)
}
}
}
The persistent mini-player sits directly above this bar — render it in the Scaffold bottomBar slot stacked above AppleMusicBottomBar. The collapsing iOS large-title nav maps to LargeTopAppBar + exitUntilCollapsedScrollBehavior() (34sp → 17sp on scroll).
6. Motion
| Moment | Compose recipe |
|---|---|
| Play button tap | animateFloatAsState 1 → 0.92 spring(dampingRatio = 0.75f); HapticFeedbackType.TextHandleMove (~iOS .soft) |
| Track change on Now Playing | Crossfade artwork with scale 0.98 → 1 over 400ms |
| Mini-player → full Now Playing | SharedTransitionLayout 44 → 340dp, 0.35s spring(dampingRatio = 0.8f); fallback scaleIn |
| Equalizer bars | rememberInfiniteTransition 3 bars’ fillMaxHeight, frozen when paused |
| Lyrics scroll | active line animateFloatAsState alpha 0.6 → 1, size 22 → 28sp, ~300ms, sync to audio |
| Heart like | Animatable 1 → 1.2 → 1 over 300ms, fill → Red; HapticFeedbackType.LongPress (~.success) |
| Shuffle toggle | animateColorAsState secondary → Red, 200ms; selection haptic |
| Long-press tile | scale → 0.96 + haptic; context menu via DropdownMenu |
// Equalizer — red bars on the currently-playing row
@Composable
fun Equalizer(color: Color, playing: Boolean = true) {
val t = rememberInfiniteTransition(label = "eq")
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
repeat(3) { i ->
val h by t.animateFloat(
0.3f, 1f,
infiniteRepeatable(tween(400 + i * 120), RepeatMode.Reverse),
label = "bar$i",
)
Box(
Modifier
.width(3.dp)
.fillMaxHeight(if (playing) h else 0.3f)
.background(color),
)
}
}
}
Haptics: prefer LocalHapticFeedback. For richer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) — map .soft (Play) to VibrationEffect.createOneShot(10, ...), .success (heart like) to a double-buzz VibrationEffect.createWaveform(longArrayOf(0, 40, 60, 40), -1), and .selection (shuffle / tab / chip) to a short tick.
7. Icons
Apple Music uses SF Symbols exclusively; the closest first-party set is androidx.compose.material:material-icons-extended. The explicit E badge is a custom rounded-rect composable (shown in §3); ship any non-matching glyph as a vector drawable via ImageVector.vectorResource(R.drawable.…).
| Component | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Play | play.fill |
Icons.Filled.PlayArrow |
| Pause | pause.fill |
Icons.Filled.Pause |
| Forward / Previous | forward.fill / backward.fill |
Icons.Filled.SkipNext / SkipPrevious |
| Shuffle | shuffle |
Icons.Filled.Shuffle |
| Repeat | repeat / repeat.1 |
Icons.Filled.Repeat / RepeatOne |
| Heart | heart / heart.fill |
Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite |
| Download | arrow.down.circle |
Icons.Filled.DownloadForOffline |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| More | ellipsis |
Icons.Filled.MoreHoriz |
| Search | magnifyingglass |
Icons.Filled.Search |
| Home (Listen Now) | house / house.fill |
Icons.Filled.Home |
| New (Browse) | music.note.list |
Icons.Filled.LibraryMusic |
| Radio | dot.radiowaves.left.and.right |
Icons.Filled.Podcasts |
| Library | square.stack |
Icons.Filled.LibraryBooks |
| Lyrics | quote.bubble |
Icons.Filled.Lyrics |
| AirPlay | airplayaudio |
Icons.Filled.Cast |
| Queue | list.bullet |
Icons.AutoMirrored.Filled.QueueMusic |
| Chevron | chevron.right |
Icons.AutoMirrored.Filled.KeyboardArrowRight |
| Explicit | custom ‘E’ in rounded rect | custom composable (see §3) |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; Palette +
SharedTransitionLayout+ modern motion are comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge()in theActivity. Light canvas wants dark-content (dark icons) system bars; the true-black dark canvas wants light-content. Now Playing’s full-bleed gradient should draw under the system bars. ApplyScaffoldinsets so the mini-player clears the gesture nav. - Font scaling: Apple Music is a flagship Dynamic Type app —
sphonors the user’s font scale; keep it on track titles, artist names, headers, body, and lyrics (current line scales up to ~36sp). Pin layout-sensitive geometry (the 64dp play glyph, scrubber thumb, mini-player frame, 10sp tab labels) by deriving fromdporCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). At the largest scales the mini-player drops text and shows only artwork + play. - TalkBack: the Play button reads
"Play Blinding Lights by The Weeknd"; the heart reads"Add to Favorites"/"Remove from Favorites". Merge track-row text withModifier.semantics(mergeDescendants = true)and mark the trailing ellipsis as a separate button. Lyrics lines expose their text; the current line can announce vialiveRegion. - Touch targets: Material guidance is 48.dp minimum. The 64dp Now Playing play button is well clear; inline play buttons and tab items are 44–49dp — bump the 20dp ellipsis and 22dp mini-player controls to a 48.dp hit box via padding.
- Contrast: pure black on white (and white on true black) is WCAG AAA. Secondary at 60% (
#3C3C43@60% light /#EBEBF5@60% dark) passes AA at ≥14sp Medium — acceptable for artist/metadata, not for primary information at small sizes. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0f, skip the mini-player spring expansion (use aCrossfade), freeze the equalizer, and render the current lyric line plainly without the scale animation. - Dynamic color: do not enable Material You
dynamicColorScheme()— Apple Music Red#FA2D48, the true-black#000000dark canvas, and the 12dp album corner are signature, intentionally system-Apple choices that must not recolor to the user’s wallpaper. The Now Playing gradient comes from album art (Palette), never from the system accent.