0.2.67 - Detail pages improvements

This commit is contained in:
ponzischeme89
2026-08-16 09:02:17 +12:00
parent 4bc4b075ec
commit bd3732fba5
31 changed files with 2479 additions and 562 deletions
+1 -1
View File
@@ -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.66"
val defaultVersionName = "0.2.67"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -295,6 +295,15 @@ class EmbyRepository(private val settings: SettingsStore) {
private val trailerAvailabilityCache =
LinkedHashMap<String, Boolean>(TRAILER_CACHE_SIZE, 0.75f, true)
private val trailerAvailabilityInFlight = mutableMapOf<String, Deferred<Boolean?>>()
private val extrasMutex = Mutex()
/**
* A title's special features, empty list included. Most of a library has none, and the
* Extras tab asks on every page open — without keeping the empty answer, walking a
* "More like this" trail would repeat the same question all the way down it.
*/
private val extrasCache =
LinkedHashMap<String, List<BaseItem>>(EXTRAS_CACHE_SIZE, 0.75f, true)
private val extrasInFlight = mutableMapOf<String, Deferred<List<BaseItem>?>>()
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
@@ -1649,6 +1658,76 @@ class EmbyRepository(private val settings: SettingsStore) {
return CachedTrailer(requireApi().getLocalTrailers(userId, itemId).items.firstOrNull())
}
/**
* A title's extras: trailers, featurettes, deleted scenes, behind-the-scenes material.
*
* Cached **including the empty answer** and single-flighted on the repository's own
* scope, for the same two reasons [getLocalTrailer] is. Most of a library has no special
* features, so an absent entry and "none" have to be different things or every page open
* re-asks; and the caller is a detail page that may be warmed on focus and cancelled
* when the D-pad moves on, so a request tied to the caller's scope would be abandoned by
* exactly the navigation that is about to want its answer.
*
* Never throws. A gateway that predates the route, an Emby that will not answer and a
* title with genuinely nothing are all "no extras", and the tab is simply not offered —
* an Extras tab that opens onto an error message is worse than no tab at all.
*/
suspend fun getExtras(itemId: String): List<BaseItem> {
if (itemId.isBlank()) return emptyList()
val inFlight = extrasMutex.withLock {
extrasCache[itemId]?.let { return it }
extrasInFlight[itemId] ?: newExtrasRequest(itemId)
}
// A failure is deliberately not cached: one bad minute must not hide a title's
// extras for the rest of the session.
return inFlight.await().orEmpty()
}
private fun newExtrasRequest(itemId: String): Deferred<List<BaseItem>?> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
val loaded = runCatching { loadExtras(itemId) }.getOrNull() ?: return@async null
extrasMutex.withLock {
extrasCache[itemId] = loaded
while (extrasCache.size > EXTRAS_CACHE_SIZE) {
extrasCache.entries.iterator().run {
next()
remove()
}
}
loaded
}
} finally {
extrasMutex.withLock { extrasInFlight.remove(itemId) }
}
}
extrasInFlight[itemId] = request
request.start()
return request
}
private suspend fun loadExtras(itemId: String): List<BaseItem> {
val loaded = if (ServerConfig.isGateway) {
// 404 is a gateway that predates the route. It is the same answer as a title
// with nothing, and treating it as one is what lets a new APK talk to an old
// container without the tab appearing and then failing.
runCatching { requireGateway().extras(itemId).items }
.getOrElse { if (it is HttpException && it.code() == 404) emptyList() else throw it }
} else {
val userId = snapshot.userId ?: error("Not connected")
val api = requireApi()
// Two calls on this path because Emby keeps trailers out of SpecialFeatures.
// The gateway does the same join server-side and returns one list.
val features = runCatching { api.getSpecialFeatures(userId, itemId) }.getOrDefault(emptyList())
val trailers = runCatching { api.getLocalTrailers(userId, itemId).items }
.getOrDefault(emptyList())
trailers + features
}
// A keyed grid must never be handed a repeated key, and the two Emby lists can name
// the same file — a trailer that has also been filed as a special feature.
return loaded.filter { it.id.isNotBlank() }.distinctBy(BaseItem::id)
}
/**
* Uploads a batch of row-engagement events. Silent on the direct path (nothing is
* listening) and silent on failure — telemetry must never surface on a TV.
@@ -2103,6 +2182,11 @@ class EmbyRepository(private val settings: SettingsStore) {
trailerInFlight.values.forEach { it.cancel() }
trailerInFlight.clear()
}
extrasMutex.withLock {
extrasCache.clear()
extrasInFlight.values.forEach { it.cancel() }
extrasInFlight.clear()
}
}
suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
@@ -2967,6 +3051,7 @@ private const val RELATED_LIMIT = 12
* point of the cache is that walking back into a page never asks again.
*/
private const val TRAILER_CACHE_SIZE = 64
private const val EXTRAS_CACHE_SIZE = 64
private const val MAX_TRAILER_CANDIDATES = 12
// Long enough that walking back and forth between a row and a detail page never re-asks,
@@ -403,6 +403,11 @@ data class BaseItem(
@SerialName("RecursiveItemCount") val recursiveItemCount: Int? = null,
@SerialName("Genres") val genres: List<String> = emptyList(),
@SerialName("CollectionName") val collectionName: String? = null,
// Catalogue facts the Details tab prints and nothing else reads. Both are absent from
// every list query and from any cached payload written before the tab existed, so both
// default — an older home cache decodes unchanged and the rows simply do not appear.
@SerialName("OriginalTitle") val originalTitle: String? = null,
@SerialName("ProductionLocations") val productionLocations: List<String> = emptyList(),
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
@SerialName("People") val people: List<EmbyPerson> = emptyList(),
@SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null,
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.data.remote
import com.ponzischeme89.memby.data.model.AuthRequest
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.AuthResult
import com.ponzischeme89.memby.data.model.ItemsResult
import com.ponzischeme89.memby.data.model.PlaybackReport
@@ -61,6 +62,17 @@ interface EmbyApi {
@Path("itemId") itemId: String,
): ItemsResult
/**
* Featurettes, deleted scenes, interviews — whatever Emby has filed beside the media
* file as a special feature. Separate from [getLocalTrailers], which is only ever asked
* for the one trailer the detail page's Trailer button needs.
*/
@GET("Users/{userId}/Items/{itemId}/SpecialFeatures")
suspend fun getSpecialFeatures(
@Path("userId") userId: String,
@Path("itemId") itemId: String,
): List<BaseItem>
/**
* Emby's own similarity ranking. The direct path has no recommendation engine behind
* it, so this is the whole of "More like this" when the gateway is not in play.
@@ -233,6 +233,15 @@ interface GatewayApi {
@GET("v1/items/{id}/episodes")
suspend fun seriesEpisodes(@Path("id") seriesId: String): GatewayItems
/**
* The item's special features — featurettes, deleted scenes, interviews — with any
* local trailer at the front. A gateway that predates the route answers 404, which the
* repository reads as "no extras" rather than as an error: the Extras tab is simply not
* offered, which is the correct answer for a household on an older container.
*/
@GET("v1/items/{id}/extras")
suspend fun extras(@Path("id") itemId: String): GatewayItems
/** Why this viewer might enjoy the item, and what else in the library is like it. */
@GET("v1/items/{id}/related")
suspend fun related(
@@ -29,27 +29,25 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
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.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.PlayArrow
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
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
@@ -65,11 +63,13 @@ 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.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.sp
@@ -79,11 +79,15 @@ import androidx.tv.material3.Text
import coil.compose.AsyncImage
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.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.CastGrid
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.extraKindLabel
import com.ponzischeme89.memby.ui.detail.formatRuntime
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
@@ -96,7 +100,6 @@ 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.ValueSeparator
import kotlinx.coroutines.launch
// 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.
@@ -121,7 +124,7 @@ internal val DetailSideGutter = 58.dp
internal val DetailStripHeight = 66.dp
/**
* How much of the content pane is left showing under the tab strip.
* 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
@@ -132,14 +135,39 @@ internal val DetailStripHeight = 66.dp
private val DetailFoldPeek = 34.dp
/**
* The height one tab's pane gets, from the viewport it has to fit inside.
* What is left of the hero once the viewer has moved down into the tabs.
*
* It was a hard 250dp, and `technicalSpecs()` — Video, Codec, Audio, Subtitles, Studio —
* fell off the bottom of a pane that deliberately cannot scroll. The budget is what is left
* of the screen once the pane has been scrolled to its resting position under the strip.
* 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 - 132.dp).coerceIn(250.dp, 420.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.
@@ -287,12 +315,10 @@ internal fun DetailPageScaffold(
showRatingsStrip: Boolean = true,
heroActions: List<DetailHeroAction> = emptyList(),
confirmation: String? = null,
pageListState: LazyListState = remember(item.id) { LazyListState() },
onZoneFocused: (DetailZone) -> Unit = {},
footer: (@Composable () -> Unit)? = null,
content: @Composable BoxScope.(DetailTab) -> Unit,
) {
val scope = rememberCoroutineScope()
// 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) {
@@ -317,44 +343,35 @@ internal fun DetailPageScaffold(
focusFirstAvailable(tabFocusRequester, stripEntryRequester, contentFocusRequester, contentEntryRequester)
}
val enterContent = { focusFirstAvailable(contentFocusRequester, contentEntryRequester) }
fun reveal(index: Int, offset: Int = 0) {
scope.launch { pageListState.animateScrollToItem(index, offset) }
}
fun revealHero() {
scope.launch {
// LazyColumn also scrolls a newly focused descendant into view. That request
// can land after onFocusChanged and used to win over reveal(0), leaving Play
// focused with the logo and metadata above the viewport. Snap once now and
// once after focus relocation has completed so hero focus always means the
// complete opening frame.
pageListState.scrollToItem(0)
withFrameNanos { }
pageListState.scrollToItem(0)
}
}
// Focus relocation belongs to LazyColumn and may run after the focus callback. Keep
// the opening frame pinned for as long as focus remains in the hero, regardless of
// which relocation wins a particular frame. Moving to Tabs or Content releases it.
androidx.compose.runtime.LaunchedEffect(pageListState, focusedZone) {
if (detailHeroScrollTarget(focusedZone) == null) return@LaunchedEffect
snapshotFlow {
pageListState.firstVisibleItemIndex to pageListState.firstVisibleItemScrollOffset
}.collect { (index, offset) ->
if (index != 0 || offset != 0) pageListState.scrollToItem(0)
}
}
// 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.
val collapse = animateFloatAsState(
targetValue = if (detailHeroCollapsed(focusedZone)) 1f else 0f,
animationSpec = tween(190),
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. Content begins below the fold and only enters when the
// viewer presses Down from the tabs.
val heroHeight = (maxHeight - DetailStripHeight - DetailFoldPeek).coerceAtLeast(340.dp)
val paneHeight = detailPaneHeight(maxHeight)
LazyColumn(
state = pageListState,
modifier = Modifier.fillMaxSize(),
) {
item(key = "hero") {
// 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,
@@ -375,89 +392,86 @@ internal fun DetailPageScaffold(
showRatingsStrip = showRatingsStrip,
actions = heroActions,
actionRequesters = actionRequesters,
height = heroHeight,
expandedHeight = expandedHero,
collapsedHeight = DetailCollapsedHeroHeight,
collapse = { collapse.value },
onActionFocused = { index ->
lastHeroIndex = index
focusedZone = DetailZone.PLAY
onZoneFocused(DetailZone.PLAY)
revealHero()
},
onPlayFocused = {
lastHeroIndex = -1
focusedZone = DetailZone.PLAY
onZoneFocused(DetailZone.PLAY)
revealHero()
},
)
}
item(key = "tabs") {
val onStripFocused = {
focusedZone = DetailZone.TABS
onZoneFocused(DetailZone.TABS)
reveal(1, -18)
}
// 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,
)
}
val onStripFocused = {
focusedZone = DetailZone.TABS
onZoneFocused(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,
)
}
}
item(key = "content") {
AnimatedContent(
targetState = selectedTab,
transitionSpec = { fadeIn(tween(110)) togetherWith fadeOut(tween(80)) },
label = "detail-tab-content",
modifier = Modifier
.fillMaxWidth()
.padding(start = DetailSideGutter, end = DetailSideGutter, top = 16.dp, bottom = 64.dp)
.height(paneHeight)
.onFocusChanged {
if (it.hasFocus) {
focusedZone = DetailZone.CONTENT
onZoneFocused(DetailZone.CONTENT)
reveal(2, -72)
}
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) {
focusedZone = DetailZone.CONTENT
onZoneFocused(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) }
}
}
footer?.let { footerContent ->
item(key = "footer") {
Box(
Modifier.fillMaxWidth().padding(
start = DetailSideGutter, end = DetailSideGutter, bottom = 64.dp,
),
) { footerContent() }
}
}
.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 {
@@ -479,9 +493,31 @@ internal fun DetailPageScaffold(
}
}
/** A focused hero is always the complete opening frame, never a scrolled Play-only crop. */
internal fun detailHeroScrollTarget(zone: DetailZone): Int? =
if (zone == DetailZone.PLAY) 0 else null
/**
* 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(
@@ -504,7 +540,14 @@ private fun DetailHero(
showRatingsStrip: Boolean,
actions: List<DetailHeroAction>,
actionRequesters: List<FocusRequester>,
height: Dp,
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,
) {
@@ -518,11 +561,46 @@ private fun DetailHero(
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
// 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".
Box(Modifier.fillMaxWidth().height(height).onVerticalNavigation(down = onNavigateDown)) {
//
// 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.
Box(
Modifier
.fillMaxWidth()
.height(expandedHeight)
.graphicsLayer {
translationY = -(expandedHeight.toPx() - collapsedHeight.toPx()) * collapse()
}
.onVerticalNavigation(down = onNavigateDown),
) {
DetailBackdrop(item, Modifier.fillMaxSize())
// 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.
Box(
Modifier
.fillMaxSize()
.graphicsLayer { alpha = collapse() }
.background(DetailBackground.copy(alpha = 0.72f)),
)
DetailCollapsedHeader(
title = title,
facts = facts,
logo = logo,
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) },
)
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),
) {
@@ -650,6 +728,60 @@ private fun DetailHero(
}
}
/**
* 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,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(start = DetailSideGutter, end = DetailSideGutter, bottom = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (logo != null) {
AsyncImage(
model = logo,
contentDescription = title,
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
modifier = Modifier.height(38.dp).width(160.dp),
)
} else {
Text(
text = title,
color = Color.White,
fontSize = 24.sp,
lineHeight = 27.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
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.
*
@@ -804,7 +936,10 @@ private fun DetailTabStrip(
focused = it.isFocused
if (it.isFocused) { onSelect(tab); onFocused() }
}
.clickable { onSelect(tab) },
// 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,
) {
Text(
@@ -855,7 +990,9 @@ internal fun DetailFocusablePane(
var focused by remember { mutableStateOf(false) }
Box(
modifier
.fillMaxSize()
// 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()
@@ -878,6 +1015,13 @@ internal fun DetailMetaRows(rows: List<TechnicalSpec>, modifier: Modifier = Modi
}
}
/**
* 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,
@@ -887,45 +1031,187 @@ internal fun DetailOverviewPane(
supportingText: String? = null,
) {
DetailFocusablePane(focusRequester, modifier) {
Column {
Text(item.overview?.takeIf(String::isNotBlank) ?: "No description available.", color = DetailText, fontSize = 16.sp, lineHeight = 23.sp, maxLines = 5, overflow = TextOverflow.Ellipsis)
supportingText?.let { Spacer(Modifier.height(10.dp)); Text(it, color = DetailAccent, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) }
Spacer(Modifier.height(16.dp))
DetailMetaRows(credits.take(4))
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 DetailCastAndDetailsPane(
item: BaseItem,
credits: List<TechnicalSpec>,
internal fun DetailDetailsPane(
rows: List<TechnicalSpec>,
specs: List<TechnicalSpec>,
detailsLoaded: Boolean,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
val releaseAndTechnical = remember(item.id, specs, credits) {
val alreadyCredited = credits.map(TechnicalSpec::label).toSet()
buildList {
item.productionYear?.let { add(TechnicalSpec("Released", it.toString())) }
item.officialRating?.takeIf(String::isNotBlank)?.let { add(TechnicalSpec("Certificate", it)) }
item.runtimeMinutes?.let { add(TechnicalSpec("Runtime", formatRuntime(it))) }
// Studio is in both vocabularies. It only became visible as a duplicate once
// the pane stopped clipping its own second half.
addAll(specs.filterNot { it.label in alreadyCredited })
}
// 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 }
}
DetailFocusablePane(focusRequester, modifier) {
if (!detailsLoaded && item.people.isEmpty() && specs.isEmpty()) {
Text("Loading cast and details…", color = DetailQuietText, fontSize = 15.sp)
if (rows.isEmpty() && specs.isEmpty()) {
Text(
text = if (detailsLoaded) "No further details are recorded." else "Loading details…",
color = DetailQuietText,
fontSize = 15.sp,
)
} else {
Column {
if (item.cast.isNotEmpty()) CastRail(people = item.cast, compact = true, showTitle = true)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(42.dp)) {
DetailMetaRows(credits, Modifier.weight(1f))
DetailMetaRows(releaseAndTechnical, Modifier.weight(1f))
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
},
)
}
}
}
@@ -933,41 +1219,22 @@ internal fun DetailCastAndDetailsPane(
}
@Composable
internal fun DetailMoreLikeThisPane(
items: List<BaseItem>,
loading: Boolean,
onSelect: (BaseItem) -> Unit,
firstFocusRequester: FocusRequester,
listState: LazyListState,
private fun DetailPosterCard(
item: BaseItem,
onClick: () -> Unit,
onFocused: () -> 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 -> LazyRow(
state = listState,
modifier = modifier.fillMaxSize().focusGroup(),
horizontalArrangement = Arrangement.spacedBy(18.dp),
contentPadding = PaddingValues(horizontal = 7.dp, vertical = 7.dp),
) {
itemsIndexed(items, key = { _, it -> it.id }) { index, related ->
DetailPosterCard(
item = related,
onClick = { onSelect(related) },
modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
)
}
}
}
}
@Composable
private fun DetailPosterCard(item: BaseItem, onClick: () -> 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) }
val scale by animateFloatAsState(if (focused) 1.06f else 1f, tween(100), label = "related-poster-focus")
Column(
modifier.width(128.dp).graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }.zIndex(if (focused) 1f else 0f).onFocusChanged { focused = it.isFocused }.clickable(onClick = onClick),
modifier
.fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
.zIndex(if (focused) 1f else 0f)
.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)
@@ -976,7 +1243,128 @@ private fun DetailPosterCard(item: BaseItem, onClick: () -> Unit, modifier: Modi
}
}
/**
* 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 DetailPanePlaceholder(message: String, modifier: Modifier = Modifier) {
Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { Text(message, color = DetailQuietText, fontSize = 15.sp) }
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 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)
.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(
Icons.Default.PlayArrow,
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),
)
}
}
}
@@ -0,0 +1,299 @@
package com.ponzischeme89.memby.ui
import androidx.activity.compose.BackHandler
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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
import com.ponzischeme89.memby.ui.theme.MembyAccentMuted
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
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.MembyOutline
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySplashTint
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.delay
/** The two answers the panel offers, named so a capture can say which one is under focus. */
internal enum class ExitChoice { STAY, CLOSE }
/**
* "Close Memby?" — the last thing between a press of Back on the launcher and the TV's own
* home screen. Only drawn when the viewer has asked for it (Settings → `confirmExitMemby`).
*
* It is deliberately the only dialog in the app drawn from the design tokens rather than
* from raw `androidx.tv.material3.Button`s. Those buttons carry Material's own colour
* scheme, which nothing else on the launcher uses, so the one panel that appears *over*
* Memby was the one thing on screen that did not look like it — and the destructive choice
* and the safe one were drawn identically, on a screen read from three metres away by
* somebody who pressed a key by accident.
*
* Three things are worth keeping:
*
* - **The two actions do not look alike.** Stay is the accent fill and takes focus first;
* closing is a quiet outline. A remote has no pointer, so the loud shape is the whole of
* what says which one the viewer probably wants.
* - **Nothing here recomposes while it appears.** The entrance is one `animateFloatAsState`
* read inside `graphicsLayer`/`drawBehind` lambdas — the draw and layer phases — never in
* a composable body. This app ships to weak TV boxes and a dialog that recomposed the
* panel per frame would stutter on the way in.
* - **Back means stay.** It is the key that raised this panel, and pressing it again must
* not be the thing that closes the app.
*
* The last two parameters exist only so the panel can be captured: `ExitConfirmationScreenshotTest`
* needs the settled frame rather than whatever the animation clock happened to be at, and
* Robolectric's window never takes focus, so the focus ring — the whole of what says which
* action a press would take — cannot be photographed without being asked for.
*/
@Composable
@OptIn(ExperimentalComposeUiApi::class)
internal fun ExitMembyConfirmation(
onStay: () -> Unit,
onExit: () -> Unit,
animateIn: Boolean = true,
focusedForCapture: ExitChoice? = null,
) {
val stayFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() }
var appeared by remember { mutableStateOf(!animateIn) }
val appear by animateFloatAsState(
targetValue = if (appeared) 1f else 0f,
animationSpec = tween(durationMillis = 180),
label = "exit-dialog-appear",
)
BackHandler(onBack = onStay)
// The delay is the one the panel this replaced used: a FocusRequester attached on the
// frame it is requested on is not placed yet and the request is dropped — which on a
// dialog leaves a television with nothing focused and no way out of it.
LaunchedEffect(Unit) {
appeared = true
delay(16L)
runCatching { stayFocus.requestFocus() }
}
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(20f)
// A wash rather than a flat 82% black: the launcher stays faintly readable
// underneath, so this reads as a question asked over your library rather than
// as a screen the app has navigated to.
.graphicsLayer { alpha = appear }
.background(
Brush.radialGradient(
listOf(
MembySplashTint.copy(alpha = 0.80f),
Color.Black.copy(alpha = 0.93f),
),
),
),
contentAlignment = Alignment.Center,
) {
val panelShape = RoundedCornerShape(MembyPanelCorner)
Column(
modifier = Modifier
.width(520.dp)
// Settles the last few pixels down onto the scrim as it fades in. Read in
// the layer phase, so the panel itself is composed once.
.graphicsLayer {
val t = appear
scaleX = 0.96f + 0.04f * t
scaleY = 0.96f + 0.04f * t
translationY = (1f - t) * 14.dp.toPx()
}
.shadow(30.dp, panelShape)
.clip(panelShape)
.background(MembySurfaceRaised)
.border(1.dp, MembyHairline, panelShape)
.padding(horizontal = 40.dp, vertical = 34.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(58.dp)
.clip(CircleShape)
.background(MembyAccentMuted)
.border(1.dp, MembyAccentBright.copy(alpha = 0.45f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.PowerSettingsNew,
contentDescription = null,
tint = MembyAccentBright,
modifier = Modifier.size(27.dp),
)
}
Spacer(Modifier.height(20.dp))
Text(
"Close Memby?",
color = MembyOnSurface,
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(10.dp))
Text(
"Your place is saved. You can pick up where you left off next time you " +
"open Memby.",
color = MembyMutedText,
fontSize = 16.sp,
lineHeight = 23.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(26.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
DialogAction(
label = "Stay in Memby",
primary = true,
onClick = onStay,
focusedForCapture = focusedForCapture == ExitChoice.STAY,
modifier = Modifier
.weight(1f)
.focusRequester(stayFocus)
.focusProperties {
left = FocusRequester.Cancel
right = exitFocus
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
)
DialogAction(
label = "Close Memby",
primary = false,
onClick = onExit,
focusedForCapture = focusedForCapture == ExitChoice.CLOSE,
modifier = Modifier
.weight(1f)
.focusRequester(exitFocus)
.focusProperties {
left = stayFocus
right = FocusRequester.Cancel
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
)
}
Spacer(Modifier.height(16.dp))
Text(
"Press Back to stay",
color = MembyQuietText,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
}
}
/**
* One dialog action. The same corner, lift and focus ring as [MembyPlayButton], because a
* viewer should not have to learn a second focus language for the panel that appears over
* the one they were just using.
*/
@Composable
private fun DialogAction(
label: String,
primary: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
focusedForCapture: Boolean = false,
) {
var hasFocus by remember { mutableStateOf(false) }
val focused = hasFocus || focusedForCapture
val lift by animateFloatAsState(
targetValue = if (focused) 1f else 0f,
animationSpec = tween(durationMillis = 110),
label = "exit-dialog-action",
)
val shape = RoundedCornerShape(MembyCardCorner)
Box(
modifier = modifier
.graphicsLayer {
val t = lift
scaleX = 1f + 0.04f * t
scaleY = 1f + 0.04f * t
translationY = -3f * t
}
.shadow(if (focused) 16.dp else 0.dp, shape)
.clip(shape)
.background(
when {
focused && primary -> MembyAccent
focused -> MembyOutline
primary -> MembyAccentMuted
else -> Color.Transparent
},
)
.border(
width = if (focused) 2.dp else 1.dp,
color = when {
focused -> Color.White
primary -> MembyAccent.copy(alpha = 0.55f)
else -> MembyOutline
},
shape = shape,
)
.onFocusChanged { hasFocus = it.isFocused }
.clickable(onClick = onClick)
.padding(horizontal = 20.dp, vertical = 13.dp),
contentAlignment = Alignment.Center,
) {
Text(
label,
color = when {
focused -> Color.White
primary -> MembyAccentBright
else -> MembyMutedText
},
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
@@ -1691,122 +1691,6 @@ internal fun MediaRow(
}
}
@Composable
internal fun CastRail(
people: List<EmbyPerson>,
modifier: Modifier = Modifier,
compact: Boolean = false,
showTitle: Boolean = true,
) {
val distinctCast = remember(people) {
people.filter(EmbyPerson::isCastMember)
.distinctForKeys { "${it.id}:${it.name}" }
.take(16)
}
if (distinctCast.isEmpty()) return
Column(modifier) {
if (showTitle) {
Text(
"Cast",
color = Color.White,
fontSize = if (compact) 16.sp else 21.sp,
fontWeight = FontWeight.SemiBold,
)
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(if (compact) 10.dp else 16.dp),
contentPadding = PaddingValues(
top = if (showTitle) 7.dp else 3.dp,
end = 24.dp,
bottom = 4.dp,
),
) {
// Emby lists the same person twice often enough that this is not a theoretical
// guard: a title where one actor is credited under two roles, or where an
// agent has written the cast twice, would otherwise hand this LazyRow two
// items under one key and take the whole detail page down with it.
items(distinctCast, key = { "${it.id}:${it.name}" }) { person ->
CastCard(person, compact)
}
}
}
}
@Composable
private fun CastCard(person: EmbyPerson, compact: Boolean) {
val repository = ServiceLocator.repository
val portrait = remember(person.id, person.primaryImageTag) {
repository.personImageUrl(person, if (compact) 180 else 280)
}
var focused by remember { mutableStateOf(false) }
val width = if (compact) 72.dp else 112.dp
val height = if (compact) 76.dp else 142.dp
val shape = RoundedCornerShape(MembyCardCorner)
val scale by animateFloatAsState(
targetValue = if (focused) 1.045f else 1f,
animationSpec = tween(110),
label = "cast-card-focus",
)
Column(
Modifier
.width(width)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.onFocusChanged { focused = it.isFocused }
.focusable(),
) {
Box(
Modifier
.fillMaxWidth()
.height(height)
.clip(shape)
.background(MembyControlSurface)
.border(
if (focused) 2.dp else 1.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.10f),
shape,
),
contentAlignment = Alignment.Center,
) {
if (portrait != null) {
AsyncImage(
model = portrait,
contentDescription = person.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(
Icons.Default.Person,
contentDescription = null,
tint = QuietText,
modifier = Modifier.size(if (compact) 28.dp else 40.dp),
)
}
}
Text(
person.name,
color = Color.White,
fontSize = if (compact) 11.sp else 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 5.dp),
)
person.role?.takeIf(String::isNotBlank)?.let { role ->
Text(
role,
color = QuietText,
fontSize = if (compact) 9.sp else 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun FavoriteShowsEmptyState(
navigationFocusRequester: FocusRequester,
@@ -690,73 +690,8 @@ private fun AppRoot(remoteConfig: MembyRemoteConfig, onCloseSettings: () -> Unit
}
}
@Composable
@OptIn(ExperimentalComposeUiApi::class)
private fun ExitMembyConfirmation(
onStay: () -> Unit,
onExit: () -> Unit,
) {
val stayFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() }
BackHandler(onBack = onStay)
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(16L)
runCatching { stayFocus.requestFocus() }
}
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(20f)
.background(Color.Black.copy(alpha = 0.82f)),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.width(480.dp)
.background(MembyControlSurface, RoundedCornerShape(18.dp))
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text(
"Close Memby?",
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
"Choose Stay to keep browsing, or close Memby and return to your TV.",
color = MembyMutedText,
fontSize = 16.sp,
textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Button(
onClick = onStay,
modifier = Modifier
.focusRequester(stayFocus)
.focusProperties {
left = FocusRequester.Cancel
right = exitFocus
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
) { Text("Stay in Memby") }
Button(
onClick = onExit,
modifier = Modifier
.focusRequester(exitFocus)
.focusProperties {
left = stayFocus
right = FocusRequester.Cancel
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
) { Text("Close Memby") }
}
}
}
}
// ExitMembyConfirmation lives in ui/ExitConfirmation.kt — it is drawn from the design
// tokens rather than from raw Material buttons, and is screenshot-tested on its own.
@androidx.media3.common.util.UnstableApi
@Composable
@@ -1,6 +1,6 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Favorite
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Movie
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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
@@ -23,9 +24,12 @@ import com.ponzischeme89.memby.data.RelatedContent
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailTabAvailability
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.castMembers
import com.ponzischeme89.memby.ui.detail.creditRows
import com.ponzischeme89.memby.ui.detail.detailPositions
import com.ponzischeme89.memby.ui.detail.detailRows
import com.ponzischeme89.memby.ui.detail.detailTab
import com.ponzischeme89.memby.ui.detail.detailTabs
import com.ponzischeme89.memby.ui.detail.franchiseStart
@@ -58,6 +62,7 @@ fun MediaDetailsOverlay(
.collectAsStateWithLifecycle(initialValue = ServiceLocator.repository.currentSettings)
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
var extras by remember(item.id) { mutableStateOf<List<BaseItem>>(emptyList()) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
LaunchedEffect(item.id) {
related = ServiceLocator.repository.getRelated(item)
@@ -65,6 +70,13 @@ fun MediaDetailsOverlay(
LaunchedEffect(item.id) {
trailer = item.takeIf { ServiceLocator.repository.hasTrailer(item.id) }
}
// Its own request, and never blocking: the Extras tab appears when the answer arrives
// and simply does not exist for the great majority of titles that have none. The
// repository caches the empty answer, so walking a "More like this" trail asks once per
// title rather than once per visit.
LaunchedEffect(item.id) {
extras = ServiceLocator.repository.getExtras(item.id)
}
LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) ServiceLocator.repository.getRatings(item) else emptyList()
}
@@ -77,6 +89,7 @@ fun MediaDetailsOverlay(
onOpenItem = onOpenItem,
related = related,
trailer = trailer,
extras = extras,
ratings = ratings,
showRatingsStrip = settings.showRatingsStrip,
hideWatchedMovies = settings.hideWatchedMovies,
@@ -99,6 +112,7 @@ internal fun MediaDetailContent(
modifier: Modifier = Modifier,
related: RelatedContent? = null,
trailer: BaseItem? = null,
extras: List<BaseItem> = emptyList(),
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
hideWatchedMovies: Boolean = false,
@@ -107,6 +121,8 @@ internal fun MediaDetailContent(
) {
val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) }
val credits = remember(item.id, item.people, item.genres) { creditRows(item) }
val facts = remember(item.id, item.people, item.mediaStreams) { detailRows(item) }
val cast = remember(item.id, item.people) { castMembers(item) }
val visibleRelated = remember(related, hideWatchedMovies) {
visibleWithWatchedPreference(related?.items.orEmpty(), hideWatchedMovies)
}
@@ -114,39 +130,69 @@ internal fun MediaDetailContent(
val franchise = remember(item.id, item.collectionName, related?.items) {
franchiseStart(item, related?.items.orEmpty())
}
val tabs = remember(item.id) { detailTabs(isSeries = false) }
// The item arrives with whatever a home row asked for and is replaced by the full
// record moments later. Panes say "loading" rather than "nothing here" until then.
val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty()
val remembered = remember(item.id) { detailPositions.get(item.id) }
var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) }
val tabs = rememberDetailTabs(
itemId = item.id,
availability = DetailTabAvailability(
isSeries = false,
// Cast is optimistic only until the full record lands: a title that genuinely
// has nobody credited loses the tab rather than offering an apology.
hasCast = cast.isNotEmpty() || !detailsLoaded,
hasExtras = extras.isNotEmpty(),
// Answered for nearly every title, so the tab is there while the request runs
// and the pane says it is looking. Appearing late would be the common case.
hasRelated = related == null || visibleRelated.isNotEmpty(),
hasDetails = facts.isNotEmpty() || specs.isNotEmpty() || !detailsLoaded,
),
frozen = focusedZone != DetailZone.PLAY,
)
// 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) {
mutableStateOf(if (restorePosition) remembered.tabKey else DetailTab.OVERVIEW.key)
mutableStateOf(if (restorePosition) remembered.tabKey else tabs.first().key)
}
val selectedTab = detailTab(tabKey, tabs)
val play = remember(item.id) { FocusRequester() }
val tabStrip = remember(item.id) { FocusRequester() }
// One requester per pane. Sharing a single "information pane" requester between the
// Overview and Cast & Details panes attached it to two nodes at once for the 80ms
// AnimatedContent spends fading the outgoing pane out, and a Down press landing in that
// window could request focus on the pane that is disappearing.
// One requester per pane. Sharing a single "information pane" requester between two
// panes attached it to two nodes at once for the 80ms AnimatedContent spends fading the
// outgoing pane out, and a Down press landing in that window could request focus on the
// pane that is disappearing.
val overviewPane = remember(item.id) { FocusRequester() }
val castPane = remember(item.id) { FocusRequester() }
val detailsPane = remember(item.id) { FocusRequester() }
val castEntry = remember(item.id) { FocusRequester() }
val castEmpty = remember(item.id) { FocusRequester() }
val firstExtra = remember(item.id) { FocusRequester() }
val firstRelated = remember(item.id) { FocusRequester() }
val relatedListState = rememberLazyListState(remembered.relatedIndex)
val relatedGridState = rememberLazyGridState(remembered.relatedIndex)
val castGridState = rememberLazyGridState(remembered.castScrollIndex)
val extrasGridState = rememberLazyGridState()
// Which card the grid hands focus back to when the page is reopened. Held here rather
// than read out of the store on every recomposition so that moving around the grid does
// not re-key the card carrying the entry requester.
var castIndex by remember(item.id) {
mutableIntStateOf(if (restorePosition) remembered.castIndex else 0)
}
val contentEntry = when (selectedTab) {
DetailTab.MORE_LIKE_THIS -> firstRelated
DetailTab.CAST_DETAILS -> castPane
DetailTab.CAST -> if (cast.isEmpty()) castEmpty else castEntry
DetailTab.EXTRAS -> firstExtra
DetailTab.DETAILS -> detailsPane
else -> overviewPane
}
DetailPositionMemory(
itemId = item.id,
tabKey = tabKey,
relatedIndex = { relatedListState.firstVisibleItemIndex },
relatedIndex = { relatedGridState.firstVisibleItemIndex },
castIndex = { castIndex },
castScrollIndex = { castGridState.firstVisibleItemIndex },
)
RestoreDetailFocus(
itemId = item.id,
@@ -187,7 +233,10 @@ internal fun MediaDetailContent(
ratings = ratings,
showRatingsStrip = showRatingsStrip,
confirmation = confirmation,
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
onZoneFocused = { zone ->
focusedZone = zone
detailPositions.update(item.id) { it.copy(zone = zone) }
},
heroActions = buildList {
add(
DetailHeroAction(
@@ -231,25 +280,71 @@ internal fun MediaDetailContent(
) { visibleTab ->
when (visibleTab) {
DetailTab.OVERVIEW -> DetailOverviewPane(item, credits, overviewPane)
DetailTab.CAST -> DetailCastPane(
people = cast,
detailsLoaded = detailsLoaded,
state = castGridState,
entryIndex = castIndex,
entryFocusRequester = castEntry,
emptyFocusRequester = castEmpty,
aboveGrid = tabStrip,
onFocusIndex = { castIndex = it },
onSelect = { /* Person pages are not built yet; the card stays inert. */ },
)
DetailTab.EXTRAS -> DetailExtrasPane(
items = extras,
onPlay = onPlay,
firstFocusRequester = firstExtra,
gridState = extrasGridState,
aboveGrid = tabStrip,
)
DetailTab.MORE_LIKE_THIS -> DetailMoreLikeThisPane(
items = visibleRelated,
loading = related == null,
onSelect = onOpenItem,
firstFocusRequester = firstRelated,
listState = relatedListState,
gridState = relatedGridState,
aboveGrid = tabStrip,
entryIndex = 0,
onFocusIndex = {},
)
DetailTab.CAST_DETAILS -> DetailCastAndDetailsPane(
item = item,
credits = credits,
DetailTab.DETAILS -> DetailDetailsPane(
rows = facts,
specs = specs,
detailsLoaded = detailsLoaded,
focusRequester = castPane,
focusRequester = detailsPane,
)
DetailTab.EPISODES -> Unit
}
}
}
/**
* The tab strip, recomputed as content arrives but held still once the viewer is inside it.
*
* Both failures this sits between are real. A tab that leads to an apology is dead weight on
* every set in the house; a tab appearing a second after the page opens shoves every tab to
* its right sideways under a thumb that is already moving. The rule is that the strip is
* live only while focus is in the hero which is where it is during the whole of the first
* second, and where somebody who has not started navigating still is.
*
* A tab is therefore never *removed* from under a viewer either, which matters more than it
* looks: if related titles come back empty while the Cast grid has focus, dropping the tab
* would renumber the strip mid-browse.
*/
@Composable
internal fun rememberDetailTabs(
itemId: String,
availability: DetailTabAvailability,
frozen: Boolean,
): List<DetailTab> {
var tabs by remember(itemId) { mutableStateOf(detailTabs(availability)) }
LaunchedEffect(itemId, availability, frozen) {
if (!frozen) tabs = detailTabs(availability)
}
return tabs
}
/**
* Writes the page's position back to [detailPositions] as it changes.
*
@@ -264,14 +359,23 @@ internal fun DetailPositionMemory(
season: Int? = null,
episodeIndex: () -> Int = { 0 },
relatedIndex: () -> Int = { 0 },
castIndex: () -> Int = { 0 },
castScrollIndex: () -> Int = { 0 },
) {
LaunchedEffect(itemId, tabKey, season) {
detailPositions.update(itemId) { it.copy(tabKey = tabKey, season = season ?: it.season) }
}
LaunchedEffect(itemId) {
snapshotFlow { episodeIndex() to relatedIndex() }.collect { (episode, relatedCard) ->
snapshotFlow {
listOf(episodeIndex(), relatedIndex(), castIndex(), castScrollIndex())
}.collect { (episode, relatedCard, castCard, castScroll) ->
detailPositions.update(itemId) {
it.copy(episodeIndex = episode, relatedIndex = relatedCard)
it.copy(
episodeIndex = episode,
relatedIndex = relatedCard,
castIndex = castCard,
castScrollIndex = castScroll,
)
}
}
}
@@ -23,6 +23,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -36,6 +37,7 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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
@@ -68,14 +70,15 @@ import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.data.seriesPaceLabel
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailTabAvailability
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.availableSeasons
import com.ponzischeme89.memby.ui.detail.castMembers
import com.ponzischeme89.memby.ui.detail.creditRows
import com.ponzischeme89.memby.ui.detail.defaultSeason
import com.ponzischeme89.memby.ui.detail.detailPositions
import com.ponzischeme89.memby.ui.detail.detailRows
import com.ponzischeme89.memby.ui.detail.detailTab
import com.ponzischeme89.memby.ui.detail.detailTabs
import com.ponzischeme89.memby.ui.detail.episodeHeadline
import com.ponzischeme89.memby.ui.detail.episodesForSeason
import com.ponzischeme89.memby.ui.detail.formatAirDate
import com.ponzischeme89.memby.ui.detail.heroFacts
@@ -121,6 +124,7 @@ fun SeriesDetailsOverlay(
var loadFailed by remember(item.id) { mutableStateOf(false) }
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
var extras by remember(item.id) { mutableStateOf<List<BaseItem>>(emptyList()) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
LaunchedEffect(item.id) {
@@ -148,6 +152,11 @@ fun SeriesDetailsOverlay(
LaunchedEffect(item.id) {
trailer = item.takeIf { repository.hasTrailer(item.id) }
}
// Independent of the episode request, like "more like this" above it: a show's specials
// and featurettes must not wait on its catalogue of episodes, nor hold it up.
LaunchedEffect(item.id) {
extras = repository.getExtras(item.id)
}
LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList()
}
@@ -163,6 +172,7 @@ fun SeriesDetailsOverlay(
onToggleMyShow = onToggleMyShow,
related = related,
trailer = trailer,
extras = extras,
ratings = ratings,
showRatingsStrip = settings.showRatingsStrip,
hideWatchedMovies = settings.hideWatchedMovies,
@@ -187,6 +197,7 @@ internal fun SeriesDetailContent(
modifier: Modifier = Modifier,
related: RelatedContent? = null,
trailer: BaseItem? = null,
extras: List<BaseItem> = emptyList(),
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
hideWatchedMovies: Boolean = false,
@@ -223,35 +234,55 @@ internal fun SeriesDetailContent(
}
val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) }
val credits = remember(item.id, item.people, item.genres) { creditRows(item) }
val facts = remember(item.id, item.people, item.mediaStreams) { detailRows(item) }
val cast = remember(item.id, item.people) { castMembers(item) }
val visibleRelated = remember(related, hideWatchedMovies) {
visibleWithWatchedPreference(related?.items.orEmpty(), hideWatchedMovies)
}
// Fixed on the first frame from what the item *is*. A series has episodes, a cast and
// technical details whether or not they have arrived yet, and a strip that waits for
// the network is a strip that moves under the viewer's thumb.
val tabs = remember(item.id) { detailTabs(isSeries = true) }
val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty()
// A series opens on its cinematic hero and Overview just like a movie. Season and
// rail positions are still remembered once the viewer enters Episodes.
var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) }
// Episodes leads, because that is what a show is for; a series' synopsis is already in
// the hero, so there is no Overview tab to duplicate it. See `detailTabs` for why each
// of these reads the way it does while the request behind it is still in flight.
val tabs = rememberDetailTabs(
itemId = item.id,
availability = DetailTabAvailability(
isSeries = true,
hasCast = cast.isNotEmpty() || !detailsLoaded,
hasExtras = extras.isNotEmpty(),
hasRelated = related == null || visibleRelated.isNotEmpty(),
hasDetails = facts.isNotEmpty() || specs.isNotEmpty() || !detailsLoaded,
),
frozen = focusedZone != DetailZone.PLAY,
)
// A series opens on its cinematic hero and its episodes. Season and grid positions are
// still remembered once the viewer has been inside a tab.
var tabKey by rememberSaveable(item.id, restorePosition) {
mutableStateOf(if (restorePosition) remembered.tabKey else DetailTab.OVERVIEW.key)
mutableStateOf(if (restorePosition) remembered.tabKey else tabs.first().key)
}
val selectedTab = detailTab(tabKey, tabs)
val play = remember(item.id) { FocusRequester() }
val tabStrip = remember(item.id) { FocusRequester() }
// One requester per pane: Overview, Cast & Details and the Episodes empty states all
// shared a single "information pane" requester, which left it attached to two live
// nodes while AnimatedContent faded the outgoing one out.
val overviewPane = remember(item.id) { FocusRequester() }
val castPane = remember(item.id) { FocusRequester() }
// One requester per pane: they used to share a single "information pane" requester,
// which left it attached to two live nodes while AnimatedContent faded the outgoing one
// out.
val detailsPane = remember(item.id) { FocusRequester() }
val castEntry = remember(item.id) { FocusRequester() }
val castEmpty = remember(item.id) { FocusRequester() }
val firstExtra = remember(item.id) { FocusRequester() }
val episodesPane = remember(item.id) { FocusRequester() }
val firstEpisode = remember(item.id) { FocusRequester() }
val firstRelated = remember(item.id) { FocusRequester() }
val seasonFocusRequesters = remember(seasons) { seasons.associateWith { FocusRequester() } }
val episodeListState = rememberLazyListState(remembered.episodeIndex)
val relatedListState = rememberLazyListState(remembered.relatedIndex)
val relatedGridState = rememberLazyGridState(remembered.relatedIndex)
val castGridState = rememberLazyGridState(remembered.castScrollIndex)
val extrasGridState = rememberLazyGridState()
var castIndex by remember(item.id) {
mutableIntStateOf(if (restorePosition) remembered.castIndex else 0)
}
// Every one of these targets has to be attached to something on screen *right now*. A
// `focusProperties` pointing at a requester that was never placed throws the moment the
@@ -269,10 +300,11 @@ internal fun SeriesDetailContent(
val belowSeasons = if (hasEpisodeCards) firstEpisode else FocusRequester.Default
val aboveEpisodes = selectedSeasonChip ?: tabStrip
val contentEntry = when (selectedTab) {
DetailTab.EPISODES -> episodeEntry
DetailTab.MORE_LIKE_THIS -> firstRelated
DetailTab.CAST_DETAILS -> castPane
else -> overviewPane
DetailTab.CAST -> if (cast.isEmpty()) castEmpty else castEntry
DetailTab.EXTRAS -> firstExtra
DetailTab.DETAILS -> detailsPane
else -> episodeEntry
}
DetailPositionMemory(
@@ -280,7 +312,9 @@ internal fun SeriesDetailContent(
tabKey = tabKey,
season = selectedSeason,
episodeIndex = { episodeListState.firstVisibleItemIndex },
relatedIndex = { relatedListState.firstVisibleItemIndex },
relatedIndex = { relatedGridState.firstVisibleItemIndex },
castIndex = { castIndex },
castScrollIndex = { castGridState.firstVisibleItemIndex },
)
RestoreDetailFocus(
itemId = item.id,
@@ -326,7 +360,10 @@ internal fun SeriesDetailContent(
confirmation = confirmation,
ratings = ratings,
showRatingsStrip = showRatingsStrip,
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
onZoneFocused = { zone ->
focusedZone = zone
detailPositions.update(item.id) { it.copy(zone = zone) }
},
heroActions = buildList {
add(
DetailHeroAction(
@@ -361,14 +398,7 @@ internal fun SeriesDetailContent(
},
) { visibleTab ->
when (visibleTab) {
DetailTab.OVERVIEW -> DetailOverviewPane(
item = item,
credits = credits,
focusRequester = overviewPane,
// The progress bar carries the position, so this only names the episode.
supportingText = nextEpisode?.let { "Up next ${episodeHeadline(it)}" },
)
DetailTab.EPISODES -> EpisodesPane(
DetailTab.EPISODES, DetailTab.OVERVIEW -> EpisodesPane(
episodes = episodes,
seasonEpisodes = seasonEpisodes,
seasons = seasons,
@@ -386,19 +416,39 @@ internal fun SeriesDetailContent(
onSelectSeason = { selectedSeason = it },
onPlay = onPlay,
)
DetailTab.CAST -> DetailCastPane(
people = cast,
detailsLoaded = detailsLoaded,
state = castGridState,
entryIndex = castIndex,
entryFocusRequester = castEntry,
emptyFocusRequester = castEmpty,
aboveGrid = tabStrip,
onFocusIndex = { castIndex = it },
onSelect = { /* Person pages are not built yet; the card stays inert. */ },
)
DetailTab.EXTRAS -> DetailExtrasPane(
items = extras,
onPlay = onPlay,
firstFocusRequester = firstExtra,
gridState = extrasGridState,
aboveGrid = tabStrip,
)
DetailTab.MORE_LIKE_THIS -> DetailMoreLikeThisPane(
items = visibleRelated,
loading = related == null,
onSelect = onOpenItem,
firstFocusRequester = firstRelated,
listState = relatedListState,
gridState = relatedGridState,
aboveGrid = tabStrip,
entryIndex = 0,
onFocusIndex = {},
)
DetailTab.CAST_DETAILS -> DetailCastAndDetailsPane(
item = item,
credits = credits,
DetailTab.DETAILS -> DetailDetailsPane(
rows = facts,
specs = specs,
detailsLoaded = detailsLoaded,
focusRequester = castPane,
focusRequester = detailsPane,
)
}
}
@@ -0,0 +1,249 @@
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
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.padding
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.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.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
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
import com.ponzischeme89.memby.ui.player.castInitials
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
/**
* The Cast tab: every credited actor as a portrait card, in a grid that fills the pane.
*
* It replaces a compact rail squeezed into the corner of a combined "Cast & Details" pane,
* which showed six faces of a forty-strong cast and gave the other thirty-four no way of
* being reached. A grid is the right shape because the question "who is that?" is
* answered by scanning faces, and a row of six is not a scan.
*/
/**
* The card's width at its narrowest.
*
* Tuned by looking rather than reasoned about, at the one geometry that matters: a 1080p
* television is 540dp tall, and once the collapsed hero and the strip have taken their share
* the pane has about 320dp. A portrait card much wider than this fills that with a single row
* and nothing under it which reads as a shelf that failed to load rather than as a grid
* with more below. At this width the second row peeks, which is the only thing on screen
* saying Down goes somewhere.
*/
private val CastCardMinWidth = 112.dp
/** Widest a card is allowed to grow to when the columns divide generously. */
private val CastCardMaxWidth = 150.dp
private val CastGridSpacing = 16.dp
/**
* How many cards fit across [available], as a whole number.
*
* A fixed column count rather than `GridCells.Adaptive` on purpose: Up out of the first row
* has to return to the tab strip, and the only way to know which cards are in the first row
* is to know how many there are. Adaptive decides that during measurement, where the focus
* properties have already been composed.
*
* Pure so the arithmetic is checkable a television at 960dp and one at 1280dp must both
* land on a whole number of sensibly sized cards rather than four fat ones or nine slivers.
*/
fun castColumns(available: Dp, minWidth: Dp = CastCardMinWidth, maxWidth: Dp = CastCardMaxWidth): Int {
if (available <= 0.dp) return 1
val spacing = CastGridSpacing
// The most cards that still leave each one at least minWidth, then walked back until
// none of them exceeds maxWidth — a five-card row of 220dp portraits reads as a poster
// shelf rather than as a cast list.
var columns = ((available + spacing) / (minWidth + spacing)).toInt().coerceAtLeast(1)
while (columns < 12 && (available - spacing * (columns - 1)) / columns > maxWidth) {
columns += 1
}
return columns
}
/**
* @param entryIndex the card focus returns to when the viewer comes back to this page.
* @param entryFocusRequester attached to that card, so the scaffold can hand focus to it.
* @param aboveGrid where Up out of the first row goes the tab strip.
*/
@Composable
internal fun CastGrid(
people: List<EmbyPerson>,
state: LazyGridState,
entryIndex: Int,
entryFocusRequester: FocusRequester,
aboveGrid: FocusRequester,
onFocusIndex: (Int) -> Unit,
onSelect: (EmbyPerson) -> Unit,
modifier: Modifier = Modifier,
imageUrlFor: (EmbyPerson) -> String? = { null },
) {
BoxWithConstraints(modifier.fillMaxSize()) {
val columns = remember(maxWidth) { castColumns(maxWidth) }
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
state = state,
modifier = Modifier.fillMaxSize().focusGroup(),
horizontalArrangement = Arrangement.spacedBy(CastGridSpacing),
verticalArrangement = Arrangement.spacedBy(18.dp),
// The focus ring is drawn outside the card's own bounds, and a card scaled up
// against the edge of the pane loses its highlight to the clip. This is the
// room it needs, top and bottom included.
contentPadding = PaddingValues(start = 4.dp, end = 12.dp, top = 6.dp, bottom = 24.dp),
) {
itemsIndexed(people, key = { _, person -> castKey(person) }) { index, person ->
CastGridCard(
person = person,
portrait = imageUrlFor(person),
onClick = { onSelect(person) },
onFocused = { onFocusIndex(index) },
modifier = Modifier
.then(
if (index == entryIndex) Modifier.focusRequester(entryFocusRequester)
else Modifier,
)
// Only the first row escapes upward. Everything below it keeps the
// grid's own vertical navigation, which is what scrolls the pane.
.focusProperties {
up = if (index < columns) aboveGrid else FocusRequester.Default
},
)
}
}
}
}
/**
* One face.
*
* The initials are drawn *behind* the portrait rather than instead of it, the stance the
* player's cast panel takes: Emby has no photo for a good part of a typical cast, and
* nothing has to decide in advance whether artwork will arrive.
*/
@Composable
private fun CastGridCard(
person: EmbyPerson,
portrait: String?,
onClick: () -> Unit,
onFocused: () -> Unit,
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)
.testTag("cast-card-${person.name}")
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onFocused()
}
.clickable(onClick = onClick),
) {
Box(
Modifier
.fillMaxWidth()
// Portrait, and the same ratio for every card whatever Emby returns, so the
// grid reads as rows rather than as a ragged wall.
.aspectRatio(2f / 3f)
.clip(shape)
.background(MembyControlSurface)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) Color.White else Color.White.copy(alpha = 0.10f),
shape = shape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = castInitials(person.name),
color = MembyQuietText,
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
)
if (portrait != null) {
AsyncImage(
model = portrait,
contentDescription = person.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clip(shape),
)
}
}
Spacer(Modifier.height(7.dp))
Text(
text = person.name,
color = if (focused) Color.White else MembyOnSurface,
fontSize = 13.sp,
lineHeight = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// Reserved rather than conditional: a card with no role recorded must not sit a
// line higher than the one beside it, or the grid's baselines go ragged.
Text(
text = person.role?.takeIf(String::isNotBlank) ?: " ",
color = MembyMutedText,
fontSize = 11.sp,
lineHeight = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
}
@@ -137,46 +137,6 @@ private fun peopleNamed(item: BaseItem, type: String, limit: Int): String? =
.takeIf(List<String>::isNotEmpty)
?.joinToString(", ")
/**
* The sections a detail page can show, in the order the strip lists them.
*
* Overview is always present and always first: it is the only one guaranteed to have
* something in it, so it is what the page can safely open on.
*/
enum class DetailTab(val key: String, val label: String) {
OVERVIEW("overview", "Overview"),
MORE_LIKE_THIS("more-like-this", "More Like This"),
EPISODES("episodes", "Episodes"),
CAST_DETAILS("cast-details", "Cast & Details"),
}
/**
* The strip for one item, decided by *what the item is* never by what has arrived from
* the network so far.
*
* This used to offer only the sections that already had content, on the reasoning that an
* empty tab is worse than a missing one. On a real TV it was worse than either: a movie
* opens with its list-row metadata, so Cast and Details appeared a second later and shoved
* the strip sideways under the viewer's thumb, and a series that failed to load its
* episodes lost a tab the household knows is there. A strip that depends only on
* [isSeries] is decided on the first frame and never moves again; a section with nothing
* in it yet says so in its own pane, where saying so costs nobody a keypress.
*/
fun detailTabs(isSeries: Boolean): List<DetailTab> = buildList {
add(DetailTab.OVERVIEW)
if (isSeries) add(DetailTab.EPISODES)
add(DetailTab.MORE_LIKE_THIS)
add(DetailTab.CAST_DETAILS)
}
/**
* Resolves a remembered tab key against what is on offer. The strip no longer changes
* under a page, so this only has to catch a key remembered from an item of the other kind
* a series' Episodes tab carried over to a movie.
*/
fun detailTab(key: String, available: List<DetailTab>): DetailTab =
available.firstOrNull { it.key == key } ?: DetailTab.OVERVIEW
/**
* Resolution, codecs and studio the things a viewer checks before settling in, kept out
* of the headline because none of them decide what to watch.
@@ -19,6 +19,17 @@ data class DetailPosition(
val zone: DetailZone = DetailZone.PLAY,
val episodeIndex: Int = 0,
val relatedIndex: Int = 0,
/**
* Which cast card was under focus, and how far the grid had been scrolled.
*
* Two numbers rather than one because a grid can be scrolled past its focused card and
* back: [castIndex] is what the viewer was standing on and is what focus returns to,
* [castScrollIndex] is the first visible row and is what stops the grid jumping to put
* that card at the top. Selecting an actor is the case this exists for a fifty-strong
* cast is several screens, and coming back to the top of it is coming back to nothing.
*/
val castIndex: Int = 0,
val castScrollIndex: Int = 0,
)
/**
@@ -0,0 +1,245 @@
package com.ponzischeme89.memby.ui.detail
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
import java.util.Locale
/**
* What a detail page is divided into, and which of those divisions this particular title
* has anything to put in.
*
* Everything here is pure. The strip is the one part of the page a viewer navigates
* blindly they learn where Cast is and press Right twice so the rules deciding what is
* in it and in what order have to be checkable without a television.
*/
/**
* The sections a detail page can show, in the order the strip lists them.
*
* The `key` is what [DetailPosition] remembers, so it outlives the enum's ordinal and must
* not be renamed casually. `cast-details` is deliberately absent: the combined pane it named
* has been split, and a key remembered from a build that had it resolves through
* [detailTab] to whatever the page now opens on.
*/
enum class DetailTab(val key: String, val label: String) {
/** A movie's landing pane: the full synopsis and the handful of facts worth leading with. */
OVERVIEW("overview", "Overview"),
/** A series' landing pane. The seasons and their episodes are what a show is *for*. */
EPISODES("episodes", "Episodes"),
CAST("cast", "Cast"),
/** Trailers, featurettes, deleted scenes — whatever Emby files as a special feature. */
EXTRAS("extras", "Extras"),
MORE_LIKE_THIS("more-like-this", "More Like This"),
/** Everything catalogue and technical: release, studios, writers, codecs. */
DETAILS("details", "Details"),
}
/**
* What this title actually has, as the strip needs to know it.
*
* Deliberately four booleans rather than the content itself: the availability rule is
* separable from the panes, and it is the half that has to be right the instant the page
* opens. Each flag carries its own answer for the "not known yet" case and they are not the
* same answer see [detailTabs].
*/
data class DetailTabAvailability(
val isSeries: Boolean,
val hasCast: Boolean = false,
val hasExtras: Boolean = false,
val hasRelated: Boolean = true,
val hasDetails: Boolean = true,
)
/**
* The strip for one item: its landing pane, then only the sections with something in them.
*
* A tab that opens onto an apology is worse than one that is not there it costs a keypress
* on every set in the house, for ever, to learn nothing. But the opposite failure is real
* too and was what the previous fixed strip existed to avoid: a tab appearing a second after
* the page opens shoves every tab to its right sideways under a viewer's thumb. Three rules
* keep both at bay:
*
* - **The landing pane is structural** and always first, so the tab under focus on the
* opening frame can never move: Episodes for a series, Overview for everything else.
* - **Optional tabs are only ever appended in this fixed order**, so one arriving can shift
* the tabs after it but can never reorder them.
* - **Each flag's "still loading" value is chosen by which way it is usually wrong.** Related
* titles are answered for nearly everything, so the caller passes `true` while the request
* is in flight and the pane says it is looking; extras exist for a small minority, so the
* caller passes `false` and the tab appears once one is actually found. The alternative in
* each case is a strip that flickers on the majority of titles.
*
* The caller freezes the result once focus has left the hero see `rememberDetailTabs`
* which is what stops any of this moving while somebody is reading it.
*/
fun detailTabs(availability: DetailTabAvailability): List<DetailTab> = buildList {
add(if (availability.isSeries) DetailTab.EPISODES else DetailTab.OVERVIEW)
if (availability.hasCast) add(DetailTab.CAST)
if (availability.hasExtras) add(DetailTab.EXTRAS)
if (availability.hasRelated) add(DetailTab.MORE_LIKE_THIS)
if (availability.hasDetails) add(DetailTab.DETAILS)
}
/**
* Resolves a remembered tab key against what is on offer, falling back to the page's landing
* pane.
*
* Three things reach here with a key that is not in the list and all of them are ordinary: a
* series' Episodes carried over to a movie, a tab whose content has since gone away, and
* `cast-details` remembered by a build that predates the split. [available] is never empty
* [detailTabs] always adds a landing pane but an empty list still has to answer something.
*/
fun detailTab(key: String, available: List<DetailTab>): DetailTab =
available.firstOrNull { it.key == key }
?: available.firstOrNull()
?: DetailTab.OVERVIEW
/**
* The cast, as the grid draws it: actors only, no duplicates, in Emby's billing order.
*
* Emby lists the same person twice on a good fraction of a real cast credited under two
* roles, or written twice by an agent and a keyed grid handed one key twice throws. The
* rule is `ui/ListKeys.kt`'s: deduplicate, never disambiguate, because folding the index in
* would key a card by where it is and position is exactly what has to survive returning to
* the page.
*
* Unbounded, unlike the rail this replaces: a grid that scrolls has no reason to stop at
* sixteen, and a large ensemble is precisely the case the grid exists for.
*/
fun castMembers(item: BaseItem): List<EmbyPerson> {
val seen = HashSet<String>()
return item.people.filter { person ->
person.isCastMember &&
person.name.isNotBlank() &&
seen.add(castKey(person))
}
}
/** The grid's key for one card: an id when Emby gave one, the name when it did not. */
fun castKey(person: EmbyPerson): String =
"${person.id}:${person.name.lowercase(Locale.US)}"
/**
* The catalogue half of the Details tab: what this title *is*, as label/value pairs.
*
* Kept apart from [technicalSpecs], which answers "will it play?", and from [creditRows],
* which is the short billing summary a pane leads with. Every row is omitted rather than
* printed empty a Details tab full of dashes says the library is broken.
*/
fun detailRows(item: BaseItem): List<TechnicalSpec> = buildList {
// The premiere date when Emby has one, because "12 Mar 2024" is strictly more than the
// year it contains; the year alone when it does not.
val released = formatAirDate(item.premiereDate) ?: item.productionYear?.toString()
released?.let { add(TechnicalSpec("Released", it)) }
item.runtimeMinutes?.let { add(TechnicalSpec("Runtime", formatRuntime(it))) }
item.officialRating?.takeIf(String::isNotBlank)?.let { add(TechnicalSpec("Certificate", it)) }
item.genres.filter(String::isNotBlank).takeIf(List<String>::isNotEmpty)
?.let { add(TechnicalSpec("Genres", it.joinToString(", "))) }
peopleJoined(item, "Director", limit = 3)?.let {
add(TechnicalSpec(if (it.contains(",")) "Directors" else "Director", it))
}
peopleJoined(item, "Writer", limit = 3)?.let {
add(TechnicalSpec(if (it.contains(",")) "Writers" else "Writer", it))
}
item.studios.map { it.name }.filter(String::isNotBlank).distinct().take(3)
.takeIf(List<String>::isNotEmpty)
?.let { add(TechnicalSpec(if (it.size > 1) "Studios" else "Studio", it.joinToString(", "))) }
// Only when it says something the heading does not. A title whose original name is its
// name is the common case, and printing it is a row that reads as a mistake.
item.originalTitle?.trim()
?.takeIf { it.isNotEmpty() && !it.equals(item.name.trim(), ignoreCase = true) }
?.let { add(TechnicalSpec("Original title", it)) }
item.productionLocations.filter(String::isNotBlank).distinct().take(3)
.takeIf(List<String>::isNotEmpty)
?.let { add(TechnicalSpec(if (it.size > 1) "Countries" else "Country", it.joinToString(", "))) }
audioLanguages(item)?.let {
add(TechnicalSpec(if (it.contains(",")) "Languages" else "Language", it))
}
}
private fun peopleJoined(item: BaseItem, type: String, limit: Int): String? =
item.people
.filter { it.type.equals(type, ignoreCase = true) }
.map { it.name }
.filter(String::isNotBlank)
.distinct()
.take(limit)
.takeIf(List<String>::isNotEmpty)
?.joinToString(", ")
/**
* The spoken languages a file carries, from its audio tracks.
*
* Emby writes three-letter codes; the ones a household actually meets are named and anything
* else is passed through uppercased, which is more use than dropping it. Deliberately not
* the subtitle tracks those are a count in [technicalSpecs], and a title with fourteen
* subtitle languages would fill this row with a paragraph.
*/
private fun audioLanguages(item: BaseItem): String? =
item.mediaStreams
.filter { it.type.equals("Audio", ignoreCase = true) }
.mapNotNull { it.language?.trim()?.takeIf(String::isNotEmpty) }
.map { code -> LANGUAGE_NAMES[code.lowercase(Locale.US)] ?: code.uppercase(Locale.US) }
.distinct()
.take(4)
.takeIf(List<String>::isNotEmpty)
?.joinToString(", ")
private val LANGUAGE_NAMES = mapOf(
"eng" to "English", "en" to "English",
"fra" to "French", "fre" to "French", "fr" to "French",
"deu" to "German", "ger" to "German", "de" to "German",
"spa" to "Spanish", "es" to "Spanish",
"ita" to "Italian", "it" to "Italian",
"jpn" to "Japanese", "ja" to "Japanese",
"kor" to "Korean", "ko" to "Korean",
"zho" to "Chinese", "chi" to "Chinese", "zh" to "Chinese",
"por" to "Portuguese", "pt" to "Portuguese",
"rus" to "Russian", "ru" to "Russian",
"nld" to "Dutch", "dut" to "Dutch", "nl" to "Dutch",
"swe" to "Swedish", "sv" to "Swedish",
"nor" to "Norwegian", "no" to "Norwegian",
"dan" to "Danish", "da" to "Danish",
"fin" to "Finnish", "fi" to "Finnish",
"pol" to "Polish", "pl" to "Polish",
"hin" to "Hindi", "hi" to "Hindi",
"ara" to "Arabic", "ar" to "Arabic",
"mri" to "Māori", "mao" to "Māori", "mi" to "Māori",
)
/**
* How an extra is described under its thumbnail "Trailer", "Behind the Scenes".
*
* Emby's `Type` on a special feature is one of a small vocabulary, and it is written in
* PascalCase; anything unrecognised is spaced out rather than dropped, so a category added
* to Emby tomorrow reads correctly on today's build.
*/
fun extraKindLabel(item: BaseItem): String = when (item.type.lowercase(Locale.US)) {
"trailer" -> "Trailer"
"teaser" -> "Teaser"
"clip" -> "Clip"
"featurette" -> "Featurette"
"short" -> "Short"
"deletedscene" -> "Deleted Scene"
"behindthescenes" -> "Behind the Scenes"
"interview" -> "Interview"
"scene" -> "Scene"
"sample" -> "Sample"
"themevideo", "thememedia" -> "Theme"
"" -> "Extra"
else -> spacedPascalCase(item.type)
}
private fun spacedPascalCase(raw: String): String {
val builder = StringBuilder(raw.length + 4)
raw.forEachIndexed { index, character ->
if (index > 0 && character.isUpperCase() && !raw[index - 1].isUpperCase()) builder.append(' ')
builder.append(character)
}
return builder.toString()
}
@@ -1,62 +1,142 @@
package com.ponzischeme89.memby.ui
import androidx.compose.ui.unit.dp
import com.ponzischeme89.memby.ui.detail.DetailPosition
import com.ponzischeme89.memby.ui.detail.DetailPositionStore
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailTabAvailability
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.detailTab
import com.ponzischeme89.memby.ui.detail.castColumns
import com.ponzischeme89.memby.ui.detail.detailTabs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The two rules the detail pages depend on and cannot check on a device: the tab strip is
* decided by what the item *is*, and a page's position survives being closed.
* The rules the detail pages depend on and cannot check on a device: what the tab strip
* offers, whether the hero is out of the way, and whether a page's position survives being
* closed.
*/
class DetailNavigationTest {
private val everything = DetailTabAvailability(
isSeries = false,
hasCast = true,
hasExtras = true,
hasRelated = true,
hasDetails = true,
)
@Test
fun `a movie always offers the same three tabs`() {
fun `a movie leads with overview and lists its sections in a fixed order`() {
assertEquals(
listOf(DetailTab.OVERVIEW, DetailTab.MORE_LIKE_THIS, DetailTab.CAST_DETAILS),
detailTabs(isSeries = false),
listOf(
DetailTab.OVERVIEW,
DetailTab.CAST,
DetailTab.EXTRAS,
DetailTab.MORE_LIKE_THIS,
DetailTab.DETAILS,
),
detailTabs(everything),
)
}
/** A show's synopsis is already in the hero, so Episodes leads and there is no Overview. */
@Test
fun `a series always offers episodes`() {
fun `a series leads with episodes`() {
assertEquals(
listOf(DetailTab.OVERVIEW, DetailTab.EPISODES, DetailTab.MORE_LIKE_THIS, DetailTab.CAST_DETAILS),
detailTabs(isSeries = true),
listOf(
DetailTab.EPISODES,
DetailTab.CAST,
DetailTab.EXTRAS,
DetailTab.MORE_LIKE_THIS,
DetailTab.DETAILS,
),
detailTabs(everything.copy(isSeries = true)),
)
}
/** A tab that would open onto an apology is not offered at all. */
@Test
fun `a section with nothing in it is left out`() {
assertEquals(
listOf(DetailTab.EPISODES, DetailTab.CAST, DetailTab.MORE_LIKE_THIS, DetailTab.DETAILS),
detailTabs(everything.copy(isSeries = true, hasExtras = false)),
)
assertEquals(
listOf(DetailTab.OVERVIEW, DetailTab.DETAILS),
detailTabs(everything.copy(hasCast = false, hasExtras = false, hasRelated = false)),
)
}
/**
* The regression this replaced: the strip used to be built from what had loaded, so a
* movie opened with one tab and grew two more when its metadata arrived moving the
* strip under whatever the viewer was already pressing.
* The landing pane is structural. Whatever else is missing, the tab under focus on the
* opening frame is there and is first so a section arriving late can shift the tabs
* after it but can never move the one the viewer is already standing on.
*/
@Test
fun `the strip does not depend on loaded metadata`() {
assertEquals(detailTabs(isSeries = false), detailTabs(isSeries = false))
assertEquals(detailTabs(isSeries = true), detailTabs(isSeries = true))
fun `the landing pane is always present and always first`() {
val nothing = DetailTabAvailability(
isSeries = false,
hasCast = false,
hasExtras = false,
hasRelated = false,
hasDetails = false,
)
assertEquals(listOf(DetailTab.OVERVIEW), detailTabs(nothing))
assertEquals(listOf(DetailTab.EPISODES), detailTabs(nothing.copy(isSeries = true)))
assertEquals(DetailTab.OVERVIEW, detailTabs(everything).first())
assertEquals(DetailTab.EPISODES, detailTabs(everything.copy(isSeries = true)).first())
}
/** Optional tabs are only appended, so their relative order never changes. */
@Test
fun `optional tabs keep their order as they appear`() {
val order = detailTabs(everything)
listOf(
everything.copy(hasCast = false),
everything.copy(hasExtras = false),
everything.copy(hasRelated = false),
everything.copy(hasDetails = false),
).forEach { partial ->
val offered = detailTabs(partial)
assertEquals(offered, order.filter { it in offered })
}
}
@Test
fun `an episodes key remembered from a series falls back on a movie`() {
assertEquals(
DetailTab.OVERVIEW,
detailTab(DetailTab.EPISODES.key, detailTabs(isSeries = false)),
)
assertEquals(DetailTab.OVERVIEW, detailTab(DetailTab.EPISODES.key, detailTabs(everything)))
assertEquals(
DetailTab.EPISODES,
detailTab(DetailTab.EPISODES.key, detailTabs(isSeries = true)),
detailTab(DetailTab.EPISODES.key, detailTabs(everything.copy(isSeries = true))),
)
}
/** A build that predates the split remembered one key for what is now two tabs. */
@Test
fun `an unknown key falls back to overview`() {
assertEquals(DetailTab.OVERVIEW, detailTab("nonsense", detailTabs(isSeries = true)))
fun `the retired cast-details key resolves to the landing pane`() {
assertEquals(DetailTab.OVERVIEW, detailTab("cast-details", detailTabs(everything)))
assertEquals(
DetailTab.EPISODES,
detailTab("cast-details", detailTabs(everything.copy(isSeries = true))),
)
}
/** A tab whose content has since gone away is the same case as one that never existed. */
@Test
fun `an unknown key falls back to whatever the page leads with`() {
assertEquals(DetailTab.OVERVIEW, detailTab("nonsense", detailTabs(everything)))
assertEquals(DetailTab.OVERVIEW, detailTab(DetailTab.EXTRAS.key, listOf()))
}
/** Two tabs must never share a key: the key is what a reopened page is restored by. */
@Test
fun `every tab key is distinct`() {
val keys = DetailTab.entries.map(DetailTab::key)
assertEquals(keys.size, keys.distinct().size)
}
@Test
@@ -87,26 +167,52 @@ class DetailNavigationTest {
assertEquals(null, position.season)
}
/**
* The whole hero/tab relationship. Focus on Play is the complete opening frame; anywhere
* below it and the hero is out of the way with the strip at the top of the usable area.
*/
@Test
fun `play focus pins the complete hero while lower zones release scrolling`() {
assertEquals(0, detailHeroScrollTarget(DetailZone.PLAY))
assertEquals(null, detailHeroScrollTarget(DetailZone.TABS))
assertEquals(null, detailHeroScrollTarget(DetailZone.CONTENT))
assertEquals(null, detailHeroScrollTarget(DetailZone.RELATED))
fun `the hero is whole on play and collapsed everywhere below it`() {
assertFalse(detailHeroCollapsed(DetailZone.PLAY))
assertTrue(detailHeroCollapsed(DetailZone.TABS))
assertTrue(detailHeroCollapsed(DetailZone.CONTENT))
assertTrue(detailHeroCollapsed(DetailZone.RELATED))
}
/**
* A whole number of sensibly sized cards at every television width this ships to, and a
* count that never drops below one a grid asked for zero columns crashes.
*
* The bounds are passed in rather than taken from the defaults so this pins the *rule*
* and not the tuning: changing how wide a cast card is must not need this edited, but a
* change that starts producing cards outside the range it was asked for must fail.
*/
@Test
fun `the cast grid divides the width into whole cards`() {
val min = 112.dp
val max = 150.dp
listOf(0, 100, 480, 720, 840, 960, 1180, 1280, 1600, 1920).forEach { width ->
val columns = castColumns(width.dp, min, max)
assertTrue("$width dp gave $columns columns", columns >= 1)
if (width.dp >= min) {
val card = (width.dp - 16.dp * (columns - 1)) / columns
assertTrue("$width dp gave cards of $card", card >= min && card <= max)
}
}
}
@Test
fun `the store is capped and keeps the most recently used`() {
val store = DetailPositionStore(maxEntries = 3)
listOf("a", "b", "c").forEach { id ->
store.update(id) { it.copy(tabKey = DetailTab.CAST_DETAILS.key) }
store.update(id) { it.copy(tabKey = DetailTab.CAST.key) }
}
// Touching "a" makes it the newest, so "b" is what falls out.
store.get("a")
store.update("d") { it.copy(tabKey = DetailTab.MORE_LIKE_THIS.key) }
assertEquals(3, store.size())
assertEquals(DetailTab.CAST_DETAILS.key, store.get("a").tabKey)
assertEquals(DetailTab.CAST.key, store.get("a").tabKey)
assertEquals(DetailTab.OVERVIEW.key, store.get("b").tabKey)
assertEquals(DetailTab.MORE_LIKE_THIS.key, store.get("d").tabKey)
}
@@ -114,7 +220,7 @@ class DetailNavigationTest {
@Test
fun `a blank item id is never stored`() {
val store = DetailPositionStore()
store.update("") { it.copy(tabKey = DetailTab.CAST_DETAILS.key) }
store.update("") { it.copy(tabKey = DetailTab.CAST.key) }
assertEquals(0, store.size())
}
@@ -24,6 +24,7 @@ import com.ponzischeme89.memby.data.model.Studio
import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.creditRows
import com.ponzischeme89.memby.ui.detail.detailRows
import com.ponzischeme89.memby.ui.detail.technicalSpecs
import org.junit.Before
import org.junit.Rule
@@ -171,9 +172,14 @@ class DetailPageScreenshotTest {
}
}
/**
* The collapsed hero. Clicking a tab is the same path a remote takes, so every one of
* these captures is also the check that the hero has got out of the way and the strip
* has moved to the top of the usable area which is what the whole redesign is for.
*/
@Test
fun `series cast tab`() {
captureTab("df_detail-series-cast-details", "Cast & Details") {
captureTab("df_detail-series-cast", "Cast") {
SeriesDetailContent(
item = series,
episodes = episodes,
@@ -187,8 +193,22 @@ class DetailPageScreenshotTest {
}
}
/** A large ensemble: the case the grid exists for, and the one a rail could not show. */
@Test
fun `movie details tab`() {
fun `movie cast tab with a large ensemble`() {
captureTab("df_detail-movie-cast-large", "Cast") {
MediaDetailContent(
item = movie.copy(people = largeCast),
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
related = related,
)
}
}
@Test
fun `movie more like this tab`() {
captureTab("df_detail-movie-more-like-this", "More Like This") {
MediaDetailContent(
item = movie,
@@ -200,18 +220,45 @@ class DetailPageScreenshotTest {
}
}
@Test
fun `movie details tab`() {
captureTab("df_detail-movie-details", "Details") {
MediaDetailContent(
item = movie,
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
related = related,
)
}
}
/** Only offered when there is something in it, which is why the fixture supplies some. */
@Test
fun `movie extras tab`() {
captureTab("df_detail-movie-extras", "Extras") {
MediaDetailContent(
item = movie,
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
related = related,
extras = extras,
)
}
}
/**
* The tab panes at the geometry the scaffold gives them on a 540dp TV the slot the
* audit found clipping every technical spec off the bottom of Cast & Details, which is
* the whole reason that tab exists. A tab does not scroll, so everything it has to say
* has to be inside this box.
* The tab panes at the geometry the scaffold gives them on a 540dp TV. A tab does not
* scroll vertically except where it is a grid, so everything a pane has to say has to be
* inside this box the audit that produced the Details tab found every technical spec
* clipped off the bottom of the pane this replaced.
*/
@Test
fun `cast and details pane at its real slot geometry`() {
capturePane("df_detail-pane-cast-details") {
DetailCastAndDetailsPane(
item = movie,
credits = creditRows(movie),
fun `details pane at its real slot geometry`() {
capturePane("df_detail-pane-details") {
DetailDetailsPane(
rows = detailRows(movie),
specs = technicalSpecs(movie),
detailsLoaded = true,
focusRequester = FocusRequester(),
@@ -223,10 +270,9 @@ class DetailPageScreenshotTest {
fun `overview pane at its real slot geometry`() {
capturePane("df_detail-pane-overview") {
DetailOverviewPane(
item = series,
credits = creditRows(series),
item = movie,
credits = creditRows(movie),
focusRequester = FocusRequester(),
supportingText = "Up next S1 E3 · The Long Count",
)
}
}
@@ -299,6 +345,33 @@ class DetailPageScreenshotTest {
person("Daniel Cho", "Weatherman"),
)
/**
* Thirty-one names, one of them with no role recorded. The grid has to stay on its
* baselines through both a card missing its character line used to sit a row higher
* than the ones beside it.
*/
private val largeCast = List(31) { index ->
person(
name = "${FIRST_NAMES[index % FIRST_NAMES.size]} ${LAST_NAMES[index % LAST_NAMES.size]}",
role = if (index == 4) "" else "${ROLES[index % ROLES.size]} ${index + 1}",
)
}
private val extras = listOf(
extra("extra-1", "Official Trailer", "Trailer", 2),
extra("extra-2", "Four Winters in the Valley", "Featurette", 21),
extra("extra-3", "The River That Was", "BehindTheScenes", 14),
extra("extra-4", "Deleted: The Second Crossing", "DeletedScene", 4),
extra("extra-5", "Interview with the Cartographer", "Interview", 11),
)
private fun extra(id: String, name: String, type: String, minutes: Int) = BaseItem(
id = id,
name = name,
type = type,
runTimeTicks = minutes * 600_000_000L,
)
private val streams = listOf(
MediaStream(
type = "Video",
@@ -383,4 +456,16 @@ class DetailPageScreenshotTest {
role = role,
type = "Actor",
)
private companion object {
val FIRST_NAMES = listOf(
"Aria", "Marcus", "Nina", "Tomas", "Ines", "Daniel", "Rangi", "Hanna",
"Petra", "Osian", "Mei", "Levi", "Sofia", "Amara", "Juno", "Kofi",
)
val LAST_NAMES = listOf(
"Vance", "Oyelaran", "Kowalczyk", "Brandt", "Ferreira", "Cho", "Whitiora",
"Lindqvist", "Novak", "Ellery", "Tanaka", "Marchetti", "Okonjo",
)
val ROLES = listOf("Detective", "Doctor", "Captain", "Surveyor", "Radio Operator")
}
}
@@ -0,0 +1,129 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyPalette
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.applyMembyPalette
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Renders the "Close Memby?" panel to PNGs under `build/screenshots/exit-confirmation/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*ExitConfirmationScreenshotTest"
* ```
*
* It is drawn over a stand-in launcher rather than over black, because the whole point of
* the wash behind it is that the library stays faintly readable underneath a capture on
* a blank background would prove nothing about the one thing worth looking at.
*
* The second capture repaints under a different palette. This panel is the only full-stop
* dialog in the app and it is now drawn entirely from the tokens, so a theme that cannot
* reach it would be visible here and nowhere else.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class ExitConfirmationScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `over the launcher`() {
capture("exit-confirmation")
}
/**
* The destructive action under the focus ring the one state a remote can reach that
* the default capture cannot show, and the one worth checking reads as deliberate
* rather than as the safe choice.
*/
@Test
fun `closing under focus`() {
capture("exit-confirmation-close-focused", focused = ExitChoice.CLOSE)
}
/** How the panel opens: the safe answer already holding the ring. */
@Test
fun `staying under focus`() {
capture("exit-confirmation-stay-focused", focused = ExitChoice.STAY)
}
@Test
fun `under a themed palette`() {
applyMembyPalette(
MembyPalette(
surface = androidx.compose.ui.graphics.Color(0xFF120A16),
surfaceRaised = androidx.compose.ui.graphics.Color(0xFF1D1224),
accent = androidx.compose.ui.graphics.Color(0xFFE0803A),
),
)
try {
capture("exit-confirmation-themed")
} finally {
applyMembyPalette(MembyPalette())
}
}
private fun capture(name: String, focused: ExitChoice? = null) {
compose.setContent {
Box(Modifier.fillMaxSize().background(MembySurface)) {
StandInLauncher()
// Settled rather than mid-entrance: the frame worth looking at is the one
// somebody actually reads.
ExitMembyConfirmation(
onStay = {},
onExit = {},
animateIn = false,
// Robolectric's window never takes focus, so a capture that pressed
// its way onto a button would photograph every action unfocused.
focusedForCapture = focused,
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/exit-confirmation/$name.png")
}
/** Enough of a shelf behind the wash to judge how much of it still shows through. */
@Composable
private fun StandInLauncher() {
Column(Modifier.fillMaxWidth().padding(40.dp)) {
Box(
Modifier.width(220.dp).height(26.dp)
.clip(RoundedCornerShape(6.dp)).background(MembyControlSurface),
)
Box(Modifier.height(24.dp))
Row {
repeat(6) {
Box(
Modifier.padding(end = 18.dp).width(160.dp).height(240.dp)
.clip(RoundedCornerShape(10.dp)).background(MembyControlSurface),
)
}
}
}
}
}