TIDAL (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports TIDAL’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the signature quality badge, 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 (TIDAL’s pure-black void, white actions, fidelity-only cyan, square art, radical flatness) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar, tonal-step surfaces instead of shadows,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for images.
1. Color Tokens
// ui/theme/TidalColors.kt
import androidx.compose.ui.graphics.Color
object TidalColors {
// Canvas & Surfaces (tonal steps — TIDAL uses these instead of shadows)
val Canvas = Color(0xFF000000)
val Surface1 = Color(0xFF0A0A0A)
val Surface2 = Color(0xFF1A1A1A)
val Divider = Color(0xFF262626)
// Text
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFF9A9A9A)
val TextTertiary = Color(0xFF5C5C5C)
// Brand
val White = Color(0xFFFFFFFF) // action color
val Cyan = Color(0xFF00FFFF) // quality-tier badges ONLY
val CyanPressed = Color(0xFF00CCCC)
val ErrorRed = Color(0xFFFF453A)
}
Wire it into a Material 3 darkColorScheme. TIDAL is dark-only; do not provide a light scheme.
// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
private val TidalScheme = darkColorScheme(
primary = TidalColors.White, // white is the action color
onPrimary = TidalColors.Canvas, // black on white
background = TidalColors.Canvas,
onBackground = TidalColors.TextPrimary,
surface = TidalColors.Surface1,
onSurface = TidalColors.TextPrimary,
surfaceVariant = TidalColors.Surface2,
outline = TidalColors.Divider,
tertiary = TidalColors.Cyan, // reserved: quality badges only
error = TidalColors.ErrorRed,
)
@Composable
fun TidalTheme(content: @Composable () -> Unit) =
MaterialTheme(colorScheme = TidalScheme, typography = TidalTypography, content = content)
2. Typography
TIDAL’s brand sans is proprietary. Drop the TTFs in res/font/ (lowercase, snake_case), or use Space Grotesk as the closest free geometric grotesque; final fallback is the system font (Roboto).
// ui/theme/TidalType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val SpaceGrotesk = FontFamily(
Font(R.font.space_grotesk_regular, FontWeight.Normal), // 400
Font(R.font.space_grotesk_semibold, FontWeight.SemiBold), // 600
Font(R.font.space_grotesk_bold, FontWeight.Bold), // 700
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object TidalText {
val TitleLarge = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.4).sp)
val NowPlaying = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 29.sp, letterSpacing = (-0.3).sp)
val Section = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val AlbumTitle = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp)
val TrackTitle = TextStyle(SpaceGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 21.sp)
val CardTitle = TextStyle(SpaceGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Subtitle = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 18.sp)
val Body = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
val Meta = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
val Badge = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 1.0.sp)
val LabelUpper = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.8.sp)
val Button = TextStyle(SpaceGrotesk, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 18.sp, letterSpacing = 0.3.sp)
val Tab = TextStyle(SpaceGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.3.sp)
val Timestamp = TextStyle(SpaceGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 13.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val TidalTypography = Typography(
headlineLarge = TidalText.TitleLarge,
headlineSmall = TidalText.Section,
titleMedium = TidalText.TrackTitle,
bodyMedium = TidalText.Body,
labelSmall = TidalText.Tab,
)
3. Signature Components
Quality-Tier Badge
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
enum class TidalQuality(val label: String) {
MASTER("MASTER"), HIFI("HIFI"), MAX("MAX"), ATMOS("DOLBY ATMOS")
}
@Composable
fun QualityBadge(quality: TidalQuality, modifier: Modifier = Modifier) {
Text(
text = quality.label,
style = TidalText.Badge,
color = TidalColors.Cyan,
modifier = modifier
.border(1.dp, TidalColors.Cyan, RoundedCornerShape(4.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
.semantics { contentDescription = "${quality.label} quality" },
)
}
Primary Play Button (flat — no shadow)
@Composable
fun TidalPlayButton(
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.93f else 1f, spring(dampingRatio = 0.75f), label = "play")
val haptics = LocalHapticFeedback.current
Box(
modifier
.size(size)
.scale(scale)
.clip(CircleShape)
.background(if (pressed) Color(0xFFE5E5E5) else TidalColors.White), // no shadow — flat
contentAlignment = Alignment.Center,
) {
Icon(
if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = TidalColors.Canvas, // black on white
modifier = Modifier.size(size * 0.4f),
)
// intentionally no .clickable indication ripple beyond default; attach onClick:
}.also {
// attach click + haptic
}
}
Use this click-wired variant in practice:
@Composable
fun TidalPlayButton(isPlaying: Boolean, onClick: () -> Unit, size: Dp = 64.dp) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.93f else 1f, spring(dampingRatio = 0.75f), label = "play")
val haptics = LocalHapticFeedback.current
Box(
Modifier
.size(size).scale(scale).clip(CircleShape)
.background(if (pressed) Color(0xFFE5E5E5) else TidalColors.White)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS medium impact
onClick()
},
contentAlignment = Alignment.Center,
) {
Icon(
if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = TidalColors.Canvas,
modifier = Modifier.size(size * 0.4f),
)
}
}
Primary / Outline Pill
enum class PillStyle { Filled, Outline }
@Composable
fun TidalPill(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, style: PillStyle = PillStyle.Filled) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, label = "pill")
val filled = style == PillStyle.Filled
Box(
modifier
.scale(scale)
.clip(CircleShape) // full pill
.then(
if (filled) Modifier.background(if (pressed) Color(0xFFE5E5E5) else TidalColors.White)
else Modifier.border(1.dp, if (pressed) TidalColors.White else TidalColors.TextSecondary, CircleShape)
)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = if (filled) 32.dp else 24.dp, vertical = 11.dp),
contentAlignment = Alignment.Center,
) {
Text(
text,
style = if (filled) TidalText.Button else TidalText.Subtitle.copy(fontWeight = FontWeight.SemiBold),
color = if (filled) TidalColors.Canvas else TidalColors.TextPrimary,
)
}
}
Track Row (with quality badge + equalizer)
@Composable
fun TrackRow(
title: String,
artist: String,
artworkUrl: String,
duration: String,
quality: TidalQuality?,
isPlaying: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Row(
modifier
.fillMaxWidth()
.height(60.dp)
.background(if (pressed) TidalColors.Surface2 else TidalColors.Canvas)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box {
AsyncImage(
model = artworkUrl,
contentDescription = null,
modifier = Modifier.size(44.dp), // square — no clip/round
contentScale = ContentScale.Crop,
)
if (isPlaying) Equalizer(Modifier.align(Alignment.CenterStart).padding(start = 4.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = TidalText.TrackTitle, color = TidalColors.TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(artist, style = TidalText.Subtitle, color = TidalColors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
quality?.let { QualityBadge(it) }
Text(duration, style = TidalText.Timestamp, color = TidalColors.TextSecondary)
Icon(Icons.Filled.MoreHoriz, "More", tint = TidalColors.TextSecondary, modifier = Modifier.size(20.dp))
}
}
@Composable
fun Equalizer(modifier: Modifier = Modifier) {
val t = rememberInfiniteTransition(label = "eq")
Row(modifier, horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.Bottom) {
repeat(3) { i ->
val h by t.animateFloat(0.3f, 1f, infiniteRepeatable(tween(360 + i * 120), RepeatMode.Reverse), label = "bar$i")
Box(Modifier.width(3.dp).height((16 * h).dp).background(TidalColors.White))
}
}
}
4. Quality Tier Mapping
fun badgeFor(format: String): TidalQuality? = when (format) {
"flac-24" -> TidalQuality.MASTER
"flac-16" -> TidalQuality.HIFI
"flac-24-max" -> TidalQuality.MAX
"atmos" -> TidalQuality.ATMOS
else -> null // no badge for lossy
}
5. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. TIDAL’s iOS tab bar is .regularMaterial blur over near-black; Android has no first-class live blur, so use a 96%-opaque black surface. Active tint is white — cyan is fidelity-only.
@Composable
fun TidalBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = TidalColors.Canvas.copy(alpha = 0.96f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Videos" to Icons.Filled.Movie,
"Search" to Icons.Filled.Search,
"My Collection" to Icons.Filled.LibraryMusic,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp)) },
label = { Text(label, style = TidalText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = TidalColors.White,
selectedTextColor = TidalColors.White,
unselectedIconColor = TidalColors.TextSecondary,
unselectedTextColor = TidalColors.TextSecondary,
indicatorColor = Color.Transparent, // TIDAL has no Material pill
),
)
}
}
}
The persistent Now Playing mini-bar sits directly above this bar: a 52.dp Surface(color = TidalColors.Surface2) row with a 1.dp TidalColors.White progress hairline pinned to its top edge — render it in the Scaffold bottomBar slot stacked above TidalBottomBar.
6. Motion
| Moment | Compose recipe |
|---|---|
| Screen / now-playing transition | fadeIn() / fadeOut() crossfade only — no slide, no spring |
| Play/pause tap | animateFloatAsState 1 → 0.93 with spring(dampingRatio = 0.75f), HapticFeedbackType.LongPress |
| Scrubber drag | Animate track thickness 1.dp → 3.dp; HapticFeedbackType.TextHandleMove ticks |
| Equalizer (now-playing row) | rememberInfiniteTransition driving 3 white bars’ height, frozen when paused |
| Favorite tap | Animatable keyframes 1 → 1.12 → 1 over 260ms; HapticFeedbackType.LongPress |
| Mini-bar → full player | Crossfade on the screen + SharedTransitionLayout artwork that fades (not springs) |
// Crossfade navigation (TIDAL's editorial restraint — no slide)
Crossfade(targetState = route, animationSpec = tween(250), label = "nav") { r ->
when (r) {
Route.Home -> HomeScreen()
Route.NowPlaying -> NowPlayingScreen()
else -> {}
}
}
Haptics: prefer LocalHapticFeedback. For scrubber ticks, LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) (API 30+) approximates iOS selection feedback.
7. Icons
The closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export TIDAL’s glyphs as vector drawables and load via ImageVector.vectorResource(R.drawable.…).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Play | play.fill |
Icons.Filled.PlayArrow |
| Pause | pause.fill |
Icons.Filled.Pause |
| Next / Previous | forward.end.fill / backward.end.fill |
Icons.Filled.SkipNext / SkipPrevious |
| Shuffle | shuffle |
Icons.Filled.Shuffle |
| Repeat | repeat |
Icons.Filled.Repeat |
| Favorite | heart / heart.fill |
Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite |
| Add to collection | plus.circle |
Icons.Outlined.AddCircle |
| Credits | info.circle |
Icons.Outlined.Info |
| Share | square.and.arrow.up |
Icons.Filled.Share |
| More | ellipsis |
Icons.Filled.MoreHoriz |
| Search | magnifyingglass |
Icons.Filled.Search |
| Home (tab) | house.fill |
Icons.Filled.Home |
| Videos (tab) | play.rectangle.fill |
Icons.Filled.Movie |
| Collection (tab) | square.stack.fill |
Icons.Filled.LibraryMusic |
| Cast | airplayaudio |
Icons.Filled.Cast |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion is comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the pure-black canvas wants light-content system bars. ApplyScaffoldinsets so the mini-bar clears gesture nav. - Font scaling:
sphonors the user’s font scale — keep it on titles, artists, body. Pin quality badges, timestamps, and tab labels (exact geometry) by deriving size fromdporCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - TalkBack: include the quality tier in the row semantics (“…, Master quality, 3:08”); the
QualityBadgealready setscontentDescription. - Touch targets: Material guidance is 48.dp minimum. The 64.dp play button clears it; give the 20.dp ellipsis and the 10.dp quality badge a 48.dp hit area via padding.
- Contrast:
#00FFFFon#000000is AAA — safe for the 10sp badge.#9A9A9Aon#000000passes AA at 14sp+; validate the 11sp meta/timestamp and lighten toward#A8A8A8if targeting strict compliance. - Dynamic color: do not enable Material You
dynamicDarkColorScheme()— TIDAL’s brand requires the fixed pure-black canvas and the white/cyan system regardless of wallpaper. There is no light theme.