Plex (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Plex’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the server picker, the On Deck rail, 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 (cool charcoal canvas — not true black, single Plex Yellow accent with mandatory dark ink, the server picker, the On Deck progress bar, the quiet unwatched dot) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,ModalBottomSheetfor the server sheet,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for poster art and On Deck stills. No color extraction — Plex’s palette is a fixed charcoal+yellow system, so Palette is not needed. Plex’s media experience is dark-first; a pure-white light scheme is not the identity.
1. Color Tokens
// ui/theme/PlexColors.kt
import androidx.compose.ui.graphics.Color
object PlexColors {
// Canvas & Surfaces (dark-first, cool charcoal — NOT true black)
val Canvas = Color(0xFF1F2326)
val Surface1 = Color(0xFF282C30) // server pill / sheet
val Surface2 = Color(0xFF32373B) // rows / search / ghost button
val Surface3 = Color(0xFF3C4146) // selected row / progress track
val Divider = Color(0xFF34393D)
// Text
val TextPrimary = Color(0xFFF2F3F4)
val TextSecondary = Color(0xFF9BA0A4)
val TextTertiary = Color(0xFF6B7075)
// Brand & status
val Yellow = Color(0xFFE5A00D) // the ONLY accent
val YellowPressed = Color(0xFFC98A09)
val YellowInk = Color(0xFF1A1304) // text on yellow — mandatory, never white
val Online = Color(0xFF4CAF50)
val Offline = Color(0xFF6B7075)
val Error = Color(0xFFE5484D)
}
Wire it into a dark-first scheme. Plex’s media surfaces are charcoal — not true black, not a light mode.
// ui/theme/Theme.kt
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
private val PlexScheme = darkColorScheme(
primary = PlexColors.Yellow, // the single accent
onPrimary = PlexColors.YellowInk, // dark-on-yellow (mandatory)
background = PlexColors.Canvas,
onBackground = PlexColors.TextPrimary,
surface = PlexColors.Surface1,
onSurface = PlexColors.TextPrimary,
surfaceVariant = PlexColors.Surface2,
outline = PlexColors.Divider,
error = PlexColors.Error,
)
@Composable
fun PlexTheme(content: @Composable () -> Unit) =
MaterialTheme(
colorScheme = PlexScheme, // media stays dark charcoal — don't branch on isSystemInDarkTheme()
typography = PlexTypography,
content = content,
)
2. Typography
Plex’s product face is Inter — neutral, legible, functional. Drop the TTFs in res/font/. Inter is SIL OFL — free to ship.
// ui/theme/PlexType.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 Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_bold, FontWeight.Bold),
Font(R.font.inter_extrabold, FontWeight.ExtraBold),
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object PlexText {
val ScreenTitle = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.4).sp)
val HeroTitle = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.3).sp)
val SectionHeader = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.2).sp)
val Subsection = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 23.sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val CardTitle = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val ButtonLabel = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp)
val ServerName = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 18.sp)
val ServerMeta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
val TechChip = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.2.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val PlexTypography = Typography(
headlineLarge = PlexText.ScreenTitle,
headlineMedium = PlexText.HeroTitle,
titleMedium = PlexText.CardTitle,
bodyMedium = PlexText.Body,
labelSmall = PlexText.Tab,
)
3. Signature Components
Server Picker Pill + Sheet Row
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
@Composable
fun ServerPill(name: String, isOnline: Boolean, onClick: () -> Unit) {
Row(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(PlexColors.Surface1)
.border(1.dp, PlexColors.Divider, RoundedCornerShape(999.dp))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(Modifier.size(8.dp).clip(CircleShape).background(if (isOnline) PlexColors.Online else PlexColors.Offline))
Text(name, style = PlexText.ServerName.copy(fontSize = 13.sp), color = PlexColors.TextPrimary)
Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null, tint = PlexColors.TextSecondary, modifier = Modifier.size(14.dp))
}
}
@Composable
fun ServerSheetRow(
name: String,
meta: String, // "Online · 2,481 items · LAN"
isOnline: Boolean,
isSelected: Boolean,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(if (isSelected) PlexColors.Surface3 else PlexColors.Surface2)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.size(9.dp).clip(CircleShape).background(if (isOnline) PlexColors.Online else PlexColors.Offline))
Column(Modifier.weight(1f)) {
Text(name, style = PlexText.ServerName, color = PlexColors.TextPrimary)
Text(meta, style = PlexText.ServerMeta, color = PlexColors.TextSecondary)
}
if (isSelected) {
Icon(Icons.Filled.Check, contentDescription = "Selected", tint = PlexColors.Yellow, modifier = Modifier.size(16.dp))
}
}
}
On Deck Tile (resume-aware)
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
@Composable
fun OnDeckTile(
artUrl: String,
title: String,
sub: String, // "S2 · E5 · 22 min left"
progress: Float, // 0f..1f
) {
Column(Modifier.width(220.dp)) {
Box(
Modifier
.size(220.dp, 124.dp)
.clip(RoundedCornerShape(6.dp))
.background(PlexColors.Surface2)
) {
AsyncImage(model = artUrl, contentDescription = title,
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
Box(
Modifier
.align(Alignment.Center)
.size(42.dp).clip(CircleShape)
.background(Color.Black.copy(alpha = 0.55f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp))
}
Box(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth().height(3.dp)
.background(Color.White.copy(alpha = 0.22f))
) {
Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(PlexColors.Yellow))
}
}
Spacer(Modifier.height(8.dp))
Text(title, style = PlexText.CardTitle, color = PlexColors.TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(sub, style = PlexText.Meta, color = PlexColors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Primary Play Button (dark-on-yellow)
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@Composable
fun PlayButton(onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Button(
onClick = onClick,
interactionSource = interaction,
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (pressed) PlexColors.YellowPressed else PlexColors.Yellow,
contentColor = PlexColors.YellowInk, // dark ink — NEVER white
),
modifier = Modifier.fillMaxWidth().height(48.dp),
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
Text("Play", style = PlexText.ButtonLabel)
}
}
Library Poster + Unwatched Dot
import androidx.compose.ui.draw.shadow
@Composable
fun LibraryPoster(artUrl: String, unwatched: Boolean) {
Box(Modifier.aspectRatio(2f / 3f).clip(RoundedCornerShape(6.dp)).background(PlexColors.Surface2)) {
AsyncImage(model = artUrl, contentDescription = null,
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
if (unwatched) {
Box(
Modifier
.align(Alignment.TopEnd)
.padding(7.dp)
.size(9.dp)
.shadow(2.dp, CircleShape)
.clip(CircleShape)
.background(PlexColors.Yellow)
)
}
}
}
Tech / Quality Chip
@Composable
fun TechChip(text: String) {
Box(
Modifier
.clip(RoundedCornerShape(4.dp))
.background(PlexColors.Surface2)
.padding(horizontal = 8.dp, vertical = 4.dp)
) { Text(text, style = PlexText.TechChip, color = PlexColors.TextSecondary) }
}
4. Navigation
Plex has a clean top inset and a 5-tab bottom strip. On Android, model the strip as a NavigationBar and the server sheet as a ModalBottomSheet. There is no tint pill — active is just Plex Yellow.
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@Composable
fun PlexBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = PlexColors.Canvas.copy(alpha = 0.96f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Library" to Icons.Filled.GridView,
"Discover" to Icons.Filled.PlayCircle,
"Live TV" to Icons.Filled.Tv,
"You" to Icons.Filled.AccountCircle,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(22.dp)) },
label = { Text(label, style = PlexText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = PlexColors.Yellow, // the only accent
selectedTextColor = PlexColors.Yellow,
unselectedIconColor = PlexColors.TextTertiary,
unselectedTextColor = PlexColors.TextTertiary,
indicatorColor = Color.Transparent, // no Material pill
),
)
}
}
}
The server sheet (ModalBottomSheet, Surface1 background, rounded 12.dp top) holds a list of ServerSheetRows; the selected server’s row uses Surface3 and a yellow check. Plex keeps a clean top inset (server pill + nav) — do not use a translucent full-bleed top bar.
5. Motion
Plex motion is quiet and calm — 150–280ms ease-out, no bounce.
| Moment | Compose recipe |
|---|---|
| Server switch | check AnimatedVisibility fadeIn(tween(150)); dismiss sheet; Home content Crossfade(tween(250)) to the new server |
| On Deck resume | tile press animateFloatAsState scale → 0.97 tween(150); on return, progress animateFloatAsState 0 → watched tween(350) |
| Card press | animateFloatAsState scale → 0.97 tween(150) |
| Mark as Watched | unwatched dot AnimatedVisibility fadeOut(tween(200)) + a “Marked as watched” snackbar |
| Status dot online | color cross-fade gray → green tween(200) (no spring) |
| Sheet present | ModalBottomSheet default slide-up; scrim Color.Black.copy(alpha = 0.55f) |
| Tab switch | instant content swap; active-tab color cross-fade tween(120) |
// On Deck progress fill on return — the canonical Plex resume cue
val fill by animateFloatAsState(if (focused) watched else 0f, tween(350), label = "onDeckFill")
Box(Modifier.fillMaxHeight().fillMaxWidth(fill).background(PlexColors.Yellow))
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for a soft tick on server selection, tile resume, and Mark-as-Watched — nothing heavier; Plex motion is deliberately quiet.
6. Icons
Plex iconography is clean and functional; the closest first-party set is androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Home (tab) | house / house.fill |
Icons.Filled.Home |
| Library (tab) | square.grid.2x2 |
Icons.Filled.GridView |
| Discover (tab) | play.circle |
Icons.Filled.PlayCircle |
| Live TV (tab) | tv |
Icons.Filled.Tv |
| You (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| Server chevron | chevron.down |
Icons.Filled.KeyboardArrowDown |
| Selected server | checkmark |
Icons.Filled.Check |
| Play (CTA / overlay) | play.fill |
Icons.Filled.PlayArrow |
| Mark as Watched | checkmark.circle |
Icons.Filled.CheckCircle |
| Add to Playlist | text.badge.plus |
Icons.Filled.PlaylistAdd |
| Search | magnifyingglass |
Icons.Filled.Search |
| Settings | gearshape |
Icons.Filled.Settings |
| Cast | airplayvideo |
Icons.Filled.Cast |
| Back | chevron.left |
Icons.Filled.ArrowBack |
| Download | arrow.down.circle |
Icons.Filled.Download |
| Online/Offline dot | (a Box circle) |
(a Box circle, not an icon) |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
ModalBottomSheet+ modern motion are comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(). Plex keeps a clean top inset (server pill / nav) — do not draw poster art under the status bar. The cool charcoal canvas wants light-content system bars. - Dark-first media: do not branch on
isSystemInDarkTheme()for the library/browse surfaces, and do not enable Material YoudynamicColorScheme()— the cool charcoal + single-yellow identity must hold regardless of wallpaper. - Dark-on-yellow is mandatory:
onPrimary = YellowInkand every yellow fill uses#1A1304content — white-on-yellow fails WCAG AA. Centralize so it can’t drift. - Font scaling:
sphonors the user’s font scale — keep it on screen/hero titles, section headers, body, summaries, meta. Pin layout-sensitive text (10sp tab labels, tech chips, progress-bar geometry, status dots, unwatched dot) by deriving fromdp. - TalkBack: label the server pill “Server: {name}, {online/offline}”; server rows “{name}, {meta}, {selected}”; On Deck tiles “{title}, {sub}, {percent} percent watched” with
Modifier.semantics { progressBarRangeInfo = ProgressBarRangeInfo(progress, 0f..1f) }. - Color-only state: status dots convey online/offline by color — always back them with the text label (“Online”/“Offline”) in the meta line so the state isn’t color-only.
- Touch targets: Material guidance is 48.dp. The Play button is 48.dp; give the 22.dp tab icons a 48.dp hit area; On Deck tiles are full-tile tappable (≥124.dp); server rows are full-row (≥44.dp).
- Contrast:
#F2F3F4on#1F2326passes AA;#9BA0A4meta passes AA at 14sp; the yellow check is an icon supported by the row’s selectedSurface3background. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, replace the HomeCrossfadeon server switch with an instant swap; keep the On Deck progress static at the watched value (no fill animation). Plex’s motion is already minimal, so little else changes. - Player: lock the video activity to landscape + immersive full-screen; pull-to-refresh rescans the current server with a subtle yellow indicator.