From b54bcc148c362a7332c502d96643b541eb2f7b83 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 17:16:06 +0100 Subject: [PATCH] Follow visible card centers and remember Home tracks --- docker/README.md | 9 ++++- .../justinvisser/wolfskin/design/HomeFocus.kt | 38 ++++++++++++++++++- .../wolfskin/design/HomeScreen.kt | 5 ++- .../justinvisser/wolfskin/design/HomeShelf.kt | 7 ++-- .../wolfskin/design/HomeViewMemory.kt | 2 + .../wolfskin/design/HomeKeysTest.kt | 20 ++++++++++ 6 files changed, 73 insertions(+), 8 deletions(-) diff --git a/docker/README.md b/docker/README.md index 45b80ff2..8200f3bb 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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. 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 fdb70c71..5a7b0393 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,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)) +} 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 9cb26163..b6c67d27 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 @@ -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)) { 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 0f37daf9..f0ceeaa0 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 @@ -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, onCard: (Int) -> Unit, onHover: (Int) -> Unit) { +fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List, 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) { 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 e178c993..cbac4dba 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 @@ -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() var focusedKey: String? = null var positionedKey: String? = null var resetScroll by mutableIntStateOf(0) 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 0d7ef6ae..dad590b4 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 @@ -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