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

@ -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>