Build the Wolfskin TV Home feed #31
6 changed files with 73 additions and 8 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue