Files
memby/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt
T
2026-08-27 11:04:51 +12:00

1852 lines
82 KiB
Kotlin

package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
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.horizontalScroll
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.IntrinsicSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
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.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
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.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
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.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
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.data.ARTWORK_DETAIL_BACKDROP_MAX_WIDTH
import com.ponzischeme89.memby.data.ARTWORK_DETAIL_PRIMARY_MAX_WIDTH
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.data.model.displayable
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.CastGrid
import com.ponzischeme89.memby.ui.detail.DetailHeroMetrics
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.TechnicalSpec
import com.ponzischeme89.memby.ui.detail.castColumns
import com.ponzischeme89.memby.ui.detail.detailCardFocus
import com.ponzischeme89.memby.ui.detail.extraKindLabel
import com.ponzischeme89.memby.ui.detail.formatRuntime
import com.ponzischeme89.memby.ui.detail.heroArtworkAlpha
import com.ponzischeme89.memby.ui.detail.heroDimAlpha
import com.ponzischeme89.memby.ui.detail.heroPrimaryAlpha
import com.ponzischeme89.memby.ui.detail.heroSupportingAlpha
import com.ponzischeme89.memby.ui.detail.pinnedHeaderAlpha
import com.ponzischeme89.memby.ui.detail.pinnedScrimAlpha
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.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyTrialTypography
import com.ponzischeme89.memby.ui.theme.ValueSeparator
// The detail page's names for the shared tokens. The neutrals used to be a shade darker
// here than on the launcher, which is visible the moment a page opens from a row.
//
// Each is a `get()` and must stay one: the tokens are snapshot state now that the palette
// comes from the server, and an alias that captured a value would pin this whole page to
// whichever theme was loaded when the class first initialised.
internal val DetailBackground: Color get() = MembySurface
internal val DetailAccent: Color get() = MembyAccent
internal val DetailText: Color get() = MembyOnSurface
internal val DetailMutedText: Color get() = MembyMutedText
internal val DetailQuietText: Color get() = MembyQuietText
internal val DetailHairline: Color get() = MembyHairline
internal val DetailSideGutter = 58.dp
/**
* The band anchored under the hero: the tab strip on a movie or series page, the season
* scroller on an episode's. One height for both, because the fold it defines is the same
* fold, and a page whose content starts at a different place depending on what it is about
* reads as two designs.
*/
internal val DetailStripHeight = 66.dp
/**
* How much of the content pane is left showing under the tab strip while the hero is whole.
*
* It does two jobs. The strip used to be anchored to the very bottom of the screen, where a
* TV's overscan ate the selection underline and part of the labels — this is the safe-area
* inset the rest of the app already keeps. And because what fills the gap is the top of the
* pane rather than more background, it is the one thing on screen saying that Down from the
* strip reveals something.
*/
private val DetailFoldPeek = 34.dp
/**
* What is left of the hero once the viewer has moved down into the tabs.
*
* Enough for the title and the fact line and nothing else. The hero's job on the opening
* frame is to say what this is and offer Play; once somebody is browsing the content it is
* context, and context that keeps two thirds of the screen is in the way.
*/
internal val DetailCollapsedHeroHeight = 104.dp
/** The bottom safe-area strip under the pane, where the footer control sits when there is one. */
private val DetailFooterHeight = 46.dp
/**
* The height one tab's pane gets once the hero has collapsed out of its way.
*
* It was a hard 250dp with a scrolling page above it, and `technicalSpecs()` — Video, Codec,
* Audio, Subtitles, Studio — fell off the bottom of a pane that deliberately cannot scroll.
* The pane now takes whatever the collapsed hero and the strip leave, which on a 540dp
* television is roughly three times what it used to get. Kept as a function because the
* screenshot tests render a pane on its own and have to place it at the real geometry.
*/
internal fun detailPaneHeight(viewportHeight: Dp): Dp =
(viewportHeight - DetailCollapsedHeroHeight - DetailStripHeight - DetailFooterHeight)
.coerceAtLeast(200.dp)
/**
* Whether the hero is out of the way.
*
* The whole hero/tab relationship is this one rule, so it is pure and pinned by a test: the
* complete opening frame while focus is on Play, and collapsed the moment focus is anywhere
* below it. Nothing about scroll position is involved any more — the page does not scroll,
* which is what stopped the strip being pushed around by the height of a two-line title.
*/
internal fun detailHeroCollapsed(zone: DetailZone): Boolean = zone != DetailZone.PLAY
/**
* Hands focus to the first target that is actually on screen, and says whether any took it.
*
* `focusProperties { down = … }` names exactly one node and throws when that node is not
* attached — which on these pages is an ordinary state rather than an error. An episode
* page has no season chips until the seasons arrive, a season scrolled out of its LazyRow
* is not composed, a tab can be selected before its pane holds anything focusable. Down
* has to do the obvious thing in every one of those cases, so the direction keys name a
* *list* of places to try and fall through to the next rather than going dead under
* somebody's thumb.
*/
internal fun focusFirstAvailable(vararg targets: FocusRequester?): Boolean {
targets.forEach { target ->
if (target != null && runCatching { target.requestFocus() }.isSuccess) return true
}
return false
}
/**
* Vertical navigation stated as intent rather than as a destination.
*
* Deliberately `onKeyEvent` and not the preview: a press is offered to whatever holds
* focus first, so a control that means something of its own by Up or Down keeps it, and
* only an otherwise unhandled press is routed. Returning false leaves Compose's own focus
* search to try, which is the right last resort. Left and Right are never touched.
*/
internal fun Modifier.onVerticalNavigation(
up: (() -> Boolean)? = null,
down: (() -> Boolean)? = null,
): Modifier = onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
when (event.key) {
Key.DirectionUp -> up?.invoke() ?: false
Key.DirectionDown -> down?.invoke() ?: false
else -> false
}
}
internal data class DetailHeroAction(
val icon: ImageVector,
val description: String,
val active: Boolean = false,
val label: String? = null,
val onClick: () -> Unit,
)
/**
* A contextual deep-link back action, shown immediately before Play rather than among the
* circular secondary actions — it names a destination ("Back to Search Results") rather than
* a plain choice, and it is offered only when the page was reached from that destination. Not
* a permanent detail-page control: most pages never construct one.
*/
internal data class DetailBackNavigation(
val label: String,
val onClick: () -> Unit,
)
/**
* Full-bleed artwork with a protected reading area on the left and at the fold.
*
* [artworkAlpha] is how much of the picture the hero is still showing — 1 at rest, lower as
* the page collapses. It is a lambda and is read only inside a `graphicsLayer`, so the
* backdrop recedes in the draw phase and the image request is never restarted: the artwork
* changing state must not cost a fetch, and a fetch in the middle of a D-pad press is the
* one thing that would make this page feel slow.
*/
@Composable
internal fun DetailBackdrop(
item: BaseItem,
modifier: Modifier = Modifier,
artworkAlpha: () -> Float = { 1f },
) {
val context = LocalContext.current
val repository = ServiceLocator.repository
val artwork = remember(item.id, item.backdropImageTags, item.imageTags) {
repository.backdropUrl(item, ARTWORK_DETAIL_BACKDROP_MAX_WIDTH)
?: repository.primaryUrl(item, ARTWORK_DETAIL_PRIMARY_MAX_WIDTH)
}
// The one place in the app where a crossfade earns its keep. Artwork is loaded without
// one everywhere else because a row of posters snapping in is faster and reads fine at
// card size; a full-screen backdrop appearing between two frames is a flash, and this
// one is behind a title the viewer is already reading. Remembered so that a redraw of
// the hero is not a new request object.
val request = remember(artwork) {
artwork?.let {
ImageRequest.Builder(context).data(it).crossfade(BackdropCrossfadeMs).build()
}
}
Box(modifier.background(DetailBackground)) {
if (request != null) {
AsyncImage(
model = request,
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.TopCenter,
modifier = Modifier
.fillMaxSize()
.cinematicBackdropPullBack(artwork, artworkAlpha),
)
}
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to DetailBackground.copy(alpha = 0.95f),
0.42f to DetailBackground.copy(alpha = 0.80f),
0.72f to DetailBackground.copy(alpha = 0.22f),
1f to DetailBackground.copy(alpha = 0.06f),
),
),
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0f to Color(0x18000000),
0.48f to DetailBackground.copy(alpha = 0.15f),
0.78f to DetailBackground.copy(alpha = 0.90f),
1f to DetailBackground,
),
),
)
}
}
/** Shared movie/series frame. The list owns vertical motion so focused bands stay visible. */
@Composable
internal fun DetailPageScaffold(
item: BaseItem,
facts: List<String>,
badges: List<String>,
playLabel: String,
onPlay: () -> Unit,
playFocusRequester: FocusRequester,
tabFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
/**
* Owned by the caller, like every other focus target here — the one exception used to be
* created inside this scaffold, which meant a page could not also hand it to
* [RestoreDetailFocus] as the control that should hold focus on the very first frame.
* Defaults for pages that never set [backNavigation] at all.
*/
backNavigationFocusRequester: FocusRequester = remember(item.id) { FocusRequester() },
modifier: Modifier = Modifier,
tabs: List<DetailTab> = emptyList(),
selectedTab: DetailTab = DetailTab.OVERVIEW,
onSelectTab: (DetailTab) -> Unit = {},
/**
* Replaces the tab strip in the band under the hero. An episode page has one thing to
* navigate — the seasons — so it supplies its own scroller rather than pretending to be
* a set of tabs; the frame, the fold and the focus contract stay identical either way.
* It is handed the requester to hand focus back to above it, the one to send focus to
* below it, and the callback that pins the page to the strip.
*/
strip: (@Composable (
heroFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
onFocused: () -> Unit,
) -> Unit)? = null,
eyebrow: String? = null,
/**
* The compact form of [eyebrow] for the pinned header — "S03E04" where the expanded
* hero has the room for "SEASON 3 · EPISODE 4". A pinned header is one line beside a
* logo and a title; the spelled-out form pushes the fact line off the end of it.
*/
pinnedEyebrow: String? = eyebrow,
subtitle: String? = null,
/**
* The heading when the item has no logo to show. An episode's page is *about* the
* episode but *headed* by the show, so it passes the series name here — without it the
* fallback prints the episode's title twice, once as the heading and once under the
* number.
*/
title: String? = null,
progress: Float = 0f,
progressLabel: String? = null,
/**
* "Estimated finish: 18 August" — how the viewer's own pace projects onto what is left
* of a series. It sits with the progress information rather than beside it, and is
* deliberately quiet: it is a nicety on a page whose job is Play.
*
* Its own line rather than an addition to [progressLabel], because the progress bar
* appears only for a part-watched episode and the estimate is at its most useful for
* somebody who finished one last night and has not started the next.
*/
paceLabel: String? = null,
reasons: List<String> = emptyList(),
/**
* Set only when the page was opened from the "Shows airing" row, and it takes the
* accent line the recommendation reason would otherwise have: someone who arrived by
* pressing "Thursday, 9pm" came for the schedule, not for why the engine likes the
* show. Never focusable, like the reason it replaces.
*/
airingNotice: AiringNotice? = null,
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
heroActions: List<DetailHeroAction> = emptyList(),
/**
* Set only when this page was opened from Search — see [DetailBackNavigation]. Rendered
* before Play; every other entry point leaves this null and gets no button.
*/
backNavigation: DetailBackNavigation? = null,
confirmation: String? = null,
onZoneFocused: (DetailZone) -> Unit = {},
footer: (@Composable () -> Unit)? = null,
content: @Composable BoxScope.(DetailTab) -> Unit,
) {
// Keep the same requesters when an async trailer action appears. Replacing the list
// while the viewer is already on Favourites would detach the focused node.
val allActionRequesters = remember(item.id) {
List(6) { FocusRequester() }
}
val actionRequesters = allActionRequesters.take(heroActions.size)
var lastHeroIndex by remember(item.id) { mutableIntStateOf(-1) }
var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) }
// Reported on the way *in* to a band, never on every focus move inside one. The pane's
// `hasFocus` fires for each card a viewer walks past, and each of those was a write into
// the position store and a callback into the page — work done once per D-pad press, in
// the one place where a press must feel free.
val enterZone: (DetailZone) -> Unit = { zone ->
if (focusedZone != zone) {
focusedZone = zone
onZoneFocused(zone)
}
}
val heroReturn = if (lastHeroIndex in actionRequesters.indices) {
actionRequesters[lastHeroIndex]
} else {
playFocusRequester
}
// The band and the pane as a whole, so a press can reach them when the one node they
// would rather land on — the selected tab, the season being watched, the first
// episode card — is not composed on this frame.
val stripEntryRequester = remember(item.id) { FocusRequester() }
val contentEntryRequester = remember(item.id) { FocusRequester() }
// Down out of the hero: the strip, and failing that the content under it. A band with
// nothing in it yet is a thing to pass through, not a thing to stop at.
val enterStripFromHero = {
focusFirstAvailable(tabFocusRequester, stripEntryRequester, contentFocusRequester, contentEntryRequester)
}
val enterContent = { focusFirstAvailable(contentFocusRequester, contentEntryRequester) }
// The one number the whole layout is driven by: 0 is the complete opening frame, 1 is
// the hero out of the way with the strip at the top of the usable area. Height, the
// backdrop, the scrim, the identity block, the action row and the pinned header are all
// bands of *this* value (see `ui/detail/DetailHeroPhases.kt`) rather than animations of
// their own, which is what makes the transformation read as one movement and what stops
// them drifting apart when it is interrupted half way.
//
// It is read *only* inside layout and draw lambdas below — never in a composable body —
// so the transition costs re-layout of three boxes rather than a recomposition of the
// page on every frame of it.
//
// A spring, not a tween, and critically damped so it can never overshoot: this value
// drives opacities, and an overshoot past 1 would be a visible flicker. Focus changes
// faster than any animation on a TV — a held D-pad produces a press every few frames —
// and a spring retargets from wherever it currently is, so repeated presses continue one
// movement rather than restarting a duration each time.
val collapse = animateFloatAsState(
targetValue = if (detailHeroCollapsed(focusedZone)) 1f else 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium,
),
label = "detail-hero-collapse",
)
BoxWithConstraints(modifier.fillMaxSize().background(DetailBackground)) {
// The opening composition is one deliberate TV frame: hero above, tabs anchored to
// its bottom edge, the top of the content pane peeking under them. Nothing scrolls
// — the three bands are a Column, and moving between them changes the hero's height
// rather than the page's offset. That is the whole of the fix: a page that scrolled
// let the hero's own height decide where the strip ended up, so a two-line title
// pushed the tabs down and the content with them.
val expandedHero = (maxHeight - DetailStripHeight - DetailFoldPeek - DetailFooterHeight)
.coerceAtLeast(320.dp)
Column(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxWidth()
.collapsingHeight(expandedHero, DetailCollapsedHeroHeight) { collapse.value }
.clipToBounds(),
) {
DetailHero(
item = item,
facts = facts,
badges = badges,
eyebrow = eyebrow,
pinnedEyebrow = pinnedEyebrow,
subtitle = subtitle,
title = title ?: item.name,
playLabel = playLabel,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
backNavigation = backNavigation,
backNavigationFocusRequester = backNavigationFocusRequester,
onNavigateDown = enterStripFromHero,
progress = progress,
progressLabel = progressLabel,
paceLabel = paceLabel,
reasons = reasons,
airingNotice = airingNotice,
ratings = ratings,
showRatingsStrip = showRatingsStrip,
actions = heroActions,
actionRequesters = actionRequesters,
expandedHeight = expandedHero,
collapsedHeight = DetailCollapsedHeroHeight,
collapse = { collapse.value },
onActionFocused = { index ->
lastHeroIndex = index
enterZone(DetailZone.PLAY)
},
onPlayFocused = {
lastHeroIndex = -1
enterZone(DetailZone.PLAY)
},
)
}
val onStripFocused = { enterZone(DetailZone.TABS) }
// The band as one focus group, so a press that cannot reach the exact stop it
// wanted still arrives somewhere in the strip.
Box(
Modifier
.focusRequester(stripEntryRequester)
.focusGroup()
.onVerticalNavigation(
up = { focusFirstAvailable(heroReturn, playFocusRequester) },
down = enterContent,
),
) {
if (strip != null) {
strip(heroReturn, contentFocusRequester, onStripFocused)
} else {
DetailTabStrip(
tabs = tabs,
selected = selectedTab,
onSelect = onSelectTab,
selectedFocusRequester = tabFocusRequester,
onExitUp = { focusFirstAvailable(heroReturn, playFocusRequester) },
onExitDown = enterContent,
onFocused = onStripFocused,
)
}
}
AnimatedContent(
targetState = selectedTab,
transitionSpec = { fadeIn(tween(110)) togetherWith fadeOut(tween(80)) },
label = "detail-tab-content",
modifier = Modifier
.fillMaxWidth()
// Takes whatever the hero has given up. While the hero is whole this is
// the fold peek; once it collapses the pane is most of the screen, which
// is the point — the content takes over rather than living in a slot.
.weight(1f)
.padding(start = DetailSideGutter, end = DetailSideGutter, top = 14.dp)
.onFocusChanged { if (it.hasFocus) enterZone(DetailZone.CONTENT) }
.focusRequester(contentEntryRequester)
.focusGroup()
// Deliberately a focus property and not a key handler: panes navigate
// vertically inside themselves (an episode list, its season chips) and
// override this where they do. A blanket handler here would take Up off
// every card in that list.
.focusProperties { up = tabFocusRequester },
) { visibleTab ->
Box(Modifier.fillMaxSize()) { content(visibleTab) }
}
// Always the same height whether or not there is a footer, so the pane above it
// does not change size when an async control appears inside it.
Box(
Modifier
.fillMaxWidth()
.height(DetailFooterHeight)
.padding(start = DetailSideGutter, end = DetailSideGutter),
contentAlignment = Alignment.CenterStart,
) { footer?.invoke() }
}
confirmation?.let {
Text(
text = it,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 24.dp)
.shadow(16.dp, RoundedCornerShape(MembyPanelCorner))
.clip(RoundedCornerShape(MembyPanelCorner))
.background(MembySurfaceRaised.copy(alpha = 0.93f))
.border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(MembyPanelCorner))
.padding(horizontal = 20.dp, vertical = 10.dp),
)
}
}
}
/**
* Gives the node a height between [expanded] and [collapsed], read from [fraction].
*
* A `Modifier.height(animatedDp)` would read the animation in composition and recompose the
* hero — and with it the whole Column — sixty times a second for the length of the
* transition. This reads it in the layout lambda instead, so a collapse costs a measure pass
* and nothing above it. Same rule as the launcher's countdown ring, one phase further in.
*
* The child is measured at its *expanded* height and simply clipped by the parent, which is
* what keeps the hero's own layout still while the box around it shrinks: nothing inside it
* re-wraps, so no focused control ever changes size or moves under a thumb.
*/
private fun Modifier.collapsingHeight(
expanded: Dp,
collapsed: Dp,
fraction: () -> Float,
): Modifier = layout { measurable, constraints ->
val expandedPx = expanded.roundToPx()
val collapsedPx = collapsed.roundToPx()
val height = expandedPx + ((collapsedPx - expandedPx) * fraction().coerceIn(0f, 1f)).toInt()
val placeable = measurable.measure(
constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity),
)
layout(placeable.width, height) { placeable.place(0, 0) }
}
@Composable
private fun DetailHero(
item: BaseItem,
facts: List<String>,
badges: List<String>,
eyebrow: String?,
pinnedEyebrow: String?,
subtitle: String?,
title: String,
playLabel: String,
onPlay: () -> Unit,
playFocusRequester: FocusRequester,
backNavigation: DetailBackNavigation?,
backNavigationFocusRequester: FocusRequester,
onNavigateDown: () -> Boolean,
progress: Float,
progressLabel: String?,
paceLabel: String?,
reasons: List<String>,
airingNotice: AiringNotice?,
ratings: List<MediaRating>,
showRatingsStrip: Boolean,
actions: List<DetailHeroAction>,
actionRequesters: List<FocusRequester>,
expandedHeight: Dp,
collapsedHeight: Dp,
/**
* 0 while the hero owns the screen, 1 once it is out of the way. A lambda rather than a
* value so nothing here recomposes while it runs — every reader of it below is inside a
* `graphicsLayer` block, which is the draw phase.
*/
collapse: () -> Float,
onPlayFocused: () -> Unit,
onActionFocused: (Int) -> Unit,
) {
// Settings promises the logo preference applies to titles generally; only the
// screensaver honoured it. The dark-logo fallback comes with it — a black title
// treatment on this near-black scrim is an invisible heading.
val repository = ServiceLocator.repository
// Keyed on the parent-logo identity as well as the item's own tags, because an episode
// carries no Logo of its own: its title treatment is the series', named by
// ParentLogoItemId/ParentLogoImageTag. Those two arrive on a route object that a
// thinner one — a re-focus behind the overlay, a lightweight recommendation card — can
// briefly replace, and a memo that could not see them resolved "no logo" once and kept
// that answer for the life of the page. It is the same key the launcher's hero and its
// metadata panel already use; the three must agree or the page contradicts the row it
// was opened from.
val logoUrl = remember(
item.id,
item.imageTags,
item.parentLogoItemId,
item.parentLogoImageTag,
repository.showTitleLogo,
) {
if (repository.showTitleLogo) repository.logoUrl(item) else null
}
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
val hasRatings = remember(ratings, showRatingsStrip) {
showRatingsStrip && ratings.displayable().isNotEmpty()
}
// The description of whichever circular action holds focus, shown on a reserved line
// under the row. The icons are a heart, a tick, a bookmark and a film reel, and at three
// metres they are guesses; naming the focused one is the cheapest way to stop that. It
// is reserved rather than conditional, so nothing moves as focus travels the row.
var actionCaption by remember(item.id) { mutableStateOf<String?>(null) }
// Down belongs to the hero as a whole, not to the row of buttons inside it: whatever
// in here holds focus, the press means "take me to the band under this".
//
// Always laid out at its full height and slid upward as it collapses, so its bottom edge
// rides the shrinking band while nothing inside it re-wraps. Measuring it at the animated
// height instead would re-flow the title, the synopsis and the action row on every frame
// of the transition — which is the "suddenly resize a focused item" failure, sixty times
// a second.
MembyTrialTypography {
Box(
Modifier
.fillMaxWidth()
.height(expandedHeight)
.graphicsLayer {
translationY = -(expandedHeight.toPx() - collapsedHeight.toPx()) * collapse()
}
.onVerticalNavigation(down = onNavigateDown),
) {
DetailBackdrop(
item = item,
modifier = Modifier.fillMaxSize(),
artworkAlpha = { heroArtworkAlpha(collapse()) },
)
// Deepened as the hero gives way, so the strip and the top of the content pane are
// read against something closer to the launcher's near-black than to artwork. A
// gradient rather than a flat wash: the picture recedes furthest where the content
// is about to be, and least at the top, which is what keeps it looking like one
// photograph settling back rather than a sheet being drawn over it.
Box(
Modifier
.fillMaxSize()
.graphicsLayer { alpha = heroDimAlpha(collapse()) }
.background(
Brush.verticalGradient(
0f to DetailBackground.copy(alpha = 0.46f),
0.55f to DetailBackground.copy(alpha = 0.78f),
1f to DetailBackground.copy(alpha = 0.90f),
),
),
)
// The separation behind the pinned header. It occupies exactly the band the pinned
// header will end up in and fades from nothing at its top edge, so what the viewer
// sees is content passing under a softening rather than a bar appearing.
Box(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.height(collapsedHeight)
.graphicsLayer { alpha = pinnedScrimAlpha(collapse()) }
.background(
Brush.verticalGradient(
0f to Color.Transparent,
0.45f to DetailBackground.copy(alpha = 0.55f),
1f to DetailBackground.copy(alpha = 0.88f),
),
),
)
DetailCollapsedHeader(
title = title,
facts = facts,
logo = logo,
eyebrow = pinnedEyebrow,
subtitle = subtitle,
modifier = Modifier
.align(Alignment.BottomStart)
// Arrives only once the expanded hero has finished leaving. Two headings
// crossing through each other reads as a fault, not as a transition.
.graphicsLayer { alpha = pinnedHeaderAlpha(collapse()) },
)
// The expanded hero. Bounded at the top by the safe inset, which is the whole of
// the fix for a hero that used to grow until it hit the edge of the screen: with a
// ceiling, something inside has to give, and the column below decides what.
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth(DetailHeroMetrics.TextWidthFraction)
.heightIn(max = (expandedHeight - DetailHeroMetrics.TopInset).coerceAtLeast(180.dp))
.graphicsLayer { alpha = heroPrimaryAlpha(collapse()) }
.padding(
start = DetailSideGutter,
end = DetailSideGutter,
bottom = DetailHeroMetrics.BottomInset,
),
) {
DetailIdentity(
logo = logo,
title = title,
eyebrow = eyebrow,
subtitle = subtitle,
contentDescription = item.name,
// Whether this title could *ever* show a logo, which is known synchronously
// from the image tags — as against whether it will, which needs the artwork
// fetched and its darkness judged. The reservation is only worth paying for
// where that swap can actually happen; a title Emby holds no logo for gets
// its heading at its natural height and gives the space to the synopsis.
reserveForLogo = logoUrl != null,
)
Spacer(Modifier.height(10.dp))
DetailFactRow(facts = facts, badges = badges)
// Everything that answers "is this worth watching" — and the only part of the
// hero allowed to give way. A weighted child is measured from what the
// unweighted ones left over, so a two-line title, a full ratings strip and a
// synopsis now cost prose rather than costing the Play button its shape.
// `fill = false` keeps it at its natural height whenever there is room, which on
// an ordinary title is always. This inversion is the fix for the malformed
// primary action, and it is the same one the home hero already makes.
Column(
modifier = Modifier
.weight(1f, fill = false)
.clipToBounds()
.graphicsLayer { alpha = heroSupportingAlpha(collapse()) },
) {
// No reserved height here, unlike everywhere else the strip appears. Most
// of a library has no scores — an episode is rated as its series, and a
// household with no MDBList key has none at all — and 42dp held open for
// them was 42dp taken off the synopsis on every one of those pages. The
// block this sits in is the flexible one, so a strip arriving late costs a
// line of prose rather than moving anything the viewer is aiming at.
if (hasRatings) {
Spacer(Modifier.height(8.dp))
RatingsStrip(ratings, visible = true)
}
// Not on an episode page: an episode inherits its series' genres, so the
// line would repeat what the show above it already said, on the one variant
// whose identity block is three lines tall to begin with.
if (item.genres.isNotEmpty() && eyebrow == null) {
Spacer(Modifier.height(8.dp))
Text(
text = item.genres.take(4).joinToString(ValueSeparator),
color = DetailMutedText,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(10.dp))
// The one thing in the hero that shrinks by degrees. Everything around it
// is a single line that is either there or not; the synopsis can honestly
// be three lines, or two, or none, so it is where the give is taken from —
// and [wholeLines] takes it a whole line at a time, because prose sliced
// through the middle of its letters reads as a rendering fault where two
// lines instead of three reads as nothing at all.
Text(
text = item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = DetailText,
fontSize = 15.sp,
lineHeight = SynopsisLineHeight,
maxLines = DetailHeroMetrics.SynopsisLines,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f, fill = false)
// Outside [wholeLines], so it clips to the *snapped* height rather
// than to the text's own. A Text handed less room still draws every
// line it was asked for; without this the dropped lines simply
// painted over whatever the column placed underneath.
.clipToBounds()
.wholeLines(SynopsisLineHeight),
)
paceLabel?.let {
Spacer(Modifier.height(10.dp))
Text(
text = it,
color = DetailQuietText,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (airingNotice != null) {
Spacer(Modifier.height(10.dp))
AiringNoticeBand(airingNotice)
} else if (reasons.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
Text(
text = reasons.first(),
color = DetailAccent,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
// Directly above the actions, because it is what the primary button *means*:
// "Resume" and a bar showing 40 minutes gone are one statement. Never inside
// the flexible block — how far in somebody is must not be what gets cut.
if (progress > 0f) {
Spacer(Modifier.height(14.dp))
DetailProgress(progress, progressLabel)
}
Spacer(Modifier.height(16.dp))
DetailHeroActions(
itemId = item.id,
playLabel = playLabel,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
backNavigation = backNavigation,
backNavigationFocusRequester = backNavigationFocusRequester,
actions = actions,
actionRequesters = actionRequesters,
caption = actionCaption,
onPlayFocused = {
actionCaption = null
onPlayFocused()
},
onBackNavigationFocused = {
actionCaption = backNavigation?.label
onPlayFocused()
},
onActionFocused = { index ->
actionCaption = actions.getOrNull(index)?.description
onActionFocused(index)
},
)
}
}
}
}
/**
* The primary action and the circular secondary ones, plus the line that names them.
*
* One component for all three page variants, so a movie, a series and an episode cannot
* grow different button metrics — which is exactly how the Continue Watching page came to
* have a Play button that did not match the one on a film.
*/
@Composable
private fun DetailHeroActions(
itemId: String,
playLabel: String,
onPlay: () -> Unit,
playFocusRequester: FocusRequester,
backNavigation: DetailBackNavigation?,
backNavigationFocusRequester: FocusRequester,
actions: List<DetailHeroAction>,
actionRequesters: List<FocusRequester>,
caption: String?,
onPlayFocused: () -> Unit,
onBackNavigationFocused: () -> Unit,
onActionFocused: (Int) -> Unit,
) {
Column {
// Scrollable rather than merely wide: a Row with more content than the hero has
// room for does not shrink its children to fit — each button keeps the intrinsic
// size [MembyPlayButton]/[DetailCircularAction] give it, and it is the row's own
// bounds that give way. Scrolling is the one fix that already generalises: any page
// with enough actions to overflow (the franchise "Start with…" action included)
// gets the same safety net.
//
// The back-navigation control is a circular icon action, not a labelled pill: a
// "Back to Search Results" pill was wide enough that bringing a freshly focused
// Play into view scrolled the row past zero, leaving the pill's rounded corner
// sheared off against Play's own edge — reading as a deformed Play button rather
// than a clipped neighbour. A 48dp circle, the same size every other secondary
// action here uses, never has to compete with Play for room in the first place.
//
// Keyed on the item: unkeyed, the scroll offset a wide row needed for one title
// would survive into the next composed in the same slot, opening it with Play
// already scrolled out of view for no reason of its own.
val actionsScroll = remember(itemId) { ScrollState(0) }
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
// Cancels the leading Spacer below, so the first button still rests at
// the same x it always has — only the room around it changes.
.offset(x = -ActionsRowEdgeInset)
.horizontalScroll(actionsScroll)
.focusGroup(),
) {
// `horizontalScroll` clips its content to its own left/right bounds so that
// scrolled-past content is actually hidden — and the leftmost button sits
// flush against that left edge. On focus it grows a few percent around its own
// centre (plus a wider elevation shadow), so without this gap its left edge
// was sheared off by the container's clip rather than by anything about the
// button itself. The gap is scrollable content, so it sits inside the clip
// boundary rather than just moving the boundary, and the offset above puts the
// resting row back where it was before the gap was added.
Spacer(Modifier.width(ActionsRowEdgeInset))
if (backNavigation != null) {
DetailCircularAction(
action = DetailHeroAction(
icon = MembyIcon.ArrowBack.mark,
description = backNavigation.label,
onClick = backNavigation.onClick,
),
onFocused = onBackNavigationFocused,
modifier = Modifier
.testTag("detail-back-to-search")
.focusRequester(backNavigationFocusRequester),
)
}
MembyPlayButton(
label = playLabel,
onClick = onPlay,
onFocused = onPlayFocused,
modifier = Modifier.testTag("detail-play").focusRequester(playFocusRequester),
)
actions.forEachIndexed { index, action ->
DetailCircularAction(
action = action,
onFocused = { onActionFocused(index) },
modifier = Modifier.focusRequester(actionRequesters[index]),
)
}
// The same gap at the far end, for a row that overflows and is scrolled all
// the way to its last action.
Spacer(Modifier.width(ActionsRowEdgeInset))
}
// Reserved, not conditional: a line that appears when focus reaches the second
// button would move the whole hero every time somebody pressed Right.
Box(Modifier.height(DetailActionCaptionHeight), contentAlignment = Alignment.CenterStart) {
caption?.let {
Text(
text = it,
color = DetailMutedText,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** Reserved for the focused action's description; see [DetailHeroActions]. */
private val DetailActionCaptionHeight = 22.dp
/**
* Room at each end of the actions row's scrollable content, clear of the horizontal-scroll
* clip boundary — see the comment where it's used in [DetailHeroActions].
*/
private val ActionsRowEdgeInset = 16.dp
/**
* The hero synopsis' line box, shared by the text style and by [wholeLines].
*
* In `sp` rather than `dp` so the two agree under a font scale: a snap computed from a
* fixed density would land part way through a line on a set with large text.
*/
private val SynopsisLineHeight = 20.sp
/**
* Reports a height that is always a whole number of [lineHeight] boxes.
*
* A `Text` handed less room than it wants draws as much as it can and cuts the last line
* through the middle of the letters, which looks like a fault rather than like a decision.
* This measures the text at its natural height and then, only when there is not room for
* all of it, rounds the reported height *down* to the last complete line — so the block
* either shows three lines, or two, or one, or nothing.
*
* A single measure pass, in the layout phase. No subcomposition, no measuring twice, and
* nothing read in composition: the same rule the collapsing hero band follows.
*/
internal fun Modifier.wholeLines(lineHeight: TextUnit): Modifier = layout { measurable, constraints ->
val line = lineHeight.roundToPx().coerceAtLeast(1)
val placeable = measurable.measure(
constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity),
)
val available = if (constraints.hasBoundedHeight) constraints.maxHeight else placeable.height
val height = if (placeable.height <= available) placeable.height else (available / line) * line
layout(placeable.width, height.coerceAtLeast(0)) { placeable.place(0, 0) }
}
/** How long the hero backdrop takes to fade in. See [DetailBackdrop]. */
private const val BackdropCrossfadeMs = 220
/**
* What the page is about: the logo when there is a legible one, the title when there is not,
* and — on an episode — the season/episode line and the episode's own name under it.
*
* The logo occupies a **reserved** height whether or not it has arrived. Deciding whether a
* logo is legible means fetching and decoding it, so every one of these pages begins on the
* text fallback and changes its mind a moment later; without the reservation that swap moved
* everything under it, on the opening frame, which is the frame the viewer is watching.
*
* Very wide and very tall logos are both a letterboxed picture inside that box, because the
* box is fixed and the scale is [ContentScale.Fit] — a logo cannot change the page's shape.
*/
@Composable
private fun DetailIdentity(
logo: String?,
title: String,
eyebrow: String?,
subtitle: String?,
contentDescription: String,
reserveForLogo: Boolean,
) {
Column {
Box(
modifier = Modifier
.then(
if (reserveForLogo) {
Modifier.height(DetailHeroMetrics.IdentityHeight)
} else {
Modifier.heightIn(max = DetailHeroMetrics.IdentityHeight)
},
)
.fillMaxWidth(),
contentAlignment = Alignment.BottomStart,
) {
if (logo != null) {
TitleLogoImage(
logoUrl = logo,
contentDescription = contentDescription,
alignment = Alignment.BottomStart,
modifier = Modifier
.width(DetailHeroMetrics.LogoMaxWidth)
.height(DetailHeroMetrics.LogoMaxHeight),
)
} else {
Text(
text = title,
color = Color.White,
fontSize = 36.sp,
lineHeight = 40.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
// An episode's logo belongs to its *series*, so without this the page would name
// the show and never say which episode it is about. The number goes first and the
// episode's own title second: the viewer already knows the show they picked, and
// "Season 3 · Episode 4" is what they came to confirm.
if (eyebrow != null) {
Spacer(Modifier.height(8.dp))
Text(
text = eyebrow,
color = DetailAccent,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.6.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (subtitle != null) {
Spacer(Modifier.height(4.dp))
Text(
text = subtitle,
color = Color.White,
fontSize = 25.sp,
lineHeight = 29.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* What is left of the hero once the viewer is browsing: the title and the fact line.
*
* It is deliberately not a shrunken copy of the hero. Everything else up there — the
* synopsis, the ratings, the reason, the actions — answers "is this worth watching", which
* is a question somebody who has moved down into the Cast grid has already answered. What
* remains is only what stops the page becoming anonymous: which title these tabs belong to.
*/
@Composable
private fun DetailCollapsedHeader(
title: String,
facts: List<String>,
logo: String?,
modifier: Modifier = Modifier,
eyebrow: String? = null,
subtitle: String? = null,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(start = DetailSideGutter, end = DetailSideGutter, bottom = 15.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (logo != null) {
AsyncImage(
model = logo,
contentDescription = title,
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
modifier = Modifier
.height(DetailHeroMetrics.PinnedLogoMaxHeight)
.width(DetailHeroMetrics.PinnedLogoMaxWidth),
)
} else {
Text(
text = title,
color = Color.White,
fontSize = 23.sp,
lineHeight = 26.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
// An episode's pinned header is headed by the show, so without this it would say
// nothing about which episode the tabs underneath belong to — which is the one
// thing the pinned state exists to keep saying.
subtitle?.let {
Spacer(Modifier.width(14.dp))
Text(
text = it,
color = Color.White.copy(alpha = 0.88f),
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
// The number, not the whole eyebrow: "S3 · E4" beside the show is what identifies
// the page, where the expanded hero has the room to spell it out.
eyebrow?.let {
Spacer(Modifier.width(12.dp))
Text(
text = it,
color = DetailAccent,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.2.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (facts.isNotEmpty()) {
Spacer(Modifier.width(16.dp))
Text(
text = facts.joinToString(FactSeparator),
color = DetailMutedText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* The schedule the viewer pressed, restated on the page it opened.
*
* A tinted band rather than one more accent line: it has to be findable in the half-second
* after the page appears, by someone who chose this show *because* of when it airs. It sits
* where the recommendation reason does, immediately above the actions, so nothing below the
* hero moves.
*/
@Composable
private fun AiringNoticeBand(notice: AiringNotice) {
val shape = RoundedCornerShape(MembyCardCorner)
Column(
modifier = Modifier
.clip(shape)
.background(DetailAccent.copy(alpha = 0.13f))
.border(1.dp, DetailAccent.copy(alpha = 0.45f), shape)
.padding(horizontal = 14.dp, vertical = 9.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = notice.label,
color = DetailAccent,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.4.sp,
maxLines = 1,
)
if (notice.headline.isNotEmpty()) {
Text(
text = notice.headline,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 12.dp),
)
}
}
if (notice.detail.isNotEmpty()) {
Spacer(Modifier.height(3.dp))
Text(
text = notice.detail,
color = DetailText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/** Compatibility entry point for previews/tests; all rendering lives in RatingsStrip. */
@Composable
internal fun DetailMovieRatings(
ratings: List<MediaRating>,
modifier: Modifier = Modifier,
) {
RatingsStrip(ratings, visible = true, modifier = modifier)
}
@Composable
private fun DetailCircularAction(
action: DetailHeroAction,
onFocused: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (focused) 1.1f else 1f, tween(100), label = "hero-action-focus")
Row(
modifier = modifier
.then(if (action.label == null) Modifier.size(48.dp) else Modifier.height(48.dp))
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
.shadow(if (focused) 16.dp else 5.dp, CircleShape)
.clip(CircleShape)
.background(if (action.active) DetailAccent else MembySurfaceRaised.copy(alpha = 0.70f))
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else Color.White.copy(alpha = 0.38f), CircleShape)
.semantics { contentDescription = action.description }
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() }
.clickable(onClick = action.onClick)
.padding(horizontal = if (action.label == null) 0.dp else 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(action.icon, action.description, tint = Color.White, modifier = Modifier.size(22.dp))
action.label?.let { label ->
Spacer(Modifier.width(8.dp))
Text(label, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Bold, maxLines = 1)
}
}
}
@Composable
internal fun DetailFactRow(
facts: List<String>,
badges: List<String> = emptyList(),
modifier: Modifier = Modifier,
) {
Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
facts.forEachIndexed { index, value ->
if (index > 0) Text(FactSeparator, color = DetailQuietText, fontSize = 13.sp)
Text(value, color = DetailMutedText, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
// Four, not three: picture, audio and airing metadata can all share this line, and
// the airing badge appended after them is the one that is news.
badges.take(4).forEach { badge ->
Spacer(Modifier.width(8.dp))
MediaBadge(badge)
}
}
}
/**
* The frame every band under the hero wears: the near-black plate, the side gutters, the
* hairline that separates it from the pane, and the chevron saying Down reveals something.
*
* Shared by the tab strip and by the episode page's season scroller, because the fold they
* define is the same fold — and two hand-written copies of it is exactly how the two pages
* came to sit their content a couple of pixels apart. [trailing] is whatever the band wants
* before the chevron: the series-progress line on an episode page, nothing on a movie's.
*/
@Composable
internal fun DetailStripFrame(
modifier: Modifier = Modifier,
trailing: (@Composable () -> Unit)? = null,
content: @Composable RowScope.() -> Unit,
) {
Box(
modifier
.fillMaxWidth()
.height(DetailStripHeight)
.background(DetailBackground)
.padding(horizontal = DetailSideGutter),
) {
Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(1.dp).background(DetailHairline))
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.Bottom,
) {
Row(
modifier = Modifier.weight(1f).fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(34.dp),
verticalAlignment = Alignment.Bottom,
content = content,
)
trailing?.invoke()
// The tab's content begins below the fold by design. Nothing said so; the peek
// under this strip and this chevron are what say it. Never focusable — it is a
// caption on the Down key, not another thing to land on.
Icon(
MembyIcon.ChevronDown.mark,
contentDescription = null,
tint = DetailQuietText,
modifier = Modifier.padding(start = 12.dp, bottom = 14.dp).size(18.dp),
)
}
}
}
@Composable
private fun DetailTabStrip(
tabs: List<DetailTab>,
selected: DetailTab,
onSelect: (DetailTab) -> Unit,
selectedFocusRequester: FocusRequester,
onExitUp: () -> Boolean,
onExitDown: () -> Boolean,
onFocused: () -> Unit,
) {
DetailStripFrame {
Row(
modifier = Modifier
.fillMaxSize()
.focusGroup()
.onVerticalNavigation(up = onExitUp, down = onExitDown),
horizontalArrangement = Arrangement.spacedBy(34.dp),
verticalAlignment = Alignment.Bottom,
) {
// Whatever else happens, one tab carries the requester the rest of the page
// aims Up and Down at. A selected tab that is not in the list — a key
// remembered from the other kind of item, for the one frame before it is
// resolved — must not leave the band with nothing attached to land on.
val anchor = tabs.firstOrNull { it == selected } ?: tabs.firstOrNull()
tabs.forEach { tab ->
var focused by remember(tab) { mutableStateOf(false) }
Column(
modifier = Modifier
.width(IntrinsicSize.Max)
.then(if (tab == anchor) Modifier.focusRequester(selectedFocusRequester) else Modifier)
.testTag("detail-tab-${tab.key}")
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) { onSelect(tab); onFocused() }
}
// Both halves, because a click is somebody arriving in the strip
// just as a D-pad press is. Selecting without reporting it would
// change the pane and leave the hero standing over it.
.clickable { onSelect(tab); onFocused() },
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Focus and selection say two different things with two different
// marks, even though on this page one causes the other. The accent
// underline is *selection* — which pane is open. The plate behind the
// label is *focus* — where the remote is. They coincide while the
// viewer is in the strip and come apart the moment they press Down,
// which is the case the distinction exists for: the open tab must go
// on saying it is open once nothing up here is focused.
Text(
tab.label,
color = if (selected == tab || focused) Color.White else DetailQuietText,
fontSize = 15.sp,
fontWeight = if (selected == tab || focused) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
modifier = Modifier
.padding(bottom = 9.dp)
.clip(RoundedCornerShape(MembyChipCorner))
.background(
if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent,
)
.padding(horizontal = 10.dp, vertical = 4.dp),
)
Box(
Modifier.fillMaxWidth().height(3.dp).background(
if (selected == tab) DetailAccent else Color.Transparent,
),
)
}
}
Spacer(Modifier.weight(1f))
}
}
}
@Composable
internal fun DetailProgress(progress: Float, label: String?, modifier: Modifier = Modifier) {
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.width(250.dp).height(5.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.22f))) {
Box(Modifier.fillMaxWidth(progress.coerceIn(0f, 1f)).height(5.dp).background(DetailAccent))
}
label?.let { Text(it, color = DetailMutedText, fontSize = 12.sp, modifier = Modifier.padding(start = 12.dp)) }
}
}
@Composable
internal fun DetailFocusablePane(
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
Box(
modifier
// Width only. The pane is most of the screen once the hero has collapsed, and a
// bordered box filling all of it to hold one sentence reads as a failed load.
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { focused = it.isFocused }
.focusable()
.clip(RoundedCornerShape(MembyCardCorner))
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White.copy(alpha = 0.8f) else Color.Transparent, RoundedCornerShape(MembyCardCorner))
.background(if (focused) Color.White.copy(alpha = 0.035f) else Color.Transparent)
.padding(16.dp),
) { content() }
}
@Composable
internal fun DetailMetaRows(rows: List<TechnicalSpec>, modifier: Modifier = Modifier, labelWidth: androidx.compose.ui.unit.Dp = 110.dp) {
MembyTrialTypography {
Column(modifier, verticalArrangement = Arrangement.spacedBy(9.dp)) {
rows.forEach { row ->
Row(Modifier.fillMaxWidth()) {
Text(row.label, color = DetailQuietText, fontSize = 13.sp, fontWeight = FontWeight.Medium, modifier = Modifier.width(labelWidth))
Text(row.value, color = DetailText, fontSize = 14.sp, lineHeight = 19.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
}
}
}
}
}
/**
* A movie's landing pane: the whole synopsis, and the four credit lines worth leading with.
*
* Two columns because the pane is now most of the screen. A single column of prose across
* 1200 dp is a line length nobody reads at three metres, and the credits sitting beside it
* rather than beneath it means neither has to be cut.
*/
@Composable
internal fun DetailOverviewPane(
item: BaseItem,
credits: List<TechnicalSpec>,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
supportingText: String? = null,
) {
MembyTrialTypography {
DetailFocusablePane(focusRequester, modifier) {
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
Column(Modifier.weight(1.5f)) {
item.taglines.firstOrNull()?.takeIf(String::isNotBlank)?.let { tagline ->
Text(
text = tagline,
color = DetailAccent,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(10.dp))
}
Text(
text = item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = DetailText,
fontSize = 16.sp,
lineHeight = 24.sp,
// The hero already shows three lines; the whole reason to press Down
// onto Overview is the rest of them.
maxLines = 12,
overflow = TextOverflow.Ellipsis,
)
supportingText?.let {
Spacer(Modifier.height(12.dp))
Text(it, color = DetailAccent, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
}
}
DetailMetaRows(credits, Modifier.weight(1f), labelWidth = 96.dp)
}
}
}
}
/**
* The Details tab: catalogue facts on the left, what the file actually is on the right.
*
* Two vocabularies that answer different questions — "what is this?" and "will it play?" —
* so they are two columns rather than one list. Everything here is deliberately the
* lowest-priority information on the page; it is a tab precisely so it does not have to
* compete for the hero.
*/
@Composable
internal fun DetailDetailsPane(
rows: List<TechnicalSpec>,
specs: List<TechnicalSpec>,
detailsLoaded: Boolean,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
// Studio is in both vocabularies — the catalogue's because it is who made the thing, the
// file's because Emby writes it onto the stream record. Printed twice, side by side, it
// reads as a page that cannot make its mind up.
val fileSpecs = remember(rows, specs) {
val alreadySaid = rows.map { it.value }.toSet()
specs.filterNot { it.value in alreadySaid }
}
MembyTrialTypography {
DetailFocusablePane(focusRequester, modifier) {
if (rows.isEmpty() && specs.isEmpty()) {
Text(
text = if (detailsLoaded) "No further details are recorded." else "Loading details…",
color = DetailQuietText,
fontSize = 15.sp,
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
DetailMetaRows(rows, Modifier.weight(1f))
Column(Modifier.weight(1f)) {
if (fileSpecs.isNotEmpty()) {
Text(
text = "FILE",
color = DetailQuietText,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.4.sp,
)
Spacer(Modifier.height(10.dp))
DetailMetaRows(fileSpecs)
}
}
}
}
}
}
}
/**
* The Cast tab. A grid rather than the rail this replaced — see `ui/detail/CastGrid.kt`.
*
* The empty state distinguishes "still coming" from "nobody recorded", because a page that
* says "no cast" a quarter of a second before the cast arrives is worse than one that says
* nothing: the viewer has already pressed Right to leave.
*/
@Composable
internal fun DetailCastPane(
people: List<EmbyPerson>,
detailsLoaded: Boolean,
state: LazyGridState,
entryIndex: Int,
entryFocusRequester: FocusRequester,
emptyFocusRequester: FocusRequester,
aboveGrid: FocusRequester,
onFocusIndex: (Int) -> Unit,
onSelect: (EmbyPerson) -> Unit,
modifier: Modifier = Modifier,
) {
if (people.isEmpty()) {
DetailFocusablePane(emptyFocusRequester, modifier) {
Text(
text = if (detailsLoaded) "No cast is recorded for this title." else "Loading cast…",
color = DetailQuietText,
fontSize = 15.sp,
)
}
return
}
val repository = ServiceLocator.repository
CastGrid(
people = people,
state = state,
entryIndex = entryIndex,
entryFocusRequester = entryFocusRequester,
aboveGrid = aboveGrid,
onFocusIndex = onFocusIndex,
onSelect = onSelect,
modifier = modifier,
// Coil is handed a plain URL and fetches it lazily as the row scrolls into view;
// nothing here pre-resolves an image for a card nobody has reached.
imageUrlFor = { person -> repository.personImageUrl(person, 300) },
)
}
/**
* More Like This: a poster grid, on the same rules as the cast grid.
*
* It was a single LazyRow, which was right when the pane was a 250dp slot below the fold. In
* a pane that now owns most of the screen one row of posters with three hundred empty pixels
* under it reads as content that failed to arrive — and a household with forty neighbours
* for a title had to walk right through all of them.
*/
@Composable
internal fun DetailMoreLikeThisPane(
items: List<BaseItem>,
loading: Boolean,
onSelect: (BaseItem) -> Unit,
firstFocusRequester: FocusRequester,
gridState: LazyGridState,
aboveGrid: FocusRequester,
entryIndex: Int,
onFocusIndex: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
when {
loading -> DetailFocusablePane(firstFocusRequester, modifier) {
Text("Finding similar titles…", color = DetailQuietText, fontSize = 15.sp)
}
items.isEmpty() -> DetailFocusablePane(firstFocusRequester, modifier) {
Text("No similar titles are available.", color = DetailQuietText, fontSize = 15.sp)
}
else -> BoxWithConstraints(modifier.fillMaxSize()) {
val columns = remember(maxWidth) { castColumns(maxWidth) }
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
state = gridState,
modifier = Modifier.fillMaxSize().focusGroup(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
contentPadding = PaddingValues(start = 4.dp, end = 12.dp, top = 6.dp, bottom = 24.dp),
) {
itemsIndexed(items, key = { _, related -> related.id }) { index, related ->
DetailPosterCard(
item = related,
onClick = { onSelect(related) },
onFocused = { onFocusIndex(index) },
modifier = Modifier
.then(
if (index == entryIndex) Modifier.focusRequester(firstFocusRequester)
else Modifier,
)
.focusProperties {
up = if (index < columns) aboveGrid else FocusRequester.Default
},
)
}
}
}
}
}
@Composable
private fun DetailPosterCard(
item: BaseItem,
onClick: () -> Unit,
onFocused: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val artwork = remember(item.id) { ServiceLocator.repository.primaryUrl(item, 420) ?: ServiceLocator.repository.backdropUrl(item, 420) }
var focused by remember { mutableStateOf(false) }
Column(
modifier
.fillMaxWidth()
.detailCardFocus(focused)
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() }
.clickable(onClick = onClick),
) {
Box(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(RoundedCornerShape(MembyCardCorner)).background(MembySurfaceRaised).border(if (focused) 2.dp else 1.dp, if (focused) Color.White else DetailHairline, RoundedCornerShape(MembyCardCorner))) {
if (artwork != null) AsyncImage(artwork, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
}
Text(item.name, color = if (focused) Color.White else DetailText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 7.dp))
}
}
/**
* The Extras tab: featurettes, deleted scenes, interviews, the trailer.
*
* Landscape thumbnails rather than portraits, because that is the shape the material is —
* an extra has a frame grab, never a poster — and because the kind of thing it is ("Deleted
* Scene") matters as much as its name, which needs the width.
*
* The tab is only offered when [items] is non-empty, so the only empty state it can reach is
* the one where the list emptied under the viewer; it still says something rather than
* drawing a blank pane.
*/
@Composable
internal fun DetailExtrasPane(
items: List<BaseItem>,
onPlay: (BaseItem) -> Unit,
firstFocusRequester: FocusRequester,
gridState: LazyGridState,
aboveGrid: FocusRequester,
modifier: Modifier = Modifier,
) {
if (items.isEmpty()) {
DetailFocusablePane(firstFocusRequester, modifier) {
Text("No extras are available for this title.", color = DetailQuietText, fontSize = 15.sp)
}
return
}
BoxWithConstraints(modifier.fillMaxSize()) {
// Landscape cards, so fewer of them fit across than the portrait grids allow — but
// the same rule holds: one row filling the pane with nothing under it reads as a
// shelf that failed rather than as a grid.
val columns = remember(maxWidth) { (maxWidth / 210.dp).toInt().coerceIn(2, 5) }
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
state = gridState,
modifier = Modifier.fillMaxSize().focusGroup(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
contentPadding = PaddingValues(start = 4.dp, end = 12.dp, top = 6.dp, bottom = 24.dp),
) {
itemsIndexed(items, key = { _, extra -> extra.id }) { index, extra ->
DetailExtraCard(
item = extra,
onClick = { onPlay(extra) },
modifier = Modifier
.then(if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier)
.focusProperties {
up = if (index < columns) aboveGrid else FocusRequester.Default
},
)
}
}
}
}
@Composable
private fun DetailExtraCard(item: BaseItem, onClick: () -> Unit, modifier: Modifier = Modifier) {
val repository = ServiceLocator.repository
val artwork = remember(item.id, item.imageTags) {
repository.primaryUrl(item, 480) ?: repository.backdropUrl(item, 480)
}
val kind = remember(item.type) { extraKindLabel(item) }
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(MembyCardCorner)
Column(
modifier
.fillMaxWidth()
.detailCardFocus(focused)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick),
) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(shape)
.background(MembySurfaceRaised)
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else DetailHairline, shape),
contentAlignment = Alignment.Center,
) {
if (artwork != null) {
AsyncImage(artwork, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
}
Icon(
MembyIcon.Play.mark,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.size(if (focused) 40.dp else 32.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = if (focused) 0.74f else 0.45f))
.padding(6.dp),
)
}
Text(
text = kind,
color = DetailAccent,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.1.sp,
maxLines = 1,
modifier = Modifier.padding(top = 8.dp),
)
Text(
text = item.name.takeIf(String::isNotBlank) ?: kind,
color = if (focused) Color.White else DetailText,
fontSize = 13.sp,
lineHeight = 17.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
item.runtimeMinutes?.let {
Text(
text = formatRuntime(it),
color = DetailQuietText,
fontSize = 11.sp,
maxLines = 1,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}