Big changes

This commit is contained in:
ponzischeme89
2026-07-29 15:26:27 +12:00
parent 8d6cf2f5a1
commit 70914400b4
62 changed files with 7501 additions and 744 deletions
@@ -3,12 +3,14 @@ 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
@@ -26,14 +28,16 @@ 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.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyListState
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
@@ -44,17 +48,18 @@ 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.focusRestorer
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.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -76,12 +81,14 @@ 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.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Person
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
@@ -95,21 +102,27 @@ 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.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private val EmbyGreen = Color(0xFF52B54B)
private val RailSurface = Color(0xF20C0F12)
private val MutedText = Color(0xFFB7BDC3)
private val QuietText = Color(0xFF8C949B)
// High-contrast neutrals tuned for the app's near-black surfaces. Quiet remains visibly
// secondary from TV distance without falling into low-contrast grey-on-black.
private val MutedText = Color(0xFFD0D6DB)
private val QuietText = Color(0xFFAEB7BF)
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),
@@ -131,53 +144,44 @@ data class HomeBrowseRow(
private data class HomeRowVisual(
val icon: ImageVector,
val colors: List<Color>,
)
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
row.id == "continue" -> HomeRowVisual(
Icons.Default.PlayCircleFilled,
listOf(Color(0xFF3A7BD5), Color(0xFF6C4DFF)),
)
row.id == "next-up" -> HomeRowVisual(
Icons.Default.SkipNext,
listOf(Color(0xFF00A896), Color(0xFF28C2A0)),
)
row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
Icons.Default.Favorite,
listOf(Color(0xFFFF5C8A), Color(0xFFB84DFF)),
)
row.id == "latest-movies" -> HomeRowVisual(
Icons.Default.Movie,
listOf(Color(0xFFFF8A3D), Color(0xFFE34B6F)),
)
row.id == "sonarr-airing-today" -> HomeRowVisual(
Icons.Default.CalendarMonth,
listOf(Color(0xFF00A9CE), Color(0xFF4169E1)),
)
row.id == "curated:apple-tv" -> HomeRowVisual(
Icons.Default.LiveTv,
listOf(Color(0xFF252A31), Color(0xFF66717E)),
)
row.id == "curated:drama-shows" -> HomeRowVisual(
Icons.Default.TheaterComedy,
listOf(Color(0xFF6A3FB5), Color(0xFFB64272)),
)
row.id == "curated:comedy-shows" -> HomeRowVisual(
Icons.Default.SentimentVerySatisfied,
listOf(Color(0xFFFFB52E), Color(0xFFFF7433)),
)
row.id.startsWith("similar:") -> HomeRowVisual(
Icons.Default.AutoAwesome,
listOf(Color(0xFF8A5CF6), Color(0xFFE458A3)),
)
row.id == "recommended" -> HomeRowVisual(
Icons.Default.Recommend,
listOf(Color(0xFF45B649), Color(0xFF00A896)),
)
row.id.startsWith("for-you:") -> HomeRowVisual(
Icons.Default.AutoAwesome,
)
else -> HomeRowVisual(
Icons.Default.VideoLibrary,
listOf(Color(0xFF536976), Color(0xFF738A96)),
)
}
@@ -186,12 +190,34 @@ fun TvNavigationRail(
selected: BrowseDestination,
expanded: Boolean,
navigationFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
onRailFocusChanged: (Boolean) -> Unit,
onDestinationSelected: (BrowseDestination) -> Unit,
modifier: Modifier = Modifier,
) {
val railWidth by animateDpAsState(
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",
@@ -207,10 +233,30 @@ fun TvNavigationRail(
// 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.
.wrapContentSize(Alignment.TopStart, unbounded = true)
.requiredWidth(railWidth)
// 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,
) {
@@ -222,7 +268,15 @@ fun TvNavigationRail(
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = "Memby",
modifier = Modifier.width(32.dp).height(27.dp),
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) {
@@ -251,12 +305,20 @@ fun TvNavigationRail(
selected = destination == selected,
expanded = expanded,
modifier = if (destination == selected) Modifier.focusRequester(navigationFocusRequester) else Modifier,
contentFocusRequester = contentFocusRequester,
onFocused = { onRailFocusChanged(true) },
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),
)
}
}
}
@@ -266,13 +328,14 @@ fun ExpandableNavigationItem(
destination: BrowseDestination,
selected: Boolean,
expanded: Boolean,
contentFocusRequester: FocusRequester,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val background by animateColorAsState(
// 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)
@@ -281,7 +344,7 @@ fun ExpandableNavigationItem(
animationSpec = tween(85),
label = "navigation-item-background",
)
val foreground by animateColorAsState(
val foreground = animateColorAsState(
targetValue = when {
focused -> Color.White
selected -> EmbyGreen
@@ -294,13 +357,12 @@ fun ExpandableNavigationItem(
modifier = modifier
.fillMaxWidth()
.height(44.dp)
.focusProperties { right = contentFocusRequester }
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onFocused()
}
.clip(RoundedCornerShape(8.dp))
.background(background)
.drawBehind { drawRect(background.value) }
.clickable(onClick = onClick)
.semantics { contentDescription = destination.label }
.padding(horizontal = 8.dp),
@@ -308,7 +370,7 @@ fun ExpandableNavigationItem(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.width(28.dp), contentAlignment = Alignment.Center) {
Icon(destination.icon, contentDescription = null, tint = foreground, modifier = Modifier.size(21.dp))
Icon(destination.icon, contentDescription = null, tint = foreground.value, modifier = Modifier.size(21.dp))
if (selected) {
Box(
Modifier
@@ -322,7 +384,7 @@ fun ExpandableNavigationItem(
if (expanded) {
Text(
destination.label,
color = foreground,
color = foreground.value,
fontSize = 15.sp,
fontWeight = if (focused || selected) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
@@ -390,7 +452,6 @@ fun MediaMetadataPanel(
item: BaseItem?,
loading: Boolean,
sectionLabel: String,
contentFocusRequester: FocusRequester,
navigationFocusRequester: FocusRequester,
onPlay: (BaseItem) -> Unit,
onContentFocused: () -> Unit,
@@ -414,7 +475,6 @@ fun MediaMetadataPanel(
onClick = { item?.let(onPlay) },
enabled = item != null,
modifier = Modifier
.focusRequester(contentFocusRequester)
.focusProperties { left = navigationFocusRequester }
.onFocusChanged { if (it.isFocused) onContentFocused() },
) {
@@ -477,7 +537,7 @@ fun MediaDetailsOverlay(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(0.68f)
.padding(start = 72.dp, end = 36.dp, top = 60.dp, bottom = 48.dp),
.padding(start = 72.dp, end = 36.dp, top = 38.dp, bottom = 32.dp),
verticalArrangement = Arrangement.Center,
) {
Text(
@@ -517,16 +577,24 @@ fun MediaDetailsOverlay(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (item.membyAiringToday) {
Spacer(Modifier.height(8.dp))
MediaBadge("AIRING TODAY")
}
Spacer(Modifier.height(16.dp))
Text(
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = Color(0xFFD8DCDF),
fontSize = 17.sp,
lineHeight = 23.sp,
maxLines = 4,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(22.dp))
if (item.cast.isNotEmpty()) {
Spacer(Modifier.height(14.dp))
CastRail(item.cast, compact = true)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = { onPlay(item) },
@@ -636,7 +704,21 @@ private fun MetadataContent(item: BaseItem, sectionLabel: String) {
if (facts.isNotEmpty()) {
Text(facts.joinToString(""), color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
val badges = mediaBadges(item)
val badges = buildList {
if (item.membyAiringToday) add("AIRING TODAY")
addAll(mediaBadges(item))
}
item.membyRecommendationReason?.takeIf(String::isNotBlank)?.let {
Text(
text = it,
color = EmbyGreen,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(0.78f),
)
}
if (badges.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
badges.forEach { MediaBadge(it) }
@@ -785,6 +867,11 @@ fun MediaRow(
) {
val rowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
val scope = rememberCoroutineScope()
// 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 pageSize = if (
row.items.firstOrNull()?.let { cardFormat(row.kind, it) } == MediaCardFormat.PORTRAIT
) 6 else 4
@@ -798,8 +885,8 @@ fun MediaRow(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(Brush.linearGradient(visual.colors))
.border(1.dp, Color.White.copy(alpha = 0.20f), CircleShape),
.background(Color.White.copy(alpha = 0.08f))
.border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
@@ -814,13 +901,13 @@ fun MediaRow(
row.title,
color = Color(0xFFF1F3F4),
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.weight(1f))
if (row.items.isNotEmpty()) {
GalleryJumpButton(
forward = false,
enabled = rowState.firstVisibleItemIndex > 0,
enabled = canScrollBack,
onClick = {
val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0)
scope.launch { rowState.scrollToItem(target) }
@@ -852,6 +939,12 @@ fun MediaRow(
}
}
}
row.items.isEmpty() && row.id == "favourite-shows" -> {
FavoriteShowsEmptyState(
navigationFocusRequester = navigationFocusRequester,
onContentFocused = onContentFocused,
)
}
row.items.isEmpty() -> {
Text(
row.emptyMessage,
@@ -865,7 +958,11 @@ fun MediaRow(
state = rowState,
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth().focusGroup().focusRestorer(),
// 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,
@@ -874,7 +971,6 @@ fun MediaRow(
) { index, item ->
var cardModifier: Modifier = Modifier
if (index == 0) {
cardModifier = cardModifier.focusProperties { left = navigationFocusRequester }
if (contentEntryFocusRequester != null) {
cardModifier = cardModifier.focusRequester(contentEntryFocusRequester)
}
@@ -910,6 +1006,172 @@ fun MediaRow(
}
}
@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(if (compact) 8.dp else 10.dp)
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(14.dp))
.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(14.dp),
)
.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 favorite shows yet",
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
)
Text(
"Mark a series as a favorite and itll be waiting here.",
color = MutedText,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
}
}
}
@Composable
private fun GalleryJumpButton(
forward: Boolean,
@@ -971,6 +1233,28 @@ fun PortraitMediaCard(
)
}
/**
* 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,
) {
MediaCard(
item, width, 2f / 3f, preferPrimary = true, showProgress = false,
showSecondaryMetadata = true, onFocused, onClick, onLongClick, modifier,
)
}
@Composable
fun LandscapeMediaCard(
item: BaseItem,
@@ -1138,12 +1422,18 @@ private fun MediaCard(
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
)
}
if (item.membyAiringToday) {
MediaBadge(
"AIRING TODAY",
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
)
}
}
Text(
item.name,
color = if (focused) Color.White else Color(0xFFD1D5D8),
color = if (focused) Color.White else Color(0xFFE1E5E8),
fontSize = 14.sp,
fontWeight = if (focused) FontWeight.SemiBold else FontWeight.Medium,
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
@@ -1153,6 +1443,7 @@ private fun MediaCard(
cardSubtitle(item, showProgress, position),
color = QuietText,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
@@ -1190,14 +1481,14 @@ private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) {
fun FocusScaleContainer(
onFocused: () -> Unit,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
contentDescription: String,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
content: @Composable BoxScope.(focused: Boolean) -> Unit,
) {
var focused by remember { mutableStateOf(false) }
var remoteLongPressHandled by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
val scale = animateFloatAsState(
targetValue = if (focused) 1.025f else 1f,
animationSpec = tween(95),
label = "media-card-focus",
@@ -1205,16 +1496,13 @@ fun FocusScaleContainer(
Box(
modifier = modifier
.zIndex(if (focused) 1f else 0f)
.then(
if (focused || scale != 1f) {
Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
}
} else {
Modifier
},
)
// 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()
@@ -1309,6 +1597,7 @@ private const val BACKDROP_SETTLE_DELAY_MS = 240L
private fun cardSubtitle(item: BaseItem, showProgress: Boolean, positionTicks: Long): String = when {
showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}"
!item.membyRecommendationReason.isNullOrBlank() -> item.membyRecommendationReason
item.isSonarrSchedule -> item.membyAirLabel ?: "Airing today"
item.isEpisode -> item.seriesName ?: "Up next"
item.productionYear != null && item.runtimeMinutes != null ->