Big changes
This commit is contained in:
@@ -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 it’ll 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 ->
|
||||
|
||||
Reference in New Issue
Block a user