diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index 4db5512d..c1342442 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.input.key.type import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.justinvisser.wolfskin.design.DiscoverOverlay import org.justinvisser.wolfskin.design.ShellKey +import org.justinvisser.wolfskin.design.isScreenEntryActive import org.justinvisser.wolfskin.design.readShellKey import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel @@ -339,7 +340,7 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false } val person = viewModel.person.takeIf { title != null } - BackHandler(enabled = title != null) { + BackHandler(enabled = isScreenEntryActive() && title != null) { if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell) } Box(modifier.onPreviewKeyEvent { event -> diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index f9a897b7..1f765f22 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -24,6 +24,8 @@ The lane retains the `cw` identity. Legacy local, server, web and plugin Home se Every navigation entry owns its own viewport even while an outgoing page and incoming page coexist during push/Back. Only the active entry may acquire Android focus or register the shell's logical key controller. An outgoing title/person page must never rebind to Home's `LazyListState` when the shared route changes. Down skips non-focusable/pending rows, reaches the final real row, and scrolls it into view after Back and refresh. +An unvisited Home row starts at its leftmost card rather than inheriting horizontal alignment from the previous row. Visited rows retain their remembered column and first-visible position across row changes, title Back and root-tab leave/return. Home does not adopt Discover’s tab-leave memory reset; Library keeps standard grid navigation. Outgoing title/person entries cannot intercept Android Back during their exit animation. + Landscape-card bottom gradients extend through the reserved state-line area, ending at the solid bottom backing with no exposed artwork band. The title-gradient top padding is **36 px / 18 dp**, down from 54 px / 27 dp; the approved 92% bottom opacity is unchanged. Captions and state lines retain their existing geometry. diff --git a/docker/README.md b/docker/README.md index c908c6bf..fb980012 100644 --- a/docker/README.md +++ b/docker/README.md @@ -392,9 +392,12 @@ 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 -when card widths differ. A visited row restores its column. Horizontal movement -keeps the track still while focus remains in its visible middle. +Between Home shelves, an unvisited row starts at its leftmost card. A visited row +restores its column and first-visible position, including after leaving and +returning to the Home tab; Home memory is not reset on tab leave. Library keeps +standard grid navigation. Horizontal movement keeps the track still while focus +remains in its visible middle. Title/person Android Back handlers use the same +active-entry ownership flag, so outgoing pages cannot consume a second Back. The initial skeleton parks focus on the nav. When the first content arrives, an untouched fresh entry focuses hero button zero; intervening nav input prevents that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus` 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 1078d8db..548292fd 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 @@ -1,7 +1,5 @@ package org.justinvisser.wolfskin.design -import kotlin.math.abs - /** Home navigation mutates the shell's per-person view, including each row's remembered column. */ class HomeFocus( private val shell: ShellState, @@ -38,8 +36,7 @@ class HomeFocus( 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, view.cards[target.id] ?: nearest) + focus(next, view.cards[target.id] ?: 0) } else if (direction < 0) { view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0) view.home.resetScroll++ @@ -77,27 +74,6 @@ class HomeFocus( else -> Unit } } - - 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) view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0 - val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2 - 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 - for (index in first..last) { - val candidate = abs((index - first) * pitch(to) + to.cardWidth.value / 2 - x) - if (candidate < distance) { best = index; distance = candidate } - } - return best - } - - private fun pitch(row: HomeRow): Float = when (row.cardKind) { - HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value - HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value - HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value - } } internal val HomeRow.focusSlots: Int get() = when (cardKind) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt index 2947bdd3..503f5338 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt @@ -8,6 +8,10 @@ import androidx.compose.runtime.remember internal val LocalScreenView = compositionLocalOf { null } internal val LocalScreenActive = compositionLocalOf { true } +/** Outgoing entries cannot consume Back while their exit animation remains composed. */ +@Composable +fun isScreenEntryActive(): Boolean = LocalScreenActive.current + /** Overlapping navigation entries retain their own viewport; only the current entry owns input. */ @Composable fun ScreenEntry(shell: ShellState, route: String, active: Boolean, content: @Composable () -> Unit) { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt index 50f40ac0..a8bcc7a9 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt @@ -105,13 +105,15 @@ class HomeKeysTest { assertNull(action) } - @Test fun downClampsToShortRowsAndNeverMovesPastTheFinalSeeAll() { + @Test fun rememberedColumnsClampToShortRowsAndTheFinalSeeAll() { val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false } val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards val long = HomeRow(id = "long", kind = HomeRowKind.Shelf, cards = cards) val short = long.copy(id = "short", cards = cards.take(2), total = 2) val tail = short.copy(id = "tail", total = 20) shell.view.cards[long.id] = cards.lastIndex + shell.view.cards[short.id] = cards.lastIndex + shell.view.cards[tail.id] = 2 var action: HomeAction? = null rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(long, short, tail)), modifier) { action = it } } @@ -132,17 +134,18 @@ class HomeKeysTest { assertEquals(tail.id, (action as HomeAction.SeeAll).row.id) } - @Test fun mixedWidthRowsUseNearestCenterAndKeepTheTrackWhenMovingBackInsideItsMiddle() { + @Test fun visitedMixedWidthRowsKeepColumnsAndTrackWhenMovingInsideTheMiddle() { val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false } val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards val posters = HomeRow(id = "posters", kind = HomeRowKind.Shelf, cards = cards) val tiles = posters.copy(id = "tiles", cardKind = HomeCardKind.Tile) shell.view.cards[posters.id] = 3 + shell.view.cards[tiles.id] = 2 rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(posters, tiles)), modifier) {} } } } press("DOWN") - assertEquals("The third tile is nearest the fourth poster's horizontal center", 2, shell.view.cards[tiles.id]) + assertEquals(2, shell.view.cards[tiles.id]) repeat(4) { press("RIGHT") } val left = rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left press("LEFT") diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowEntryTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowEntryTest.kt new file mode 100644 index 00000000..61382594 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowEntryTest.kt @@ -0,0 +1,61 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.compose.foundation.lazy.LazyListState +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Home starts new rows at zero and retains visited positions independently of other root tabs. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class HomeRowEntryTest { + private val people = listOf(ShellPerson("justin", "Justin", "J", true)) + private val cards = List(12) { HomeTitle("item-$it", "Film $it", "movie") } + + @Test fun unvisitedRowsStartLeftmostAcrossCardWidths() { + for (from in HomeCardKind.entries) for (to in HomeCardKind.entries) { + val shell = ShellState(people, "justin").apply { pickerVisible = false; navFocused = false } + val source = HomeRow("source", HomeRowKind.Shelf, cards = cards, cardKind = from) + val target = source.copy(id = "target", cardKind = to) + val focus = HomeFocus(shell, listOf(source, target)) {} + focus.focus(0, 3) + focus.key(ShellKey.Down) + assertEquals("$from to $to", target.id, focus.row?.id) + assertEquals("$from to $to must start leftmost", 0, focus.column) + assertEquals(0, shell.rememberedView("home").home.firstCards[target.id]) + } + } + + @Test fun visitedColumnsAndFirstVisiblePositionsSurviveRowChangesAndRootTabLeave() { + val shell = ShellState(people, "justin").apply { pickerVisible = false; navFocused = false } + val source = HomeRow("source", HomeRowKind.Shelf, cards = cards) + val rows = listOf(source, source.copy(id = "target", cardKind = HomeCardKind.Tile)) + val focus = HomeFocus(shell, rows) {} + focus.focus(0, 8) + focus.key(ShellKey.Left) + val view = shell.rememberedView("home") + val first = view.home.firstCards.getValue(source.id) + val track = LazyListState(first, 19) + view.home.tracks[source.id] = track + focus.key(ShellKey.Down) + focus.key(ShellKey.Up) + assertEquals(7, focus.column) + assertEquals(first, view.home.firstCards[source.id]) + + shell.focusNav(shell.tabs.indexOf("discover")); shell.key(ShellKey.Ok) + shell.focusNav(shell.tabs.indexOf("home")); shell.key(ShellKey.Ok) + val restored = HomeFocus(shell, rows) {} + assertSame(view, shell.rememberedView("home")) + assertEquals(7, restored.column) + restored.key(ShellKey.Down) + restored.key(ShellKey.Up) + assertEquals(7, restored.column) + assertEquals(first, view.home.firstCards[source.id]) + assertSame(track, view.home.tracks[source.id]) + assertEquals(first, track.firstVisibleItemIndex) + assertEquals(19, track.firstVisibleItemScrollOffset) + } +}