Integrate libraries and correct Home review blockers

This commit is contained in:
justinzeus 2026-11-05 19:03:33 +01:00
commit 14a74ac43b
642 changed files with 2377 additions and 169 deletions

View file

@ -25,12 +25,16 @@ class HomeFixture(context: Context) {
val continuation = if (status == HomeAvailability.SignedOut || status == HomeAvailability.Unavailable) listOf(homeDiscoverState(status)) else continuation(profile, state)
var rows = buildHomeRows(library, continuation, state != "jf-down", state == "loading")
rows = rows.map { row ->
if (!row.catalogue) row else row.copy(cards = row.cards.map { title ->
val names = owners[title.tmdbId?.let { "${title.type}:$it" } ?: title.id].orEmpty()
val held = title.held || profile.getString("name") in names
val other = if (held) null else names.filter { it != profile.getString("name") }.takeIf { it.isNotEmpty() }?.joinToString(" and ")
if (held == title.held && other == title.otherOwner) title else title.copy(held = held, otherOwner = other)
})
if (!row.catalogue) row else {
fun owned(title: HomeTitle): HomeTitle {
val names = owners[title.tmdbId?.let { "${title.type}:$it" } ?: title.id].orEmpty()
val held = title.held || profile.getString("name") in names
val other = if (held) null else names.filter { it != profile.getString("name") }.takeIf { it.isNotEmpty() }?.joinToString(" and ")
return if (held == title.held && other == title.otherOwner) title else title.copy(held = held, otherOwner = other)
}
val cards = row.cards.map(::owned)
row.copy(cards = cards, allCards = if (row.allCards === row.cards) cards else row.allCards.map(::owned))
}
}
if (state == "missing") {
val missing = profiles.flatMap { person -> listOf("movies", "shows", "anime", "discover", "nextUp", "recentlyAdded").flatMap { key -> person.items(key).filterIndexed { index, _ -> index % 3 == 0 } } }.map { it.getString("id") }.toSet()
@ -60,10 +64,10 @@ class HomeFixture(context: Context) {
val showRow = take(shows, minOf(12, kotlin.math.ceil(shows.size.toDouble() / (groupRows.size + 1)).toInt()))
val remaining = (movies + shows).count { it.identity() !in seen }
val size = if (groupRows.isEmpty()) 0 else minOf(8, kotlin.math.ceil(remaining.toDouble() / groupRows.size).toInt())
val watchedRows = groupRows.map { (seed, items) -> Triple(seed, take(items, size), items.size) }.filter { it.second.size >= 3 }
if (movieRow.isNotEmpty()) add(shelf("pk-m", "Movies for you", movieRow, movies.size))
if (showRow.isNotEmpty()) add(shelf("pk-s", "Series for you", showRow, shows.size))
watchedRows.take(2).forEachIndexed { index, (seed, items, total) -> add(shelf("pk-w$index", "Because you watched ${seed.getString("title")}", items, total)) }
val watchedRows = groupRows.map { (seed, items) -> Triple(seed, take(items, size), items) }.filter { it.second.size >= 3 }
if (movieRow.isNotEmpty()) add(shelf("pk-m", "Movies for you", movieRow, movies.size, movies))
if (showRow.isNotEmpty()) add(shelf("pk-s", "Series for you", showRow, shows.size, shows))
watchedRows.take(2).forEachIndexed { index, (seed, items, all) -> add(shelf("pk-w$index", "Because you watched ${seed.getString("title")}", items, all.size, all)) }
}
val excluded = flatMap { row -> row.cards }.map { it.id }.toSet()
val base = pool.filter { it.getString("id") !in excluded }
@ -72,15 +76,18 @@ class HomeFixture(context: Context) {
val rotated = base.drop(shift) + base.take(shift)
add(shelf("new-$index", "New on $service", rotated.take(12).sortedByDescending { it.optInt("year") }))
}
add(shelf("trending", "Trending", base.take(WolfskinTokens.Behaviour.rowLimit.toInt()), base.size))
add(shelf("trending", "Trending", base.take(WolfskinTokens.Behaviour.rowLimit.toInt()), base.size, base))
listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").forEach { genre ->
val titles = base.filter { genre in genres(it) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.getString("type") == "movie") }
if (titles.isNotEmpty()) add(shelf("genre-$genre", genre, titles.take(WolfskinTokens.Behaviour.rowLimit.toInt()), titles.size))
if (titles.isNotEmpty()) add(shelf("genre-$genre", genre, titles.take(WolfskinTokens.Behaviour.rowLimit.toInt()), titles.size, titles))
}
add(HomeRow("explore", HomeRowKind.Explore, "Explore more in Discover"))
}
private fun shelf(id: String, name: String, items: List<JSONObject>, total: Int = items.size) = HomeRow(id, HomeRowKind.Shelf, name, items.map(::title), catalogue = true, total = total, target = "see-all:$id")
private fun shelf(id: String, name: String, items: List<JSONObject>, total: Int = items.size, allItems: List<JSONObject> = items): HomeRow {
val cards = items.map(::title)
return HomeRow(id, HomeRowKind.Shelf, name, cards, catalogue = true, total = total, allCards = if (allItems === items) cards else allItems.map(::title))
}
private fun title(item: JSONObject): HomeTitle = HomeTitle(
id = item.getString("id"), title = item.getString("title"), type = item.getString("type"),

View file

@ -1,33 +1,49 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
/** Captions fade without moving the reserved band under the focused poster. */
@Composable
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, availability: String? = null) {
val alpha = remember(title, facts, availability) { Animatable(0f) }
LaunchedEffect(alpha, visible) { alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))) }
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Row(Modifier.clipToBounds(), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Layout.captionOffset)) {
val entries = facts.split(" · ") + listOfNotNull(availability)
entries.forEachIndexed { index, fact ->
if (index > 0) Text("·", color = WolfskinTokens.Color.fg.copy(alpha = WolfskinTokens.Home.factSeparatorOpacity), style = WolfskinTokens.Type.caption)
Text(fact, color = if (availability != null && index == entries.lastIndex) WolfskinTokens.Color.accentText else WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false)
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false,
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
titleOverflow: TextOverflow = TextOverflow.Clip) {
val alpha = remember(title, facts) { Animatable(0f) }
LaunchedEffect(alpha, visible) {
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
}
val styledFacts = remember(facts, accentLastFact) {
buildAnnotatedString {
val separator = " · "
append(facts)
if (accentLastFact) {
val suffix = facts.lastIndexOf(separator).takeIf { it >= 0 }?.plus(separator.length) ?: facts.length
if (suffix < facts.length) addStyle(SpanStyle(color = WolfskinTokens.Color.accentText), suffix, facts.length)
}
val muted = WolfskinTokens.Color.muted
val separatorStyle = SpanStyle(color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity))
var start = facts.indexOf(separator)
while (start >= 0) {
val dot = start + separator.indexOf('·')
addStyle(separatorStyle, dot, dot + 1)
start = facts.indexOf(separator, start + separator.length)
}
}
}
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
Text(title, modifier = Modifier.exactLineBox(titleStyle), color = WolfskinTokens.Color.fg, style = titleStyle, maxLines = 1, overflow = titleOverflow)
Text(styledFacts, modifier = Modifier.exactLineBox(factsStyle), color = WolfskinTokens.Color.muted, style = factsStyle, maxLines = 1)
}
}

View file

@ -23,6 +23,7 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
"wifi" -> R.drawable.wolfskin_wifi
"film" -> R.drawable.wolfskin_film
"eye" -> R.drawable.wolfskin_eye
"down" -> R.drawable.wolfskin_down
else -> R.drawable.wolfskin_right
}
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)

View file

@ -7,13 +7,12 @@ class HomeFocus(
private val shell: ShellState,
private val rows: List<HomeRow>,
private val onAction: (HomeAction) -> Unit,
) {
private var armed: Pair<String, Int>? = null
) : ShellContentFocus {
override val cardKey: String? get() = row?.let { if (it.kind == HomeRowKind.Shelf) it.cards.getOrNull(column)?.id ?: it.id else null }
val row: HomeRow? get() = rows.getOrNull(shell.view.row)
val column: Int get() = row?.let { shell.view.cards[it.id] ?: 0 } ?: 0
fun focus(index: Int, column: Int) {
armed = null
shell.navFocused = false
shell.view.row = index
shell.view.home.focusedKey = rows[index].id
@ -23,21 +22,10 @@ class HomeFocus(
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 {
override fun key(key: ShellKey): Boolean {
val current = row ?: return false
if (key == ShellKey.Back) { armed = null; return false }
if (key == ShellKey.Ok) {
if (repeat > 0) return true
if (current.kind == HomeRowKind.Shelf) {
if (down) armed = current.id to column else {
if (armed == (current.id to column)) activate(current)
armed = null
}
} else if (down) activate(current)
return true
}
if (!down) return true
armed = null
if (key == ShellKey.Back) return false
if (key == ShellKey.Ok) { activate(current); return true }
if (reenterVisible()) return true
when (key) {
ShellKey.Left -> focus(shell.view.row, (column - 1).coerceAtLeast(0))

View file

@ -14,7 +14,7 @@ import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Type as T
import org.justinvisser.wolfskin.design.HomeType as T
/** The hero occupies one row while its backdrop extends beneath the first shelves. */
@Composable
@ -23,29 +23,29 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
if (!row.skeleton) {
val title = row.cards.first()
val artModifier = Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)
HomeArt(title, WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight, artModifier, landscape = true, hero = true)
TitleArtwork(title.title, title.type, title.poster, WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight, artModifier, backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = true, hero = true)
Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f))))
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) {
Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f)))
Text(when (row.heroKind) { "resume" -> "Continue watching"; "next" -> "Up next"; "new" -> "New in your library"; else -> "For you" }, color = C.accentText, style = T.label)
Text(when (row.heroKind) { "resume" -> "Continue watching"; "next" -> "Up next"; "new" -> "New in your library"; else -> "For you" }, modifier = Modifier.exactLineBox(T.label), color = C.accentText, style = T.label)
}
Spacer(Modifier.height(H.kickerBottom))
Text(title.displayTitle, color = C.fg, style = T.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(title.displayTitle, modifier = Modifier.exactLineBox(T.displayHero), color = C.fg, style = T.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(H.titleBottom))
Row(Modifier.heightIn(min = H.factsHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.factsGap)) {
Text(if (title.isEpisode) title.subtitle else title.year?.toString().orEmpty(), color = C.muted, style = T.body, maxLines = 1)
if (row.heroKind == "resume" || title.runtimeSeconds != null) Text(if (row.heroKind == "resume") title.remaining else title.runtime, color = C.muted, style = T.body, maxLines = 1)
Text(if (title.isEpisode) title.subtitle else title.year?.toString().orEmpty(), modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1)
if (row.heroKind == "resume" || title.runtimeSeconds != null) Text(if (row.heroKind == "resume") title.remaining else title.runtime, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1)
HomeRatingsRow(ratings)
}
Spacer(Modifier.height(H.factsBottom))
Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax))
Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
Spacer(Modifier.height(H.overviewBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, lift = true) { onButton(0) }
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, lift = true) { onButton(1) }
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, lift = true, textStyle = T.button) { onButton(0) }
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, lift = true, textStyle = T.button) { onButton(1) }
}
}
} else {
@ -69,7 +69,11 @@ fun HomeSection(person: ShellPerson, savedAt: String?) {
Text("Discover", color = C.fg, style = T.sectionTitle)
Avatar(person, H.sectionAvatar, T.avatarLetterSectionHeader)
Text("Picks for you first, then what’s out there", color = C.muted, style = T.body)
if (savedAt != null) Text("Saved $savedAt", color = C.warn, style = T.caption)
if (savedAt != null) Box(Modifier.height(WolfskinTokens.Shell.settingsPillHeight)
.border(H.hairline, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill))
.padding(horizontal = WolfskinTokens.Shell.settingsPillPadding + H.hairline), contentAlignment = Alignment.Center) {
Text("Saved $savedAt", color = C.warn, style = T.caption.copy(fontWeight = T.label.fontWeight))
}
}
}
}
@ -78,15 +82,15 @@ fun HomeSection(person: ShellPerson, savedAt: String?) {
@Composable
fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> Unit, onHover: () -> Unit) {
Box(Modifier.fillMaxWidth().height(L.row_state)) {
Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX, vertical = H.statePaddingY)) {
Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX + H.hairline, vertical = H.statePaddingY + H.hairline)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.plateTop)) {
Glyph(if (row.calm) "profile" else "alert", if (row.calm) C.accentText else C.warn, Modifier.size(H.stateIcon))
Text(row.title, color = C.fg, style = T.stateTitle.copy(fontWeight = FontWeight.Bold))
Text(row.title, modifier = Modifier.exactLineBox(T.stateTitle), color = C.fg, style = T.stateTitle.copy(fontWeight = FontWeight.Bold))
}
Spacer(Modifier.height(H.stateGap))
Text(row.text, color = C.muted, style = T.body)
Text(row.text, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body)
Spacer(Modifier.height(H.stateTextBottom))
PickerButton("Try again", focused, row.calm, onFocus = onHover, lift = true, onClick = retry)
PickerButton("Try again", focused, row.calm, onFocus = onHover, lift = true, textStyle = T.button, onClick = retry)
}
}
}

View file

@ -66,6 +66,7 @@ data class HomeRow(
val heroKind: String = "next",
val skeleton: Boolean = false,
val calm: Boolean = false,
val allCards: List<HomeTitle> = cards,
) {
val hasSeeAll: Boolean get() = total > cards.size
val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick") 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 }

View file

@ -26,7 +26,7 @@ import androidx.tv.material3.Text
import java.util.Locale
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Type as T
import org.justinvisser.wolfskin.design.HomeType as T
/** Formatting is measured before fitting, so decimal commas obey the same one-line rule. */
@Composable

View file

@ -32,8 +32,8 @@ fun HomeScreen(
val action by rememberUpdatedState(onAction)
val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } }
DisposableEffect(shell, focus) {
shell.contentInput = focus::key
onDispose { shell.contentInput = null }
shell.contentFocus = focus
onDispose { if (shell.contentFocus === focus) shell.contentFocus = null }
}
LaunchedEffect(snapshot.rows, view) {
snapshot.rows.forEach { item ->
@ -41,8 +41,15 @@ fun HomeScreen(
}
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 }
else if (snapshot.rows.getOrNull(view.row)?.focusable != true) view.row = snapshot.rows.indexOfFirst { it.focusable }
if (snapshot.rows.none { it.focusable }) {
if (view.home.focusedKey == null && !shell.navFocused) view.home.loadingEntryRevision = shell.inputRevision
shell.navFocused = true
shell.navIndex = shell.currentTab
} else {
if (view.home.loadingEntryRevision == shell.inputRevision) focus.focus(snapshot.rows.indexOfFirst { it.focusable }, 0)
view.home.loadingEntryRevision = null
if (snapshot.rows.getOrNull(view.row)?.focusable != true) view.row = snapshot.rows.indexOfFirst { it.focusable }
}
view.home.focusedKey = snapshot.rows.getOrNull(view.row)?.id
}
val density = LocalDensity.current
@ -50,7 +57,7 @@ fun HomeScreen(
val topInset = if (snapshot.rows.firstOrNull()?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine
val context = LocalContext.current
LaunchedEffect(view.row, snapshot.rows) {
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { HomeImages.prefetch(context, it) }
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) }
}
LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) {
val key = snapshot.rows.getOrNull(view.row)?.id
@ -86,7 +93,7 @@ fun HomeScreen(
LazyColumn(state = view.listState, modifier = modifier.testTag("wolfskin-home").semantics { stateDescription = "${row?.id}:${focus.column}" }, contentPadding = PaddingValues(top = topInset, bottom = trailingSpace)) {
itemsIndexed(snapshot.rows, key = { _, item -> item.id }, contentType = { _, item -> item.kind.name.lowercase() }) { index, item ->
val selected = index == view.row && !shell.navFocused
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) }
when (item.kind) {
HomeRowKind.Hero -> HomeHero(item, selected, focus.column, if (showRatings) heroRatings 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) }
@ -94,10 +101,10 @@ fun HomeScreen(
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)) {
Glyph(if (item.calm) "eye" else "alert", if (item.calm) C.muted else C.warn, Modifier.size(L.buttonIcon))
Text(item.text, color = if (item.calm) C.muted else C.warn, style = WolfskinTokens.Type.body)
Text(item.text, color = if (item.calm) C.muted else C.warn, style = HomeType.body)
}
HomeRowKind.Explore -> Box(Modifier.fillMaxWidth().height(L.row_endcta).padding(start = L.safeSide, top = H.endTop)) {
PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }, lift = true) { activate(0) }
PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }, lift = true, textStyle = HomeType.button) { activate(0) }
}
}
}

View file

@ -1,7 +1,6 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
@ -15,11 +14,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.zIndex
@ -28,7 +28,7 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Type as T
import org.justinvisser.wolfskin.design.HomeType as T
/** Every lazy row and card uses a stable source identity and an explicit content type. */
@Composable
@ -50,28 +50,25 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title ->
val selected = focused && column == index
Box(Modifier.size(width, height).zIndex(if (selected) 2f else 0f).focusScale(selected, WolfskinTokens.Focus.scaleCard).focusLift(selected, WolfskinTokens.Radius.card, H.cardLiftOffset, H.cardLiftBlur, H.cardLiftOpacity).focusRing(selected, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).testTag("home-card:${row.id}:$index").then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) }.clickable { onCard(index) } else Modifier)) {
Box(Modifier.fillMaxSize().clip(RoundedCornerShape(WolfskinTokens.Radius.card))) {
when {
row.skeleton -> Box(Modifier.fillMaxSize().background(C.surface))
title == null -> SeeAll(row.total)
else -> {
HomeArt(title, width, height, Modifier.fillMaxSize(), landscape = row.cardKind != HomeCardKind.Poster)
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)
if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) }
if (title.resumeSeconds > 0 && title.runtimeSeconds != null) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = H.progressInset).padding(bottom = H.progressBottom).fillMaxWidth().height(L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
Box(Modifier.fillMaxHeight().fillMaxWidth((title.resumeSeconds / title.runtimeSeconds).toFloat().coerceIn(0f, 1f)).background(C.accent))
}
HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth())
}
val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index")
.then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier)
if (title == null && !row.skeleton) {
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label, lift = true)
} else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height, lift = true) {
if (title == null) Box(Modifier.fillMaxSize().background(C.surface)) else {
TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster)
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)
if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) }
if (title.resumeSeconds > 0 && title.runtimeSeconds != null) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = H.progressInset).padding(bottom = if (row.cardKind == HomeCardKind.Poster) H.posterProgressBottom else H.progressBottom).fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
Box(Modifier.fillMaxHeight().fillMaxWidth((title.resumeSeconds / title.runtimeSeconds).toFloat().coerceIn(0f, 1f)).background(C.accent))
}
HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth())
}
}
}
}
if (row.cardKind == HomeCardKind.Poster && focused && !row.skeleton) {
val title = row.cards.getOrNull(column)
val facts = title?.facts ?: "${row.total} titles"
val availability = when {
title == null -> null
row.catalogue -> if (title.held) "In your library" else title.otherOwner?.let { "In $it’s library" }
@ -79,7 +76,10 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
title.resumeSeconds > 0 -> title.remaining
else -> null
}
Caption(title?.title ?: "See all ${row.title}", facts, true, Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(H.captionWidth), availability)
val facts = remember(title, row.total, availability) {
(title?.facts ?: "${row.total} titles").replace(" · ", " · ") + availability?.let { " · $it" }.orEmpty()
}
Caption(title?.title ?: "See all ${row.title}", facts, true, Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(H.captionWidth), accentLastFact = availability != null, titleStyle = T.button, factsStyle = T.caption, titleOverflow = TextOverflow.Ellipsis)
}
}
}
@ -88,20 +88,11 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
private fun TileOverlay(title: HomeTitle) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
Column(Modifier.fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))).padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) {
Text(title.displayTitle, color = C.fg, style = T.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(title.subtitle, color = C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(title.displayTitle, modifier = Modifier.exactLineBox(T.button), color = C.fg, style = T.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(title.subtitle, modifier = Modifier.exactLineBox(T.caption), color = C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
@Composable
private fun SeeAll(total: Int) {
Column(Modifier.fillMaxSize().background(C.surface).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)), verticalArrangement = Arrangement.spacedBy(H.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph("interface", C.accent, Modifier.size(H.seeAllIcon))
Text("See all", color = C.fg, style = T.listTitle)
Text("$total", color = C.muted, style = T.label)
}
}
val HomeRow.cardWidth: Dp get() = when (cardKind) { HomeCardKind.Poster -> K.posterW; HomeCardKind.Tile -> K.tileW; HomeCardKind.Wide -> K.wideW }
val HomeRow.cardHeight: Dp get() = when (cardKind) { HomeCardKind.Poster -> K.posterH; HomeCardKind.Tile -> K.tileH; HomeCardKind.Wide -> K.wideH }

View file

@ -0,0 +1,24 @@
package org.justinvisser.wolfskin.design
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
/** Home keeps full CSS line boxes without moving retained shell or library typography. */
object HomeType {
private fun fullBox(style: TextStyle): TextStyle = style.copy(
platformStyle = PlatformTextStyle(includeFontPadding = false),
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None, LineHeightStyle.Mode.Tight),
)
val body = fullBox(WolfskinTokens.Type.body)
val button = fullBox(WolfskinTokens.Type.button)
val caption = fullBox(WolfskinTokens.Type.caption)
val label = fullBox(WolfskinTokens.Type.label)
val displayHero = fullBox(WolfskinTokens.Type.displayHero)
val stateTitle = fullBox(WolfskinTokens.Type.stateTitle)
val sectionTitle = fullBox(WolfskinTokens.Type.sectionTitle)
val rowHeading = WolfskinTokens.Type.rowHeading
val ratingPlate = WolfskinTokens.Type.ratingPlate
val listTitle = fullBox(WolfskinTokens.Type.listTitle)
val avatarLetterSectionHeader = WolfskinTokens.Type.avatarLetterSectionHeader
}

View file

@ -9,6 +9,7 @@ import androidx.compose.runtime.setValue
class HomeViewMemory {
val ratings = HomeRatingCache()
val firstCards = mutableStateMapOf<String, Int>()
var loadingEntryRevision: Long? = null
var focusedKey: String? = null
var positionedKey: String? = null
var resetScroll by mutableIntStateOf(0)

View file

@ -32,8 +32,8 @@ fun Wordmark(modifier: Modifier = Modifier) {
/** Initials are drawn over opaque person colour at the exact slot size. */
@Composable
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier) {
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus) {
Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) {
Text(person.initial, color = WolfskinTokens.Color.onFocus, style = textStyle)
Text(person.initial, color = letterColor, style = textStyle)
}
}

View file

@ -0,0 +1,56 @@
package org.justinvisser.wolfskin.design
import org.json.JSONArray
import org.json.JSONObject
/** The locked fixture is supplied by debug assets or JVM resources, never a release fallback. */
class LibraryFixture(json: String, private val posterPrefix: String) {
private val document = JSONObject(json)
private val profiles = document.getJSONArray("profiles")
private val people = (0 until profiles.length()).associate { profiles.getJSONObject(it).let { person -> person.getString("key") to person } }
private val holders = buildMap<String, MutableList<String>> {
people.forEach { (key, person) ->
for (destination in listOf("movies", "shows", "anime")) {
val library = person.optJSONArray(destination) ?: continue
for (index in 0 until library.length()) {
val item = library.getJSONObject(index)
getOrPut("${item.optString("type")}:${item.optString("tmdbId")}") { mutableListOf() }.add(key.replaceFirstChar { it.uppercase() })
}
}
}
}
fun library(person: String, destination: String): List<LibraryTitle> = readTitles(people.getValue(person).optJSONArray(destination) ?: JSONArray())
fun picks(person: String, type: String): List<LibraryTitle> = readTitles(people.getValue(person).getJSONArray("discover")).filter { it.type == type }
fun trending(): List<LibraryTitle> = readTitles(document.getJSONObject("seeAll").getJSONArray("trending"))
fun duplicatePage(): List<LibraryTitle> = readTitles(document.getJSONObject("seeAll").getJSONArray("duplicate"))
private fun readTitles(array: JSONArray): List<LibraryTitle> = List(array.length()) { index ->
val item = array.getJSONObject(index)
val type = item.getString("type")
val ratings = item.optJSONObject("ratings")
val owners = holders["$type:${item.optString("tmdbId")}"].orEmpty()
val mine = item.optString("availability") == "in_library"
LibraryTitle(
id = item.getString("id"), title = item.getString("title"), type = type,
year = item.optInt("year").takeIf { it > 0 }, runtimeSeconds = item.optLong("runtimeSeconds").takeIf { it > 0 },
seasons = item.optInt("seasonCount").takeIf { it > 0 },
poster = item.optString("poster").takeIf { it.isNotBlank() && it != "null" }?.let { posterPrefix + it },
played = item.optBoolean("played"), progress = item.optDouble("progress", 0.0).toFloat(),
addedAt = item.optString("addedAt"), ratings = buildList {
fun append(source: String, field: String, label: String? = null) {
val score = ratings?.optDouble(field, Double.NaN) ?: Double.NaN
val maximum = if (source.startsWith("rt")) 100 else 10
if (score.isFinite() && score in 0.0..maximum.toDouble()) add(LibraryRating(source, score, label))
}
append("rtCritics", "rtCritics", ratings?.optString("rtCriticsLabel"))
append("rtAudience", "rtAudience", ratings?.optString("rtAudienceLabel"))
if (type == "movie" && (ratings?.optInt("imdbVotes", 0) ?: 0) > 0) append("imdb", "imdb")
if (isEmpty() && (ratings?.optInt("tmdbVotes", 0) ?: 0) > 0) append("tmdb", "tmdb")
},
availability = if (mine) "In your library" else if (owners.isNotEmpty()) "In ${owners.joinToString(" and ")}’s library" else "",
badge = if (mine) "held" else null,
tmdbId = item.optString("tmdbId"),
)
}
}

View file

@ -0,0 +1,156 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.derivedStateOf
import java.util.Locale
private val leadingArticle = Regex("^(the|a|an)\\s+", RegexOption.IGNORE_CASE)
/** Immutable title facts cross the provider boundary without SDK objects. */
data class LibraryTitle(
val id: String,
val title: String,
val type: String,
val year: Int? = null,
val runtimeSeconds: Long? = null,
val seasons: Int? = null,
val poster: String? = null,
val played: Boolean = false,
val progress: Float = 0f,
val addedAt: String = "",
val ratings: List<LibraryRating> = emptyList(),
val availability: String = "",
val badge: String? = null,
val tmdbId: String? = null,
) {
val sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT)
val catalogueKey: String = "$type:${tmdbId ?: id}"
val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#'
val leadScore: Double get() = ratings.firstOrNull()?.let { if (it.source.startsWith("rt")) it.score else it.score * 10 } ?: -1.0
fun facts(catalogue: Boolean): String = buildList {
year?.let { add(it.toString()) }
add(if (type == "movie") runtimeSeconds?.let { val minutes = (it + 30) / 60; if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" } ?: "Movie" else seasons?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series")
if (catalogue) { if (availability.isNotEmpty()) add(availability) }
else if (played) add("Watched")
else if (progress > 0f && runtimeSeconds != null) add("${((runtimeSeconds * (1 - progress) + 30) / 60).toInt().coerceAtLeast(1)} min left")
}.joinToString(" · ")
}
/** Ordered rating entries are formatted after the active TV locale is known. */
data class LibraryRating(val source: String, val score: Double, val label: String? = null) {
fun formatted(locale: Locale = Locale.getDefault()): String = if (source.startsWith("rt")) "${score.toInt()}%" else String.format(locale, "%.1f", score)
}
enum class LibraryStatus { Ready, Loading, Unavailable }
enum class GridTail { None, Retry, ShowMore }
/** A grid retains its exact card, toolbar choices and rail return point with the shell view. */
class LibraryGridState(
val title: String,
items: List<LibraryTitle>,
val catalogue: Boolean = false,
val onTitle: (LibraryTitle) -> Unit = {},
val onRecover: () -> Unit = {},
val onDiscover: () -> Unit = {},
val onReadAhead: (Int) -> Unit = {},
) : ShellContentFocus {
var source by mutableStateOf(items)
var status by mutableStateOf(LibraryStatus.Ready)
var savedAt by mutableStateOf<String?>(null)
var sort by mutableStateOf(0)
var unwatched by mutableStateOf(false)
var mediaType by mutableStateOf("all")
var tail by mutableStateOf(GridTail.None)
var pagination: LibraryPagination? = null
var pageRevision by mutableStateOf(0)
var refreshRevision by mutableStateOf(0)
var pointerReentry: (() -> Int?)? = null
var index by mutableStateOf(0)
var toolbar by mutableStateOf(false)
var tool by mutableStateOf(0)
var inRail by mutableStateOf(false)
var railLetter by mutableStateOf('#')
private var railReturn = 0
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" }
val hasToolbar: Boolean get() = !catalogue || mixed
val items: List<LibraryTitle> by derivedStateOf {
val filtered = source.filter { (!unwatched || !it.played) && (mediaType == "all" || it.type == mediaType) }
if (catalogue) filtered else when (sort) {
1 -> filtered.sortedWith(compareByDescending<LibraryTitle> { it.addedAt }.thenBy { it.sortName })
2 -> filtered.sortedWith(compareByDescending<LibraryTitle> { it.leadScore }.thenBy { it.sortName })
else -> filtered.sortedBy { it.sortName }
}
}
val railVisible: Boolean get() = !catalogue && sort == 0 && items.size >= WolfskinTokens.Behaviour.letterRailMin && status == LibraryStatus.Ready
val tailIndex: Int get() = ((items.size + columns - 1) / columns) * columns
override val cardKey: String? get() = if (toolbar || inRail || status != LibraryStatus.Ready || items.isEmpty()) null else items.getOrNull(index)?.id ?: "grid-tail"
val focusedTitle: LibraryTitle? get() = if (toolbar || inRail) null else items.getOrNull(index)
override fun key(key: ShellKey): Boolean {
if (inRail) return railKey(key)
if (key == ShellKey.Back) return false
if (key == ShellKey.Up || key == ShellKey.Down || key == ShellKey.Left || key == ShellKey.Right) {
val reentry = pointerReentry
pointerReentry = null
reentry?.invoke()?.let { index = it; toolbar = false; return true }
}
if (status == LibraryStatus.Loading) return false
if (status == LibraryStatus.Unavailable || (items.isEmpty() && !toolbar)) {
when (key) {
ShellKey.Up -> if (status == LibraryStatus.Ready && hasToolbar) toolbar = true else return false
ShellKey.Ok -> if (status == LibraryStatus.Unavailable) onRecover() else if (unwatched) { unwatched = false; index = 0 } else onDiscover()
else -> Unit
}
return true
}
if (toolbar) return toolbarKey(key)
val currentItems = items
when (key) {
ShellKey.Up -> if (index >= columns) index -= columns else if (hasToolbar) toolbar = true else return false
ShellKey.Down -> when {
index / columns < currentItems.lastIndex / columns -> index = (index + columns).coerceAtMost(currentItems.lastIndex)
tail != GridTail.None && index < tailIndex -> index = tailIndex
}
ShellKey.Left -> if (index % columns > 0) index--
ShellKey.Right -> if (index % columns < columns - 1 && index < currentItems.lastIndex) index++ else if (railVisible) { inRail = true; railReturn = index; railLetter = currentItems[index].letter }
ShellKey.Ok -> currentItems.getOrNull(index)?.let(onTitle) ?: if (pagination != null) pageRevision++ else onRecover()
}
onReadAhead(index)
return true
}
private fun toolbarKey(key: ShellKey): Boolean {
val count = if (catalogue) 3 else 2
when (key) {
ShellKey.Up -> return false
ShellKey.Down -> { toolbar = false; index = index.coerceIn(0, items.lastIndex.coerceAtLeast(0)) }
ShellKey.Left -> tool = (tool - 1).coerceAtLeast(0)
ShellKey.Right -> tool = (tool + 1).coerceAtMost(count - 1)
ShellKey.Ok -> {
if (catalogue) mediaType = listOf("all", "movie", "tv")[tool]
else if (tool == 0) sort = (sort + 1) % 3 else unwatched = !unwatched
index = 0
}
else -> Unit
}
return true
}
private fun railKey(key: ShellKey): Boolean {
val letters = items.map { it.letter }.distinct()
when (key) {
ShellKey.Up, ShellKey.Down -> {
val next = (letters.indexOf(railLetter) + if (key == ShellKey.Up) -1 else 1).coerceIn(0, letters.lastIndex)
railLetter = letters[next]
}
ShellKey.Left, ShellKey.Ok -> { index = items.indexOfFirst { it.letter == railLetter }; inRail = false }
ShellKey.Back -> { index = railReturn; inRail = false }
else -> Unit
}
return true
}
}

View file

@ -0,0 +1,36 @@
package org.justinvisser.wolfskin.design
import kotlinx.coroutines.CancellationException
/** Provider pages are merged by title identity; transport pagination is never displayed. */
data class LibraryPage(val items: List<LibraryTitle>, val hasMore: Boolean)
/** Duplicate-only reads pause until Show more; failures pause until Try again. */
class LibraryPagination(private val grid: LibraryGridState, private val load: suspend (Int) -> LibraryPage) {
private var nextPage = 2
private var hasMore = true
private var reading = false
suspend fun readAhead(force: Boolean = false) {
if (reading || !hasMore || !force && (grid.tail != GridTail.None || grid.index < grid.items.size - WolfskinTokens.Behaviour.nextPageCards)) return
reading = true
try {
val page = load(nextPage)
val known = grid.source.mapTo(mutableSetOf()) { it.catalogueKey }
val additional = page.items.filter { known.add(it.catalogueKey) }
nextPage++
hasMore = page.hasMore
if (additional.isEmpty() && page.items.isNotEmpty() && hasMore) grid.tail = GridTail.ShowMore
else {
grid.source = grid.source + additional
grid.tail = GridTail.None
grid.index = grid.index.coerceAtMost(grid.items.lastIndex.coerceAtLeast(0))
}
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
grid.tail = GridTail.Retry
} finally {
reading = false
}
}
}

View file

@ -0,0 +1,115 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L
/** Poster slots use exact requests and share the shell's draw-only focus treatment. */
@Composable
fun LibraryPoster(
title: LibraryTitle,
focused: Boolean,
ratingsVisible: Boolean,
catalogue: Boolean,
modifier: Modifier = Modifier,
offlineArt: ((String) -> ImageBitmap?)? = null,
) {
TitleCard(focused, modifier.testTag("poster-${title.id}")) {
val bitmap = remember(title.poster) { title.poster?.let { offlineArt?.invoke(it) } }
if (bitmap != null) Image(bitmap, title.title, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else if (title.poster != null && offlineArt == null) {
val context = LocalContext.current
val request = remember(title.poster, context) { WolfskinImages.request(context, title.poster, (WolfskinTokens.Card.posterW.value * 2).toInt(), (WolfskinTokens.Card.posterH.value * 2).toInt(), true) }
AsyncImage(request, title.title, imageLoader = WolfskinImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
} else MissingLibraryArt(title)
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
if (title.progress > 0f && title.progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap)
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).background(C.progressTrack, CircleShape)) {
Box(Modifier.fillMaxHeight().fillMaxWidth(title.progress).background(C.accent, CircleShape))
}
}
if (catalogue && title.badge != null) Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset)
.size(WolfskinTokens.Layout.badge).background(if (title.badge == "held") C.ok else C.badgeRest, CircleShape), contentAlignment = Alignment.Center) {
Text(if (title.badge == "held") "✓" else if (title.badge == "failed") "!" else "◷", color = if (title.badge == "held") C.onFocus else C.accent, style = WolfskinTokens.Type.button)
}
}
}
@Composable
private fun MissingLibraryArt(title: LibraryTitle) {
val hue = remember(title.title) { title.title.fold(0) { hash, character -> (hash * L.fallbackHueMultiplier.toInt() + character.code) % 360 }.toFloat() }
Column(Modifier.fillMaxSize().background(Brush.linearGradient(listOf(
Color.hsl(hue, L.fallbackSaturationTop, L.fallbackLightnessTop),
Color.hsl(hue, L.fallbackSaturationBottom, L.fallbackLightnessBottom))))
.padding(L.fallbackPadding), verticalArrangement = Arrangement.SpaceBetween) {
Glyph(if (title.type == "movie") "film" else "nextup", C.fg.copy(alpha = L.fallbackIconOpacity), Modifier.size(L.fallbackIcon))
Text(title.title, style = WolfskinTokens.Type.button, color = C.fg, maxLines = 4)
}
}
/** A measured prefix stays on one line, shrinking only to the approved type floor. */
@Composable
fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifier, visible: Boolean = true) {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val available = with(density) { (WolfskinTokens.Card.posterW - L.plateSide * 2).toPx() }
val formatted = ratings.map { it.formatted() }
fun width(count: Int, size: TextUnit): Float = (0 until count).sumOf { index ->
val labelWidth = measurer.measure(formatted[index], WolfskinTokens.Type.ratingPlate.copy(fontSize = size)).size.width
val pixels = with(density) { size.toPx() }
(labelWidth + pixels * (if (ratings[index].source in listOf("imdb", "tmdb")) L.wordmarkRatio else 1f) + pixels * L.markGapEm + if (index > 0) pixels * L.plateGapEm else 0f).toDouble()
}.toFloat()
var size = WolfskinTokens.Type.ratingPlate.fontSize
var count = ratings.size
while (size > L.plateFloor.fontSize && width(count, size) > available) size = (size.value - L.plateStep).coerceAtLeast(L.plateFloor.fontSize.value).sp
while (count > 1 && width(count, size) > available) count--
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "library-plate")
Row(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha }.background(Brush.verticalGradient(
0f to C.bg.copy(alpha = L.plateOpacity), L.plateMidStop to C.bg.copy(alpha = L.plateMidOpacity), 1f to Color.Transparent))
.padding(start = L.plateSide, end = L.plateSide, top = L.plateTop, bottom = L.plateBottom),
horizontalArrangement = Arrangement.spacedBy(with(density) { (size.toPx() * L.plateGapEm).toDp() }), verticalAlignment = Alignment.CenterVertically) {
repeat(count) { index ->
val rating = ratings[index]
val mark = when (rating.source) {
"rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh
"rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright
"imdb" -> R.drawable.ic_rating_imdb
else -> R.drawable.ic_rating_tmdb
}
val markHeight = with(density) { size.toDp() }
val unknownCritic = rating.source == "rtCritics" && rating.label != "Fresh" && rating.label != "Certified Fresh" && rating.label != "Rotten"
val desaturated = remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) }
Row(horizontalArrangement = Arrangement.spacedBy(markHeight * L.markGapEm), verticalAlignment = Alignment.CenterVertically) {
Image(androidx.compose.ui.res.painterResource(mark), rating.source,
Modifier.size(markHeight * if (rating.source in listOf("imdb", "tmdb")) L.wordmarkRatio else 1f, markHeight)
.graphicsLayer { this.alpha = if (unknownCritic) L.unknownCriticOpacity else 1f },
colorFilter = if (unknownCritic) desaturated else null)
Text(formatted[index], color = C.fg, style = WolfskinTokens.Type.ratingPlate.copy(fontSize = size), maxLines = 1, softWrap = false)
}
}
}
}

View file

@ -0,0 +1,274 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as G
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
/** One lazy frame owns headers, toolbar, continuous poster rows and recoverable tails. */
@Composable
fun LibraryScreen(
shell: ShellState,
grid: LibraryGridState,
modifier: Modifier = Modifier,
ratingsSignedIn: Boolean = false,
cachedRatings: Set<String> = emptySet(),
ratingsReachable: Boolean = true,
offlineArt: ((String) -> ImageBitmap?)? = null,
) {
val view = shell.view
val density = LocalDensity.current
val context = LocalContext.current
val active = !shell.navFocused && !shell.pickerVisible
val items = grid.items
val focused = grid.focusedTitle
var plateKey by remember { mutableStateOf<String?>(null) }
DisposableEffect(shell, grid) {
shell.contentFocus = grid
onDispose { if (shell.contentFocus === grid) shell.contentFocus = null }
}
LaunchedEffect(grid) {
var revision = grid.pageRevision
snapshotFlow { Triple(grid.index, grid.source.size, grid.pageRevision) }.collect { (_, _, nextRevision) ->
grid.pagination?.readAhead(force = nextRevision != revision)
revision = nextRevision
}
}
LaunchedEffect(grid.index / grid.columns, items, offlineArt) {
if (offlineArt == null) {
val start = (grid.index / grid.columns + 1) * grid.columns
for (index in start until (start + grid.columns).coerceAtMost(items.size)) {
items[index].poster?.let { WolfskinImages.loader(context).enqueue(WolfskinImages.request(context, it, (K.posterW.value * 2).toInt(), (K.posterH.value * 2).toInt(), true)) }
}
}
}
LaunchedEffect(focused?.id, active, ratingsSignedIn, ratingsReachable) {
plateKey = null
if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings)) {
if (focused.id !in cachedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong())
plateKey = focused.id
}
}
LaunchedEffect(grid.status) {
if (grid.status == LibraryStatus.Loading) shell.focusNav(shell.currentTab)
}
val headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1
val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap
LaunchedEffect(grid.index, grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) {
val index = if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index
val row = index.coerceAtLeast(0) / grid.columns
if (grid.toolbar || row == 0 && active) {
animateLibraryScroll(view, headerCount, 0f, density)
} else if (row > 0) {
val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * row - L.pinnedLine).toPx() }
animateLibraryScroll(view, headerCount, target, density)
}
}
Box(modifier.semantics { stateDescription = "${grid.title}:${if (grid.toolbar) "tool-${grid.tool}" else if (grid.inRail) "rail-${grid.railLetter}" else focused?.id ?: "recovery"}" }.testTag("library-grid")) {
LazyColumn(state = view.listState, modifier = Modifier.fillMaxSize().pointerInput(grid, headerCount) {
awaitPointerEventScope {
while (true) {
if (awaitPointerEvent().type == PointerEventType.Scroll) grid.pointerReentry = {
val visible = view.listState.layoutInfo.visibleItemsInfo
if (visible.none { it.index == headerCount + grid.index / grid.columns }) {
visible.firstOrNull { it.index >= headerCount }?.let { ((it.index - headerCount) * grid.columns).coerceAtMost(grid.items.lastIndex) }
} else null
}
}
}
}, contentPadding = PaddingValues(bottom = WolfskinTokens.Shell.stageHeight - L.pinnedLine - rowHeight)) {
item(key = "header", contentType = "header") { LibraryHeader(grid, shell.person) }
if (headerCount == 2) item(key = "toolbar", contentType = "chips") { LibraryToolbar(grid, active) }
when (grid.status) {
LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) {
Box(Modifier.fillMaxWidth().height(rowHeight)) {
Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) }
}
}
}
LibraryStatus.Unavailable -> item(key = "unavailable", contentType = "state") { LibraryRecovery(grid, active) }
LibraryStatus.Ready -> if (items.isEmpty()) item(key = "empty", contentType = "state") { LibraryRecovery(grid, active && !grid.toolbar) }
else {
items((items.size + grid.columns - 1) / grid.columns, key = { row -> "grid-${items[row * grid.columns].id}" }, contentType = { "grid" }) { row ->
Box(Modifier.fillMaxWidth().height(rowHeight)) {
Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
for (column in 0 until grid.columns) {
val index = row * grid.columns + column
val title = items.getOrNull(index) ?: break
key(if (grid.catalogue) title.catalogueKey else title.id) {
val isFocused = active && !grid.toolbar && !grid.inRail && grid.index == index
LibraryPoster(title, isFocused, plateKey == title.id && isFocused, grid.catalogue,
Modifier.zIndex(if (isFocused) 1f else 0f).pointerInput(index, grid) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if ((event.type == PointerEventType.Move || event.type == PointerEventType.Enter) && event.changes.any { it.type == PointerType.Mouse }) {
grid.index = index; grid.toolbar = false; shell.navFocused = false
}
}
}
}.clickable { grid.index = index; grid.toolbar = false; shell.navFocused = false; grid.onTitle(title) }, offlineArt)
}
}
}
if (active && !grid.toolbar && !grid.inRail && grid.index / grid.columns == row) {
val title = items.getOrNull(grid.index)
if (title != null) Caption(title.title, title.facts(grid.catalogue), true,
Modifier.offset(L.safeSide + K.posterPitch * (grid.index % grid.columns), K.posterH + L.captionOffset).width(G.captionWidth),
accentLastFact = if (grid.catalogue) title.availability.isNotEmpty() else title.played || title.progress > 0f)
}
}
}
if (grid.tail != GridTail.None) item(key = "tail", contentType = "grid") {
LibraryTail(grid, active && grid.index == grid.tailIndex)
}
}
}
}
if (grid.railVisible) LibraryRail(grid)
}
}
@Composable
private fun LibraryHeader(grid: LibraryGridState, person: ShellPerson) {
val count = if (grid.catalogue) grid.items.size else grid.source.size
Box(Modifier.fillMaxWidth().height(L.row_lhead)) {
Row(Modifier.offset(L.safeSide, L.navBand), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsHeaderGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
Text(grid.title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle)
Text(if (grid.status != LibraryStatus.Ready) "" else "$count ${if (grid.catalogue) "titles" else if (grid.title == "Movies") if (count == 1) "film" else "films" else "series"}", modifier = Modifier.alignByBaseline(), color = C.muted, style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal))
grid.savedAt?.let { Text("Saved $it", color = C.warn, style = WolfskinTokens.Type.caption) }
}
}
}
@Composable
private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) {
Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) {
val labels = if (grid.catalogue) listOf("All", "Movies", "Series") else listOf("Sort ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}", if (grid.unwatched) "Unwatched" else "All")
labels.forEachIndexed { index, label ->
val focused = active && grid.toolbar && grid.tool == index
val selected = if (grid.catalogue) listOf("all", "movie", "tv")[index] == grid.mediaType else index == 1 && grid.unwatched
Row(Modifier.height(G.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, CircleShape)
.then(if (selected && !focused) Modifier.border(G.borderWidth, C.accent, CircleShape) else Modifier)
.clickable { grid.toolbar = true; grid.tool = index; grid.key(ShellKey.Ok) }
.padding(horizontal = G.chipPadding), horizontalArrangement = Arrangement.spacedBy(G.chipContentGap), verticalAlignment = Alignment.CenterVertically) {
val color = if (focused) C.onFocus else if (selected) C.accentText else C.fg
if (!grid.catalogue) Glyph(if (index == 0) "advanced" else "eye", color, Modifier.size(G.chipIcon))
if (!grid.catalogue && index == 0) Text(buildAnnotatedString {
withStyle(SpanStyle(color = color.copy(alpha = G.chipLabelOpacity), fontWeight = FontWeight.Medium)) { append("Sort") }
append(" ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}")
}, color = color, style = WolfskinTokens.Type.label)
else Text(label, color = color, style = WolfskinTokens.Type.label)
}
}
}
}
@Composable
private fun LibraryRecovery(grid: LibraryGridState, focused: Boolean) {
val lineBoxes = androidx.compose.ui.text.style.LineHeightStyle(
androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center,
androidx.compose.ui.text.style.LineHeightStyle.Trim.None,
)
val unavailable = grid.status == LibraryStatus.Unavailable
val title = if (unavailable) "Couldn’t reach your library" else if (grid.unwatched) "Nothing unwatched in ${grid.title}" else "Nothing in ${grid.title} yet"
val text = if (unavailable) "Jellyfin isn’t answering. Nothing here is lost; try again in a moment." else if (grid.unwatched) "Everything here has been watched." else "Titles you or others request for you land here."
val button = if (unavailable) "Try again" else if (grid.unwatched) "Show all" else "Open Discover"
Box(Modifier.fillMaxWidth().height(L.row_state).padding(start = L.safeSide, top = G.panelTop)) {
Column(Modifier.width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
.border(G.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(G.borderWidth)
.padding(horizontal = G.panelPaddingX, vertical = G.panelPaddingY)) {
Row(horizontalArrangement = Arrangement.spacedBy(G.panelIconGap), verticalAlignment = Alignment.CenterVertically) {
Glyph(if (unavailable) "alert" else if (grid.title == "Movies") "film" else "nextup", if (unavailable) C.warn else C.accentText, Modifier.size(WolfskinTokens.Shell.panelIcon))
Text(title, color = C.fg, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold, lineHeightStyle = lineBoxes))
}
Spacer(Modifier.height(G.panelTitleGap))
Text(text, color = C.muted, style = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBoxes))
Spacer(Modifier.height(G.panelTextGap))
PickerButton(button, focused, !unavailable) { grid.key(ShellKey.Ok) }
}
}
}
@Composable
private fun LibraryTail(grid: LibraryGridState, focused: Boolean) {
val retry = grid.tail == GridTail.Retry
Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) {
Column(Modifier.offset(L.safeSide).size(K.posterW, K.posterH).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)).border(G.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.card))
.clickable { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }.padding(G.tailPadding),
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))
Text(if (retry) "Try again" else "Show more", color = C.fg, style = WolfskinTokens.Type.button, textAlign = TextAlign.Center)
Text(if (retry) "More titles couldn’t load" else "Only titles already shown came back", color = C.muted, style = WolfskinTokens.Type.caption, textAlign = TextAlign.Center)
}
Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused,
Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth))
}
}
@Composable
private fun BoxScope.LibraryRail(grid: LibraryGridState) {
Column(Modifier.align(Alignment.TopEnd).padding(end = L.safeSide, top = L.navBand).width(G.railWidth)) {
val letters = remember(grid.items) { grid.items.map { it.letter }.toSet() }
for (letter in "#ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
val current = if (grid.inRail) grid.railLetter == letter else grid.focusedTitle?.letter == letter
Box(Modifier.width(G.railWidth).height(if (current && grid.inRail) G.railFocusHeight else G.railLetterHeight)
.background(if (current && grid.inRail) C.fg else androidx.compose.ui.graphics.Color.Transparent, CircleShape)
.clickable(enabled = letter in letters) { grid.railLetter = letter; grid.inRail = true; grid.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
Text(letter.toString(), color = if (current) if (grid.inRail) C.onFocus else C.accent else if (letter in letters) C.muted else C.railEmpty,
style = if (current && grid.inRail) G.railFocusedLetter else G.railLetter)
}
}
}
}
private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int, target: Float, density: androidx.compose.ui.unit.Density) {
val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap
val first = view.listState.firstVisibleItemIndex
val current = with(density) {
(if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead +
if (first < headerCount) WolfskinTokens.Shell.zero else
(if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
} + view.listState.firstVisibleItemScrollOffset
val distance = target - current
if (distance == 0f) return
view.listState.scroll {
var previous = 0f
androidx.compose.animation.core.animate(0f, distance,
animationSpec = androidx.compose.animation.core.tween(WolfskinTokens.Motion.scroll,
easing = androidx.compose.animation.core.CubicBezierEasing(.33f, 1f, .68f, 1f))) { value, _ ->
scrollBy(value - previous)
previous = value
}
}
}

View file

@ -0,0 +1,31 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.tv.material3.Text
/** Shelves expose the full underlying list without displaying transport pagination. */
@Composable
fun LibrarySeeAllCard(count: Int, focused: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier,
width: Dp = WolfskinTokens.Card.posterW, height: Dp = WolfskinTokens.Card.posterH,
titleStyle: TextStyle = WolfskinTokens.Type.listTitle, countStyle: TextStyle = WolfskinTokens.Type.label,
lift: Boolean = false) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
TitleCard(focused, modifier.clickable(onClick = onClick), width, height, lift) {
Column(Modifier.fillMaxSize().background(WolfskinTokens.Color.surface, shape)
.border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape),
verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge))
Text("See all", modifier = Modifier.exactLineBox(titleStyle), color = WolfskinTokens.Color.fg, style = titleStyle)
Text(count.toString(), modifier = Modifier.exactLineBox(countStyle), color = WolfskinTokens.Color.muted, style = countStyle)
}
}
}

View file

@ -22,6 +22,12 @@ sealed interface ShellAction {
enum class ShellKey { Up, Down, Left, Right, Ok, Back }
enum class PeopleStatus { Ready, Loading, Unavailable }
/** Screens participate in the shell's hold guard and Back ladder without a second key adapter. */
interface ShellContentFocus {
val cardKey: String?
fun key(key: ShellKey): Boolean
}
/** A root destination retains focus, horizontal tracks and its actual Compose scroller. */
class RememberedView {
val listState = LazyListState()
@ -29,6 +35,7 @@ class RememberedView {
val cards = mutableStateMapOf<String, Int>()
var savedAt by mutableStateOf<String?>(null)
val home by lazy { HomeViewMemory() }
var library: LibraryGridState? = null
}
/** Focus and Back transitions are independent of drawing and server availability. */
@ -56,7 +63,9 @@ class ShellState(
private var pickerReturnRoute = "home"
private val views = mutableStateMapOf<Pair<String, String>, RememberedView>()
private var armedCard: String? = null
var contentInput: ((ShellKey, Boolean, Int) -> Boolean)? = null
var contentFocus: ShellContentFocus? = null
var inputRevision = 0L
private set
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
@ -69,13 +78,12 @@ class ShellState(
}
/** Pointer navigation uses the same transitions as D-pad selection. */
fun focusNav(index: Int) { navIndex = index; navFocused = true }
fun focusPerson(index: Int) { pickerIndex = index; gearFocused = false }
fun focusNav(index: Int) { inputRevision++; navIndex = index; navFocused = true }
fun focusPerson(index: Int) { inputRevision++; pickerIndex = index; gearFocused = false }
/** Repeats are consumed, and a row activation is cancelled when focus moves. */
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = null): Boolean {
if (!pickerVisible && !navFocused && !requestsVisible && route != "settings" &&
contentInput?.invoke(key, down, repeat) == true) return true
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = if (!pickerVisible && !navFocused && !requestsVisible) contentFocus?.cardKey else null): Boolean {
if (down) inputRevision++
if (key == ShellKey.Ok) {
if (repeat > 0) return true
if (card != null) {
@ -159,6 +167,7 @@ class ShellState(
}
private fun contentKey(key: ShellKey) {
if (contentFocus?.key(key) == true) return
when (key) {
ShellKey.Up -> if (route == "settings" && settingsRow > 0) settingsRow-- else { navFocused = true; navIndex = currentTab }
ShellKey.Down -> if (route == "settings") settingsRow = (settingsRow + 1).coerceAtMost(SETTINGS_GROUPS.lastIndex)
@ -190,10 +199,12 @@ class ShellState(
onAction(ShellAction.HandOff(group))
}
}
else contentFocus?.key(ShellKey.Ok)
}
/** Back never discards a root view, including the card below a focused nav. */
fun back() {
if (!pickerVisible && !navFocused && contentFocus?.key(ShellKey.Back) == true) return
if (route == "settings") {
route = pickerReturnRoute
onAction(ShellAction.Pop)

View file

@ -0,0 +1,26 @@
package org.justinvisser.wolfskin.design
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.AlignmentLine
import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.constrainHeight
import kotlin.math.roundToInt
/** Android's intrinsic paragraph minimum must not enlarge an explicitly untrimmed CSS line box. */
fun Modifier.exactLineBox(style: TextStyle): Modifier {
if (style.lineHeightStyle?.trim != LineHeightStyle.Trim.None) return this
return layout { measurable, constraints ->
val placeable = measurable.measure(constraints.copy(minHeight = 0))
val lineHeight = style.lineHeight.toPx()
val first = placeable[FirstBaseline]
val last = placeable[LastBaseline]
val lines = if (first == AlignmentLine.Unspecified || last == AlignmentLine.Unspecified) 1
else ((last - first) / lineHeight).roundToInt() + 1
val height = constraints.constrainHeight((lineHeight * lines).roundToInt())
layout(placeable.width, height) { placeable.placeRelative(0, (height - placeable.height) / 2) }
}
}

View file

@ -35,20 +35,21 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
private fun SlotImage(source: String, width: Dp, height: Dp, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) {
val context = LocalContext.current
val request = remember(context, source, width, height, poster) {
HomeImages.request(context, source, (width.value * 2).toInt(), (height.value * 2).toInt(), poster)
WolfskinImages.request(context, source, (width.value * 2).toInt(), (height.value * 2).toInt(), poster)
}
AsyncImage(request, imageLoader = HomeImages.loader(context), contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, onError = { onError() })
AsyncImage(request, imageLoader = WolfskinImages.loader(context), contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, onError = { onError() })
}
/** Poster-only landscapes use the upright one-sheet over a dimmed wash of the same image. */
@Composable
fun HomeArt(title: HomeTitle, width: Dp, height: Dp, modifier: Modifier = Modifier, landscape: Boolean = false, hero: Boolean = false) {
val source = if (landscape) title.backdrop ?: title.poster else title.poster
fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height: Dp, modifier: Modifier = Modifier,
backdrop: String? = null, posterOnly: Boolean = false, landscape: Boolean = false, hero: Boolean = false) {
val source = if (landscape) backdrop ?: poster else poster
val failed = remember(source) { androidx.compose.runtime.mutableStateOf(false) }
val missing = source == null || failed.value
val density = LocalDensity.current
val fallback = if (missing && !hero) remember(title.title, width, height, density) {
val hue = title.title.fold(0) { hash, character -> (hash * 31 + character.code) % 360 }.toFloat()
val fallback = if (missing && !hero) remember(title, width, height, density) {
val hue = title.fold(0) { hash, character -> (hash * 31 + character.code) % 360 }.toFloat()
val radians = Math.toRadians(160.0)
val directionX = kotlin.math.sin(radians).toFloat()
val directionY = -kotlin.math.cos(radians).toFloat()
@ -60,7 +61,7 @@ fun HomeArt(title: HomeTitle, width: Dp, height: Dp, modifier: Modifier = Modifi
} else null
Box(modifier.then(if (fallback != null) Modifier.background(fallback) else Modifier.background(C.surface2))) {
if (!missing) {
if (landscape && (title.posterOnly || title.backdrop == null) && title.poster != null) {
if (landscape && (posterOnly || backdrop == null) && poster != null) {
val wash = remember(hero) {
val matrix = ColorMatrix().apply { setToSaturation(if (hero) 1.2f else 1f) }
val brightness = if (hero) .55f else .5f
@ -72,15 +73,15 @@ fun HomeArt(title: HomeTitle, width: Dp, height: Dp, modifier: Modifier = Modifi
.requiredSize(width + inset * 2, height + inset * 2)
.blur(if (hero) H.heroBlur else H.cardBlur)
.graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } }
SlotImage(title.poster, width, height, washModifier, colorFilter = wash, onError = { failed.value = true })
SlotImage(poster, width, height, washModifier, colorFilter = wash, onError = { failed.value = true })
val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset
val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f)
val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop)
SlotImage(title.poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true })
SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true })
} else SlotImage(source, if (hero) WolfskinTokens.Card.posterW * 5 else width, if (hero) WolfskinTokens.Card.posterW * 5 * (9f / 16f) else height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true })
} else if (!hero) {
Glyph(if (title.type == "movie") "film" else "nextup", C.fg.copy(alpha = .45f), Modifier.offset(H.cardPadding, H.cardPadding).size(H.artGlyph))
if (!landscape) Text(title.title, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 4, modifier = Modifier.align(Alignment.BottomStart).padding(H.cardPadding))
Glyph(if (type == "movie") "film" else "nextup", C.fg.copy(alpha = .45f), Modifier.offset(H.cardPadding, H.cardPadding).size(H.artGlyph))
if (!landscape) Text(title, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 4, modifier = Modifier.align(Alignment.BottomStart).padding(H.cardPadding))
} else HomeAmbient(Modifier.fillMaxSize())
}
}

View file

@ -0,0 +1,24 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
/** Shelf, grid and continuation cards share one fixed slot and draw-only focus surface. */
@Composable
fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = WolfskinTokens.Card.posterW,
height: Dp = WolfskinTokens.Card.posterH, lift: Boolean = false, content: @Composable BoxScope.() -> Unit) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.then(if (lift) Modifier.focusLift(focused, WolfskinTokens.Radius.card, WolfskinTokens.Home.cardLiftOffset, WolfskinTokens.Home.cardLiftBlur, WolfskinTokens.Home.cardLiftOpacity) else Modifier)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent)) {
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
}
}

View file

@ -102,7 +102,8 @@ private fun RecoveryPicker(state: ShellState) {
/** Recovery actions remain focusable while profiles cannot be read. */
@Composable
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, lift: Boolean = false, onClick: () -> Unit) {
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, lift: Boolean = false,
textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) {
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.then(if (lift) Modifier.focusLift(focused, WolfskinTokens.Radius.button, WolfskinTokens.Home.buttonLiftOffset, WolfskinTokens.Home.buttonLiftBlur, WolfskinTokens.Home.buttonLiftOpacity) else Modifier)
@ -112,7 +113,7 @@ fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) {
if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
Text(label, color = if (primary) C.onFocus else C.fg, style = WolfskinTokens.Type.button)
Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle)
}
}
}

View file

@ -11,8 +11,8 @@ import coil3.request.ImageRequest
import coil3.request.allowRgb565
import kotlinx.coroutines.Dispatchers
/** Home shares one bounded loader; upstream's hidden-switch screens retain their existing loader. */
object HomeImages {
/** Wolfskin screens share one bounded loader; hidden-switch screens retain the upstream loader. */
object WolfskinImages {
private var application: Context? = null
private var loader: ImageLoader? = null
private const val MEBIBYTE = 1024L * 1024L

View file

@ -285,6 +285,53 @@ object WolfskinTokens {
const val navBandOpacity = 0.72f
const val navSolidStop = 0.84f
}
object Library {
val chipHeight = 34.0.dp
val chipPadding = 14.0.dp
val chipGap = 8.0.dp
val chipTop = 8.0.dp
val chipIcon = 13.0.dp
val captionWidth = 320.0.dp
const val captionSeparatorOpacity = 0.4f
val plateTop = 7.0.dp
val plateSide = 8.0.dp
val plateBottom = 23.0.dp
val plateFloor = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
const val plateStep = 0.25f
const val plateGapEm = 0.64f
const val markGapEm = 0.29f
const val wordmarkRatio = 1.8f
const val plateOpacity = 0.9f
const val plateMidOpacity = 0.72f
const val plateMidStop = 0.52f
val railWidth = 26.0.dp
val railLetterHeight = 15.0.dp
val railFocusHeight = 22.0.dp
val railLetter = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
val railFocusedLetter = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 18.2.sp, letterSpacing = 0.0.em)
val panelPaddingX = 20.0.dp
val panelPaddingY = 17.0.dp
val panelTextGap = 12.0.dp
val panelTitleGap = 4.0.dp
val panelIconGap = 7.0.dp
val panelTop = 10.0.dp
val borderWidth = 1.0.dp
val chipContentGap = 6.0.dp
const val chipLabelOpacity = 0.7f
val tailIcon = 22.0.dp
val tailPadding = 10.0.dp
val tailGap = 5.0.dp
const val imageReads = 4.0f
const val unknownCriticOpacity = 0.55f
val fallbackPadding = 11.0.dp
val fallbackIcon = 22.0.dp
const val fallbackHueMultiplier = 31.0f
const val fallbackSaturationTop = 0.22f
const val fallbackLightnessTop = 0.22f
const val fallbackSaturationBottom = 0.26f
const val fallbackLightnessBottom = 0.1f
const val fallbackIconOpacity = 0.45f
}
object Home {
val kickerGap = 6.0.dp
val kickerBottom = 5.0.dp
@ -351,6 +398,6 @@ object WolfskinTokens {
val buttonLiftOffset = 6.0.dp
val buttonLiftBlur = 20.0.dp
const val buttonLiftOpacity = 0.5f
const val factSeparatorOpacity = 0.4f
val posterProgressBottom = 6.0.dp
}
}

View file

@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:pathData="M6,9 L12,15 L18,9" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round"/></vector>

View file

@ -1,3 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:pathData="M2,12s3.6,-7 10,-7s10,7 10,7s-3.6,7 -10,7S2,12 2,12Z M15,12a3,3 0,1 1,-6 0a3,3 0,1 1,6 0" />
</vector>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:pathData="M2,12 C2,12 5.6,5 12,5 C18.4,5 22,12 22,12 C22,12 18.4,19 12,19 C5.6,19 2,12 2,12 M15,12 A3,3 0,1 1,9 12 A3,3 0,1 1,15 12" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round"/></vector>

View file

@ -19,7 +19,7 @@ class HomeArtTest {
@Test fun imageFailureShowsTheTitleFallbackInsteadOfAnEmptyCard() {
val title = HomeTitle("missing-image", "A missing film", "movie", poster = "file:///android_asset/wolfskin-home/not-present.jpg")
rule.setContent { WolfskinTheme { HomeArt(title, WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)) } }
rule.setContent { WolfskinTheme { TitleArtwork(title.title, title.type, title.poster, WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)) } }
rule.waitUntil(5000) { rule.onAllNodesWithText(title.title).fetchSemanticsNodes().isNotEmpty() }
rule.onNodeWithText(title.title).assertIsDisplayed()
}

View file

@ -8,6 +8,11 @@ import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import org.json.JSONArray
import org.json.JSONObject
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import java.util.Locale
@ -52,9 +57,30 @@ class HomeCaptureTest {
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "home").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
recordTextLayouts(File(directory, "$name-layout.json"))
} finally { Locale.setDefault(previous) }
}
private fun recordTextLayouts(output: File) {
val nodes = JSONArray()
rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
val results = mutableListOf<TextLayoutResult>()
node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(results)
results.forEach { layout ->
val lines = JSONArray()
repeat(layout.lineCount) { line ->
lines.put(JSONObject().put("top", layout.getLineTop(line)).put("bottom", layout.getLineBottom(line)).put("baseline", layout.getLineBaseline(line)))
}
nodes.put(JSONObject().put("text", layout.layoutInput.text.text)
.put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top)
.put("width", layout.size.width).put("height", layout.size.height)
.put("lineHeight", layout.layoutInput.style.lineHeight.toString())
.put("lineHeightStyle", layout.layoutInput.style.lineHeightStyle.toString()).put("lines", lines))
}
}
output.writeText(nodes.toString(2))
}
private fun preload(snapshot: HomeSnapshot, focused: Int) = runBlocking {
val rows = if (focused == 0) snapshot.rows.take(2) else snapshot.rows.drop(focused).take(3)
for (row in rows) for (title in row.cards.take(8)) {
@ -63,7 +89,7 @@ class HomeCaptureTest {
val width = if (hero) 1280 else (row.cardWidth.value * 2).toInt()
val height = if (hero) 720 else (row.cardHeight.value * 2).toInt()
val source = if (landscape) title.backdrop ?: title.poster else title.poster
if (source != null) HomeImages.loader(rule.activity).execute(HomeImages.request(rule.activity, source, width, height, !landscape))
if (source != null) WolfskinImages.loader(rule.activity).execute(WolfskinImages.request(rule.activity, source, width, height, !landscape))
}
}

View file

@ -152,6 +152,67 @@ class HomeKeysTest {
assertEquals("A visited row retains its column", 3, shell.view.cards[posters.id])
}
@Test fun asynchronousFreshHomePlaysNextInTwoPresses() {
val fixture = HomeFixture(rule.activity)
val shell = ShellState(fixturePeople(), "justin")
val delivery = kotlinx.coroutines.CompletableDeferred<HomeSnapshot>()
var action: HomeAction? = null
rule.setContent {
val snapshot by produceState(fixture.snapshot("justin", "loading")) { value = delivery.await() }
WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } }
}
press("OK")
rule.waitForIdle()
assertTrue(shell.navFocused)
rule.runOnIdle { delivery.complete(fixture.snapshot("justin")) }
rule.waitForIdle()
assertFalse(shell.navFocused)
assertEquals(0, shell.view.cards["hero"])
press("OK")
val play = action as HomeAction.Play
assertFalse(play.resume)
assertEquals("Yu Yu Hakusho", play.title.seriesTitle)
}
@Test fun asynchronousHomeDoesNotStealFocusAfterNavInput() {
val fixture = HomeFixture(rule.activity)
val shell = ShellState(fixturePeople(), "justin")
val delivery = kotlinx.coroutines.CompletableDeferred<HomeSnapshot>()
rule.setContent {
val snapshot by produceState(fixture.snapshot("justin", "loading")) { value = delivery.await() }
WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) {} } }
}
press("OK")
press("RIGHT")
rule.runOnIdle { delivery.complete(fixture.snapshot("justin")) }
rule.waitForIdle()
assertTrue(shell.navFocused)
assertEquals(1, shell.navIndex)
}
@Test fun heldOkRepeatEventsDoNotReplayButtonsOrActivateCardsBeforeRelease() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val snapshot = HomeFixture(rule.activity).snapshot("justin")
val actions = mutableListOf<HomeAction>()
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { actions += it } } } }
val input = rule.onNodeWithTag("wolfskin-shell")
fun repeatOk() = rule.runOnIdle {
rule.activity.dispatchKeyEvent(android.view.KeyEvent(0L, 0L, android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 3))
}
input.performKeyInput { keyDown(Key.DirectionCenter) }
repeatOk()
assertEquals(1, actions.size)
assertTrue(actions.single() is HomeAction.Play)
input.performKeyInput { keyUp(Key.DirectionCenter) }
press("DOWN")
input.performKeyInput { keyDown(Key.DirectionCenter) }
repeatOk()
assertEquals(1, actions.size)
input.performKeyInput { keyUp(Key.DirectionCenter) }
assertEquals(2, actions.size)
assertTrue(actions.last() is HomeAction.Details)
}
private fun press(name: String) {
val key = when (name) {
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown

View file

@ -0,0 +1,89 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import java.util.Locale
import org.junit.Before
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Each native screen state uses offline prototype data and real shell focus drawing. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class LibraryCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val previousLocale = Locale.getDefault()
private val fixture by lazy { libraryFixture() }
@Before fun fullscreen() {
Locale.setDefault(Locale.US)
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
@After fun restoreLocale() { Locale.setDefault(previousLocale) }
private fun capture(name: String, person: String = "shared", destination: String = "movies", catalogue: String? = null, ratings: Boolean = true, reachable: Boolean = true, cached: Boolean = false, configure: (LibraryGridState) -> Unit = {}) {
val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination(destination) }
val source = when (catalogue) {
"picks" -> fixture.picks(person, "movie")
"trending" -> fixture.trending()
"duplicate" -> fixture.duplicatePage()
else -> fixture.library(person, destination)
}
val grid = LibraryGridState(if (catalogue == "picks") "Movies for you" else if (catalogue != null) "Trending" else destination.replaceFirstChar { it.uppercase() }, source, catalogue != null)
configure(grid)
shell.view.library = grid
rule.setContent {
WolfskinTheme {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
LibraryScreen(shell, grid, modifier, ratingsSignedIn = ratings, ratingsReachable = reachable,
cachedRatings = if (cached) setOf(grid.items[grid.index].id) else emptySet(), offlineArt = ::fixtureArt)
}
}
}
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory")).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
}
@Test fun st021_sharedMovies() = capture("st-021")
@Test fun st022_ratingSort() = capture("st-022") { it.sort = 2 }
@Test fun st023_rowEndClamps() = capture("st-023") { it.index = 5; it.key(ShellKey.Right) }
@Test fun st024_anime() = capture("st-024", "justin", "anime")
@Test fun st025_unwatched() = capture("st-025") { it.unwatched = true }
@Test fun st026_empty() = capture("st-026", "mindel") { it.source = emptyList() }
@Test fun st027_unavailable() = capture("st-027", "mindel", "shows") { it.status = LibraryStatus.Unavailable }
@Test fun st028_loading() = capture("st-028") { it.status = LibraryStatus.Loading }
@Test fun st030_fullPicks() = capture("st-030", "justin", "discover", "picks")
@Test fun st035_duplicateOnlyTail() = capture("st-035", "justin", "discover", "duplicate") { it.tail = GridTail.ShowMore; it.index = it.tailIndex }
@Test fun st039_trending() = capture("st-039", "justin", "discover", "trending")
@Test fun st095_widestPosterPlate() = capture("st-095") { grid -> grid.index = grid.items.indexOfFirst { it.title == "Grave of the Fireflies" } }
@Test fun st097_criticsPlate() = capture("st-097")
@Test fun st098_tmdbPlate() = capture("st-098", "justin", "anime") { grid -> grid.index = grid.items.indexOfFirst { it.title == "Chainsaw Man" } }
@Test fun st112_cachedWhileDown() = capture("st-112", reachable = false, cached = true)
@Test fun st113_uncachedWhileDown() = capture("st-113", reachable = false)
@Test fun unsignedRatingsHidden() = capture("library-unsigned", ratings = false)
}
fun libraryFixture(): LibraryFixture = LibraryFixture(LibraryCaptureTest::class.java.getResourceAsStream("/wolfskin-library.json")!!.bufferedReader().use { it.readText() }, "/library/")
fun fixtureArt(path: String): ImageBitmap? = LibraryCaptureTest::class.java.getResourceAsStream(path)?.use { BitmapFactory.decodeStream(it)?.asImageBitmap() }

View file

@ -0,0 +1,187 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.runtime.remember
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.Locale
/** Acceptance data drives Android keys through the real shell and grid controller. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class LibraryKeysTest {
@get:Rule val rule = createComposeRule()
private val shell = ShellState(fixturePeople(), "justin")
private val fixture by lazy { libraryFixture() }
private var opened: LibraryTitle? = null
private fun render() {
rule.setContent {
WolfskinTheme {
ShellSurface(shell) { modifier ->
if (shell.route in listOf("movies", "shows", "anime")) {
val view = shell.view
val grid = remember(shell.person.key, shell.route, view) {
view.library ?: LibraryGridState(shell.route, fixture.library(shell.person.key, shell.route), onTitle = { opened = it }).also { view.library = it }
}
LibraryScreen(shell, grid, modifier, offlineArt = ::fixtureArt)
} else ShellPlaceholder(shell.route, modifier)
}
}
}
}
private fun press(key: Key) { rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }; rule.waitForIdle() }
private fun press(keys: List<Key>) { keys.forEach(::press) }
@Test fun spiritedAwayInApprovedTwentyPresses() {
val document = LibraryKeysTest::class.java.getResourceAsStream("/library-screen.json")!!.bufferedReader().use { JSONObject(it.readText()) }
val paths = document.getJSONArray("pressPaths")
render()
for (index in 0 until paths.length()) {
val path = paths.getJSONObject(index)
val keys = path.getJSONArray("keys")
assertEquals(path.getInt("presses"), keys.length())
for (keyIndex in 0 until keys.length()) press(when (keys.getString(keyIndex)) {
"UP" -> Key.DirectionUp
"DOWN" -> Key.DirectionDown
"LEFT" -> Key.DirectionLeft
"RIGHT" -> Key.DirectionRight
"OK" -> Key.DirectionCenter
else -> error("Unknown acceptance key")
})
assertEquals("shared", shell.person.key)
assertEquals("movies", shell.route)
assertFalse(shell.navFocused)
assertEquals("Spirited Away", shell.view.library!!.focusedTitle!!.title)
}
}
@Test fun rowEdgesToolbarSortAndBackKeepExactCard() {
render()
press(listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter))
val grid = shell.view.library!!
press(Key.DirectionLeft)
assertEquals(0, grid.index)
repeat(6) { press(Key.DirectionRight) }
assertEquals(5, grid.index)
assertFalse(grid.inRail)
press(listOf(Key.DirectionDown, Key.Back))
assertTrue(shell.navFocused)
assertEquals(11, grid.index)
press(Key.DirectionDown)
assertEquals(11, grid.index)
press(listOf(Key.DirectionUp, Key.DirectionUp))
assertTrue(grid.toolbar)
press(Key.DirectionCenter)
assertEquals(1, grid.sort)
assertTrue(grid.toolbar)
press(Key.DirectionCenter)
assertEquals(2, grid.sort)
assertEquals("Grave of the Fireflies", grid.items.first().title)
}
@Test fun cardActsOnReleaseAndHeldRepeatCannotOpenTwice() {
render()
press(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter))
rule.runOnIdle {
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
assertNull(opened)
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 1)))
assertNull(opened)
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
assertEquals(shell.view.library!!.focusedTitle, opened)
opened = null
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
assertNull(opened)
}
}
@Test fun railGateJumpAndBackRespectOriginalCard() {
val titles = (0 until 200).map { index -> LibraryTitle("$index", "${('A'.code + index / 10).toChar()}Title $index", "movie") }
val grid = LibraryGridState("Movies", titles)
assertTrue(grid.railVisible)
grid.index = 5
grid.key(ShellKey.Right)
assertTrue(grid.inRail)
grid.key(ShellKey.Down)
assertEquals('B', grid.railLetter)
grid.key(ShellKey.Back)
assertEquals(5, grid.index)
assertFalse(grid.inRail)
grid.key(ShellKey.Right); grid.key(ShellKey.Down); grid.key(ShellKey.Ok)
assertEquals('B', grid.focusedTitle!!.letter)
grid.source = titles.take(199)
assertFalse(grid.railVisible)
grid.source = titles; grid.sort = 1
assertFalse(grid.railVisible)
}
@Test fun mixedSeeAllPreservesFullListAndClampsShortRow() {
val grid = LibraryGridState("Full list", (0 until 8).map { LibraryTitle("$it", "Title $it", if (it % 2 == 0) "movie" else "tv") }, catalogue = true)
grid.index = 5
grid.key(ShellKey.Down)
assertEquals(7, grid.index)
grid.key(ShellKey.Up); grid.key(ShellKey.Up)
assertTrue(grid.toolbar)
grid.key(ShellKey.Right); grid.key(ShellKey.Ok)
assertEquals(listOf("0", "2", "4", "6"), grid.items.map { it.id })
grid.key(ShellKey.Left); grid.key(ShellKey.Ok)
assertEquals(8, grid.items.size)
}
@Test fun downClampsShortLastRowBeforeEnteringRecoveryTail() {
var recoveries = 0
val grid = LibraryGridState("Trending",
(0 until 20).map { LibraryTitle("$it", "Title $it", "movie") },
catalogue = true, onRecover = { recoveries++ },
).apply { tail = GridTail.Retry; index = 14 }
shell.pickerVisible = false
shell.enterDestination("discover")
rule.setContent {
WolfskinTheme {
ShellSurface(shell) { modifier ->
LibraryScreen(shell, grid, modifier)
}
}
}
press(Key.DirectionDown)
assertEquals(19, grid.index)
assertEquals("Title 19", grid.focusedTitle!!.title)
assertEquals(0, recoveries)
press(Key.DirectionDown)
assertEquals(24, grid.index)
assertEquals("grid-tail", grid.cardKey)
press(Key.DirectionDown)
assertEquals(24, grid.index)
press(Key.DirectionCenter)
assertEquals(1, recoveries)
press(Key.DirectionUp)
assertEquals(18, grid.index)
assertEquals("Title 18", grid.focusedTitle!!.title)
}
@Test fun scoresFollowLocaleAndUnratedSortsLast() {
assertEquals("7,8", LibraryRating("tmdb", 7.8).formatted(Locale.forLanguageTag("nl-NL")))
assertEquals("7.8", LibraryRating("tmdb", 7.8).formatted(Locale.US))
assertEquals("87%", LibraryRating("rtCritics", 87.9).formatted(Locale.forLanguageTag("nl-NL")))
val grid = LibraryGridState("Movies", listOf(
LibraryTitle("none", "The Apple", "movie"),
LibraryTitle("imdb", "A Bee", "movie", ratings = listOf(LibraryRating("imdb", 8.0))),
LibraryTitle("rt", "An Ant", "movie", ratings = listOf(LibraryRating("rtCritics", 80.0))),
))
grid.sort = 2
assertEquals(listOf("rt", "imdb", "none"), grid.items.map { it.id })
}
}

View file

@ -0,0 +1,55 @@
package org.justinvisser.wolfskin.design
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Test
/** Paging never loops through repeated pages or loses the readable list after a failure. */
class LibraryPaginationTest {
private fun title(id: Int) = LibraryTitle("$id", "Title $id", "movie")
@Test fun duplicateOnlyReadWaitsForExplicitRetryThenAppendsUniqueTitles() = runBlocking {
val grid = LibraryGridState("Trending", (0 until 20).map(::title), catalogue = true)
var calls = 0
val pager = LibraryPagination(grid) { page ->
calls++
when (page) {
2 -> LibraryPage((0 until 20).map(::title), true)
3 -> LibraryPage((18 until 25).map(::title), false)
else -> error("Unexpected extra read")
}
}
grid.index = 9
pager.readAhead()
assertEquals(0, calls)
grid.index = 10
pager.readAhead()
assertEquals(GridTail.ShowMore, grid.tail)
pager.readAhead()
assertEquals(1, calls)
pager.readAhead(force = true)
assertEquals((0 until 25).map { "$it" }, grid.source.map { it.id })
assertEquals(GridTail.None, grid.tail)
pager.readAhead(force = true)
assertEquals(2, calls)
}
@Test fun failedReadRetainsTitlesAndRetriesSamePage() = runBlocking {
val grid = LibraryGridState("Trending", (0 until 12).map(::title), catalogue = true)
var reachable = false
val pages = mutableListOf<Int>()
val pager = LibraryPagination(grid) { page ->
pages += page
if (!reachable) throw java.io.IOException("offline")
LibraryPage(listOf(title(12)), false)
}
grid.index = 11
pager.readAhead()
assertEquals(GridTail.Retry, grid.tail)
assertEquals((0 until 12).map { "$it" }, grid.source.map { it.id })
reachable = true
pager.readAhead(force = true)
assertEquals(listOf(2, 2), pages)
assertEquals("12", grid.source.last().id)
}
}

View file

@ -0,0 +1,494 @@
{
"id": "library",
"title": "Libraries: Movies, Shows, Anime (one continuous 6-column grid)",
"specSection": "6.4",
"source": "index.html:1403-1426, 1563-1611",
"layout": [
{
"id": "lhead",
"type": "lhead",
"token": null,
"title": "Movies",
"yPx": 0,
"heightPx": 236,
"cards": null,
"focusable": false,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "ltools",
"type": "chips",
"token": null,
"title": "",
"yPx": 236,
"heightPx": 104,
"cards": null,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g0",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 340,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g1",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 828,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g2",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 1316,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g3",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 1804,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g4",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 2292,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g5",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 2780,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g6",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 3268,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g7",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 3756,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g8",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 4244,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g9",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 4732,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g10",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 5220,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g11",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 5708,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g12",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 6196,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
},
{
"id": "g13",
"type": "grid",
"token": "poster",
"title": "",
"yPx": 6684,
"heightPx": 488,
"cards": 6,
"focusable": true,
"endsInSeeAll": false,
"sampleFill": false
}
],
"states": [
{
"id": "st-021",
"name": "Shared Movies: 84, continuous grid",
"statesGroup": "Libraries",
"capture": "st-021-shared-movies-84-continuous-grid.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-021-shared-movies-84-continuous-grid.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Shared Movies: 84, continuous grid · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
},
{
"id": "st-022",
"name": "Shared Movies sorted by Rating (G10)",
"statesGroup": "Libraries",
"capture": "st-022-shared-movies-sorted-by-rating-g10.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-022-shared-movies-sorted-by-rating-g10.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Shared Movies sorted by Rating (G10) · Person: Shared · Screen: Movies · Focus: g0 › Grave of the Fireflies"
},
{
"id": "st-023",
"name": "Right at a row end clamps (no letter rail below 200)",
"statesGroup": "Libraries",
"capture": "st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Right at a row end clamps (no letter rail below 200) · Person: Shared · Screen: Movies · Focus: g0 › Bohemian Rhapsody"
},
{
"id": "st-024",
"name": "Justin Anime: 18 series",
"statesGroup": "Libraries",
"capture": "st-024-justin-anime-18-series.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-024-justin-anime-18-series.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "justin",
"focusReadout": "State: Justin Anime: 18 series · Person: Justin · Screen: Anime · Focus: g0 › Bleach"
},
{
"id": "st-025",
"name": "Unwatched filter",
"statesGroup": "Libraries",
"capture": "st-025-unwatched-filter.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-025-unwatched-filter.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Unwatched filter · Person: Shared · Screen: Movies · Focus: g0 › Annie Hall"
},
{
"id": "st-026",
"name": "Empty destination",
"statesGroup": "Libraries",
"capture": "st-026-empty-destination.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-026-empty-destination.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "mindel",
"focusReadout": "State: Empty destination · Person: Mindel · Screen: Movies · Focus: Nothing in Movies yet › Open Discover"
},
{
"id": "st-027",
"name": "Jellyfin unreachable (error)",
"statesGroup": "Libraries",
"capture": "st-027-jellyfin-unreachable-error.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-027-jellyfin-unreachable-error.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "mindel",
"focusReadout": "State: Jellyfin unreachable (error) · Person: Mindel · Screen: Shows · Focus: Couldn’t reach your library › Try again"
},
{
"id": "st-028",
"name": "Loading (skeleton, no saved copy)",
"statesGroup": "Libraries",
"capture": "st-028-loading-skeleton-no-saved-copy.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-028-loading-skeleton-no-saved-copy.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "nav",
"person": "shared",
"focusReadout": "State: Loading (skeleton, no saved copy) · Person: Shared · Screen: Movies · Focus: nav › Movies"
},
{
"id": "st-095",
"name": "Plate: widest real set on a 256 px poster",
"statesGroup": "Ratings: one-line plate (round 3)",
"capture": "st-095-plate-widest-real-set-on-a-256-px-poster.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-095-plate-widest-real-set-on-a-256-px-poster.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Plate: widest real set on a 256 px poster · Person: Shared · Screen: Movies · Focus: g4 › Grave of the Fireflies"
},
{
"id": "st-097",
"name": "Poster: RT critics on a library grid (Shared Movies)",
"statesGroup": "Ratings (round 2)",
"capture": "st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Poster: RT critics on a library grid (Shared Movies) · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
},
{
"id": "st-098",
"name": "Poster: TMDB only (Justin Anime)",
"statesGroup": "Ratings (round 2)",
"capture": "st-098-poster-tmdb-only-justin-anime.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-098-poster-tmdb-only-justin-anime.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "justin",
"focusReadout": "State: Poster: TMDB only (Justin Anime) · Person: Justin · Screen: Anime · Focus: g0 › Chainsaw Man"
},
{
"id": "st-112",
"name": "Conductarr down: a cached rating still shows",
"statesGroup": "Ratings (round 2)",
"capture": "st-112-conductarr-down-a-cached-rating-still-shows.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-112-conductarr-down-a-cached-rating-still-shows.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Conductarr down: a cached rating still shows · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
},
{
"id": "st-113",
"name": "Conductarr down: an uncached title shows nothing",
"statesGroup": "Ratings (round 2)",
"capture": "st-113-conductarr-down-an-uncached-title-shows-nothing.jpg",
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-113-conductarr-down-an-uncached-title-shows-nothing.jpg",
"screenKind": "library",
"overlay": null,
"focusZone": "content",
"person": "shared",
"focusReadout": "State: Conductarr down: an uncached title shows nothing · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
}
],
"focusMap": {
"entry": "first grid card (not the toolbar); returning: remembered card per person",
"zones": {
"toolbar": {
"up": "nav",
"down": "grid",
"left": "previous chip",
"right": "next chip",
"ok": "Sort cycles A–Z -> Recently added -> Rating; Filter toggles All / Unwatched; focus returns to the toolbar",
"back": "nav",
"source": "index.html:445"
},
"grid": {
"up": "row above, same column (top row -> toolbar)",
"down": "row below, same column (shorter last row: its last card)",
"left": "previous column (column 0: stays)",
"right": "next column; at the row end: stays (no rail below 200 titles)",
"ok": "title page (key-up)",
"back": "nav, place kept",
"ratings": "one-line plate after 350 ms",
"source": "index.html:446"
},
"letterRail": "only at >= 200 titles and A–Z sort: Right at a row end enters; Up/Down move letters and scroll live; OK or Left drops focus on that letter's first title; Back returns to where you were (index.html:1586-1605)"
},
"exit": [
"Up from toolbar -> nav",
"Back -> nav"
]
},
"pressPaths": [
{
"goal": "Reach “Spirited Away” (68th of 84) in Shared Movies by scrolling",
"keys": [
"RIGHT",
"RIGHT",
"OK",
"UP",
"RIGHT",
"RIGHT",
"OK",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"RIGHT",
"RIGHT"
],
"presses": 20,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
}
],
"data": [
{
"component": "Library grid",
"source": "Jellyfin /Users/{id}/Items?ParentId=…&SortBy=SortName over the destination's libraries, read in full in 200-item chunks ahead of focus",
"fields": [
"SortName",
"ProductionYear",
"ImageTags.Primary",
"UserData.Played",
"UserData.PlaybackPositionTicks",
"ProviderIds.Tmdb"
],
"cache": "LKG snapshot per destination; refresh on open if older than 10 min",
"states": [
"empty",
"Jellyfin unreachable (LKG + Saved chip, or state row)",
"missing art"
],
"pageRow": "index.html:469"
},
{
"component": "Destinations",
"source": "Conductarr GET /curation/library-audiences (owner, media_type, is_anime, libraries[], users[]); until sign-in, the name rule (parked board mapping-source = name-rule)",
"pageRow": "index.html:468"
}
],
"strings": {
"count": "{n} films | {n} series",
"empty": [
"Nothing in {Destination} yet",
"Titles you or others request for you land here.",
"Open Discover"
],
"emptyUnwatched": [
"Nothing unwatched in {Destination}",
"Everything here has been watched.",
"Show all"
],
"down": [
"Couldn’t reach your library",
"Jellyfin isn’t answering. Nothing here is lost; try again in a moment.",
"Try again"
],
"sort": [
"A–Z",
"Recently added",
"Rating"
],
"filter": [
"All",
"Unwatched"
]
},
"ratingScoreFormat": {
"rule": "TMDB, IMDb and \"Ep\" scores (0-10) are formatted with the TV locale and exactly one decimal, e.g. String.format(Locale.getDefault(), \"%.1f\", x): 7,8 on a Dutch-locale box, 7.8 on an English one. RT percentages unchanged (87%). The page prints 7.8 always: a stand-in for the English locale, not authoritative.",
"acceptance": "on a Dutch-locale emulator a TMDB 7.8 renders 7,8 and the one-line plate rule (spec 4.8) still holds because the width is unchanged [INFERENCE: comma and point advance widths not measured]; on an English-locale emulator it renders 7.8 as in the captures",
"source": "Justin 2026-11-05, corr=c248698d8546e1ab; spec 4.7 rule 5, 10.1 check 8"
},
"$schema": "wolfskin-tv-spec/screen@1",
"tokens": "data/tv-spec/tokens.json",
"captures": [
"st-021-shared-movies-84-continuous-grid.jpg",
"st-022-shared-movies-sorted-by-rating-g10.jpg",
"st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg",
"st-024-justin-anime-18-series.jpg",
"st-025-unwatched-filter.jpg",
"st-026-empty-destination.jpg",
"st-027-jellyfin-unreachable-error.jpg",
"st-028-loading-skeleton-no-saved-copy.jpg",
"st-095-plate-widest-real-set-on-a-256-px-poster.jpg",
"st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg",
"st-098-poster-tmdb-only-justin-anime.jpg",
"st-112-conductarr-down-a-cached-rating-still-shows.jpg",
"st-113-conductarr-down-an-uncached-title-shows-nothing.jpg"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Some files were not shown because too many files have changed in this diff Show more