diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt index fe5d7250..47f843b2 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt @@ -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() - @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() } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt index 3bac263f..ad713eef 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt @@ -50,7 +50,7 @@ fun WolfskinLibraryContent( seeAll: WolfskinSeeAllLists, modifier: Modifier = Modifier, ) { - val view = shell.view + val view = shell.viewFor(route) val fixture by produceState(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") diff --git a/docker/README.md b/docker/README.md index e8a47a18..0eaed1a6 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt index 3b29c285..4fc442f8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt @@ -8,18 +8,19 @@ class HomeFocus( private val rows: List, 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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index 08b06147..969fbe45 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt index 4279fd45..20417308 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt @@ -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)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeViewMemory.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeViewMemory.kt index 9827b732..65f379ad 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeViewMemory.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeViewMemory.kt @@ -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() + val tracks = mutableMapOf() var loadingEntryRevision: Long? = null var focusedKey: String? = null var positionedKey: String? = null diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index 1aca0224..df2c3a9f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -40,8 +40,8 @@ fun LibraryScreen( cachedRatings: Set = 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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index e348b499..81d17cd2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -68,7 +68,10 @@ class ShellState( private set val tabs: List 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) {