Files
memby/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt
T
ponzischeme89andClaude Opus 5 4a4df7a73c App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the
player; MDBList ratings strip; episode and schedule detail pages; series
pace estimate; what's new panel; install-permission onboarding step;
synced per-profile preferences; Emby outage banner.

Gateway: rebuilt admin console (one fragment per page), preference
history and restore, merged Continue Watching, Emby health probe,
subtitle selection and Bazarr download, structured request logging with
per-request identity, and embedded build version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:33:56 +12:00

2270 lines
88 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.ponzischeme89.memby.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onLongClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.material.icons.filled.Recommend
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.filled.Tv
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
import com.ponzischeme89.memby.ui.detail.formatRuntime
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import java.util.Locale
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
// The launcher's names for the shared tokens. Both surfaces read from one palette now
// (ui/theme/DesignTokens.kt): a detail page opens from a row and the two sit side by side,
// so a green or a grey that differs by a shade differs in front of the viewer.
private val EmbyGreen = MembyAccent
private val RailSurface = Color(0xF20C0F12)
private val MutedText = MembyMutedText
private val QuietText = MembyQuietText
internal val TvRailCollapsedWidth = 54.dp
internal val TvRailExpandedWidth = 184.dp
internal val TvRailContentShift = 112.dp
enum class BrowseDestination(val label: String, val icon: ImageVector) {
HOME("Home", Icons.Default.Home),
FOR_YOU("For You", Icons.Default.AutoAwesome),
SEARCH("Search", Icons.Default.Search),
MOVIES("Movies", Icons.Default.Movie),
SHOWS("TV Shows", Icons.Default.Tv),
FAVORITES("Favourites", Icons.Default.Favorite),
PROFILES("Switch user", Icons.Default.Person),
SETTINGS("Settings", Icons.Default.Settings),
}
// No NEXT_UP: those episodes are part of CONTINUE, which is one row.
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }
data class HomeBrowseRow(
val id: String,
val title: String,
val items: List<BaseItem>,
val kind: MediaRowKind,
val loading: Boolean = false,
val emptyMessage: String,
val showSecondaryMetadata: Boolean = true,
val showWatchedEpisodeCount: Boolean = false,
)
private data class HomeRowVisual(
val icon: ImageVector,
)
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
row.id == "continue" -> HomeRowVisual(
Icons.Default.PlayCircleFilled,
)
row.id == "continue-shows" -> HomeRowVisual(
Icons.Default.PlayCircleFilled,
)
row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
Icons.Default.Favorite,
)
row.id == "latest-movies" -> HomeRowVisual(
Icons.Default.Movie,
)
row.id == "sonarr-airing-today" -> HomeRowVisual(
Icons.Default.CalendarMonth,
)
row.id == "curated:apple-tv" -> HomeRowVisual(
Icons.Default.LiveTv,
)
row.id == "curated:drama-shows" -> HomeRowVisual(
Icons.Default.TheaterComedy,
)
row.id == "curated:comedy-shows" -> HomeRowVisual(
Icons.Default.SentimentVerySatisfied,
)
row.id.startsWith("similar:") -> HomeRowVisual(
Icons.Default.AutoAwesome,
)
row.id == "recommended" -> HomeRowVisual(
Icons.Default.Recommend,
)
row.id.startsWith("for-you:") -> HomeRowVisual(
Icons.Default.AutoAwesome,
)
else -> HomeRowVisual(
Icons.Default.VideoLibrary,
)
}
@Composable
fun TvNavigationRail(
selected: BrowseDestination,
expanded: Boolean,
navigationFocusRequester: FocusRequester,
onRailFocusChanged: (Boolean) -> Unit,
onDestinationSelected: (BrowseDestination) -> Unit,
modifier: Modifier = Modifier,
) {
var railHasFocus by remember { mutableStateOf(false) }
val logoScale = remember { Animatable(0.72f) }
val logoAlpha = remember { Animatable(0f) }
val logoRotation = remember { Animatable(0f) }
LaunchedEffect(Unit) {
kotlinx.coroutines.coroutineScope {
launch { logoScale.animateTo(1f, tween(620)) }
launch { logoAlpha.animateTo(1f, tween(420)) }
}
}
LaunchedEffect(selected) {
if (selected == BrowseDestination.HOME) {
logoRotation.snapTo(0f)
logoRotation.animateTo(360f, tween(850))
}
}
LaunchedEffect(railHasFocus) {
if (railHasFocus) {
// Spatial focus may initially choose the first rail item (Home). Correct that
// only as focus enters the rail; subsequent up/down movement must remain free.
runCatching { navigationFocusRequester.requestFocus() }
}
}
val railWidth = animateDpAsState(
targetValue = if (expanded) TvRailExpandedWidth else TvRailCollapsedWidth,
animationSpec = tween(160),
label = "navigation-rail-width",
)
Box(
modifier = modifier
.width(TvRailCollapsedWidth)
.fillMaxHeight()
.zIndex(8f),
) {
Column(
modifier = Modifier
// The parent deliberately reports only the collapsed footprint to the
// home Row. requiredWidth lets the focused surface draw outward without
// remeasuring gallery cards or clipping their labels to 54dp.
// Only width may escape the collapsed 54dp footprint. Unbounding height
// here would make fillMaxHeight lose the screen constraint and stop the
// rail surface at its final child (the version label).
.wrapContentWidth(Alignment.Start, unbounded = true)
// Read in the measure lambda, not the modifier chain: expanding the rail
// is the most frequent animation in the app, and reading the animated Dp
// during composition recomposes the rail and every item in it per frame.
.layout { measurable, constraints ->
val width = railWidth.value.roundToPx()
val placeable = measurable.measure(
constraints.copy(minWidth = width, maxWidth = width),
)
layout(placeable.width, placeable.height) { placeable.place(0, 0) }
}
.fillMaxHeight()
.background(RailSurface)
.onFocusChanged { focusState ->
val hasFocus = focusState.hasFocus
if (railHasFocus != hasFocus) {
railHasFocus = hasFocus
onRailFocusChanged(hasFocus)
}
}
.focusGroup()
.padding(horizontal = 5.dp, vertical = 15.dp),
horizontalAlignment = Alignment.Start,
) {
Row(
modifier = Modifier.height(42.dp).padding(horizontal = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = "Memby",
modifier = Modifier
.width(32.dp)
.height(27.dp)
.graphicsLayer {
scaleX = logoScale.value
scaleY = logoScale.value
alpha = logoAlpha.value
rotationZ = logoRotation.value
},
)
if (expanded) {
Column(verticalArrangement = Arrangement.Center) {
Text(
"Memby",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
Text(
"Matts Android TV client",
color = QuietText,
fontSize = 9.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
Spacer(Modifier.height(16.dp))
BrowseDestination.entries.forEach { destination ->
ExpandableNavigationItem(
destination = destination,
selected = destination == selected,
expanded = expanded,
modifier = if (destination == selected) Modifier.focusRequester(navigationFocusRequester) else Modifier,
onFocused = {},
onClick = { onDestinationSelected(destination) },
)
Spacer(Modifier.height(4.dp))
}
Spacer(Modifier.weight(1f))
Text(
if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}",
color = QuietText,
fontSize = if (expanded) 10.sp else 8.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp),
)
}
}
}
@Composable
fun UserSwitcherOverlay(
profiles: List<EmbyProfile>,
activeProfileId: String?,
onProfileSelected: (EmbyProfile) -> Unit,
onManageProfiles: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val profileIds = profiles.map(EmbyProfile::id)
val focusRequesters = remember(profileIds) {
List(profiles.size + 1) { FocusRequester() }
}
val profileListState = remember(profileIds) { LazyListState() }
val focusScope = rememberCoroutineScope()
var focusedIndex by remember(profileIds) {
mutableStateOf(userSwitcherInitialIndex(profileIds, activeProfileId))
}
LaunchedEffect(profileIds, activeProfileId) {
focusedIndex = userSwitcherInitialIndex(profileIds, activeProfileId)
if (profiles.isNotEmpty()) {
profileListState.scrollToItem(focusedIndex)
}
// Let a lazily composed off-screen profile attach its FocusRequester first.
delay(16)
runCatching { focusRequesters[focusedIndex].requestFocus() }
}
Box(
modifier
.fillMaxSize()
.zIndex(20f)
.background(Color.Black.copy(alpha = 0.56f)),
) {
Column(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 52.dp)
.width(292.dp)
.heightIn(max = 400.dp)
.shadow(12.dp, RoundedCornerShape(MembyPanelCorner))
.clip(RoundedCornerShape(MembyPanelCorner))
.background(Color(0xFF090B0D))
.border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner))
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (event.key) {
Key.DirectionUp, Key.DirectionDown -> {
val direction = if (event.key == Key.DirectionUp) {
UserSwitcherDirection.UP
} else {
UserSwitcherDirection.DOWN
}
val next = userSwitcherNextIndex(
currentIndex = focusedIndex,
profileCount = profiles.size,
direction = direction,
)
focusedIndex = next
if (next < profiles.size) {
focusScope.launch {
profileListState.scrollToItem(next)
delay(16)
runCatching { focusRequesters[next].requestFocus() }
}
} else {
runCatching { focusRequesters[next].requestFocus() }
}
true
}
Key.DirectionLeft, Key.Back -> {
onDismiss()
true
}
// Keep focus inside this modal instead of allowing spatial focus
// search to land on a dimmed card behind it.
Key.DirectionRight -> true
else -> false
}
}
.padding(10.dp),
) {
Text(
"Switch user",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp),
)
Text(
"Choose whos watching",
color = QuietText,
fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp),
)
LazyColumn(
state = profileListState,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 248.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
itemsIndexed(
items = profiles,
key = { _, profile -> profile.id },
) { index, profile ->
UserSwitcherProfileItem(
profile = profile,
current = profile.id == activeProfileId,
modifier = Modifier
.focusRequester(focusRequesters[index])
.onFocusChanged { if (it.isFocused) focusedIndex = index },
onClick = { onProfileSelected(profile) },
)
}
}
Spacer(Modifier.height(6.dp))
Box(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color.White.copy(alpha = 0.07f)),
)
Spacer(Modifier.height(6.dp))
UserSwitcherAction(
label = "Manage users",
modifier = Modifier
.focusRequester(focusRequesters[profiles.size])
.onFocusChanged {
if (it.isFocused) focusedIndex = profiles.size
},
onClick = onManageProfiles,
)
}
}
}
@Composable
private fun UserSwitcherProfileItem(
profile: EmbyProfile,
current: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
Row(
modifier = modifier
.fillMaxWidth()
.height(44.dp)
.onFocusChanged { focused = it.isFocused }
.clip(RoundedCornerShape(MembyChipCorner))
.background(
when {
focused -> Color.White.copy(alpha = 0.11f)
current -> EmbyGreen.copy(alpha = 0.07f)
else -> Color.Transparent
},
)
.clickable(onClick = onClick)
.semantics {
contentDescription =
if (current) "${profile.username}, current user" else profile.username
}
.padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
Modifier
.size(26.dp)
.background(if (current) EmbyGreen else Color(0xFF2B3035), CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
profile.username.trim().firstOrNull()?.uppercase() ?: "?",
color = Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
}
Text(
profile.username,
color = if (focused || current) Color.White else MutedText,
fontSize = 14.sp,
fontWeight = if (current) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (current) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = EmbyGreen,
modifier = Modifier.size(14.dp),
)
}
}
}
@Composable
private fun UserSwitcherAction(
label: String,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
Row(
modifier = modifier
.fillMaxWidth()
.height(40.dp)
.onFocusChanged { focused = it.isFocused }
.clip(RoundedCornerShape(MembyChipCorner))
.background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent)
.clickable(onClick = onClick)
.semantics { contentDescription = label }
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Settings,
contentDescription = null,
tint = if (focused) Color.White else QuietText,
modifier = Modifier.size(17.dp),
)
Spacer(Modifier.width(12.dp))
Text(
label,
color = if (focused) Color.White else QuietText,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
fun ExpandableNavigationItem(
destination: BrowseDestination,
selected: Boolean,
expanded: Boolean,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
// Not `by`: both colours are read in the draw phase / at the point of use, so the
// 85ms focus transition repaints this row rather than recomposing it.
val background = animateColorAsState(
targetValue = when {
focused -> Color.White.copy(alpha = 0.13f)
selected -> EmbyGreen.copy(alpha = 0.10f)
else -> Color.Transparent
},
animationSpec = tween(85),
label = "navigation-item-background",
)
val foreground = animateColorAsState(
targetValue = when {
focused -> Color.White
selected -> EmbyGreen
else -> MutedText
},
animationSpec = tween(85),
label = "navigation-item-foreground",
)
Row(
modifier = modifier
.fillMaxWidth()
.height(44.dp)
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onFocused()
}
.clip(RoundedCornerShape(8.dp))
.drawBehind { drawRect(background.value) }
.clickable(onClick = onClick)
.semantics { contentDescription = destination.label }
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.width(28.dp), contentAlignment = Alignment.Center) {
Icon(destination.icon, contentDescription = null, tint = foreground.value, modifier = Modifier.size(21.dp))
if (selected) {
Box(
Modifier
.align(Alignment.CenterStart)
.width(2.dp)
.height(18.dp)
.background(EmbyGreen, RoundedCornerShape(2.dp)),
)
}
}
if (expanded) {
Text(
destination.label,
color = foreground.value,
fontSize = 15.sp,
fontWeight = if (focused || selected) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
)
}
}
}
@Composable
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
val context = LocalContext.current
val repo = ServiceLocator.repository
val imageUrl = item?.let { repo.backdropUrl(it, 1280) ?: repo.primaryUrl(it, 960) }
var displayedUrl by remember { mutableStateOf(imageUrl) }
LaunchedEffect(imageUrl) {
if (displayedUrl == null) {
displayedUrl = imageUrl
} else {
delay(BACKDROP_SETTLE_DELAY_MS)
displayedUrl = imageUrl
}
}
val request = remember(displayedUrl, context) {
displayedUrl?.let {
ImageRequest.Builder(context)
.data(it)
.size(1280, 720)
.allowHardware(true)
.crossfade(false)
.build()
}
}
Box(modifier.background(Color(0xFF090B0D))) {
if (request != null) {
AsyncImage(
model = request,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to Color(0xFF090B0D),
0.58f to Color(0xE3090B0D),
1f to Color(0xA6090B0D),
),
),
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0f to Color(0x73090B0D),
0.66f to Color(0xD6090B0D),
1f to Color(0xFF090B0D),
),
),
)
}
}
@Composable
fun MediaMetadataPanel(
item: BaseItem?,
loading: Boolean,
sectionLabel: String,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(modifier) {
val compactLayout = maxHeight < 260.dp
val contentWidth = metadataPanelContentWidth(maxWidth, compactLayout)
Column(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().fillMaxWidth()) {
when {
item == null && loading -> MetadataLoadingSkeleton(contentWidth)
item == null -> {
Column(
modifier = Modifier.width(contentWidth),
verticalArrangement = Arrangement.spacedBy(if (compactLayout) 7.dp else 10.dp),
) {
Text(sectionLabel, color = QuietText, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Text(
"Your library",
color = Color.White,
fontSize = if (compactLayout) 25.sp else 29.sp,
fontWeight = FontWeight.SemiBold,
)
Text("Choose something to watch.", color = MutedText, fontSize = 14.sp)
}
}
else -> MetadataContent(item, sectionLabel, contentWidth, compactLayout)
}
}
}
}
}
internal fun metadataPanelContentWidth(availableWidth: Dp, compact: Boolean): Dp =
(availableWidth * if (compact) 0.84f else 0.76f)
.coerceIn(280.dp, 720.dp)
.coerceAtMost(availableWidth)
@Composable
private fun MetadataLoadingSkeleton(contentWidth: Dp) {
Column(
modifier = Modifier.width(contentWidth),
verticalArrangement = Arrangement.spacedBy(11.dp),
) {
SkeletonBlock(contentWidth * 0.18f, 11.dp)
SkeletonBlock(contentWidth * 0.62f, 30.dp)
SkeletonBlock(contentWidth * 0.46f, 14.dp)
Spacer(Modifier.height(2.dp))
SkeletonBlock(contentWidth, 13.dp)
SkeletonBlock(contentWidth * 0.86f, 13.dp)
}
}
@Composable
private fun SkeletonBlock(width: Dp, height: Dp) {
Box(
Modifier
.width(width)
.height(height)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFF30363B)),
)
}
@Composable
fun MediaQuickActionsOverlay(
item: BaseItem,
onOpenDetails: (BaseItem) -> Unit,
onSetFavorite: (BaseItem, Boolean) -> Unit,
onSetPlayed: (BaseItem, Boolean) -> Unit,
rowTitle: String? = null,
rowPinned: Boolean = false,
onToggleRowPinned: (() -> Unit)? = null,
onHideRow: (() -> Unit)? = null,
onMoveRow: ((Int) -> Unit)? = null,
onClose: () -> Unit,
) {
val hasRowActions = rowTitle != null &&
onToggleRowPinned != null &&
onHideRow != null &&
onMoveRow != null
val actionCount = if (hasRowActions) 8 else 4
val focusRequesters = remember(item.id) { List(actionCount) { FocusRequester() } }
var focusedIndex by remember(item.id) { mutableStateOf(0) }
// The menu can appear while OK is still physically held. Until that opening press
// is released, consume all activation events so it cannot trigger the first action.
var openingPressReleased by remember(item.id) { mutableStateOf(false) }
LaunchedEffect(item.id) {
delay(16)
runCatching { focusRequesters.first().requestFocus() }
}
Box(
Modifier
.fillMaxSize()
.zIndex(20f)
.background(Color.Black.copy(alpha = 0.58f)),
) {
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp)
.width(352.dp)
.shadow(12.dp, RoundedCornerShape(MembyPanelCorner))
.clip(RoundedCornerShape(MembyPanelCorner))
.background(Color(0xFF090B0D))
.border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner))
.onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
val activationKey =
native.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
native.keyCode == android.view.KeyEvent.KEYCODE_ENTER ||
native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER
if (activationKey && !openingPressReleased) {
if (native.action == android.view.KeyEvent.ACTION_UP) {
openingPressReleased = true
}
return@onPreviewKeyEvent true
}
if (event.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false
}
when (event.key) {
Key.DirectionUp, Key.DirectionDown -> {
val direction = if (event.key == Key.DirectionUp) {
QuickActionDirection.UP
} else {
QuickActionDirection.DOWN
}
focusedIndex = quickActionNextIndex(
currentIndex = focusedIndex,
actionCount = actionCount,
direction = direction,
)
runCatching { focusRequesters[focusedIndex].requestFocus() }
true
}
Key.DirectionLeft, Key.Back -> {
onClose()
true
}
Key.DirectionRight -> true
else -> false
}
}
.padding(10.dp),
) {
Text(
"Quick actions",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp),
)
Text(
item.name,
color = QuietText,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp),
)
QuickActionMenuItem(
label = "View details",
icon = Icons.Default.Info,
modifier = Modifier
.focusRequester(focusRequesters[0])
.onFocusChanged { if (it.isFocused) focusedIndex = 0 },
onClick = { onOpenDetails(item) },
)
Spacer(Modifier.height(2.dp))
QuickActionMenuItem(
label = if (item.isFavorite) "Remove from favourites" else "Add to favourites",
icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
modifier = Modifier
.focusRequester(focusRequesters[1])
.onFocusChanged { if (it.isFocused) focusedIndex = 1 },
onClick = {
onSetFavorite(item, !item.isFavorite)
onClose()
},
)
Spacer(Modifier.height(2.dp))
QuickActionMenuItem(
label = if (item.userData?.played == true) "Mark unwatched" else "Mark watched",
icon = Icons.Default.CheckCircle,
modifier = Modifier
.focusRequester(focusRequesters[2])
.onFocusChanged { if (it.isFocused) focusedIndex = 2 },
onClick = {
onSetPlayed(item, item.userData?.played != true)
onClose()
},
)
if (hasRowActions) {
Spacer(Modifier.height(6.dp))
Box(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color.White.copy(alpha = 0.07f)),
)
Text(
rowTitle.orEmpty(),
color = QuietText,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 3.dp),
)
QuickActionMenuItem(
label = if (rowPinned) "Unpin row" else "Pin row to top",
icon = Icons.Default.PushPin,
modifier = Modifier
.focusRequester(focusRequesters[3])
.onFocusChanged { if (it.isFocused) focusedIndex = 3 },
onClick = onToggleRowPinned!!,
)
QuickActionMenuItem(
label = "Move row up",
icon = Icons.Default.ArrowUpward,
modifier = Modifier
.focusRequester(focusRequesters[4])
.onFocusChanged { if (it.isFocused) focusedIndex = 4 },
onClick = { onMoveRow!!(-1) },
)
QuickActionMenuItem(
label = "Move row down",
icon = Icons.Default.ArrowDownward,
modifier = Modifier
.focusRequester(focusRequesters[5])
.onFocusChanged { if (it.isFocused) focusedIndex = 5 },
onClick = { onMoveRow!!(1) },
)
QuickActionMenuItem(
label = "Hide this row",
icon = Icons.Default.VisibilityOff,
modifier = Modifier
.focusRequester(focusRequesters[6])
.onFocusChanged { if (it.isFocused) focusedIndex = 6 },
onClick = onHideRow!!,
)
}
Spacer(Modifier.height(6.dp))
Box(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color.White.copy(alpha = 0.07f)),
)
Spacer(Modifier.height(6.dp))
QuickActionMenuItem(
label = "Close",
icon = Icons.Default.ChevronLeft,
modifier = Modifier
.focusRequester(focusRequesters[actionCount - 1])
.onFocusChanged { if (it.isFocused) focusedIndex = actionCount - 1 },
onClick = onClose,
)
}
}
}
@Composable
private fun QuickActionMenuItem(
label: String,
icon: ImageVector,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
Row(
modifier = modifier
.fillMaxWidth()
.height(42.dp)
.onFocusChanged { focused = it.isFocused }
.clip(RoundedCornerShape(MembyChipCorner))
.background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent)
.clickable(onClick = onClick)
.semantics { contentDescription = label }
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
icon,
contentDescription = null,
tint = if (focused) Color.White else QuietText,
modifier = Modifier.size(17.dp),
)
Spacer(Modifier.width(12.dp))
Text(
label,
color = if (focused) Color.White else MutedText,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
private fun MetadataContent(
item: BaseItem,
sectionLabel: String,
contentWidth: Dp,
compact: Boolean,
) {
if (item.isSchedule) {
ScheduleMetadataContent(item, sectionLabel, contentWidth, compact)
return
}
Column(
modifier = Modifier.width(contentWidth).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 6.dp),
) {
Text(sectionLabel.uppercase(), color = EmbyGreen, fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.1.sp)
Text(
item.seriesName?.takeIf { item.isEpisode } ?: item.name,
color = Color.White,
fontSize = if (compact) 25.sp else 29.sp,
lineHeight = if (compact) 28.sp else 32.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (item.isEpisode && item.seriesName != null) {
Text(item.name, color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
val facts = buildList {
item.episodeCode?.let(::add)
item.productionYear?.let { add(it.toString()) }
item.runtimeMinutes?.let { add(formatRuntime(it)) }
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
item.genres.take(2).takeIf { it.isNotEmpty() }?.let { add(it.joinToString(ValueSeparator)) }
}
// No score on this line. Emby's community rating stood beside these facts in gold
// with nothing saying whose number it was; the strip below says that for every
// score it shows, and two ratings in one panel only invited the comparison.
if (facts.isNotEmpty()) {
Text(
facts.joinToString(FactSeparator),
color = MutedText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ItemRatingsStrip(
item = item,
load = true,
compact = compact,
modifier = Modifier.fillMaxWidth(),
)
val badges = buildList {
airingBadgeLabel(item)?.let(::add)
addAll(mediaBadges(item))
}
item.membyRecommendationReason?.takeIf(String::isNotBlank)?.let {
Text(
text = it,
color = EmbyGreen,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
if (badges.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
badges.forEach { MediaBadge(it) }
}
}
Text(
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = Color(0xFFD0D4D7),
fontSize = 14.sp,
lineHeight = 18.sp,
maxLines = if (compact) 2 else 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
MetadataStatus(item)
}
}
@Composable
private fun ScheduleMetadataContent(
item: BaseItem,
sectionLabel: String,
contentWidth: Dp,
compact: Boolean,
) {
Column(
modifier = Modifier.width(contentWidth).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 6.dp),
) {
Text(
sectionLabel.uppercase(),
color = EmbyGreen,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.2.sp,
)
Text(
item.name,
color = Color.White,
fontSize = if (compact) 25.sp else 29.sp,
lineHeight = if (compact) 28.sp else 32.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (item.isTvSchedule) {
Text(
listOfNotNull(
item.membyEpisodeCode,
item.membyEpisodeTitle?.takeIf(String::isNotBlank),
).joinToString(FactSeparator),
color = MutedText,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Text(
listOfNotNull(
item.productionYear?.toString(),
item.runtimeMinutes?.let(::formatRuntime),
item.genres.take(2).joinToString(ValueSeparator).takeIf(String::isNotBlank),
).joinToString(FactSeparator),
color = MutedText,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
item.membyAirLabel?.takeIf(String::isNotBlank)?.let { MediaBadge(it.uppercase()) }
item.membyAvailabilityText?.takeIf(String::isNotBlank)?.let {
MediaBadge(it.uppercase())
}
}
Text(
item.overview?.takeIf(String::isNotBlank)
?: if (item.isMovieSchedule) {
"Movie details will appear when they become available."
} else {
"Episode details will appear when they become available."
},
color = Color(0xFFD0D4D7),
fontSize = 14.sp,
lineHeight = 18.sp,
maxLines = if (compact) 2 else 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
Text(
if (item.isMovieSchedule) "Upcoming digital releases" else "TV this week",
color = QuietText,
fontSize = 12.sp,
)
}
}
@Composable
private fun MetadataStatus(item: BaseItem) {
val position = item.userData?.playbackPositionTicks ?: 0L
val runtime = item.runTimeTicks ?: 0L
val progress = if (runtime > 0) (position.toFloat() / runtime).coerceIn(0f, 1f) else 0f
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
if (progress > 0f) {
Box(
Modifier.width(150.dp).height(4.dp).background(Color.White.copy(alpha = 0.22f), RoundedCornerShape(2.dp)),
) {
Box(Modifier.fillMaxWidth(progress).height(4.dp).background(EmbyGreen, RoundedCornerShape(2.dp)))
}
Text("${(progress * 100).toInt()}% watched", color = MutedText, fontSize = 13.sp)
}
if (item.userData?.played == true) {
Icon(Icons.Default.CheckCircle, contentDescription = "Watched", tint = EmbyGreen, modifier = Modifier.size(17.dp))
Text("Watched", color = MutedText, fontSize = 13.sp)
}
if (item.isFavorite) {
Icon(Icons.Default.Favorite, contentDescription = "Favourite", tint = EmbyGreen, modifier = Modifier.size(17.dp))
Text("Favourite", color = MutedText, fontSize = 13.sp)
}
}
}
/**
* The 28dp icon chip every home row header leads with, and the gap after it.
*
* Extracted because a header that skipped the chip started its title 38dp further left than
* the rows above and below it. The launcher's row titles are read as one column, and one
* that steps sideways breaks the column.
*/
internal val HomeRowHeaderIconGap = 10.dp
/** Vertical gap between a row's header and its cards. One value for every row. */
internal val HomeRowHeaderSpacing = 6.dp
@Composable
internal fun HomeRowHeaderIcon(icon: ImageVector, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(28.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.08f))
.border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(15.dp),
)
}
}
@Composable
fun MediaBadge(label: String, modifier: Modifier = Modifier) {
Text(
label,
color = Color(0xFFE4E7E9),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = modifier
.border(1.dp, Color.White.copy(alpha = 0.28f), RoundedCornerShape(4.dp))
.background(Color.Black.copy(alpha = 0.22f), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
internal fun mediaBadges(item: BaseItem): List<String> {
val video = item.mediaStreams.firstOrNull { it.type.equals("Video", true) }
val audio = item.mediaStreams.firstOrNull { it.type.equals("Audio", true) }
return buildList {
if ((video?.width ?: 0) >= UHD_MIN_WIDTH) add("4K")
// Same reading of the streams as the detail page's spec row, so a file cannot be
// HDR10+ in one place and plain HDR in the other.
dynamicRangeLabel(video?.videoRange, video?.videoRangeType, video?.title)
?.let { add(it.uppercase(Locale.US)) }
if (video?.codec.equals("hevc", true) || video?.codec.equals("h265", true)) add("HEVC")
if (audio?.title?.contains("atmos", true) == true) add("DOLBY ATMOS")
}.distinct()
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
@Composable
internal fun MediaRow(
row: HomeBrowseRow,
availableWidth: Dp,
navigationFocusRequester: FocusRequester,
contentEntryFocusRequester: FocusRequester?,
returnFocusItemId: String?,
returnFocusRequester: FocusRequester,
verticalFocusRequest: RowFocusRequest?,
onVerticalFocusRequestConsumed: (Int) -> Unit,
onMoveVertical: (itemIndex: Int, direction: RowFocusDirection) -> Boolean,
onContentFocused: () -> Unit,
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
onItemLongPressed: (BaseItem) -> Unit,
density: String = "standard",
artworkStyle: String = "automatic",
horizontalState: LazyListState? = null,
modifier: Modifier = Modifier,
) {
val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
val rowState = horizontalState ?: savedRowState
val verticalEntryFocusRequester = remember { FocusRequester() }
val scope = rememberCoroutineScope()
val requestedEntryIndex = verticalFocusRequest
?.takeIf { it.rowId == row.id && row.items.isNotEmpty() }
?.itemIndex
?.coerceIn(0, row.items.lastIndex)
LaunchedEffect(verticalFocusRequest?.requestId, requestedEntryIndex) {
val request = verticalFocusRequest
?.takeIf { it.rowId == row.id && requestedEntryIndex != null }
?: return@LaunchedEffect
rowState.scrollToItem(requestedEntryIndex!!)
// LazyRow applies scrollToItem during layout. Wait for the requested card's focus
// node to attach before transferring focus; retrying covers slower TV frames.
repeat(3) {
kotlinx.coroutines.delay(16L)
if (runCatching { verticalEntryFocusRequester.requestFocus() }.isSuccess) {
onVerticalFocusRequestConsumed(request.requestId)
return@LaunchedEffect
}
}
onVerticalFocusRequestConsumed(request.requestId)
}
// firstVisibleItemIndex changes on every scroll frame; read it through derivedStateOf so
// only the button's enabled/disabled flip recomposes the row header.
val canScrollBack by remember(rowState) {
derivedStateOf { rowState.firstVisibleItemIndex > 0 }
}
val canScrollForward by remember(rowState) {
derivedStateOf { rowState.canScrollForward }
}
val pageSize = if (
row.items.firstOrNull()?.let { cardFormat(row.kind, it, artworkStyle) } == MediaCardFormat.PORTRAIT
) 6 else 4
Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val visual = homeRowVisual(row)
HomeRowHeaderIcon(visual.icon)
Spacer(Modifier.width(HomeRowHeaderIconGap))
Text(
row.title,
color = Color(0xFFF1F3F4),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.weight(1f))
if (row.items.isNotEmpty()) {
GalleryJumpButton(
forward = false,
enabled = canScrollBack,
onClick = {
val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0)
scope.launch { rowState.scrollToItem(target) }
},
)
Spacer(Modifier.width(8.dp))
GalleryJumpButton(
forward = true,
enabled = canScrollForward,
onClick = {
val target = (rowState.firstVisibleItemIndex + pageSize)
.coerceAtMost(row.items.lastIndex)
scope.launch { rowState.scrollToItem(target) }
},
)
}
}
when {
row.items.isEmpty() && row.loading -> {
LazyRow(
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 9.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(5) {
TvLoadingPlaceholder(
portrait = row.kind in setOf(MediaRowKind.MOVIES, MediaRowKind.SHOWS),
availableWidth = availableWidth,
)
}
}
}
row.items.isEmpty() && row.id == "favourite-shows" -> {
FavoriteShowsEmptyState(
navigationFocusRequester = navigationFocusRequester,
onContentFocused = onContentFocused,
)
}
row.items.isEmpty() -> {
Text(
row.emptyMessage,
color = QuietText,
fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 36.dp, vertical = 18.dp),
)
}
else -> {
LazyRow(
state = rowState,
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
// focusRestorer pins a lazy item. Replacing a For You result set
// can dispose that item during a focus transfer and make Compose
// release the same pin twice. LazyListState already preserves the
// row position; native TV spatial search safely handles row changes.
modifier = Modifier.fillMaxWidth().focusGroup(),
) {
itemsIndexed(
row.items,
key = { _, item -> item.id },
contentType = { _, item -> if (cardFormat(row.kind, item, artworkStyle) == MediaCardFormat.PORTRAIT) "portrait" else "landscape" },
) { index, item ->
var cardModifier: Modifier = Modifier
if (index == 0) {
if (contentEntryFocusRequester != null) {
cardModifier = cardModifier.focusRequester(contentEntryFocusRequester)
}
}
if (item.id == returnFocusItemId) {
cardModifier = cardModifier.focusRequester(returnFocusRequester)
}
if (index == requestedEntryIndex) {
cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester)
}
cardModifier = cardModifier.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false
}
when (event.key) {
Key.DirectionUp -> onMoveVertical(index, RowFocusDirection.UP)
Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN)
else -> false
}
}
val focused: () -> Unit = {
onContentFocused()
onItemFocused(item)
}
when (cardFormat(row.kind, item, artworkStyle)) {
// The resume bar belongs to the row, not to the card's shape: a
// household that has set artwork to posters still needs to see
// how far into something it is.
MediaCardFormat.PORTRAIT -> PortraitMediaCard(
item, availableWidth, row.showSecondaryMetadata, focused,
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
density, row.showWatchedEpisodeCount,
showProgress = row.kind == MediaRowKind.CONTINUE,
)
MediaCardFormat.LANDSCAPE -> if (row.kind == MediaRowKind.CONTINUE) {
ContinueWatchingCard(
item, availableWidth, row.showSecondaryMetadata, focused,
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
density, row.showWatchedEpisodeCount,
)
} else {
LandscapeMediaCard(
item, availableWidth, row.showSecondaryMetadata, focused,
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
density, row.showWatchedEpisodeCount,
)
}
}
}
}
}
}
}
}
@Composable
internal fun CastRail(
people: List<EmbyPerson>,
modifier: Modifier = Modifier,
compact: Boolean = false,
showTitle: Boolean = true,
) {
val cast = remember(people) { people.filter(EmbyPerson::isCastMember).take(16) }
if (cast.isEmpty()) return
Column(modifier) {
if (showTitle) {
Text(
"Cast",
color = Color.White,
fontSize = if (compact) 16.sp else 21.sp,
fontWeight = FontWeight.SemiBold,
)
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(if (compact) 10.dp else 16.dp),
contentPadding = PaddingValues(
top = if (showTitle) 7.dp else 3.dp,
end = 24.dp,
bottom = 4.dp,
),
) {
items(cast, key = { "${it.id}:${it.name}" }) { person ->
CastCard(person, compact)
}
}
}
}
@Composable
private fun CastCard(person: EmbyPerson, compact: Boolean) {
val repository = ServiceLocator.repository
val portrait = remember(person.id, person.primaryImageTag) {
repository.personImageUrl(person, if (compact) 180 else 280)
}
var focused by remember { mutableStateOf(false) }
val width = if (compact) 72.dp else 112.dp
val height = if (compact) 76.dp else 142.dp
val shape = RoundedCornerShape(MembyCardCorner)
val scale by animateFloatAsState(
targetValue = if (focused) 1.045f else 1f,
animationSpec = tween(110),
label = "cast-card-focus",
)
Column(
Modifier
.width(width)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.onFocusChanged { focused = it.isFocused }
.focusable(),
) {
Box(
Modifier
.fillMaxWidth()
.height(height)
.clip(shape)
.background(Color(0xFF24292E))
.border(
if (focused) 2.dp else 1.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.10f),
shape,
),
contentAlignment = Alignment.Center,
) {
if (portrait != null) {
AsyncImage(
model = portrait,
contentDescription = person.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(
Icons.Default.Person,
contentDescription = null,
tint = QuietText,
modifier = Modifier.size(if (compact) 28.dp else 40.dp),
)
}
}
Text(
person.name,
color = Color.White,
fontSize = if (compact) 11.sp else 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 5.dp),
)
person.role?.takeIf(String::isNotBlank)?.let { role ->
Text(
role,
color = QuietText,
fontSize = if (compact) 9.sp else 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun FavoriteShowsEmptyState(
navigationFocusRequester: FocusRequester,
onContentFocused: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 36.dp, vertical = 10.dp)
.clip(RoundedCornerShape(MembyPanelCorner))
.background(Color.White.copy(alpha = if (focused) 0.11f else 0.055f))
.border(
2.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.14f),
RoundedCornerShape(MembyPanelCorner),
)
.focusProperties { left = navigationFocusRequester }
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onContentFocused()
}
.focusable()
.padding(horizontal = 22.dp, vertical = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Box(
modifier = Modifier
.size(46.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.09f)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.FavoriteBorder,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(25.dp),
)
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"No favourite shows yet",
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
)
Text(
"Mark a series as a favourite and itll be waiting here.",
color = MutedText,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
}
}
}
@Composable
private fun GalleryJumpButton(
forward: Boolean,
enabled: Boolean,
onClick: () -> Unit,
) {
FocusScaleContainer(
onFocused = {},
onClick = { if (enabled) onClick() },
onLongClick = null,
contentDescription = if (forward) "Next page" else "Previous page",
// These mirror remote left/right paging for pointer users. Keeping them out of
// the focus graph prevents Up from a card landing in a tiny header control.
modifier = Modifier
.width(42.dp)
.height(36.dp)
.focusProperties { canFocus = false },
) { focused ->
Box(
Modifier
.fillMaxSize()
.clip(RoundedCornerShape(MembyChipCorner))
.background(
when {
focused -> Color.White.copy(alpha = 0.16f)
enabled -> Color.White.copy(alpha = 0.08f)
else -> Color.White.copy(alpha = 0.03f)
},
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (forward) Icons.Default.ChevronRight else Icons.Default.ChevronLeft,
contentDescription = null,
tint = if (enabled) Color.White else QuietText.copy(alpha = 0.45f),
modifier = Modifier.size(23.dp),
)
}
}
}
private enum class MediaCardFormat { PORTRAIT, LANDSCAPE }
private val MediaRowHorizontalInset = 36.dp
private val MediaRowCardSpacing = 16.dp
/**
* Sizes a shelf to an integer number of complete cards.
*
* The old fractional "cards across" values intentionally left the next card hanging
* outside the viewport. Besides looking clipped, that became more pronounced on smaller
* TVs and whenever the navigation rail reduced the content width. Work from the actual
* row width (including its padding and gaps) so every visible card has a complete edge.
*/
internal fun responsiveRowCardWidth(
availableWidth: Dp,
preferredCardsAcross: Int,
minWidth: Dp,
maxWidth: Dp,
horizontalInset: Dp = MediaRowHorizontalInset,
spacing: Dp = MediaRowCardSpacing,
): Dp {
val usableWidth = (availableWidth - horizontalInset * 2).coerceAtLeast(1.dp)
val cardsThatFitMinimum =
((usableWidth + spacing) / (minWidth + spacing)).toInt().coerceAtLeast(1)
var cardCount = preferredCardsAcross.coerceIn(1, cardsThatFitMinimum)
// Very wide viewports should gain another complete card instead of a large dead area.
while (cardCount < cardsThatFitMinimum) {
val candidate = (usableWidth - spacing * (cardCount - 1)) / cardCount
if (candidate <= maxWidth) break
cardCount += 1
}
return ((usableWidth - spacing * (cardCount - 1)) / cardCount)
.coerceAtMost(maxWidth)
.coerceAtLeast(1.dp)
}
private fun cardFormat(
kind: MediaRowKind,
item: BaseItem,
artworkStyle: String = "automatic",
): MediaCardFormat = when {
artworkStyle == "poster" -> MediaCardFormat.PORTRAIT
artworkStyle == "backdrop" -> MediaCardFormat.LANDSCAPE
kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE
item.isEpisode -> MediaCardFormat.LANDSCAPE
else -> MediaCardFormat.PORTRAIT
}
@Composable
fun PortraitMediaCard(
item: BaseItem,
availableWidth: Dp,
showSecondaryMetadata: Boolean,
onFocused: () -> Unit,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
density: String = "standard",
showWatchedEpisodeCount: Boolean = false,
showProgress: Boolean = false,
) {
val cardsAcross = when (density) {
"compact" -> 8
"large" -> 5
else -> 7
}
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp)
MediaCard(
item, width, 2f / 3f, preferPrimary = true, showProgress = showProgress,
showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
)
}
/**
* A poster card at an explicit width, for grids rather than rows.
*
* [PortraitMediaCard] derives its width from the row's available width, which is the
* wrong input for a grid whose column count is already decided. Same card underneath —
* same artwork loading, badges and focus treatment.
*/
@Composable
fun PosterGridCard(
item: BaseItem,
width: Dp,
onFocused: () -> Unit,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
showWatchedEpisodeCount: Boolean = false,
) {
MediaCard(
item, width, 2f / 3f, preferPrimary = true, showProgress = false,
showSecondaryMetadata = true, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
)
}
@Composable
fun LandscapeMediaCard(
item: BaseItem,
availableWidth: Dp,
showSecondaryMetadata: Boolean,
onFocused: () -> Unit,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
density: String = "standard",
showWatchedEpisodeCount: Boolean = false,
) {
val cardsAcross = when (density) {
"compact" -> 5
"large" -> 3
else -> 4
}
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp)
MediaCard(
item, width, 16f / 9f, preferPrimary = false, showProgress = false,
showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
)
}
@Composable
fun ContinueWatchingCard(
item: BaseItem,
availableWidth: Dp,
showSecondaryMetadata: Boolean,
onFocused: () -> Unit,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
density: String = "standard",
showWatchedEpisodeCount: Boolean = false,
) {
val cardsAcross = when (density) {
"compact" -> 5
"large" -> 3
else -> 4
}
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp)
MediaCard(
item, width, 16f / 9f, preferPrimary = false, showProgress = true,
showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
)
}
@Composable
private fun MediaCard(
item: BaseItem,
width: Dp,
aspectRatio: Float,
preferPrimary: Boolean,
showProgress: Boolean,
showSecondaryMetadata: Boolean,
showWatchedEpisodeCount: Boolean,
onFocused: () -> Unit,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier,
) {
val repo = ServiceLocator.repository
val density = LocalDensity.current
val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720)
val heightPx = (widthPx / aspectRatio).toInt().coerceAtLeast(1)
val image = remember(item.id, widthPx, preferPrimary) {
if (preferPrimary) {
repo.primaryUrl(item, widthPx)?.let { it to ContentScale.Fit }
?: repo.backdropUrl(item, widthPx)?.let { it to ContentScale.Crop }
} else {
repo.backdropUrl(item, widthPx)?.let { it to ContentScale.Crop }
?: repo.primaryUrl(item, widthPx)?.let { it to ContentScale.Fit }
}
}
val imageUrl = image?.first
val position = item.userData?.playbackPositionTicks ?: 0L
val runtime = item.runTimeTicks ?: 0L
val progress = if (runtime > 0L) (position.toFloat() / runtime).coerceIn(0f, 1f) else 0f
var failed by remember(item.id, imageUrl) { mutableStateOf(false) }
var loading by remember(item.id, imageUrl) { mutableStateOf(imageUrl != null) }
val context = LocalContext.current
val imageRequest = remember(imageUrl, widthPx, heightPx, context) {
imageUrl?.let {
ImageRequest.Builder(context)
.data(it)
.size(widthPx, heightPx)
.allowHardware(true)
.crossfade(false)
.build()
}
}
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
onLongClick = onLongClick,
contentDescription = cardDescription(item, progress, showWatchedEpisodeCount),
modifier = modifier.width(width),
) { focused ->
Column {
Box(
Modifier
.width(width)
.aspectRatio(aspectRatio)
// Keep the modifier node stable while focus changes. Adding/removing
// the shadow node forces the poster subtree to be rebuilt exactly
// when a row is moving into view, which shows up as a small hitch on
// lower-powered TV hardware.
.shadow(
elevation = if (focused) 7.dp else 0.dp,
shape = RoundedCornerShape(MembyCardCorner),
)
.clip(RoundedCornerShape(MembyCardCorner))
.background(Color(0xFF20252A))
.border(
2.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
RoundedCornerShape(MembyCardCorner),
),
contentAlignment = Alignment.Center,
) {
if (loading) {
ArtworkLoadingSkeleton(Modifier.fillMaxSize())
}
if (imageRequest != null) {
AsyncImage(
model = imageRequest,
contentDescription = null,
contentScale = image?.second ?: ContentScale.Crop,
onLoading = { loading = true },
onSuccess = {
failed = false
loading = false
},
onError = {
failed = true
loading = false
},
modifier = Modifier.fillMaxSize(),
)
}
if (imageUrl == null || failed) {
Icon(
Icons.Default.BrokenImage,
contentDescription = "Artwork unavailable",
tint = QuietText,
modifier = Modifier.size(30.dp),
)
}
if (showProgress && progress > 0f) {
Box(
Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(5.dp)
.background(Color.Black.copy(alpha = 0.65f)),
) {
Box(Modifier.fillMaxWidth(progress).height(5.dp).background(EmbyGreen))
}
}
if (item.userData?.played == true || item.isFavorite) {
Column(
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
if (item.userData?.played == true) {
Icon(
Icons.Default.CheckCircle,
contentDescription = "Watched",
tint = EmbyGreen,
modifier = Modifier.size(19.dp),
)
}
if (item.isFavorite) {
Icon(
Icons.Default.Favorite,
contentDescription = "Favourite",
tint = EmbyGreen,
modifier = Modifier.size(19.dp),
)
}
}
}
if (item.isSchedule) {
scheduleStatusBadgeLabel(item.membyAvailability.orEmpty())?.let {
ScheduleStatusBadge(
status = item.membyAvailability.orEmpty(),
label = it,
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
)
}
// Bottom-left, because the two top corners already carry the air day and
// this episode's availability, and the progress bar owns the bottom edge
// only on cards that can be resumed — which a schedule card cannot.
lifecycleBadgeLabel(item)?.let {
LifecycleBadge(
status = item.membyLifecycle.orEmpty(),
label = it,
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
)
}
}
airingBadgeLabel(item)?.let { label ->
MediaBadge(
label,
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
)
}
}
Text(
item.name,
color = if (focused) Color.White else Color(0xFFE1E5E8),
fontSize = 14.sp,
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
)
if (showSecondaryMetadata) {
Text(
cardSubtitle(item, showProgress, position, showWatchedEpisodeCount),
color = QuietText,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
)
} else {
Spacer(Modifier.height(3.dp))
}
ItemRatingsStrip(
item = item,
load = focused,
compact = true,
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
)
}
}
}
@Composable
private fun ScheduleStatusBadge(status: String, label: String, modifier: Modifier = Modifier) {
val color = when (status) {
"available" -> EmbyGreen
"downloading" -> Color(0xFF5DA9FF)
"awaiting" -> Color(0xFFFFB454)
"unmonitored" -> QuietText
else -> Color(0xFFE1E5E8)
}
Text(
text = label,
color = if (status == "available") Color(0xFF071008) else Color(0xFF090B0D),
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp,
modifier = modifier
.clip(RoundedCornerShape(4.dp))
.background(color)
.padding(horizontal = 7.dp, vertical = 4.dp),
)
}
internal fun scheduleStatusBadgeLabel(status: String): String? = when (status) {
"available" -> "ADDED"
"downloading" -> "DOWNLOADING"
"awaiting" -> "AWAITING"
"unmonitored" -> "UNMONITORED"
// The day badge and air-time subtitle already say when a future episode airs.
// Repeating "upcoming" adds no state and can collide with the more useful day.
else -> null
}
/**
* The show's or film's lifecycle, as Sonarr and Radarr word it — CONTINUING, ENDED, IN
* CINEMAS. It answers a different question from the availability badge above it: that one
* is about the household's copy of this episode, this one is about whether there will be
* any more of them.
*
* The wording is the gateway's, taken verbatim off the card, so a status a build predates
* still reads correctly rather than falling back to a slug. A card carrying no lifecycle —
* an older gateway, a cached row, a show *arr has no status for — simply wears no tag.
*/
internal fun lifecycleBadgeLabel(item: BaseItem): String? =
item.membyLifecycleText?.trim()?.takeIf(String::isNotEmpty)?.uppercase()
@Composable
internal fun LifecycleBadge(status: String, label: String, modifier: Modifier = Modifier) {
// One colour per status, matching what the same word is coloured in *arr: still being
// made is green, over is red, not out yet is blue, in cinemas is amber.
val (background, foreground) = when (status) {
"continuing", "released" -> EmbyGreen to Color(0xFF071008)
"upcoming", "announced" -> Color(0xFF5DA9FF) to Color(0xFF090B0D)
"incinemas" -> Color(0xFFFFB454) to Color(0xFF090B0D)
"ended" -> Color(0xFFE04747) to Color.White
else -> Color(0xFF3A4249) to Color(0xFFE1E5E8)
}
Text(
text = label,
color = foreground,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
.clip(RoundedCornerShape(4.dp))
.background(background)
.padding(horizontal = 7.dp, vertical = 4.dp),
)
}
@Composable
fun FocusScaleContainer(
onFocused: () -> Unit,
onClick: () -> Unit,
contentDescription: String,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
content: @Composable BoxScope.(focused: Boolean) -> Unit,
) {
var focused by remember { mutableStateOf(false) }
val pressScope = rememberCoroutineScope()
var holdJob by remember { mutableStateOf<Job?>(null) }
var remoteLongPressHandled by remember { mutableStateOf(false) }
val scale = animateFloatAsState(
targetValue = if (focused) 1.025f else 1f,
animationSpec = tween(95),
label = "media-card-focus",
)
Box(
modifier = modifier
.zIndex(if (focused) 1f else 0f)
// Always a graphicsLayer, and the scale read inside it. Branching on
// `scale != 1f` meant reading the animated value during composition, so
// every card recomposed on each frame of its own focus animation.
.graphicsLayer {
scaleX = scale.value
scaleY = scale.value
}
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) {
onFocused()
} else {
holdJob?.cancel()
holdJob = null
remoteLongPressHandled = false
}
}
.then(
if (onLongClick != null) {
Modifier
.onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
val activationKey = native.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
native.keyCode == android.view.KeyEvent.KEYCODE_ENTER ||
native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER
when {
activationKey &&
native.action == android.view.KeyEvent.ACTION_DOWN &&
native.repeatCount == 0 &&
holdJob == null &&
!remoteLongPressHandled -> {
holdJob = pressScope.launch {
delay(QuickActionsHoldDurationMillis)
remoteLongPressHandled = true
holdJob = null
onLongClick()
}
true
}
activationKey &&
native.action == android.view.KeyEvent.ACTION_DOWN -> true
activationKey &&
native.action == android.view.KeyEvent.ACTION_UP -> {
val wasLongPress = remoteLongPressHandled
holdJob?.cancel()
holdJob = null
remoteLongPressHandled = false
if (!wasLongPress) onClick()
true
}
else -> false
}
}
.clickable(onClick = onClick)
} else {
Modifier.clickable(onClick = onClick)
},
)
.semantics(mergeDescendants = true) {
this.contentDescription = contentDescription
if (onLongClick != null) {
onLongClick("Quick actions") {
onLongClick()
true
}
}
},
content = { content(focused) },
)
}
@Composable
fun TvLoadingPlaceholder(
portrait: Boolean,
availableWidth: Dp,
modifier: Modifier = Modifier,
) {
val width = if (portrait) {
responsiveRowCardWidth(availableWidth, 7, 102.dp, 218.dp)
} else {
responsiveRowCardWidth(availableWidth, 4, 164.dp, 360.dp)
}
Column(modifier.width(width)) {
Box(
Modifier
.width(width)
.aspectRatio(if (portrait) 2f / 3f else 16f / 9f)
.clip(RoundedCornerShape(MembyCardCorner)),
) {
ArtworkLoadingSkeleton(Modifier.fillMaxSize())
}
Spacer(Modifier.height(8.dp))
SkeletonBlock(width * 0.72f, 11.dp)
Spacer(Modifier.height(5.dp))
SkeletonBlock(width * 0.48f, 8.dp)
}
}
@Composable
private fun ArtworkLoadingSkeleton(modifier: Modifier = Modifier) {
Box(
modifier.background(
Brush.linearGradient(
colors = listOf(
Color(0xFF20262B),
Color(0xFF343B41),
Color(0xFF20262B),
),
),
),
)
}
private const val BACKDROP_SETTLE_DELAY_MS = 240L
internal fun watchedEpisodeCountLabel(item: BaseItem): String? {
if (!item.isSeries) return null
val total = item.recursiveItemCount?.takeIf { it > 0 } ?: return null
val unwatched = item.userData?.unplayedItemCount?.coerceIn(0, total) ?: return null
val watched = total - unwatched
return "$watched of $total watched"
}
private fun cardSubtitle(
item: BaseItem,
showProgress: Boolean,
positionTicks: Long,
showWatchedEpisodeCount: Boolean,
): String = when {
showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}"
showWatchedEpisodeCount -> watchedEpisodeCountLabel(item)
?: defaultCardSubtitle(item)
else -> defaultCardSubtitle(item)
}
private fun defaultCardSubtitle(item: BaseItem): String = when {
!item.membyRecommendationReason.isNullOrBlank() -> item.membyRecommendationReason
item.isSchedule -> item.membyAirLabel ?: "Coming up"
item.isEpisode -> item.seriesName ?: "Up next"
item.productionYear != null && item.runtimeMinutes != null ->
"${item.productionYear}$FactSeparator${formatRuntime(requireNotNull(item.runtimeMinutes))}"
item.productionYear != null -> item.productionYear.toString()
item.isSeries -> "Series"
else -> item.type
}
internal fun airingBadgeLabel(item: BaseItem): String? = when {
item.isSchedule -> item.membyAirDayLabel
?.takeIf(String::isNotBlank)
?.uppercase()
item.membyAiringToday -> "AIRING TODAY"
else -> null
}
private fun cardDescription(
item: BaseItem,
progress: Float,
showWatchedEpisodeCount: Boolean,
): String = buildString {
append(item.name)
item.seriesName?.let { append(", ").append(it) }
if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched")
if (item.userData?.played == true) append(", watched")
if (showWatchedEpisodeCount) watchedEpisodeCountLabel(item)?.let { append(", ").append(it) }
if (item.isFavorite) append(", favourite")
if (item.isSchedule) {
item.membyAirLabel?.let { append(", ").append(it) }
item.membyAvailabilityText?.let { append(", ").append(it) }
item.membyLifecycleText?.let { append(", ").append(it) }
}
}
private fun formatTvPosition(ticks: Long): String {
val minutes = (ticks / 600_000_000L).toInt().coerceAtLeast(0)
return formatRuntime(minutes)
}