Follow visible card centers and remember Home tracks
Some checks failed
review/design-claude Request changes: separator dot colour, vertical rhythm, focus entry after load

This commit is contained in:
justinzeus 2026-11-05 17:16:06 +01:00
parent 2b65ee7b53
commit b54bcc148c
6 changed files with 73 additions and 8 deletions

View file

@ -130,12 +130,15 @@ The final action opens Discover at its top. Discover (`discover`) and See all
later screens. Title navigation remains the shell's later-slice target. later screens. Title navigation remains the shell's later-slice target.
All Home components and immutable screen models live in `:wolfskin-design`. All Home components and immutable screen models live in `:wolfskin-design`.
The shell's remembered view owns vertical position, each row's card and the The shell's remembered view owns vertical position, each row's card and first
bounded six-hour ratings session cache. Back preserves the viewport; returning 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 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. next arrow re-enters visible content when scrolling moved focus off-screen.
Pointing changes focus without activation, cards activate on key-up, and held Pointing changes focus without activation, cards activate on key-up, and held
OK events cannot activate twice. 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.
`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release `HomeReadFacade` is the only Home code importing Jellyfin API clients. Release
variants read the selected user's Resume, Next up, newest titles and destination variants read the selected user's Resume, Next up, newest titles and destination
@ -144,6 +147,8 @@ disk cache. A missing saved library becomes a recovery panel; a saved library
stays visible when Jellyfin is unreachable. Playback resolves an actual film or stays visible when Jellyfin is unreachable. Playback resolves an actual film or
episode and checks Jellyfin before opening upstream's player. No season, episode and checks Jellyfin before opening upstream's player. No season,
recommendation stub or different person's session is promoted to playback. recommendation stub or different person's session is promoted to playback.
An unresolved person switch shows the same sign-in guard for Home as for native
library content, before mounting Home's source reads.
Debug variants additionally package the locked fixture and local artwork under Debug variants additionally package the locked fixture and local artwork under
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them. `wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.

View file

@ -1,5 +1,7 @@
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import kotlin.math.abs
/** Home navigation mutates the shell's per-person view, including each row's remembered column. */ /** Home navigation mutates the shell's per-person view, including each row's remembered column. */
class HomeFocus( class HomeFocus(
private val shell: ShellState, private val shell: ShellState,
@ -15,7 +17,10 @@ class HomeFocus(
shell.navFocused = false shell.navFocused = false
shell.view.row = index shell.view.row = index
shell.view.home.focusedKey = rows[index].id shell.view.home.focusedKey = rows[index].id
shell.view.cards[rows[index].id] = column.coerceIn(0, (rows[index].count - 1).coerceAtLeast(0)) 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)
} }
fun key(key: ShellKey, down: Boolean, repeat: Int): Boolean { fun key(key: ShellKey, down: Boolean, repeat: Int): Boolean {
@ -43,7 +48,7 @@ class HomeFocus(
while (next in rows.indices && !rows[next].focusable) next += direction while (next in rows.indices && !rows[next].focusable) next += direction
if (next in rows.indices) { if (next in rows.indices) {
val target = rows[next] val target = rows[next]
val nearest = (column * pitch(current) / pitch(target)).toInt() val nearest = nearest(current, target)
focus(next, shell.view.cards[target.id] ?: nearest) focus(next, shell.view.cards[target.id] ?: nearest)
} else if (direction < 0) { } else if (direction < 0) {
shell.view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0) shell.view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
@ -84,9 +89,38 @@ 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 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 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) { private fun pitch(row: HomeRow): Float = when (row.cardKind) {
HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value
HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value
HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value
} }
} }
internal val HomeRow.focusSlots: Int get() = when (cardKind) {
HomeCardKind.Poster -> 6
HomeCardKind.Tile -> 4
HomeCardKind.Wide -> 3
}
/** 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))
}

View file

@ -36,6 +36,9 @@ fun HomeScreen(
onDispose { shell.contentInput = null } onDispose { shell.contentInput = null }
} }
LaunchedEffect(snapshot.rows, view) { LaunchedEffect(snapshot.rows, view) {
snapshot.rows.forEach { item ->
view.cards[item.id]?.let { view.cards[item.id] = it.coerceIn(0, (item.count - 1).coerceAtLeast(0)) }
}
val remembered = snapshot.rows.indexOfFirst { it.id == view.home.focusedKey && it.focusable } val remembered = snapshot.rows.indexOfFirst { it.id == view.home.focusedKey && it.focusable }
if (remembered >= 0) view.row = remembered if (remembered >= 0) view.row = remembered
if (snapshot.rows.none { it.focusable }) { shell.navFocused = true; shell.navIndex = shell.currentTab } if (snapshot.rows.none { it.focusable }) { shell.navFocused = true; shell.navIndex = shell.currentTab }
@ -86,7 +89,7 @@ fun HomeScreen(
fun activate(column: Int) { focus.focus(index, column); focus.key(ShellKey.Ok, true, 0); focus.key(ShellKey.Ok, false, 0) } fun activate(column: Int) { focus.focus(index, column); focus.key(ShellKey.Ok, true, 0); focus.key(ShellKey.Ok, false, 0) }
when (item.kind) { when (item.kind) {
HomeRowKind.Hero -> HomeHero(item, selected, focus.column, if (showRatings) heroRatings else emptyList(), ::activate) { focus.focus(index, it) } HomeRowKind.Hero -> HomeHero(item, selected, focus.column, if (showRatings) heroRatings else emptyList(), ::activate) { focus.focus(index, it) }
HomeRowKind.Shelf -> HomeShelf(item, selected, view.cards[item.id] ?: 0, if (showRatings && selected) focusedRatings else emptyList(), ::activate) { focus.focus(index, it) } HomeRowKind.Shelf -> HomeShelf(item, view.home, selected, view.cards[item.id] ?: 0, if (showRatings && selected) focusedRatings else emptyList(), ::activate) { focus.focus(index, it) }
HomeRowKind.Section -> HomeSection(shell.person, snapshot.savedAt) HomeRowKind.Section -> HomeSection(shell.person, snapshot.savedAt)
HomeRowKind.State -> HomeRecovery(item, selected, { activate(0) }) { focus.focus(index, 0) } HomeRowKind.State -> HomeRecovery(item, selected, { activate(0) }) { focus.focus(index, 0) }
HomeRowKind.Note -> Row(Modifier.fillMaxWidth().height(L.row_note).padding(start = L.safeSide, top = H.noteTop), horizontalArrangement = Arrangement.spacedBy(H.noteGap)) { HomeRowKind.Note -> Row(Modifier.fillMaxWidth().height(L.row_note).padding(start = L.safeSide, top = H.noteTop), horizontalArrangement = Arrangement.spacedBy(H.noteGap)) {

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -31,12 +32,12 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Type as T
/** Every lazy row and card uses a stable source identity and an explicit content type. */ /** Every lazy row and card uses a stable source identity and an explicit content type. */
@Composable @Composable
fun HomeShelf(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) { fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) {
val width = row.cardWidth val width = row.cardWidth
val height = row.cardHeight val height = row.cardHeight
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero
val slots = if (row.cardKind == HomeCardKind.Poster) 6 else if (row.cardKind == HomeCardKind.Tile) 4 else 3 val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
val first = (column - (slots - 2)).coerceIn(0, (row.count - slots).coerceAtLeast(0)) SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
val track = rememberLazyListState(first) val track = rememberLazyListState(first)
val density = LocalDensity.current val density = LocalDensity.current
LaunchedEffect(first) { LaunchedEffect(first) {

View file

@ -2,11 +2,13 @@ package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
/** Focus-follow runs on a new row, never on re-entry to an already-positioned saved viewport. */ /** Focus-follow runs on a new row, never on re-entry to an already-positioned saved viewport. */
class HomeViewMemory { class HomeViewMemory {
val ratings = HomeRatingCache() val ratings = HomeRatingCache()
val firstCards = mutableStateMapOf<String, Int>()
var focusedKey: String? = null var focusedKey: String? = null
var positionedKey: String? = null var positionedKey: String? = null
var resetScroll by mutableIntStateOf(0) var resetScroll by mutableIntStateOf(0)

View file

@ -132,6 +132,26 @@ class HomeKeysTest {
assertEquals(tail.id, (action as HomeAction.SeeAll).row.id) assertEquals(tail.id, (action as HomeAction.SeeAll).row.id)
} }
@Test fun mixedWidthRowsUseNearestCenterAndKeepTheTrackWhenMovingBackInsideItsMiddle() {
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
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])
repeat(4) { press("RIGHT") }
val left = rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left
press("LEFT")
assertEquals("Moving back within the visible middle must not shift the track", left,
rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left, .1f)
press("UP")
assertEquals("A visited row retains its column", 3, shell.view.cards[posters.id])
}
private fun press(name: String) { private fun press(name: String) {
val key = when (name) { val key = when (name) {
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown