0.2.68
This commit is contained in:
@@ -46,7 +46,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.67"
|
||||
val defaultVersionName = "0.2.68"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user