This commit is contained in:
ponzischeme89
2026-08-16 12:13:51 +12:00
parent bd3732fba5
commit b9374baaf1
47 changed files with 2793 additions and 364 deletions
@@ -1,7 +1,9 @@
package com.ponzischeme89.memby.ui
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
@@ -20,11 +22,13 @@ 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.padding
import androidx.compose.foundation.layout.size
@@ -64,6 +68,7 @@ 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
@@ -72,26 +77,37 @@ 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.compose.ui.zIndex
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.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
@@ -215,21 +231,46 @@ internal data class DetailHeroAction(
val onClick: () -> Unit,
)
/** Full-bleed artwork with a protected reading area on the left and at the fold. */
/**
* 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) {
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, 1920) ?: repository.primaryUrl(item, 1280)
}
// 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 (artwork != null) {
if (request != null) {
AsyncImage(
model = artwork,
model = request,
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.TopCenter,
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = artworkAlpha().coerceIn(0f, 1f) },
)
}
Box(
@@ -283,6 +324,12 @@ internal fun DetailPageScaffold(
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
@@ -327,6 +374,16 @@ internal fun DetailPageScaffold(
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 {
@@ -345,14 +402,27 @@ internal fun DetailPageScaffold(
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. 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. 190ms because this is context getting out of the way, not an
// effect: fast enough not to be waited on, slow enough not to read as a jump.
// 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 = tween(190),
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium,
),
label = "detail-hero-collapse",
)
@@ -377,6 +447,7 @@ internal fun DetailPageScaffold(
facts = facts,
badges = badges,
eyebrow = eyebrow,
pinnedEyebrow = pinnedEyebrow,
subtitle = subtitle,
title = title ?: item.name,
playLabel = playLabel,
@@ -397,20 +468,15 @@ internal fun DetailPageScaffold(
collapse = { collapse.value },
onActionFocused = { index ->
lastHeroIndex = index
focusedZone = DetailZone.PLAY
onZoneFocused(DetailZone.PLAY)
enterZone(DetailZone.PLAY)
},
onPlayFocused = {
lastHeroIndex = -1
focusedZone = DetailZone.PLAY
onZoneFocused(DetailZone.PLAY)
enterZone(DetailZone.PLAY)
},
)
}
val onStripFocused = {
focusedZone = DetailZone.TABS
onZoneFocused(DetailZone.TABS)
}
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(
@@ -447,12 +513,7 @@ internal fun DetailPageScaffold(
// 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) {
focusedZone = DetailZone.CONTENT
onZoneFocused(DetailZone.CONTENT)
}
}
.onFocusChanged { if (it.hasFocus) enterZone(DetailZone.CONTENT) }
.focusRequester(contentEntryRequester)
.focusGroup()
// Deliberately a focus property and not a key handler: panes navigate
@@ -525,6 +586,7 @@ private fun DetailHero(
facts: List<String>,
badges: List<String>,
eyebrow: String?,
pinnedEyebrow: String?,
subtitle: String?,
title: String,
playLabel: String,
@@ -559,6 +621,15 @@ private fun DetailHero(
if (repository.showTitleLogo) repository.logoUrl(item, 720) 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".
//
@@ -576,154 +647,372 @@ private fun DetailHero(
}
.onVerticalNavigation(down = onNavigateDown),
) {
DetailBackdrop(item, Modifier.fillMaxSize())
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.
// 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 = collapse() }
.background(DetailBackground.copy(alpha = 0.72f)),
.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)
// Comes in over the second half of the transition, after the full hero has
// finished going. Two headings crossing through each other reads as a fault.
.graphicsLayer { alpha = ((collapse() - 0.5f) * 2f).coerceIn(0f, 1f) },
// 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)
// Out over the first half, and comfortably before the action row would be
// clipped by the shrinking band.
.graphicsLayer { alpha = (1f - collapse() * 2f).coerceIn(0f, 1f) }
.padding(start = DetailSideGutter, end = DetailSideGutter, bottom = 30.dp)
.fillMaxWidth(0.58f),
.fillMaxWidth(DetailHeroMetrics.TextWidthFraction)
.heightIn(max = (expandedHeight - DetailHeroMetrics.TopInset).coerceAtLeast(180.dp))
.graphicsLayer { alpha = heroPrimaryAlpha(collapse()) }
.padding(
start = DetailSideGutter,
end = DetailSideGutter,
bottom = DetailHeroMetrics.BottomInset,
),
) {
if (logo != null) {
AsyncImage(
model = logo,
contentDescription = item.name,
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
modifier = Modifier.width(330.dp).height(92.dp),
)
} else {
Text(
text = title,
color = Color.White,
fontSize = 38.sp,
lineHeight = 42.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(10.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(6.dp))
Text(
text = subtitle,
color = Color.White,
fontSize = 26.sp,
lineHeight = 30.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
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)
if (showRatingsStrip) Spacer(Modifier.height(8.dp))
RatingsStrip(ratings, visible = showRatingsStrip, reserveSpace = true)
if (item.genres.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
// 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.genres.take(4).joinToString(ValueSeparator),
color = DetailMutedText,
fontSize = 14.sp,
maxLines = 1,
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,
)
}
}
Spacer(Modifier.height(10.dp))
Text(
text = item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = DetailText,
fontSize = 15.sp,
lineHeight = 20.sp,
maxLines = 3,
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(12.dp))
Spacer(Modifier.height(14.dp))
DetailProgress(progress, progressLabel)
}
paceLabel?.let {
// Tighter under the bar than under the synopsis: with a bar above it this
// is the second half of one thought, without one it is a line of its own.
Spacer(Modifier.height(if (progress > 0f) 6.dp else 12.dp))
Spacer(Modifier.height(16.dp))
DetailHeroActions(
playLabel = playLabel,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
actions = actions,
actionRequesters = actionRequesters,
caption = actionCaption,
onPlayFocused = {
actionCaption = null
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(
playLabel: String,
onPlay: () -> Unit,
playFocusRequester: FocusRequester,
actions: List<DetailHeroAction>,
actionRequesters: List<FocusRequester>,
caption: String?,
onPlayFocused: () -> Unit,
onActionFocused: (Int) -> Unit,
) {
Column {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.focusGroup(),
) {
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]),
)
}
}
// 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 = DetailQuietText,
color = DetailMutedText,
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))
}
}
}
/** Reserved for the focused action's description; see [DetailHeroActions]. */
private val DetailActionCaptionHeight = 22.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.
*/
private 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) {
AsyncImage(
model = logo,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
alignment = Alignment.BottomStart,
modifier = Modifier
.width(DetailHeroMetrics.LogoMaxWidth)
.height(DetailHeroMetrics.LogoMaxHeight),
)
} else {
Text(
text = reasons.first(),
color = DetailAccent,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
text = title,
color = Color.White,
fontSize = 36.sp,
lineHeight = 40.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.focusGroup(),
) {
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]),
)
}
}
}
// 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,
)
}
}
}
@@ -742,11 +1031,13 @@ private fun DetailCollapsedHeader(
facts: List<String>,
logo: String?,
modifier: Modifier = Modifier,
eyebrow: String? = null,
subtitle: String? = null,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(start = DetailSideGutter, end = DetailSideGutter, bottom = 14.dp),
.padding(start = DetailSideGutter, end = DetailSideGutter, bottom = 15.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (logo != null) {
@@ -755,20 +1046,51 @@ private fun DetailCollapsedHeader(
contentDescription = title,
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
modifier = Modifier.height(38.dp).width(160.dp),
modifier = Modifier
.height(DetailHeroMetrics.PinnedLogoMaxHeight)
.width(DetailHeroMetrics.PinnedLogoMaxWidth),
)
} else {
Text(
text = title,
color = Color.White,
fontSize = 24.sp,
lineHeight = 27.sp,
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(
@@ -894,6 +1216,53 @@ internal fun DetailFactRow(
}
}
/**
* 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(
Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = DetailQuietText,
modifier = Modifier.padding(start = 12.dp, bottom = 14.dp).size(18.dp),
)
}
}
}
@Composable
private fun DetailTabStrip(
tabs: List<DetailTab>,
@@ -904,14 +1273,7 @@ private fun DetailTabStrip(
onExitDown: () -> Boolean,
onFocused: () -> Unit,
) {
Box(
Modifier
.fillMaxWidth()
.height(DetailStripHeight)
.background(DetailBackground)
.padding(horizontal = DetailSideGutter),
) {
Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(1.dp).background(DetailHairline))
DetailStripFrame {
Row(
modifier = Modifier
.fillMaxSize()
@@ -942,31 +1304,35 @@ private fun DetailTabStrip(
.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(start = 4.dp, end = 4.dp, bottom = 13.dp),
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 || focused) DetailAccent else Color.Transparent,
if (selected == tab) DetailAccent else Color.Transparent,
),
)
}
}
Spacer(Modifier.weight(1f))
// 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(
Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = DetailQuietText,
modifier = Modifier.size(18.dp).padding(bottom = 2.dp),
)
}
}
}
@@ -1227,12 +1593,10 @@ private fun DetailPosterCard(
) {
val artwork = remember(item.id) { ServiceLocator.repository.primaryUrl(item, 420) ?: ServiceLocator.repository.backdropUrl(item, 420) }
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (focused) 1.06f else 1f, tween(100), label = "related-poster-focus")
Column(
modifier
.fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
.zIndex(if (focused) 1f else 0f)
.detailCardFocus(focused)
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() }
.clickable(onClick = onClick),
) {
@@ -1305,13 +1669,11 @@ private fun DetailExtraCard(item: BaseItem, onClick: () -> Unit, modifier: Modif
}
val kind = remember(item.type) { extraKindLabel(item) }
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (focused) 1.05f else 1f, tween(100), label = "extra-card-focus")
val shape = RoundedCornerShape(MembyCardCorner)
Column(
modifier
.fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
.zIndex(if (focused) 1f else 0f)
.detailCardFocus(focused)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick),
) {
@@ -27,7 +27,6 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Tv
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -37,6 +36,7 @@ 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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
@@ -61,6 +61,7 @@ import com.ponzischeme89.memby.ui.detail.episodeHeadline
import com.ponzischeme89.memby.ui.detail.episodesForSeason
import com.ponzischeme89.memby.ui.detail.detailPositions
import com.ponzischeme89.memby.ui.detail.heroFacts
import com.ponzischeme89.memby.ui.detail.paddedEpisodeCode
import com.ponzischeme89.memby.ui.detail.playbackProgress
import com.ponzischeme89.memby.ui.detail.primaryActionLabel
import com.ponzischeme89.memby.ui.detail.remainingLabel
@@ -68,6 +69,7 @@ import com.ponzischeme89.memby.ui.detail.seasonMarkers
import com.ponzischeme89.memby.ui.detail.seasonProgressLabel
import com.ponzischeme89.memby.ui.detail.seriesEpisodeComparator
import com.ponzischeme89.memby.ui.detail.seriesProgressLabel
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import kotlinx.coroutines.delay
/**
@@ -232,11 +234,17 @@ internal fun EpisodeDetailContent(
}
}
// Held rather than rebuilt per composition, as on the other two pages.
val heroFacts = remember(item.id, item.runTimeTicks, item.premiereDate) { heroFacts(item) }
val heroBadges = remember(item.id, item.mediaStreams) { mediaBadges(item) }
val eyebrow = remember(item.id) { episodeEyebrow(item) }
DetailPageScaffold(
item = item,
facts = heroFacts(item),
badges = mediaBadges(item),
eyebrow = episodeEyebrow(item),
facts = heroFacts,
badges = heroBadges,
eyebrow = eyebrow,
pinnedEyebrow = remember(item.id) { paddedEpisodeCode(item) },
subtitle = item.name.takeIf(String::isNotBlank),
title = item.seriesName?.takeIf(String::isNotBlank) ?: item.name,
playLabel = primaryActionLabel(item),
@@ -347,54 +355,11 @@ private fun SeasonScroller(
onSelect: (Int) -> Unit,
onFocused: () -> 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,
) {
if (markers.isEmpty()) {
Text(
text = "Loading seasons…",
color = DetailQuietText,
fontSize = 13.sp,
modifier = Modifier.weight(1f).padding(bottom = 16.dp),
)
} else {
LazyRow(
state = listState,
// Up and Down out of the band belong to the scaffold, which knows
// where to fall back to when the exact stop it wants is not composed
// — a season chip scrolled out of this row is an ordinary state.
modifier = Modifier
.weight(1f)
.fillMaxSize()
.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(28.dp),
verticalAlignment = Alignment.Bottom,
) {
items(markers, key = { it.season }) { marker ->
SeasonStop(
marker = marker,
selected = marker.season == selectedSeason,
watching = marker.season == currentSeason,
focusRequester = if (marker.season == selectedSeason) {
selectedFocusRequester
} else {
seasonFocusRequesters[marker.season]
},
onSelect = { onSelect(marker.season) },
onFocused = onFocused,
)
}
}
}
// The same frame the tab strip wears, from the same component: the plate, the gutters,
// the hairline and the chevron are the fold, and the fold must be in the same place on
// an episode page as on a film's.
DetailStripFrame(
trailing = {
seriesProgress?.let {
Text(
text = it,
@@ -405,13 +370,43 @@ private fun SeasonScroller(
modifier = Modifier.padding(start = 18.dp, bottom = 15.dp),
)
}
// Same caption on the Down key the tab strip carries; never focusable.
Icon(
Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = DetailQuietText,
modifier = Modifier.padding(start = 12.dp, bottom = 14.dp).size(18.dp),
},
) {
if (markers.isEmpty()) {
Text(
text = "Loading seasons…",
color = DetailQuietText,
fontSize = 13.sp,
modifier = Modifier.weight(1f).padding(bottom = 16.dp),
)
} else {
LazyRow(
state = listState,
// Up and Down out of the band belong to the scaffold, which knows
// where to fall back to when the exact stop it wants is not composed
// — a season chip scrolled out of this row is an ordinary state.
modifier = Modifier
.weight(1f)
.fillMaxSize()
.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(28.dp),
verticalAlignment = Alignment.Bottom,
) {
items(markers, key = { it.season }) { marker ->
SeasonStop(
marker = marker,
selected = marker.season == selectedSeason,
watching = marker.season == currentSeason,
focusRequester = if (marker.season == selectedSeason) {
selectedFocusRequester
} else {
seasonFocusRequesters[marker.season]
},
onSelect = { onSelect(marker.season) },
onFocused = onFocused,
)
}
}
}
}
}
@@ -446,9 +441,15 @@ private fun SeasonStop(
.clickable(onClick = onSelect),
horizontalAlignment = Alignment.Start,
) {
// Focus and selection are marked separately, as they are in the tab strip: the
// accent underline says which season the list below is showing, the plate says
// where the remote is. They part company the moment the viewer presses Down.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
modifier = Modifier
.clip(androidx.compose.foundation.shape.RoundedCornerShape(MembyChipCorner))
.background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent)
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
if (done) {
Icon(
@@ -487,12 +488,12 @@ private fun SeasonStop(
color = if (done) DetailQuietText.copy(alpha = 0.5f) else DetailQuietText,
fontSize = 10.sp,
maxLines = 1,
modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 11.dp),
modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 2.dp, bottom = 8.dp),
)
}
Box(
Modifier.fillMaxWidth().height(3.dp).background(
if (selected || focused) DetailAccent else Color.Transparent,
if (selected) DetailAccent else Color.Transparent,
),
)
}
@@ -107,6 +107,7 @@ 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.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
@@ -1891,6 +1892,18 @@ private fun HomeScreen(
// home one page at a time; without this it would drop three levels to the launcher.
var detailsTrail by remember { mutableStateOf<List<BaseItem>>(emptyList()) }
var restoreDetailPosition by remember { mutableStateOf(false) }
// The page playback was started from, so returning from the player comes back to it
// rather than to the launcher.
//
// Deliberately a *reopen* rather than leaving the page composed under the player. The
// page is what tells the viewer where they were, and it has to tell them the truth: the
// episode they just finished is now watched, the film is now part-way through, and the
// next episode is a different one. Reopening runs the same restore path a Back press
// through the trail already uses (`DetailPosition` plus `RestoreDetailFocus`), so the
// tab, the season, the grid offset and the band that held focus all come back with it —
// and focus is re-requested by that path rather than depending on Compose having held
// it across another activity's window.
var detailsResumeTarget by remember { mutableStateOf<DetailsReturn?>(null) }
// Belongs to the *route*, not to the show: set only when a page is opened from the
// "Shows airing" row, and dropped the moment the viewer moves anywhere else, so the
// same series reached from Favourites or a search never claims a schedule.
@@ -3237,6 +3250,23 @@ private fun HomeScreen(
onSignedIn = { addingProfile = false },
)
}
// Coming back from the player. The page is reopened rather than restored from
// under the player — see [detailsResumeTarget] — and the item is re-requested on
// the way in, so what appears has this playback's progress on it rather than the
// record the row handed over before the film started.
LifecycleResumeEffect(Unit) {
detailsResumeTarget?.let { target ->
detailsResumeTarget = null
if (detailsItem == null) {
homeViewModel.focusItem(target.item)
detailsTrail = target.trail
detailsAiringNotice = target.airingNotice
restoreDetailPosition = true
detailsItem = target.item
}
}
onPauseOrDispose { }
}
detailsItem?.let { selected ->
BackHandler {
val previous = detailsTrail.lastOrNull()
@@ -3281,6 +3311,9 @@ private fun HomeScreen(
detailsAiringNotice = null
},
onPlay = {
// Kept, not discarded: this is what the viewer comes back to when the
// film ends or they press Back out of the player.
detailsResumeTarget = DetailsReturn(selected, detailsTrail, detailsAiringNotice)
detailsItem = null
detailsTrail = emptyList()
restoreDetailPosition = false
@@ -4070,6 +4103,20 @@ private fun FocusedHomeMetadata(
)
}
/**
* The detail page a playback was launched from, held for the length of that playback.
*
* The trail comes with it, so pressing Back after the film still walks the "More like this"
* chain the viewer arrived by rather than dropping straight to the launcher; the airing
* notice comes with it because it belonged to the route into the page and that route has
* not changed.
*/
private data class DetailsReturn(
val item: BaseItem,
val trail: List<BaseItem>,
val airingNotice: AiringNotice?,
)
@Composable
private fun FocusedDetailsOverlay(
homeViewModel: HomeViewModel,
@@ -13,6 +13,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
@@ -153,7 +154,11 @@ internal fun MediaDetailContent(
)
// Every newly opened title starts as a complete hero frame. Remembering a content
// tab also restored its focus and caused the page to reopen below the artwork.
var tabKey by remember(item.id, restorePosition) {
//
// `rememberSaveable`, as on the series page: a movie's tab is the same kind of state a
// show's is, and the two pages having different answers to "does this survive the
// activity being recreated" is precisely the drift this pass exists to remove.
var tabKey by rememberSaveable(item.id, restorePosition) {
mutableStateOf(if (restorePosition) remembered.tabKey else tabs.first().key)
}
val selectedTab = detailTab(tabKey, tabs)
@@ -213,10 +218,20 @@ internal fun MediaDetailContent(
}
}
// Held rather than rebuilt: the scaffold takes these as lists, so allocating a new one
// per composition would make every focus move on the page a fresh identity for the
// hero's fact row and its badges, and recompose both.
val heroFacts = remember(item.id, item.productionYear, item.runTimeTicks, item.officialRating) {
heroFacts(item)
}
val heroBadges = remember(badges, item.id, item.membyAirDayLabel) {
badges + listOfNotNull(airingBadgeLabel(item))
}
DetailPageScaffold(
item = item,
facts = heroFacts(item),
badges = badges + listOfNotNull(airingBadgeLabel(item)),
facts = heroFacts,
badges = heroBadges,
tabs = tabs,
selectedTab = selectedTab,
onSelectTab = { tabKey = it.key },
@@ -33,7 +33,6 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -335,13 +334,20 @@ internal fun SeriesDetailContent(
}
}
// Held rather than rebuilt per composition, as on the movie page: the scaffold takes
// lists, and a new identity for them on every focus move recomposes the fact row.
val heroFacts = remember(item.id, seasons.size, item.productionYear, item.officialRating) {
heroFacts(item, seasons.size)
}
val heroBadges = remember(item.id, item.mediaStreams) { mediaBadges(item) }
DetailPageScaffold(
item = item,
facts = heroFacts(item, seasons.size),
facts = heroFacts,
// A series' streams describe its episodes; when Emby gives them, they are as true
// of the show as they are of a film, and the row this page opened from already
// badges them.
badges = mediaBadges(item),
badges = heroBadges,
tabs = tabs,
selectedTab = selectedTab,
onSelectTab = { tabKey = it.key },
@@ -667,22 +673,11 @@ internal fun EpisodeCard(
shape = RoundedCornerShape(6.dp),
),
)
// The same cue a focused poster on the launcher wears. It was a hand-rolled
// black disc with its own diameter and its own icon size, which meant Play
// looked like one thing on a home row and another on an episode.
if (focused) {
Box(
modifier = Modifier
.align(Alignment.Center)
.size(40.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color.Black.copy(alpha = 0.76f)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = "Play selected episode",
tint = Color.White,
modifier = Modifier.size(25.dp),
)
}
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
}
if (episode.isPlayed) {
Icon(
@@ -1,7 +1,5 @@
package com.ponzischeme89.memby.ui.detail
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -35,7 +33,6 @@ import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
@@ -43,7 +40,6 @@ 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.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.data.model.EmbyPerson
@@ -172,21 +168,11 @@ private fun CastGridCard(
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (focused) 1.06f else 1f,
animationSpec = tween(110),
label = "cast-grid-focus",
)
val shape = RoundedCornerShape(MembyCardCorner)
Column(
modifier = modifier
.fillMaxWidth()
.graphicsLayer {
scaleX = scale
scaleY = scale
translationY = if (focused) -3f else 0f
}
.zIndex(if (focused) 1f else 0f)
.detailCardFocus(focused)
.testTag("cast-card-${person.name}")
.onFocusChanged {
focused = it.isFocused
@@ -0,0 +1,44 @@
package com.ponzischeme89.memby.ui.detail
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.zIndex
/**
* What a focused card on a detail page does: lifts a little, grows a little, and comes to
* the front so its ring is not clipped by the card beside it.
*
* There were three copies of this — the cast grid's, the "More like this" poster's and the
* Extras thumbnail's — at 1.06, 1.06 and 1.05, over 110ms, 100ms and 100ms. Nobody chose
* three different figures; they were written on three different days. One card language
* means a viewer learns what focus looks like once, and it means the *cost* of focus is one
* decision rather than three.
*
* The animated value is read only inside the `graphicsLayer` block, which is the draw
* phase, so travelling a grid costs a redraw of the two cards involved rather than a
* recomposition of every card in it. That rule is the whole reason this is a modifier and
* not a wrapper composable.
*/
@Composable
fun Modifier.detailCardFocus(focused: Boolean): Modifier {
val scale by animateFloatAsState(
targetValue = if (focused) DetailCardFocusScale else 1f,
animationSpec = tween(DetailCardFocusMs),
label = "detail-card-focus",
)
return this
.graphicsLayer {
scaleX = scale
scaleY = scale
translationY = if (focused) DetailCardFocusLiftPx else 0f
}
.zIndex(if (focused) 1f else 0f)
}
private const val DetailCardFocusScale = 1.05f
private const val DetailCardFocusMs = 100
private const val DetailCardFocusLiftPx = -3f
@@ -0,0 +1,153 @@
package com.ponzischeme89.memby.ui.detail
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* One number drives the whole detail hero, and this is what it means.
*
* The page has a single `collapse` float — 0 is the complete cinematic opening frame, 1 is
* the compact pinned identity with the content pane owning the screen. Every part of the
* transformation is a *band* of that one value rather than an animation of its own: the
* synopsis goes first, the actions follow, the artwork recedes throughout, and the pinned
* header arrives last. Because they are bands of one number they cannot drift out of step,
* cannot be interrupted separately, and cannot each cost their own animation frame.
*
* They live here, pure, for the reason the rest of `ui/detail/` does: what a viewer sees at
* 40% of the way through a collapse is a design decision, and a decision that can only be
* checked by watching a television is a decision nobody checks. [DetailHeroPhaseTest] pins
* the ordering — which is the part that matters, far more than any individual figure.
*/
/**
* How far through [start]..[end] [value] has travelled, clamped to 0..1.
*
* The single primitive under every band below. Degenerate ranges answer 0 below the point
* and 1 at or above it rather than dividing by zero, because a band collapsed to an instant
* is a legitimate way to say "switch here".
*/
fun fadeBand(value: Float, start: Float, end: Float): Float {
// The upper bound is tested first, which is what makes a band collapsed to an instant
// read as a switch at that instant rather than as one that never fires.
if (value >= end) return 1f
if (value <= start) return 0f
return ((value - start) / (end - start)).coerceIn(0f, 1f)
}
/**
* The synopsis, genres, ratings, pace line and recommendation reason.
*
* First to go, and gone well before anything moves far. This is the half of the hero that
* answers "is this worth watching" — a question somebody who has pressed Down has already
* answered — so removing it is what makes the collapse read as *progressive disclosure*
* rather than as everything on the page shrinking at once.
*/
fun heroSupportingAlpha(collapse: Float): Float = 1f - fadeBand(collapse, 0f, 0.34f)
/**
* The identity block, the fact line, the progress bar and the action row.
*
* They leave second and together: they are the reason the hero exists, and the viewer's eye
* is on them. Fully out by 0.62 so the action row has finished fading before the shrinking
* band could ever clip it — a button cut in half by a moving edge is the single ugliest
* frame this transition can produce.
*/
fun heroPrimaryAlpha(collapse: Float): Float = 1f - fadeBand(collapse, 0.22f, 0.62f)
/**
* The compact pinned identity.
*
* Deliberately starts after [heroPrimaryAlpha] has finished, so the two headings never
* cross through each other — two titles dissolving through one another reads as a fault
* rather than as one heading becoming another.
*/
fun pinnedHeaderAlpha(collapse: Float): Float = fadeBand(collapse, 0.62f, 1f)
/**
* The soft plate behind the pinned header.
*
* It leads the header in — the separation should already be there when the title arrives on
* it, or the plate reads as a second thing appearing. It is a gradient, never a bar: the
* requirement is a *perceptible* edge between a fixed header and scrolling content, and a
* solid block reads as a toolbar borrowed from a phone.
*/
fun pinnedScrimAlpha(collapse: Float): Float = fadeBand(collapse, 0.30f, 0.92f)
/**
* How much the backdrop artwork gives up as content takes over.
*
* It recedes rather than disappearing: at rest the picture is the page, pinned it is a tone
* behind the tabs. Never to zero — an artwork that vanishes at some point in the travel
* produces a visible step, and the whole promise here is continuity.
*/
fun heroArtworkAlpha(collapse: Float): Float = 1f - 0.42f * collapse.coerceIn(0f, 1f)
/** The deepening wash over the artwork, so the strip and the pane are read against near-black. */
fun heroDimAlpha(collapse: Float): Float = collapse.coerceIn(0f, 1f)
/**
* The hero's fixed geometry.
*
* Everything here exists because something in the hero must not move. The identity block is
* a reserved height so that a logo arriving after the text fallback — which is what always
* happens, since the logo has to be decoded before it can be judged legible — does not shove
* the rest of the hero up or down. The top inset is the safe area the expanded content may
* never cross, which is what stopped the logo travelling to the top edge of the screen on a
* title with a lot to say. And the caps are what make a very wide or very tall logo a
* letterboxed picture inside a known box rather than a layout that changes size per title.
*/
object DetailHeroMetrics {
/**
* The expanded hero's content may not enter the top of the screen.
*
* The hero content is anchored to the *bottom* of its band and grows upward, so without
* a ceiling a page with a two-line title, ratings, genres, a synopsis and a pace line
* simply grew until it hit the top edge — and then kept going, squeezing whatever was
* last in the column, which is the primary action. This is that ceiling.
*/
val TopInset: Dp = 40.dp
/**
* Between the expanded content and the strip below it.
*
* Small, because the reserved caption line under the action row is already most of the
* gap that used to be here — and because every dp of it is a dp the synopsis does not
* get. The hero is a tight budget on a 540dp television and this is the least valuable
* thing in it.
*/
val BottomInset: Dp = 10.dp
/**
* How many lines of synopsis the hero shows.
*
* Two, not three. The hero's job is "what is this and shall I play it"; the whole of
* the description is one press away in Overview, which is the tab the page opens on.
* The third line was the difference between the movie hero fitting its band and
* overflowing it, and a line of prose is the cheapest thing on the page to give up.
*/
const val SynopsisLines: Int = 2
/**
* Reserved for the logo or the text title, whichever this title turns out to have.
*
* A constant, because it is the swap between them that has to be invisible: the logo is
* only judged legible once it has been fetched and decoded, so every page begins on the
* text fallback and changes its mind a moment later.
*/
val IdentityHeight: Dp = 86.dp
val LogoMaxWidth: Dp = 296.dp
val LogoMaxHeight: Dp = 78.dp
/** The pinned header's logo. Small enough to be identity, not so small it is decoration. */
val PinnedLogoMaxWidth: Dp = 148.dp
val PinnedLogoMaxHeight: Dp = 34.dp
/**
* How much of the width the expanded hero's text column takes.
*
* The right-hand two fifths belong to the backdrop, which is the only reason the page
* looks cinematic at all; a reading column wider than this puts prose over the picture.
*/
const val TextWidthFraction: Float = 0.56f
}
@@ -0,0 +1,162 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.detail.DetailHeroMetrics
import com.ponzischeme89.memby.ui.detail.fadeBand
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 org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The detail hero's collapse, as a set of rules rather than as something to watch on a
* television.
*
* What is worth pinning here is the *ordering* — which part of the hero leaves first, and
* that the two headings never overlap — not any individual figure. A tuning change should
* pass this; a change that lets the pinned title fade up through the expanded one, or lets
* the action row still be visible when the band has nearly finished shrinking onto it,
* should not.
*/
class DetailHeroPhaseTest {
private val travel = (0..100).map { it / 100f }
@Test
fun `a band is clamped at both ends and travels linearly between them`() {
assertEquals(0f, fadeBand(0f, 0.2f, 0.6f), 0.0001f)
assertEquals(0f, fadeBand(0.2f, 0.2f, 0.6f), 0.0001f)
assertEquals(0.5f, fadeBand(0.4f, 0.2f, 0.6f), 0.0001f)
assertEquals(1f, fadeBand(0.6f, 0.2f, 0.6f), 0.0001f)
assertEquals(1f, fadeBand(1f, 0.2f, 0.6f), 0.0001f)
}
/** A band collapsed to an instant is a legitimate way to say "switch here". */
@Test
fun `a zero width band switches rather than dividing by zero`() {
assertEquals(0f, fadeBand(0.4f, 0.5f, 0.5f), 0.0001f)
assertEquals(1f, fadeBand(0.5f, 0.5f, 0.5f), 0.0001f)
assertEquals(1f, fadeBand(0.6f, 0.5f, 0.5f), 0.0001f)
}
/** Both ends of the travel are a settled state, with nothing half-drawn at either. */
@Test
fun `the expanded and pinned frames are each complete`() {
assertEquals(1f, heroSupportingAlpha(0f), 0.0001f)
assertEquals(1f, heroPrimaryAlpha(0f), 0.0001f)
assertEquals(0f, pinnedHeaderAlpha(0f), 0.0001f)
assertEquals(0f, heroDimAlpha(0f), 0.0001f)
assertEquals(1f, heroArtworkAlpha(0f), 0.0001f)
assertEquals(0f, heroSupportingAlpha(1f), 0.0001f)
assertEquals(0f, heroPrimaryAlpha(1f), 0.0001f)
assertEquals(1f, pinnedHeaderAlpha(1f), 0.0001f)
assertEquals(1f, heroDimAlpha(1f), 0.0001f)
}
/**
* The whole of progressive disclosure, in one assertion: the secondary half of the hero
* is always at least as far gone as the primary half. Reversing this would be the
* failure the design exists to avoid — everything shrinking together rather than the
* page shedding what the viewer has finished with.
*/
@Test
fun `the supporting information always leaves ahead of the actions`() {
travel.forEach { collapse ->
assertTrue(
"at $collapse the supporting block was still ahead of the primary one",
heroSupportingAlpha(collapse) <= heroPrimaryAlpha(collapse) + 0.0001f,
)
}
}
/**
* Two titles dissolving through one another reads as a fault. The expanded hero has to
* be completely gone before the pinned header has begun.
*/
@Test
fun `the two headings are never on screen together`() {
travel.forEach { collapse ->
assertTrue(
"at $collapse both headings were visible",
heroPrimaryAlpha(collapse) <= 0.0001f || pinnedHeaderAlpha(collapse) <= 0.0001f,
)
}
}
/** The separation is already there when the pinned title arrives on it. */
@Test
fun `the pinned plate leads the pinned header in`() {
travel.forEach { collapse ->
assertTrue(
"at $collapse the header had outrun its plate",
pinnedHeaderAlpha(collapse) <= pinnedScrimAlpha(collapse) + 0.0001f,
)
}
}
/** Every band is a proper opacity for the whole of the travel, and none reverses. */
@Test
fun `every band is monotonic and within range`() {
listOf(
"supporting" to ::heroSupportingAlpha,
"primary" to ::heroPrimaryAlpha,
"artwork" to ::heroArtworkAlpha,
).forEach { (name, band) ->
var previous = band(0f)
travel.forEach { collapse ->
val value = band(collapse)
assertTrue("$name left 0..1 at $collapse", value in 0f..1f)
assertTrue("$name rose again at $collapse", value <= previous + 0.0001f)
previous = value
}
}
listOf(
"pinned header" to ::pinnedHeaderAlpha,
"pinned plate" to ::pinnedScrimAlpha,
"dim" to ::heroDimAlpha,
).forEach { (name, band) ->
var previous = band(0f)
travel.forEach { collapse ->
val value = band(collapse)
assertTrue("$name left 0..1 at $collapse", value in 0f..1f)
assertTrue("$name fell back at $collapse", value >= previous - 0.0001f)
previous = value
}
}
}
/**
* The backdrop recedes; it never disappears. An artwork that reaches zero somewhere in
* the travel produces a visible step, and continuity is the whole promise here.
*/
@Test
fun `the backdrop recedes without ever vanishing`() {
travel.forEach { collapse ->
assertTrue("the backdrop vanished at $collapse", heroArtworkAlpha(collapse) > 0.4f)
}
assertTrue(heroArtworkAlpha(1f) < heroArtworkAlpha(0f))
}
/**
* The hero is anchored to the bottom of its band and grows upward, so the top inset is
* the only thing between a title with a lot to say and the top edge of the screen. It
* has to be a real safe area — and the reading column has to leave the backdrop
* something, or the page stops being cinematic.
*/
@Test
fun `the hero keeps a safe top area and leaves the backdrop room`() {
assertTrue(DetailHeroMetrics.TopInset.value >= 24f)
assertTrue(DetailHeroMetrics.TextWidthFraction in 0.4f..0.7f)
// The reserved identity block has to hold the largest thing that can go in it: two
// lines of the 36sp title fallback, which is what a page shows until the logo has
// been fetched and judged legible.
assertTrue(DetailHeroMetrics.IdentityHeight >= DetailHeroMetrics.LogoMaxHeight)
assertTrue(DetailHeroMetrics.PinnedLogoMaxHeight < DetailHeroMetrics.LogoMaxHeight)
assertTrue(DetailHeroMetrics.PinnedLogoMaxWidth < DetailHeroMetrics.LogoMaxWidth)
}
}
@@ -22,6 +22,7 @@ import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.MediaStream
import com.ponzischeme89.memby.data.model.Studio
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -70,6 +71,39 @@ class EpisodeDetailScreenshotTest {
}
}
/**
* The worst case the hero can be handed, and the one 0.2.67 got wrong.
*
* An episode page carries more than any other variant the show's name where a film has
* one line, the season and episode number, the episode's own title, the fact row, the
* ratings strip, a synopsis, a progress bar and an "Up next" line and all of it above
* the action row. The hero is anchored to the bottom of its band and grows upward, so
* before the reading column was made the part that gives way, this stack pushed the
* heading off the top of the screen and left the Play button squeezed into whatever
* height was left over.
*/
@Test
fun `episode page with everything the hero can carry`() {
capture("df_episode-detail-crowded") { EpisodePage(item = crowded) }
}
/**
* The same case, asserted rather than looked at.
*
* A screenshot shows a malformed button to somebody who opens the file; this fails the
* build. The primary action is a fixed-height control, so anything materially under its
* natural height means the column above it has taken its space back.
*/
@Test
fun `the primary action keeps its shape however crowded the hero is`() {
compose.setContent { PreviewSurface(alignment = Alignment.TopStart) { EpisodePage(item = crowded) } }
val density = compose.density
val play = compose.onNodeWithTag("detail-play").fetchSemanticsNode()
val heightDp = with(density) { play.size.height.toDp() }
assertTrue("Play was $heightDp tall", heightDp >= MinimumPlayButtonHeight)
assertTrue("Play was $heightDp tall", play.size.width > play.size.height)
}
/** The first frame, before the series' episode list lands. The hero must be whole. */
@Test
fun `episode page loading`() {
@@ -206,6 +240,16 @@ class EpisodeDetailScreenshotTest {
people = cast,
)
/**
* A long show name with no logo artwork, a long episode title, a resume position and a
* full ratings strip every line the hero can be asked to draw, at once.
*/
private val crowded = current.copy(
seriesName = "The Longest Northbound Winter of Signal Hill",
name = "What the Log Says About the Night of the Second Crossing",
userData = UserItemData(playbackPositionTicks = 21L * TICKS_PER_MINUTE),
)
private val seasonOneTitles = listOf(
"Carrier Wave", "Dead Air", "Nightingale", "Six Weeks Out", "The Hill", "Landfall",
)
@@ -252,5 +296,12 @@ class EpisodeDetailScreenshotTest {
private companion object {
const val DIR = "build/screenshots/episode-detail"
const val TICKS_PER_MINUTE = 600_000_000L
/**
* What [MembyPlayButton] measures at its full size: a 23dp icon inside 12dp of
* vertical padding, plus its border. Deliberately a floor rather than the exact
* figure, so retuning the button is not a test edit but squeezing it is.
*/
val MinimumPlayButtonHeight = 40.dp
}
}