Fix TV artwork, episode layout and focus visibility
This commit is contained in:
parent
34087d8eb5
commit
9d2471c5b1
28 changed files with 466 additions and 117 deletions
|
|
@ -50,8 +50,11 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
|
|||
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
|
||||
}) else null }
|
||||
val context = LocalContext.current
|
||||
val request = remember(context, path, poster) {
|
||||
WolfskinImages.request(context, path, if (poster) 256 else 1280, if (poster) 384 else 720, poster)
|
||||
val request = remember(context, path, poster, dim) {
|
||||
val upright = poster || dim
|
||||
WolfskinImages.request(context, path,
|
||||
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
|
||||
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
|
||||
}
|
||||
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
|
||||
else AsyncImage(
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
|||
add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero)
|
||||
if (!state.hasCatalogue) add(L.row_state) else {
|
||||
if (note) add(L.row_note)
|
||||
state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
|
||||
state.lanes.forEach { lane -> add(D.shelfHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -22,15 +19,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
|
||||
|
|
@ -47,27 +42,20 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
|||
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
|
||||
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0))
|
||||
val active = state.contentFocused && state.focusedRow == row
|
||||
val slots = ((L.contentWidth + K.posterGap) / K.posterPitch).toInt()
|
||||
val slots = K.posterSlots
|
||||
val first = state.shell.view.cards["first:${lane.id}"] ?: 0
|
||||
var initialized by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(column, count) {
|
||||
var target = first
|
||||
if (column > target + slots - 2) target = column - (slots - 2)
|
||||
if (column < target + 1) target = (column - 1).coerceAtLeast(0)
|
||||
target = target.coerceIn(0, (count - slots).coerceAtLeast(0))
|
||||
val density = LocalDensity.current
|
||||
LaunchedEffect(column, count, active) {
|
||||
val target = shelfFirst(column, first, slots, count)
|
||||
state.shell.view.cards["first:${lane.id}"] = target
|
||||
val offset = cards.layoutInfo.visibleItemsInfo.firstOrNull { it.index == target }?.offset
|
||||
if (initialized && offset != null) {
|
||||
if (offset != 0) cards.animateScrollBy(offset.toFloat(), tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
||||
} else cards.scrollToItem(target)
|
||||
initialized = true
|
||||
cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() })
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
|
||||
Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
|
||||
Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = DiscoverType.rowHeading, maxLines = 1)
|
||||
LazyRow(state = cards, userScrollEnabled = false,
|
||||
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
|
||||
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
|
||||
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
|
||||
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
|
||||
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
|
||||
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
|
||||
|
|
@ -97,7 +85,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
|||
Caption(
|
||||
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
|
||||
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
|
||||
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), L.rowHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth),
|
||||
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth),
|
||||
accentFirstFact = lane.hidden,
|
||||
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -107,9 +107,5 @@ internal val HomeRow.focusSlots: Int get() = when (cardKind) {
|
|||
}
|
||||
|
||||
/** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */
|
||||
internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int {
|
||||
var first = previous
|
||||
if (column > first + focusSlots - 2) first = column - (focusSlots - 2)
|
||||
if (column < first + 1) first = (column - 1).coerceAtLeast(0)
|
||||
return first.coerceIn(0, (count - focusSlots).coerceAtLeast(0))
|
||||
}
|
||||
internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int =
|
||||
shelfFirst(column, previous, focusSlots, count)
|
||||
|
|
|
|||
|
|
@ -61,25 +61,17 @@ fun HomeScreen(
|
|||
LaunchedEffect(view.row, snapshot.rows) {
|
||||
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) }
|
||||
}
|
||||
LaunchedEffect(view) {
|
||||
if (view.home.positionedKey != null) {
|
||||
view.home.focusedKey?.let { view.home.tracks[it] }?.let { track ->
|
||||
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
|
||||
}
|
||||
view.listState.scrollToItem(view.listState.firstVisibleItemIndex, view.listState.firstVisibleItemScrollOffset)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) {
|
||||
LaunchedEffect(view, view.row, view.home.resetScroll, heights) {
|
||||
val key = snapshot.rows.getOrNull(view.row)?.id
|
||||
if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect
|
||||
val topRequested = shell.navFocused && view.home.resetScroll > 0 && view.home.positionedReset != view.home.resetScroll
|
||||
val target = if (topRequested) 0f else with(density) {
|
||||
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
|
||||
}.coerceAtLeast(0f)
|
||||
view.home.positionedKey = key
|
||||
view.home.positionedReset = view.home.resetScroll
|
||||
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
|
||||
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
|
||||
view.home.positionedKey = key
|
||||
view.home.positionedReset = view.home.resetScroll
|
||||
}
|
||||
val cache = view.home.ratings
|
||||
val ratingsReader = LocalScreenRatings.current
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ package org.justinvisser.wolfskin.design
|
|||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
|
|
@ -16,9 +14,8 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
|
|
@ -43,18 +40,12 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
|
|||
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
|
||||
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
|
||||
val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
|
||||
val targetFirst by rememberUpdatedState(first)
|
||||
val density = LocalDensity.current
|
||||
LaunchedEffect(track) {
|
||||
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
|
||||
var positionedFirst = targetFirst
|
||||
snapshotFlow { targetFirst }.collectLatest { target ->
|
||||
if (target != positionedFirst) {
|
||||
positionedFirst = target
|
||||
val pitch = with(density) { (width + K.posterGap).toPx() }
|
||||
track.animateScrollBy((target - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base))
|
||||
}
|
||||
}
|
||||
var positioned by remember(track) { mutableStateOf(false) }
|
||||
LaunchedEffect(track, first, focused, width, density) {
|
||||
if (!positioned) track.scrollToItem(first)
|
||||
else track.scrollShelfTo(first, with(density) { (width + K.posterGap).toPx() })
|
||||
positioned = true
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
|
||||
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
|
|||
Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
|
||||
}
|
||||
itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row ->
|
||||
Row(Modifier.height(D.gridHeight).padding(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||
Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||
row.forEachIndexed { column, item ->
|
||||
val index = rowIndex * columns + column
|
||||
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
|
||||
/** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */
|
||||
internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int {
|
||||
var first = previous
|
||||
if (column > first + slots - 2) first = column - (slots - 2)
|
||||
if (column < first + 1) first = (column - 1).coerceAtLeast(0)
|
||||
return first.coerceIn(0, (count - slots).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */
|
||||
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
|
||||
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
|
||||
if (distance != 0f) animateScrollBy(distance, tween(WolfskinTokens.Motion.base,
|
||||
easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
||||
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
|
||||
}
|
||||
|
|
@ -98,14 +98,14 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
|
|||
for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat()
|
||||
ColorFilter.colorMatrix(matrix)
|
||||
}
|
||||
TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight,
|
||||
TitleArtwork(item.poster, WolfskinTokens.Image.heroPosterRequestWidth, WolfskinTokens.Image.heroPosterRequestHeight,
|
||||
Modifier.fillMaxSize().artOverscan(D.ambientInset).graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }
|
||||
.blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter)
|
||||
val posterShape = remember { RoundedCornerShape(WolfskinTokens.Radius.heroSheet) }
|
||||
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
|
||||
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
|
||||
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
|
||||
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape))
|
||||
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape), poster = true)
|
||||
}
|
||||
Box(Modifier.fillMaxSize().background(horizontal))
|
||||
Box(Modifier.fillMaxSize().background(vertical))
|
||||
|
|
@ -131,13 +131,13 @@ fun TitleTileArtwork(item: TitleItem) {
|
|||
val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply {
|
||||
for (index in 0 until 15) values[index] *= D.tileAmbientBrightness
|
||||
}) }
|
||||
TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter)
|
||||
val height = WolfskinTokens.Card.tileH - D.tileSheetInset * 2
|
||||
TitleArtwork(item.poster, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter)
|
||||
val height = D.episodeCardHeight - D.tileSheetInset * 2
|
||||
val shape = remember { RoundedCornerShape(D.tileSheetRadius) }
|
||||
val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) }
|
||||
TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(),
|
||||
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true)
|
||||
} else TitleArtwork(item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize())
|
||||
} else TitleArtwork(item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
|||
fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) {
|
||||
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
|
||||
val item = state.data.item
|
||||
val episode = when {
|
||||
state.row == "episodes" -> state.data.episodes.getOrNull(state.column)
|
||||
state.data.watchTarget.kind == "episode" -> state.data.watchTarget
|
||||
else -> state.data.playTarget?.takeIf { it.kind == "episode" }
|
||||
}
|
||||
val type = if (item.kind == "movie") "Movie" else "Series"
|
||||
CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
|
||||
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
|
|
@ -63,7 +68,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine)
|
||||
}
|
||||
}
|
||||
Text(item.overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
|
||||
Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
|
||||
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
|
||||
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
||||
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.design
|
|||
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -90,6 +91,10 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
val list = shell.view.listState
|
||||
var wheelPending by remember { mutableStateOf(false) }
|
||||
val focus = remember { FocusRequester() }
|
||||
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
||||
val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held &&
|
||||
state.data.item.kind == "tv" && state.data.item.seasons > 0
|
||||
val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead
|
||||
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
|
||||
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
||||
}
|
||||
|
|
@ -105,19 +110,19 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
if (state.data.related.isNotEmpty()) add("related")
|
||||
}
|
||||
}
|
||||
val rowOffsets = remember(displayedRows, density) {
|
||||
val rowOffsets = remember(displayedRows, density, headerHeight) {
|
||||
FloatArray(displayedRows.size + 1).apply {
|
||||
displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row).toPx() } }
|
||||
displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row, headerHeight).toPx() } }
|
||||
}
|
||||
}
|
||||
val position by remember(list, rowOffsets) { derivedStateOf {
|
||||
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
|
||||
} }
|
||||
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows) {
|
||||
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) {
|
||||
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
|
||||
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
|
||||
val y = rowOffsets[index]
|
||||
val height = with(density) { titleRowHeight(state.row).toPx() }
|
||||
val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() }
|
||||
val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f
|
||||
else y - with(density) { L.pinnedLine.toPx() }
|
||||
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
|
||||
|
|
@ -148,7 +153,11 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
|
||||
val active = state.row == id && !shell.navFocused && state.sheet == null
|
||||
when (id) {
|
||||
"header" -> Box(Modifier.height(L.row_dhead)) { TitleHeader(state, active) }
|
||||
"header" -> Box(Modifier.height(headerHeight)) {
|
||||
TitleHeader(state, active, if (compactSeries) Modifier.onSizeChanged { size ->
|
||||
measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap
|
||||
} else Modifier)
|
||||
}
|
||||
"seasons" -> TitleSeasons(state, active)
|
||||
"episodes" -> TitleMediaShelf(state, true, active)
|
||||
"cast" -> TitleCast(state, active)
|
||||
|
|
@ -156,16 +165,16 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
}
|
||||
} }
|
||||
item(key = "end-padding", contentType = "spacer") {
|
||||
Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last())).coerceAtLeast(D.minimumEndPadding)))
|
||||
Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last(), headerHeight)).coerceAtLeast(D.minimumEndPadding)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun titleRowHeight(row: String): Dp = when (row) {
|
||||
"header" -> L.row_dhead
|
||||
private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) {
|
||||
"header" -> headerHeight
|
||||
"seasons" -> L.row_chips
|
||||
"episodes" -> L.rowHeadingBand + WolfskinTokens.Card.tileH + L.rowGapAfter
|
||||
"episodes" -> L.rowHeadingBand + D.episodeCardHeight + L.rowGapAfter
|
||||
"cast" -> D.castHeight
|
||||
else -> L.rowHeadingBand + WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + L.rowGapAfter
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ internal fun TitleRowPrefetch(data: TitlePageData, row: String?) {
|
|||
queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight)
|
||||
"episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) {
|
||||
val item = data.episodes[index]
|
||||
queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight)
|
||||
queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight)
|
||||
}
|
||||
"related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots))
|
||||
queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ package org.justinvisser.wolfskin.design
|
|||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.background
|
||||
|
|
@ -40,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Text
|
||||
import kotlin.math.roundToInt
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
|
@ -50,7 +49,9 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
|||
val track = rememberLazyListState()
|
||||
val focusOwner = LocalTitleFocusOwner.current
|
||||
val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() }
|
||||
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) }
|
||||
LaunchedEffect(state.column, active, state.data.cast.size) {
|
||||
if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size)
|
||||
}
|
||||
Box(Modifier.height(D.castHeight)) {
|
||||
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2),
|
||||
|
|
@ -81,11 +82,13 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
|||
val titles = if (episodes) state.data.episodes else state.data.related
|
||||
val id = if (episodes) "episodes" else "related"
|
||||
val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
|
||||
val height = if (episodes) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
|
||||
val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH
|
||||
val track = rememberLazyListState()
|
||||
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
|
||||
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() }
|
||||
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) }
|
||||
LaunchedEffect(state.column, active, titles.size) {
|
||||
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
|
||||
}
|
||||
Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
|
||||
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
|
||||
|
|
@ -114,7 +117,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
|
|||
val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } }
|
||||
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
|
||||
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
|
||||
} else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
} else Box(Modifier.size(WolfskinTokens.Card.tileW, D.episodeCardHeight).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
|
||||
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
|
||||
TitleTileArtwork(item)
|
||||
|
|
@ -122,6 +125,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
|
|||
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
|
||||
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
val progress = item.progress
|
||||
if (progress > 0f && progress < 1f) {
|
||||
|
|
@ -155,15 +159,8 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
|
|||
}
|
||||
}
|
||||
|
||||
/** Keep a full lookahead card, but do not recenter a row when reversing direction. */
|
||||
private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float) {
|
||||
val first = firstVisibleItemIndex
|
||||
val next = when {
|
||||
column < first -> column
|
||||
column >= first + slots - 1 -> (column - slots + 2).coerceAtLeast(0)
|
||||
else -> first
|
||||
}
|
||||
if (next != first) animateScrollBy((next - first) * pitch - firstVisibleItemScrollOffset, titleTrackScrollSpec)
|
||||
/** Apply the same lookahead and settled-offset rule to cast, episodes and related shelves. */
|
||||
private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float, count: Int) {
|
||||
val previous = firstVisibleItemIndex + (firstVisibleItemScrollOffset / pitch).roundToInt()
|
||||
scrollShelfTo(shelfFirst(column, previous, slots, count), pitch)
|
||||
}
|
||||
|
||||
private val titleTrackScrollSpec = tween<Float>(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))
|
||||
|
|
|
|||
|
|
@ -48,12 +48,12 @@ fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height
|
|||
.requiredSize(width + inset * 2, height + inset * 2)
|
||||
.blur(if (hero) H.heroBlur else H.cardBlur)
|
||||
.graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } }
|
||||
SlotImage(poster, width, height, washModifier, colorFilter = wash, onError = { failed.value = true })
|
||||
SlotImage(poster, if (hero) WolfskinTokens.Layout.heroSheetWidth else width, if (hero) WolfskinTokens.Layout.heroSheetHeight else height, washModifier, colorFilter = wash, onError = { failed.value = true })
|
||||
val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset
|
||||
val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f)
|
||||
val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop)
|
||||
SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true })
|
||||
} else SlotImage(source, if (hero) WolfskinTokens.Card.posterW * 5 else width, if (hero) WolfskinTokens.Card.posterW * 5 * (9f / 16f) else height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true })
|
||||
} else SlotImage(source, width, height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true })
|
||||
} else if (!hero) MediaArtworkFallback(title, type == "movie", showTitle = !landscape)
|
||||
else HomeAmbient(Modifier.fillMaxSize())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,10 +52,9 @@ private fun PeoplePicker(state: ShellState) {
|
|||
}
|
||||
}
|
||||
}
|
||||
val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1)
|
||||
val left = (S.stageWidth - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - L.gear) / 2
|
||||
val left = (S.stageWidth - L.gear) / 2
|
||||
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarRing)
|
||||
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing)
|
||||
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
|
||||
Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
|
||||
if (state.gearFocused) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import coil3.ComponentRegistry
|
||||
import coil3.ImageLoader
|
||||
import coil3.disk.DiskCache
|
||||
|
|
@ -10,6 +11,7 @@ import coil3.request.crossfade
|
|||
import coil3.memory.MemoryCache
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.allowRgb565
|
||||
import coil3.size.Scale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
|
|
@ -61,7 +63,7 @@ object WolfskinImages {
|
|||
}
|
||||
|
||||
fun request(context: Context, source: Any, width: Int, height: Int, poster: Boolean): ImageRequest =
|
||||
ImageRequest.Builder(context).data(source).size(width, height).allowRgb565(poster).build()
|
||||
ImageRequest.Builder(context).data(sourceForSize(source, width, height)).size(width, height).scale(Scale.FIT).allowRgb565(poster).build()
|
||||
|
||||
fun prefetch(context: Context, row: HomeRow) {
|
||||
val count = when (row.cardKind) { HomeCardKind.Poster -> 7; HomeCardKind.Tile -> 5; HomeCardKind.Wide -> 4 }
|
||||
|
|
@ -73,4 +75,35 @@ object WolfskinImages {
|
|||
if (source != null) loader(context).enqueue(request(context, source, width, height, poster))
|
||||
}
|
||||
}
|
||||
|
||||
private val jellyfinImagePath = Regex("/Items/[^/]+/Images/[^/]+(?:/\\d+)?", RegexOption.IGNORE_CASE)
|
||||
private val tmdbSizePath = Regex("/t/p/(?:w\\d+|original)/")
|
||||
private val dimensionParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight")
|
||||
|
||||
private fun sourceForSize(source: Any, width: Int, height: Int): Any {
|
||||
if (source !is String) return source
|
||||
val uri = Uri.parse(source)
|
||||
if (uri.scheme != "https" && uri.scheme != "http") return source
|
||||
if (uri.host == "image.tmdb.org") {
|
||||
val size = if (height > width) when {
|
||||
width <= 500 -> "w500"
|
||||
width <= 780 -> "w780"
|
||||
else -> "original"
|
||||
} else when {
|
||||
width <= 780 -> "w780"
|
||||
width <= 1280 -> "w1280"
|
||||
else -> "original"
|
||||
}
|
||||
return uri.buildUpon().path(uri.path?.replace(tmdbSizePath, "/t/p/$size/")).build().toString()
|
||||
}
|
||||
if (!jellyfinImagePath.matches(uri.path.orEmpty())) return source
|
||||
val builder = uri.buildUpon().clearQuery()
|
||||
for (name in uri.queryParameterNames) {
|
||||
if (name.lowercase() !in dimensionParameters) {
|
||||
uri.getQueryParameters(name).forEach { value -> builder.appendQueryParameter(name, value) }
|
||||
}
|
||||
}
|
||||
return builder.appendQueryParameter("fillWidth", width.toString())
|
||||
.appendQueryParameter("fillHeight", height.toString()).build().toString()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -224,8 +224,12 @@ object WolfskinTokens {
|
|||
const val tileRequestHeight = 234
|
||||
const val wideRequestWidth = 560
|
||||
const val wideRequestHeight = 315
|
||||
const val heroRequestWidth = 1280
|
||||
const val heroRequestHeight = 720
|
||||
const val heroRequestWidth = 1920
|
||||
const val heroRequestHeight = 1080
|
||||
const val heroPosterRequestWidth = 420
|
||||
const val heroPosterRequestHeight = 630
|
||||
const val episodeRequestWidth = 400
|
||||
const val episodeRequestHeight = 280
|
||||
const val castRequestWidth = 200
|
||||
const val castRequestHeight = 200
|
||||
const val memoryCacheMB = 64.0f
|
||||
|
|
@ -416,6 +420,8 @@ object WolfskinTokens {
|
|||
const val sheetOpacity = 0.5f
|
||||
}
|
||||
object Title {
|
||||
val episodeCardHeight = 140.0.dp
|
||||
val seriesHeaderGap = 20.0.dp
|
||||
val lineGap = 11.0.dp
|
||||
val lineLift = -2.0.dp
|
||||
val overviewGap = 17.0.dp
|
||||
|
|
@ -609,6 +615,7 @@ object WolfskinTokens {
|
|||
val posterProgressBottom = 6.0.dp
|
||||
}
|
||||
object Discover {
|
||||
val shelfHeadingBand = 52.0.dp
|
||||
val sheetPaddingX = 32.0.dp
|
||||
val sheetPaddingY = 40.0.dp
|
||||
val sheetGap = 9.0.dp
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertTrue
|
||||
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
|
||||
|
||||
/** Row ends, mixed card widths and real title Back navigation keep the selected art inside the view. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class HomeViewportTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun rowEndsRowChangesAndTitleBackPreserveTheFocusedCardMargin() {
|
||||
val cards = HomeFixture(rule.activity).snapshot("justin").rows.flatMap { it.cards }.distinctBy { it.id }.take(12)
|
||||
val rows = HomeCardKind.entries.flatMap { kind ->
|
||||
val slots = when (kind) { HomeCardKind.Poster -> 6; HomeCardKind.Tile -> 4; HomeCardKind.Wide -> 3 }
|
||||
listOf(slots, cards.size).map { count -> HomeRow("${kind.name}-$count", HomeRowKind.Shelf,
|
||||
"${kind.name} shelf", cards.take(count), kind) }
|
||||
}
|
||||
val snapshot = HomeSnapshot(rows)
|
||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
|
||||
var title by mutableStateOf<TitleState?>(null)
|
||||
rule.setContent { WolfskinTheme {
|
||||
val page = title
|
||||
if (page != null) TitleSurface(page, shell)
|
||||
else ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action ->
|
||||
if (action is HomeAction.Details) {
|
||||
shell.enterDestination("title/viewport")
|
||||
title = TitleState(TitlePageData(TitleItem(action.title.id, action.title.title, "movie", held = true)), shell.person) { effect ->
|
||||
if (effect == TitleAction.Pop) { shell.enterDestination("home"); title = null }
|
||||
}
|
||||
}
|
||||
} }
|
||||
} }
|
||||
for ((index, row) in rows.withIndex()) {
|
||||
if (index > 0) press(Key.DirectionDown)
|
||||
assertVisible(shell, rows)
|
||||
repeat(row.count) { press(Key.DirectionLeft) }
|
||||
assertVisible(shell, rows)
|
||||
repeat(row.count - 1) { press(Key.DirectionRight) }
|
||||
assertVisible(shell, rows)
|
||||
capture("viewport-${row.id}")
|
||||
press(Key.DirectionCenter)
|
||||
rule.onNodeWithTag("title-page").assertIsDisplayed()
|
||||
press(Key.Back)
|
||||
assertVisible(shell, rows)
|
||||
}
|
||||
repeat(rows.lastIndex) { press(Key.DirectionUp); assertVisible(shell, rows) }
|
||||
}
|
||||
|
||||
private fun press(key: Key) {
|
||||
rule.onRoot().performKeyInput { pressKey(key) }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun assertVisible(shell: ShellState, rows: List<HomeRow>) {
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val row = rows[shell.view.row]
|
||||
val column = shell.view.cards[row.id] ?: 0
|
||||
val bounds = rule.onNodeWithTag("home-card:${row.id}:$column").fetchSemanticsNode().boundsInRoot
|
||||
val density = rule.density.density
|
||||
val margin = WolfskinTokens.Card.posterGap.value * density
|
||||
val stage = WolfskinTokens.Shell.stageWidth.value * density
|
||||
assertTrue("Focused card is clipped: ${row.id}:$column $bounds", bounds.width >= row.cardWidth.value * density)
|
||||
assertTrue("Focused card needs side margins: ${row.id}:$column $bounds", bounds.left >= margin && bounds.right <= stage - margin)
|
||||
assertTrue("Focused row must clear the nav: ${row.id}:$column $bounds", bounds.top >= WolfskinTokens.Layout.navBand.value * density)
|
||||
assertTrue("Focused card must clear the bottom: ${row.id}:$column $bounds", bounds.bottom <= WolfskinTokens.Shell.stageHeight.value * density - margin)
|
||||
}
|
||||
|
||||
private fun capture(name: String) {
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "home").apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue