0.2.67 - Detail pages improvements
This commit is contained in:
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user