Build the Wolfskin TV Home feed #31

Merged
justinzeus merged 14 commits from fm/tv-slice3-home into main 2026-11-05 21:34:30 +01:00
9 changed files with 125 additions and 45 deletions
Showing only changes of commit 9c8fceb18c - Show all commits

View file

@ -18,6 +18,7 @@ import io.mockk.mockk
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.assertSame
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@ -32,9 +33,25 @@ import org.robolectric.annotation.Config
class HomeSeeAllNavigationTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun personalMovieContinuationReachesTheEighteenthTitleAndReturnsToHome() {
@Test fun personalMovieContinuationReachesTheEighteenthTitleAndReturnsToHome() = continuation("pk-m", 5) { grid ->
assertEquals(18, grid.items.size)
repeat(2) { press(Key.DirectionDown) }
repeat(5) { press(Key.DirectionRight) }
rule.onNodeWithText("Bleach the Movie: Fade to Black").assertIsDisplayed()
assertEquals("movie-bleach-the-movie-fade-to-black-2008", grid.focusedTitle?.id)
}
@Test fun horizontallyScrolledContinuationRestoresTheExactHomeViewport() = continuation("trending", 20) { grid ->
press(Key.DirectionDown)
assertEquals(6, grid.index)
rule.onNodeWithTag("library-grid").assertIsDisplayed()
}
private fun continuation(rowId: String, expectedColumn: Int, exerciseGrid: (LibraryGridState) -> Unit) {
rule.mainClock.autoAdvance = false
val snapshot = HomeFixture(rule.activity).snapshot("justin")
val row = snapshot.rows.first { it.id == "pk-m" }
val row = snapshot.rows.first { it.id == rowId }
val cardTag = "home-card:$rowId:$expectedColumn"
val navigation = NavigationManager()
val lists = WolfskinSeeAllLists()
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") {
@ -58,24 +75,56 @@ class HomeSeeAllNavigationTest {
else WolfskinLibraryContent(rule.activity, route, shell, user, "fixture", facade, navigation, lists, modifier)
} }
}
rule.onNodeWithTag("home-card:pk-m:5").assertIsDisplayed()
settle()
rule.onNodeWithTag(cardTag).assertIsDisplayed()
val home = shell.view
val track = home.home.tracks.getValue(rowId)
val verticalIndex = home.listState.firstVisibleItemIndex
val verticalOffset = home.listState.firstVisibleItemScrollOffset
val rowOffset = home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset
val horizontalIndex = track.firstVisibleItemIndex
val horizontalOffset = track.firstVisibleItemScrollOffset
press(Key.DirectionCenter)
assertEquals("seeall/justin/pk-m", (navigation.backStack.last() as Destination.Wolfskin).route)
val grid = requireNotNull(lists.get("justin/pk-m"))
assertEquals(18, grid.items.size)
repeat(2) { press(Key.DirectionDown) }
repeat(5) { press(Key.DirectionRight) }
rule.onNodeWithText("Bleach the Movie: Fade to Black").assertIsDisplayed()
assertEquals("movie-bleach-the-movie-fade-to-black-2008", grid.focusedTitle?.id)
press(Key.Back)
rule.waitForIdle()
assertTrue(navigation.backStack.last() is Destination.Home)
assertEquals(row.id, snapshot.rows[shell.view.row].id)
assertEquals(5, shell.view.cards[row.id])
rule.onNodeWithTag("home-card:pk-m:5").assertIsDisplayed()
assertEquals("seeall/justin/$rowId", (navigation.backStack.last() as Destination.Wolfskin).route)
val grid = requireNotNull(lists.get("justin/$rowId"))
try {
rule.onNodeWithTag("library-grid").assertIsDisplayed()
exerciseGrid(grid)
press(Key.Back)
rule.waitForIdle()
assertTrue(navigation.backStack.last() is Destination.Home)
assertEquals(row.id, snapshot.rows[shell.view.row].id)
assertEquals(expectedColumn, shell.view.cards[row.id])
assertSame("Home retains its original route memory", home, shell.view)
assertSame("Home retains its original horizontal state", track, home.home.tracks.getValue(rowId))
assertEquals("Vertical first item", verticalIndex, home.listState.firstVisibleItemIndex)
assertEquals("Vertical pixel offset", verticalOffset, home.listState.firstVisibleItemScrollOffset)
assertEquals("Selected row pixel offset", rowOffset, home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset)
assertEquals("Horizontal first item", horizontalIndex, track.firstVisibleItemIndex)
assertEquals("Horizontal pixel offset", horizontalOffset, track.firstVisibleItemScrollOffset)
rule.onNodeWithTag(cardTag).assertIsDisplayed()
} catch (failure: AssertionError) {
throw AssertionError(
"Expected $rowId:$expectedColumn vertical=$verticalIndex/$verticalOffset row-offset=$rowOffset horizontal=$horizontalIndex/$horizontalOffset; " +
"actual route=${shell.route} row=${home.row} card=${home.cards[rowId]} nav=${shell.navFocused} handler=${shell.contentFocus?.javaClass?.simpleName} grid=${grid.index} " +
"vertical=${home.listState.firstVisibleItemIndex}/${home.listState.firstVisibleItemScrollOffset} horizontal=${track.firstVisibleItemIndex}/${track.firstVisibleItemScrollOffset} " +
"visible-rows=${home.listState.layoutInfo.visibleItemsInfo.map { it.key to it.offset }}",
failure,
)
}
}
private fun press(key: Key) {
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
settle()
}
private fun settle() {
repeat(3) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
}
rule.mainClock.advanceTimeBy(1_000)
rule.waitForIdle()
}
}

View file

@ -50,7 +50,7 @@ fun WolfskinLibraryContent(
seeAll: WolfskinSeeAllLists,
modifier: Modifier = Modifier,
) {
val view = shell.view
val view = shell.viewFor(route)
val fixture by produceState<LibraryFixture?>(null, context) {
if (BuildConfig.DEBUG) value = withContext(Dispatchers.IO) {
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
@ -89,7 +89,7 @@ fun WolfskinLibraryContent(
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
}
}
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null)
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null, view = view)
}
val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime")

View file

@ -131,10 +131,16 @@ the complete underlying list rather than the visible shelf window. Title content
remains the later-slice target.
All Home components and immutable screen models live in `:wolfskin-design`.
The shell's remembered view owns vertical position, each row's card and first
visible card, and the bounded six-hour ratings session cache. Back preserves the viewport; returning
to Home does not re-run focus-follow over a manually scrolled saved view. The
next arrow re-enters visible content when scrolling moved focus off-screen.
The shell's remembered view owns vertical position, each row's selected card and
actual horizontal `LazyListState`, and the bounded six-hour ratings session cache.
Composing destinations bind `viewFor(route)` before navigation effects run, so a
new See all grid cannot borrow or clamp Home's scroller. Re-entry restores the
remembered track before the page with `scrollToItem`, preserving exact indices and
pixel offsets rather than animating back. Android focus stays with the shell;
cards use its logical focus, without requests against uncomposed lazy items.
Back preserves the viewport; returning to Home does not re-run focus-follow over
a manually scrolled saved view. The next arrow re-enters visible content when
scrolling moved focus off-screen.
Pointing changes focus without activation, cards activate on key-up, and held
OK events cannot activate twice.
Between shelves, an unvisited row selects the nearest visible card center even

View file

@ -8,18 +8,19 @@ class HomeFocus(
private val rows: List<HomeRow>,
private val onAction: (HomeAction) -> Unit,
) : ShellContentFocus {
private val view = shell.viewFor("home")
override val cardKey: String? get() = row?.let { if (it.kind == HomeRowKind.Shelf) it.cards.getOrNull(column)?.id ?: it.id else null }
val row: HomeRow? get() = rows.getOrNull(shell.view.row)
val column: Int get() = row?.let { shell.view.cards[it.id] ?: 0 } ?: 0
val row: HomeRow? get() = rows.getOrNull(view.row)
val column: Int get() = row?.let { view.cards[it.id] ?: 0 } ?: 0
fun focus(index: Int, column: Int) {
shell.navFocused = false
shell.view.row = index
shell.view.home.focusedKey = rows[index].id
view.row = index
view.home.focusedKey = rows[index].id
val target = rows[index]
val bounded = column.coerceIn(0, (target.count - 1).coerceAtLeast(0))
shell.view.cards[target.id] = bounded
if (target.kind == HomeRowKind.Shelf) shell.view.home.firstCards[target.id] = target.adjustedFirst(bounded, shell.view.home.firstCards[target.id] ?: 0)
view.cards[target.id] = bounded
if (target.kind == HomeRowKind.Shelf) view.home.firstCards[target.id] = target.adjustedFirst(bounded, view.home.firstCards[target.id] ?: 0)
}
override fun key(key: ShellKey): Boolean {
@ -28,19 +29,19 @@ class HomeFocus(
if (key == ShellKey.Ok) { activate(current); return true }
if (reenterVisible()) return true
when (key) {
ShellKey.Left -> focus(shell.view.row, (column - 1).coerceAtLeast(0))
ShellKey.Right -> focus(shell.view.row, (column + 1).coerceAtMost((current.count - 1).coerceAtLeast(0)))
ShellKey.Left -> focus(view.row, (column - 1).coerceAtLeast(0))
ShellKey.Right -> focus(view.row, (column + 1).coerceAtMost((current.count - 1).coerceAtLeast(0)))
ShellKey.Up, ShellKey.Down -> {
val direction = if (key == ShellKey.Up) -1 else 1
var next = shell.view.row + direction
var next = view.row + direction
while (next in rows.indices && !rows[next].focusable) next += direction
if (next in rows.indices) {
val target = rows[next]
val nearest = nearest(current, target)
focus(next, shell.view.cards[target.id] ?: nearest)
focus(next, view.cards[target.id] ?: nearest)
} else if (direction < 0) {
shell.view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
shell.view.home.resetScroll++
view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
view.home.resetScroll++
shell.navFocused = true
shell.navIndex = shell.currentTab
}
@ -50,7 +51,6 @@ class HomeFocus(
}
private fun reenterVisible(): Boolean {
val view = shell.view
val layout = view.listState.layoutInfo
if (layout.totalItemsCount == 0 || view.home.positionedKey != row?.id || view.listState.isScrollInProgress) return false
val density = layout.viewportSize.width / WolfskinTokens.Shell.stageWidth.value
@ -79,9 +79,9 @@ class HomeFocus(
private fun nearest(from: HomeRow, to: HomeRow): Int {
if (to.kind != HomeRowKind.Shelf) return (column * pitch(from) / pitch(to)).toInt()
val fromFirst = if (from.kind == HomeRowKind.Shelf) shell.view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0
val fromFirst = if (from.kind == HomeRowKind.Shelf) view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0
val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2
val first = (shell.view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0))
val first = (view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0))
val last = minOf(to.count - 1, first + to.focusSlots - 1)
var best = first
var distance = Float.POSITIVE_INFINITY

View file

@ -27,7 +27,7 @@ fun HomeScreen(
modifier: Modifier = Modifier,
onAction: (HomeAction) -> Unit,
) {
val view = shell.view
val view = shell.viewFor("home")
LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt }
val action by rememberUpdatedState(onAction)
val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } }
@ -59,6 +59,14 @@ 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) {
val key = snapshot.rows.getOrNull(view.row)?.id
if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect

View file

@ -8,13 +8,17 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@ -38,11 +42,19 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
val track = rememberLazyListState(first)
val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
val targetFirst by rememberUpdatedState(first)
val density = LocalDensity.current
LaunchedEffect(first) {
val pitch = with(density) { (width + K.posterGap).toPx() }
track.animateScrollBy((first - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base))
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))
}
}
}
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))

View file

@ -1,5 +1,6 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
@ -9,6 +10,7 @@ import androidx.compose.runtime.setValue
class HomeViewMemory {
val ratings = HomeRatingCache()
val firstCards = mutableStateMapOf<String, Int>()
val tracks = mutableMapOf<String, LazyListState>()
var loadingEntryRevision: Long? = null
var focusedKey: String? = null
var positionedKey: String? = null

View file

@ -40,8 +40,8 @@ fun LibraryScreen(
cachedRatings: Set<String> = emptySet(),
ratingsReachable: Boolean = true,
offlineArt: ((String) -> ImageBitmap?)? = null,
view: RememberedView = shell.view,
) {
val view = shell.view
val density = LocalDensity.current
val context = LocalContext.current
val active = !shell.navFocused && !shell.pickerVisible

View file

@ -68,7 +68,10 @@ class ShellState(
private set
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
val view: RememberedView get() = viewFor(route)
/** A composing destination must not borrow the previous route's scroller before its enter effect. */
fun viewFor(destination: String): RememberedView = views.getOrPut(person.key to destination) { RememberedView() }
/** External intents and upstream pops remain authoritative over the screen. */
fun enterDestination(destination: String) {