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