Round 2 release candidate: Home, Discover, trailer, crash fix and stock screens #64

Merged
justinzeus merged 25 commits from fm/tv-round2-rc into fm/tv-round2 2026-11-07 21:14:15 +01:00
7 changed files with 82 additions and 32 deletions
Showing only changes of commit c5dbe91076 - Show all commits

View file

@ -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 ->

View file

@ -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.

View file

@ -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`

View file

@ -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) {

View file

@ -8,6 +8,10 @@ import androidx.compose.runtime.remember
internal val LocalScreenView = compositionLocalOf<RememberedView?> { 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) {

View file

@ -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")

View file

@ -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)
}
}