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>
2270 lines
88 KiB
Kotlin
2270 lines
88 KiB
Kotlin
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(
|
||
"Matt’s 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 who’s 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 it’ll 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)
|
||
}
|