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
6 changed files with 73 additions and 8 deletions
Showing only changes of commit b54bcc148c - Show all commits

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.
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
bounded six-hour ratings session cache. Back preserves the viewport; returning
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.
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.
`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release
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
episode and checks Jellyfin before opening upstream's player. No season,
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
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.

View file

@ -1,5 +1,7 @@
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,
@ -15,7 +17,10 @@ class HomeFocus(
shell.navFocused = false
shell.view.row = index
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 {
@ -43,7 +48,7 @@ class HomeFocus(
while (next in rows.indices && !rows[next].focusable) next += direction
if (next in rows.indices) {
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)
} else if (direction < 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) {
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) {
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 }
}
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 }
if (remembered >= 0) view.row = remembered
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) }
when (item.kind) {
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.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)) {

View file

@ -14,6 +14,7 @@ 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.ui.Alignment
import androidx.compose.ui.Modifier
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. */
@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 height = row.cardHeight
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 = (column - (slots - 2)).coerceIn(0, (row.count - slots).coerceAtLeast(0))
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 density = LocalDensity.current
LaunchedEffect(first) {

View file

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

View file

@ -132,6 +132,26 @@ class HomeKeysTest {
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) {
val key = when (name) {
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown