Integrate native Home and replay full title launch paths

This commit is contained in:
justinzeus 2026-11-05 21:49:34 +01:00
commit fc02ce8646
697 changed files with 2956 additions and 146 deletions

View file

@ -10,6 +10,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.TextStyle
import androidx.tv.material3.Text
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.draw.clipToBounds
@ -18,30 +19,31 @@ import androidx.compose.ui.unit.constrainHeight
/** Captions fade without moving the reserved band under the focused poster. */
@Composable
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false) {
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false,
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
val alpha = remember(title, facts) { Animatable(0f) }
LaunchedEffect(title, facts, visible) {
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, modifier = Modifier.cssLineBox(WolfskinTokens.Type.button), maxLines = 1, overflow = TextOverflow.Ellipsis)
CaptionFacts(facts, accentFirstFact, accentLastFact)
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow)
CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle)
}
}
/** CSS flex facts: only coloured role/ownership spans shrink; ordinary facts and dots keep their width. */
@Composable
private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean) {
private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean, style: TextStyle) {
val parts = remember(facts) { facts.split(" · ") }
val muted = WolfskinTokens.Color.muted
val colour = WolfskinTokens.Color.accentText
val style = WolfskinTokens.Type.caption
Layout(
content = {
parts.forEachIndexed { index, part ->
val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast)
Text(part, color = if (accented) colour else muted, style = style, modifier = Modifier.cssLineBox(style), maxLines = 1, overflow = TextOverflow.Clip)
if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style, modifier = Modifier.cssLineBox(style))
Text(part, color = if (accented) colour else muted, style = style, modifier = Modifier.exactLineBox(style), maxLines = 1, overflow = TextOverflow.Clip)
if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style, modifier = Modifier.exactLineBox(style))
}
},
modifier = Modifier.fillMaxWidth().clipToBounds(),

View file

@ -101,7 +101,7 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
@Composable
private fun SheetButton(label: String, primary: Boolean = false, onClick: () -> Unit) {
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, true, primary, onClick) }
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, true, primary, onClick = onClick) }
}
@Composable
@ -124,7 +124,9 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
val foreground = if (focused) C.onFocus else C.fg
val secondary = if (focused) C.onFocus else C.muted
val context = LocalContext.current
val request = remember(item.poster, context) { LibraryImages.request(context, item.poster, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt()) }
val request = remember(item.poster, context) {
item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) }
}
val posterReady = remember(request) { mutableStateOf(false) }
Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel))
@ -135,7 +137,7 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
}.clickable(onClick = onClick)
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
AsyncImage(model = request, imageLoader = LibraryImages.loader(context), contentDescription = null,
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
.background(if (posterReady.value) Color.Transparent else C.surface3),
onState = { posterReady.value = it is AsyncImagePainter.State.Success },

View file

@ -10,7 +10,6 @@ import androidx.compose.ui.layout.LayoutModifier
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
@ -18,11 +17,7 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
/** CSS leading constrains the line box, even when the platform font's natural box is taller. */
internal fun Modifier.cssLineBox(style: TextStyle, top: Dp = 0.dp, bottom: Dp = 0.dp): Modifier =
then(CssLineBox(style.lineHeight, top, bottom))
private data class CssLineBox(val lineHeight: TextUnit, val top: Dp, val bottom: Dp) : LayoutModifier, ParentDataModifier {
internal data class CssLineBox(val lineHeight: TextUnit, val top: Dp, val bottom: Dp) : LayoutModifier, ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any = parentData ?: this@CssLineBox
fun Density.exactHeight(height: Int): Float {
val margins = top.toPx() + bottom.toPx()

View file

@ -31,8 +31,6 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.allowRgb565
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
@ -52,12 +50,12 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
}) else null }
val context = LocalContext.current
val request = remember(context, path, poster) {
ImageRequest.Builder(context).data(path).size(if (poster) 256 else 1280, if (poster) 384 else 720).allowRgb565(poster).build()
WolfskinImages.request(context, path, if (poster) 256 else 1280, if (poster) 384 else 720, poster)
}
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
else AsyncImage(
model = request,
imageLoader = LibraryImages.loader(context),
imageLoader = WolfskinImages.loader(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter,
)
}

View file

@ -5,7 +5,12 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.platform.LocalDensity
@ -58,3 +63,13 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color:
}
}
}
/** Pointing changes the same logical focus as the D-pad without activating an action. */
@Composable
fun Modifier.focusOnPointerEnter(onEnter: () -> Unit): Modifier {
val action by rememberUpdatedState(onEnter)
val interactions = remember { MutableInteractionSource() }
val hovered by interactions.collectIsHoveredAsState()
LaunchedEffect(hovered) { if (hovered) action() }
return hoverable(interactions)
}

View file

@ -20,6 +20,8 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
"sparkle", "seerr" -> R.drawable.wolfskin_sparkle
"advanced" -> R.drawable.wolfskin_sort
"alert" -> R.drawable.wolfskin_alert
"wifi" -> R.drawable.wolfskin_wifi
"clock" -> R.drawable.wolfskin_clock
"plus" -> R.drawable.wolfskin_plus
"check" -> R.drawable.wolfskin_check
"clock" -> R.drawable.wolfskin_clock

View file

@ -0,0 +1,32 @@
package org.justinvisser.wolfskin.design
import android.graphics.Matrix
import android.graphics.RadialGradient
import android.graphics.Shader
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.toArgb
/** Empty hero art keeps the locked elliptical ambient wash rather than flashing a black frame. */
@Composable
fun HomeAmbient(modifier: Modifier = Modifier) {
val brush = remember {
object : ShaderBrush() {
override fun createShader(size: Size): Shader {
val centerX = size.width * .7f
val centerY = size.height * .2f
val radiusX = size.width * 1.2f
val radiusY = size.height * .9f
return RadialGradient(centerX, centerY, radiusX, intArrayOf(WolfskinTokens.Color.surface3.toArgb(), WolfskinTokens.Color.bg.toArgb()), null, Shader.TileMode.CLAMP).apply {
setLocalMatrix(Matrix().apply { setScale(1f, radiusY / radiusX, centerX, centerY) })
}
}
}
}
Box(modifier.background(brush))
}

View file

@ -0,0 +1,114 @@
package org.justinvisser.wolfskin.design
import kotlin.math.abs
/** Home navigation mutates the shell's per-person view, including each row's remembered column. */
class HomeFocus(
private val shell: ShellState,
private val rows: List<HomeRow>,
private val onAction: (HomeAction) -> Unit,
) : ShellContentFocus {
private val view = shell.rememberedView("home")
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(view.row)
val column: Int get() = row?.let { view.cards[it.id] ?: 0 } ?: 0
fun focus(index: Int, column: Int) {
shell.navFocused = false
view.row = index
view.home.focusedKey = rows[index].id
val target = rows[index]
val bounded = column.coerceIn(0, (target.count - 1).coerceAtLeast(0))
view.cards[target.id] = bounded
if (target.kind == HomeRowKind.Shelf) view.home.firstCards[target.id] = target.adjustedFirst(bounded, view.home.firstCards[target.id] ?: 0)
}
override fun key(key: ShellKey): Boolean {
val current = row ?: return false
if (key == ShellKey.Back) return false
if (key == ShellKey.Ok) { activate(current); return true }
if (reenterVisible()) return true
when (key) {
ShellKey.Left -> focus(view.row, (column - 1).coerceAtLeast(0))
ShellKey.Right -> focus(view.row, (column + 1).coerceAtMost((current.count - 1).coerceAtLeast(0)))
ShellKey.Up, ShellKey.Down -> {
val direction = if (key == ShellKey.Up) -1 else 1
var next = view.row + direction
while (next in rows.indices && !rows[next].focusable) next += direction
if (next in rows.indices) {
val target = rows[next]
val nearest = nearest(current, target)
focus(next, view.cards[target.id] ?: nearest)
} else if (direction < 0) {
view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
view.home.resetScroll++
shell.navFocused = true
shell.navIndex = shell.currentTab
}
}
}
return true
}
private fun reenterVisible(): Boolean {
val layout = view.listState.layoutInfo
if (layout.totalItemsCount == 0 || view.home.positionedKey != row?.id || view.listState.isScrollInProgress) return false
val density = layout.viewportSize.width / WolfskinTokens.Shell.stageWidth.value
val current = layout.visibleItemsInfo.firstOrNull { it.index == view.row }
if (current != null && current.offset + current.size >= WolfskinTokens.Layout.navSolidAfter.value * density &&
current.offset <= layout.viewportSize.height - WolfskinTokens.Layout.rowHeadingBand.value * 2 * density) return false
val first = layout.visibleItemsInfo.firstOrNull {
rows[it.index].focusable && it.offset + it.size > WolfskinTokens.Layout.rowHeadingBand.value * density &&
it.offset < layout.viewportSize.height - WolfskinTokens.Layout.row_endcta.value * density
} ?: return false
focus(first.index, view.cards[rows[first.index].id] ?: 0)
return true
}
private fun activate(row: HomeRow) {
when (row.kind) {
HomeRowKind.Hero -> row.cards.firstOrNull()?.let { title ->
onAction(if (column == 0 && row.heroKind != "pick") HomeAction.Play(title, row.heroKind == "resume") else HomeAction.Details(title))
}
HomeRowKind.Shelf -> if (column < row.cards.size) onAction(HomeAction.Details(row.cards[column])) else onAction(HomeAction.SeeAll(row))
HomeRowKind.State -> onAction(HomeAction.Retry)
HomeRowKind.Explore -> onAction(HomeAction.Discover)
else -> Unit
}
}
private fun nearest(from: HomeRow, to: HomeRow): Int {
if (to.kind != HomeRowKind.Shelf) return (column * pitch(from) / pitch(to)).toInt()
val fromFirst = if (from.kind == HomeRowKind.Shelf) view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0
val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2
val first = (view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0))
val last = minOf(to.count - 1, first + to.focusSlots - 1)
var best = first
var distance = Float.POSITIVE_INFINITY
for (index in first..last) {
val candidate = abs((index - first) * pitch(to) + to.cardWidth.value / 2 - x)
if (candidate < distance) { best = index; distance = candidate }
}
return best
}
private fun pitch(row: HomeRow): Float = when (row.cardKind) {
HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value
HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value
HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value
}
}
internal val HomeRow.focusSlots: Int get() = when (cardKind) {
HomeCardKind.Poster -> 6
HomeCardKind.Tile -> 4
HomeCardKind.Wide -> 3
}
/** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */
internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int {
var first = previous
if (column > first + focusSlots - 2) first = column - (focusSlots - 2)
if (column < first + 1) first = (column - 1).coerceAtLeast(0)
return first.coerceIn(0, (count - focusSlots).coerceAtLeast(0))
}

View file

@ -0,0 +1,96 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.HomeType as T
/** The hero occupies one row while its backdrop extends beneath the first shelves. */
@Composable
fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) {
Box(Modifier.fillMaxWidth().height(L.row_hero)) {
if (!row.skeleton) {
val title = row.cards.first()
val artModifier = Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)
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" }, modifier = Modifier.exactLineBox(T.label), color = C.accentText, style = T.label)
}
Spacer(Modifier.height(H.kickerBottom))
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(), 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).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) }, textStyle = T.button) { onButton(0) }
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
}
}
} else {
HomeAmbient(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom)) {
Box(Modifier.size(H.heroSkeletonWidth, H.heroSkeletonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
Spacer(Modifier.height(H.heroSkeletonGap))
Box(Modifier.size(H.heroSkeletonFactsWidth, H.heroSkeletonFactsHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
Spacer(Modifier.height(H.heroSkeletonButtonGap))
Box(Modifier.size(H.heroSkeletonButtonWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button)))
}
}
}
}
/** Discover continues in the same scroller, under the active person's identity. */
@Composable
fun HomeSection(person: ShellPerson, savedAt: String?) {
Box(Modifier.fillMaxWidth().height(L.row_section)) {
Row(Modifier.padding(start = L.safeSide, top = H.sectionTop), horizontalArrangement = Arrangement.spacedBy(H.sectionGap), verticalAlignment = Alignment.CenterVertically) {
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) 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))
}
}
}
}
/** Failures keep a focusable recovery action without covering working library shelves. */
@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 + 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, modifier = Modifier.exactLineBox(T.stateTitle), color = C.fg, style = T.stateTitle.copy(fontWeight = FontWeight.Bold))
}
Spacer(Modifier.height(H.stateGap))
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, textStyle = T.button, onClick = retry)
}
}
}

View file

@ -0,0 +1,92 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.Immutable
import java.util.Locale
import kotlin.math.roundToInt
/** A screen-owned media projection, independent of Jellyfin and catalogue DTOs. */
@Immutable
data class HomeTitle(
val id: String,
val title: String,
val type: String,
val year: Int? = null,
val runtimeSeconds: Int? = null,
val seasons: Int? = null,
val overview: String = "",
val poster: String? = null,
val backdrop: String? = null,
val posterOnly: Boolean = false,
val seriesTitle: String? = null,
val season: Int? = null,
val episode: Int? = null,
val resumeSeconds: Double = 0.0,
val played: Boolean = false,
val addedAt: String = "",
val genres: List<String> = emptyList(),
val ratings: List<HomeRating> = emptyList(),
val held: Boolean = false,
val otherOwner: String? = null,
val tmdbId: Int? = null,
) {
val isEpisode: Boolean get() = type == "episode"
val displayTitle: String get() = seriesTitle ?: title
val episodeLabel: String get() = "S$season E$episode"
val runtime: String get() {
val minutes = ((runtimeSeconds ?: 0) / 60.0).roundToInt()
return if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m"
}
val remaining: String get() = "${(((runtimeSeconds ?: 0) - resumeSeconds) / 60).roundToInt().coerceAtLeast(1)} min left"
val subtitle: String get() = if (isEpisode) "$episodeLabel · $title" else "${if (type == "movie") "Movie" else "Series"} · ${year ?: ""}"
val facts: String get() = listOfNotNull(year?.toString(), if (type == "movie") runtimeSeconds?.let { runtime } ?: "Movie" else seasons?.let { "$it ${if (it == 1) "season" else "seasons"}" } ?: "Series").joinToString(" · ")
}
/** Only observed, range-checked scores reach this model; formatting follows the TV locale. */
@Immutable
data class HomeRating(val mark: String, val score: Double, val percentage: Boolean = false, val episode: Boolean = false) {
fun format(locale: Locale = Locale.getDefault()): String = if (percentage) "${score.toInt()}%" else String.format(locale, "%.1f", score)
}
enum class HomeCardKind { Poster, Tile, Wide }
enum class HomeRowKind { Hero, Shelf, Section, State, Note, Explore }
enum class HomeAvailability { Ready, Loading, SignedOut, Unavailable, Saved }
/** Rows retain stable keys while a refresh replaces their immutable contents. */
@Immutable
data class HomeRow(
val id: String,
val kind: HomeRowKind,
val title: String = "",
val cards: List<HomeTitle> = emptyList(),
val cardKind: HomeCardKind = HomeCardKind.Poster,
val catalogue: Boolean = false,
val total: Int = cards.size,
val target: String? = null,
val text: String = "",
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 }
val focusable: Boolean get() = !skeleton && count > 0
}
/** One snapshot paints immediately; source status never discards a saved view. */
@Immutable
data class HomeSnapshot(
val rows: List<HomeRow>,
val conductarr: HomeAvailability = HomeAvailability.SignedOut,
val jellyfinAvailable: Boolean = true,
val savedAt: String? = null,
)
/** Home emits integration intents, never owns the upstream player or later-slice pages. */
sealed interface HomeAction {
data class Play(val title: HomeTitle, val resume: Boolean) : HomeAction
data class Details(val title: HomeTitle) : HomeAction
data class SeeAll(val row: HomeRow) : HomeAction
data object Discover : HomeAction
data object Retry : HomeAction
}

View file

@ -0,0 +1,28 @@
package org.justinvisser.wolfskin.design
/** The screen's ratings cache uses an injected clock and never caches failed reads. */
class HomeRatingCache(private val clock: () -> Long = System::currentTimeMillis) {
private data class Entry(val ratings: List<HomeRating>, val readAt: Long)
private val entries = linkedMapOf<String, Entry>()
fun read(title: HomeTitle): List<HomeRating>? {
val key = key(title)
val entry = entries[key] ?: return null
if (clock() - entry.readAt >= lifetimeMillis) { entries.remove(key); return null }
return entry.ratings
}
fun save(title: HomeTitle, ratings: List<HomeRating>) {
val key = key(title)
entries.remove(key)
entries[key] = Entry(ratings, clock())
while (entries.size > capacity) entries.remove(entries.keys.first())
}
private fun key(title: HomeTitle): String = if (title.isEpisode) "episode:${title.id}" else "${title.type}:${title.tmdbId ?: title.id}"
companion object {
private const val capacity = 300
private val lifetimeMillis = (WolfskinTokens.Behaviour.cacheRatingsHours * 60 * 60 * 1000).toLong()
}
}

View file

@ -0,0 +1,91 @@
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.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
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.HomeType as T
/** Formatting is measured before fitting, so decimal commas obey the same one-line rule. */
@Composable
fun HomeRatingsRow(ratings: List<HomeRating>, modifier: Modifier = Modifier, fontSize: TextUnit = T.ratingPlate.fontSize, plate: Boolean = false) {
val density = LocalDensity.current
val size = with(density) { fontSize.toDp() }
val entryGap = if (plate) size * .64f else H.ratingGap
val markGap = if (plate) size * .29f else H.ratingMarkGap
Row(modifier, horizontalArrangement = Arrangement.spacedBy(entryGap), verticalAlignment = Alignment.CenterVertically) {
ratings.forEach { rating ->
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
val resource = when (rating.mark) {
"rotten" -> R.drawable.ic_rotten_tomatoes_rotten
"upright" -> R.drawable.ic_rt_upright
"spilled" -> R.drawable.ic_rt_spilled
"imdb" -> R.drawable.ic_rating_imdb
"tmdb" -> R.drawable.ic_rating_tmdb
else -> R.drawable.ic_rotten_tomatoes_fresh
}
val muted = rating.mark == "tomato"
Image(painterResource(resource), null, Modifier.size(size * if (rating.mark == "imdb" || rating.mark == "tmdb") 1.8f else 1f, size).graphicsLayer { alpha = if (muted) .55f else 1f }, colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
if (rating.episode) Text("Ep", color = C.muted, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontWeight = T.label.fontWeight), maxLines = 1)
Text(rating.format(), color = C.fg, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontFeatureSettings = "tnum"), maxLines = 1, softWrap = false)
}
}
}
}
/** The plate is a prefix of the full ratings set; its first entry is never discarded. */
@Composable
fun HomeRatingPlate(ratings: List<HomeRating>, visible: Boolean, modifier: Modifier = Modifier) {
val alpha by animateFloatAsState(if (visible && ratings.isNotEmpty()) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate")
if (ratings.isEmpty()) return
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val locale = Locale.getDefault()
BoxWithConstraints(modifier.graphicsLayer { this.alpha = alpha }.background(Brush.verticalGradient(0f to C.bg.copy(alpha = .9f), .52f to C.bg.copy(alpha = .72f), 1f to C.bg.copy(alpha = 0f))).padding(start = H.plateSide, end = H.plateSide, top = H.plateTop, bottom = H.plateFade)) {
val available = with(density) { maxWidth.toPx() }
val fitted = remember(ratings, available, density, locale) {
val base = T.ratingPlate.fontSize
val floor = T.caption.fontSize
fun width(size: TextUnit, entries: List<HomeRating>): Float {
val em = with(density) { size.toPx() }
return entries.sumOf { rating ->
val mark = em * if (rating.mark == "imdb" || rating.mark == "tmdb") 1.8f else 1f
val number = measurer.measure(rating.format(locale), T.ratingPlate.copy(fontSize = size)).size.width
val episode = if (rating.episode) measurer.measure("Ep", T.ratingPlate.copy(fontSize = size, fontWeight = T.label.fontWeight)).size.width + em * .29f else 0f
(mark + em * .29f + number + episode).toDouble()
}.toFloat() + (entries.size - 1).coerceAtLeast(0) * em * .64f
}
var size = base
val initialWidth = width(base, ratings)
if (initialWidth > available) size = base * (available / initialWidth).coerceAtLeast(floor.value / base.value)
val step = (base.value - floor.value) / 8
while (size > floor && width(size, ratings) > available) size = base * ((size.value - step).coerceAtLeast(floor.value) / base.value)
var count = ratings.size
while (count > 1 && width(size, ratings.take(count)) > available) count--
size to ratings.take(count)
}
HomeRatingsRow(fitted.second, fontSize = fitted.first, plate = true)
}
}

View file

@ -0,0 +1,55 @@
package org.justinvisser.wolfskin.design
/** Source reads preserve their order; the hero is removed by stable item identity. */
data class HomeLibrary(
val resume: List<HomeTitle> = emptyList(),
val nextUp: List<HomeTitle> = emptyList(),
val newest: List<HomeTitle> = emptyList(),
val destinations: Map<String, List<HomeTitle>> = emptyMap(),
val destinationTotals: Map<String, Int> = emptyMap(),
)
/** Merge the two sources into the single Home frame without inventing playable items. */
fun buildHomeRows(
library: HomeLibrary,
continuation: List<HomeRow>,
libraryAvailable: Boolean = true,
loading: Boolean = false,
): List<HomeRow> = buildList {
if (loading) {
add(HomeRow("hero-sk", HomeRowKind.Hero, skeleton = true))
listOf(HomeCardKind.Tile, HomeCardKind.Tile, HomeCardKind.Poster, HomeCardKind.Poster).forEachIndexed { index, kind ->
add(HomeRow("sk$index", HomeRowKind.Shelf, cardKind = kind, skeleton = true))
}
return@buildList
}
val choice = when {
library.resume.isNotEmpty() -> "resume" to library.resume.first()
library.nextUp.isNotEmpty() -> "next" to library.nextUp.first()
library.newest.isNotEmpty() -> "new" to library.newest.first()
else -> continuation.firstNotNullOfOrNull { it.cards.firstOrNull() }?.let { "pick" to it }
}
val hero = choice?.second
if (hero != null) add(HomeRow("hero", HomeRowKind.Hero, cards = listOf(hero), heroKind = choice.first))
if (!libraryAvailable) {
add(HomeRow("jf-down", HomeRowKind.State, "Your library can’t be reached", text = "Jellyfin isn’t answering, so nothing can play right now."))
} else {
listOf(Triple("cw", "Continue watching", library.resume), Triple("nu", "Next up", library.nextUp), Triple("new", "New in your library", library.newest)).forEach { (id, title, source) ->
val cards = source.filter { it.id != hero?.id }
if (cards.isNotEmpty()) add(HomeRow(id, HomeRowKind.Shelf, title, cards, if (id == "cw") HomeCardKind.Wide else HomeCardKind.Tile))
}
listOf("movies", "shows", "anime").forEach { destination ->
val titles = library.destinations[destination].orEmpty().sortedWith(compareBy<HomeTitle> { it.played }.thenByDescending { it.addedAt })
if (titles.isNotEmpty()) add(HomeRow("lib-$destination", HomeRowKind.Shelf, if (destination == "anime") "Anime" else "Your $destination", titles.take(WolfskinTokens.Behaviour.rowLimit.toInt()), total = library.destinationTotals[destination] ?: titles.size, target = destination))
}
}
add(HomeRow("disc-h", HomeRowKind.Section, "Discover"))
addAll(continuation)
}
/** Authentication failure is distinct from an unreachable signed-in catalogue. */
fun homeDiscoverState(status: HomeAvailability): HomeRow = if (status == HomeAvailability.SignedOut) {
HomeRow("signin", HomeRowKind.State, "Discover needs sign-in", text = "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual.", calm = true)
} else {
HomeRow("cond-down", HomeRowKind.State, "Discover is resting", text = "Conductarr can’t be reached. Your library still plays.")
}

View file

@ -0,0 +1,129 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
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
/** One collector owns the focused card dwell; cached scores appear on re-entry without another read. */
@Composable
fun HomeScreen(
shell: ShellState,
snapshot: HomeSnapshot,
modifier: Modifier = Modifier,
onAction: (HomeAction) -> Unit,
) {
val view = shell.rememberedView("home")
LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt }
val action by rememberUpdatedState(onAction)
val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } }
DisposableEffect(shell, focus) {
shell.contentFocus = focus
onDispose { if (shell.contentFocus === focus) shell.contentFocus = null }
}
LaunchedEffect(snapshot.rows, view) {
snapshot.rows.forEach { item ->
view.cards[item.id]?.let { view.cards[item.id] = it.coerceIn(0, (item.count - 1).coerceAtLeast(0)) }
}
val remembered = snapshot.rows.indexOfFirst { it.id == view.home.focusedKey && it.focusable }
if (remembered >= 0) view.row = remembered
if (snapshot.rows.none { it.focusable }) {
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
val heights = remember(snapshot.rows) { snapshot.rows.map { rowHeight(it) } }
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 { WolfskinImages.prefetch(context, it) }
}
LaunchedEffect(view) {
if (view.home.positionedKey != null) {
view.home.focusedKey?.let { view.home.tracks[it] }?.let { track ->
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
}
view.listState.scrollToItem(view.listState.firstVisibleItemIndex, view.listState.firstVisibleItemScrollOffset)
}
}
LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) {
val key = snapshot.rows.getOrNull(view.row)?.id
if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect
val topRequested = shell.navFocused && view.home.resetScroll > 0 && view.home.positionedReset != view.home.resetScroll
val target = if (topRequested) 0f else with(density) {
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
}.coerceAtLeast(0f)
view.home.positionedKey = key
view.home.positionedReset = view.home.resetScroll
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
}
val cache = view.home.ratings
val row = snapshot.rows.getOrNull(view.row)
val title = row?.cards?.getOrNull(focus.column)
val hero = snapshot.rows.firstOrNull { it.kind == HomeRowKind.Hero }?.cards?.firstOrNull()
var heroRatings by remember(hero?.id) { mutableStateOf(hero?.let(cache::read).orEmpty()) }
var focusedRatings by remember(title?.id, shell.navFocused) { mutableStateOf(title?.let(cache::read).orEmpty()) }
LaunchedEffect(title?.id, hero?.id, shell.navFocused, shell.pickerVisible, snapshot.conductarr) {
if (hero != null && snapshot.conductarr == HomeAvailability.Ready) {
heroRatings = hero.ratings
cache.save(hero, heroRatings)
}
if (title != null && !shell.navFocused && !shell.pickerVisible && snapshot.conductarr == HomeAvailability.Ready) {
val saved = cache.read(title)
if (saved == null && row.kind != HomeRowKind.Hero) delay(WolfskinTokens.Motion.ratingDwell.toLong())
focusedRatings = saved ?: title.ratings.also { cache.save(title, it) }
}
}
val showRatings = snapshot.conductarr != HomeAvailability.SignedOut
val trailingSpace = maxOf(H.frameBottom, WolfskinTokens.Shell.stageHeight - L.pinnedLine - (heights.lastOrNull() ?: WolfskinTokens.Shell.zero))
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) }
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) }
HomeRowKind.Section -> HomeSection(shell.person, snapshot.savedAt)
HomeRowKind.State -> HomeRecovery(item, selected, { activate(0) }) { focus.focus(index, 0) }
HomeRowKind.Note -> Row(Modifier.fillMaxWidth().height(L.row_note).padding(start = L.safeSide, top = H.noteTop), horizontalArrangement = Arrangement.spacedBy(H.noteGap)) {
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 = 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) }, textStyle = HomeType.button) { activate(0) }
}
}
}
}
}
private fun rowHeight(row: HomeRow) = when (row.kind) {
HomeRowKind.Hero -> L.row_hero
HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.Shell.zero) + L.rowGapAfter
HomeRowKind.Section -> L.row_section
HomeRowKind.State -> L.row_state
HomeRowKind.Note -> L.row_note
HomeRowKind.Explore -> L.row_endcta
}

View file

@ -0,0 +1,110 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collectLatest
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
import androidx.tv.material3.Text
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.HomeType as T
/** Every lazy row and card uses a stable source identity and an explicit content type. */
@Composable
fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) {
val width = row.cardWidth
val height = row.cardHeight
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
val targetFirst by rememberUpdatedState(first)
val density = LocalDensity.current
LaunchedEffect(track) {
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
var positionedFirst = targetFirst
snapshotFlow { targetFirst }.collectLatest { target ->
if (target != positionedFirst) {
positionedFirst = target
val pitch = with(density) { (width + K.posterGap).toPx() }
track.animateScrollBy((target - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base))
}
}
}
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))
val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList()
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
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)
} else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height) {
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 availability = when {
title == null -> null
row.catalogue -> if (title.held) "In your library" else title.otherOwner?.let { "In $it’s library" }
title.played -> "Watched"
title.resumeSeconds > 0 -> title.remaining
else -> null
}
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)
}
}
}
@Composable
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, 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)
}
}
}
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

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

View file

@ -1,32 +0,0 @@
package org.justinvisser.wolfskin.design
import android.content.Context
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.intercept.Interceptor
import coil3.memory.MemoryCache
import coil3.request.ImageRequest
import coil3.request.allowRgb565
import coil3.request.crossfade
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import coil3.disk.directory
/** Every grid shares one bounded cache and four concurrent image reads. */
object LibraryImages {
private var loader: ImageLoader? = null
private val reads = Semaphore(WolfskinTokens.Library.imageReads.toInt())
@Synchronized
fun loader(context: Context): ImageLoader = loader ?: SingletonImageLoader.get(context).newBuilder()
.memoryCache(MemoryCache.Builder().maxSizeBytes((WolfskinTokens.Image.memoryCacheMB * 1024 * 1024).toLong()).build())
.diskCache(DiskCache.Builder().directory(context.cacheDir.resolve("wolfskin-library-art"))
.maxSizeBytes((WolfskinTokens.Image.diskCacheMB * 1024 * 1024).toLong()).build())
.components { add(Interceptor { chain -> reads.withPermit { chain.proceed() } }) }
.crossfade(false).build().also { loader = it }
fun request(context: Context, data: Any?, width: Int = WolfskinTokens.Image.posterRequestWidth,
height: Int = WolfskinTokens.Image.posterRequestHeight, poster: Boolean = true): ImageRequest =
ImageRequest.Builder(context).data(data).size(width, height).allowRgb565(poster).crossfade(false).build()
}

View file

@ -5,11 +5,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
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.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@ -29,20 +27,15 @@ fun LibraryPoster(
offlineArt: ((String) -> ImageBitmap?)? = null,
) {
val poster = title.poster
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Box(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
.testTag("poster-${title.id}")) {
Box(Modifier.fillMaxSize().clip(shape).background(C.surface2)) {
TitleCard(focused, modifier.testTag("poster-${title.id}")) {
val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } }
if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else if (poster != null && offlineArt == null) {
var ready by remember(poster) { mutableStateOf(false) }
if (!ready) MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
val context = LocalContext.current
val request = remember(poster, context) { LibraryImages.request(context, poster) }
AsyncImage(request, title.posterTitle, imageLoader = LibraryImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop,
val request = remember(poster, context) { WolfskinImages.request(context, poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true) }
AsyncImage(request, title.posterTitle, imageLoader = WolfskinImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop,
onSuccess = { ready = true }, onError = { ready = false })
} else MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
@ -63,7 +56,6 @@ fun LibraryPoster(
if (held) C.onFocus else color, Modifier.size(L.badgeIcon))
}
}
}
}
}

View file

@ -39,9 +39,9 @@ fun LibraryScreen(
cachedRatings: Set<String> = emptySet(),
ratingsReachable: Boolean = true,
offlineArt: ((String) -> ImageBitmap?)? = null,
view: RememberedView = shell.view,
registerFocus: Boolean = true,
) {
val view = shell.view
val density = LocalDensity.current
val context = LocalContext.current
val active = !shell.navFocused && !shell.pickerVisible
@ -64,7 +64,7 @@ fun LibraryScreen(
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 { LibraryImages.loader(context).enqueue(LibraryImages.request(context, it)) }
items[index].poster?.let { WolfskinImages.loader(context).enqueue(WolfskinImages.request(context, it, (K.posterW.value * 2).toInt(), (K.posterH.value * 2).toInt(), true)) }
}
}
}

View file

@ -8,20 +8,23 @@ 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) {
width: Dp = WolfskinTokens.Card.posterW, height: Dp = WolfskinTokens.Card.posterH,
titleStyle: TextStyle = WolfskinTokens.Type.listTitle, countStyle: TextStyle = WolfskinTokens.Type.label) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Column(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card)
.background(WolfskinTokens.Color.surface, shape).border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape)
.clickable(onClick = onClick), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge))
Text("See all", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.listTitle)
Text(count.toString(), color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.label)
TitleCard(focused, modifier.clickable(onClick = onClick), width, height) {
Column(Modifier.fillMaxSize().background(WolfskinTokens.Color.surface, shape)
.border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape),
verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.seeAllGap, 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

@ -33,6 +33,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
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.Shell as S
import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V
@ -100,13 +101,13 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier,
}
}
}
if (savedAt != null) {
(savedAt ?: state.view.savedAt)?.let { time ->
Row(Modifier.align(Alignment.TopEnd)
.offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2)
.height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button))
.padding(horizontal = V.side + V.border), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) {
Glyph("wifi", C.warn, Modifier.size(V.icon))
Text("Saved $savedAt", color = C.warn, style = WolfskinTokens.Type.caption)
Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption)
}
}
val avatar = boxes.last()

View file

@ -35,6 +35,8 @@ class RememberedView {
val listState = LazyListState()
var row by mutableStateOf(0)
val cards = mutableStateMapOf<String, Int>()
var savedAt by mutableStateOf<String?>(null)
val home by lazy { HomeViewMemory() }
var library: LibraryGridState? = null
}
@ -67,9 +69,11 @@ class ShellState(
private val views = mutableStateMapOf<Pair<String, String>, RememberedView>()
private var armedCard: String? = 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() }
val view: RememberedView get() = rememberedView(route)
/** Screen options share the same per-person memory as their root's focus and scroller. */
fun rememberedView(destination: String): RememberedView =
@ -87,18 +91,18 @@ 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? = if (!pickerVisible && !navFocused && !requestsVisible && !contentOverlayVisible) contentFocus?.cardKey else null): Boolean {
if (down) inputRevision++
if (key == ShellKey.Ok && repeat > 0) return true
if (!pickerVisible && !requestsVisible && contentOverlayVisible) {
if (down) contentFocus?.key(key)
return true
}
if (key == ShellKey.Ok) {
if (repeat > 0) return true
if (card != null) {
if (down) armedCard = card else {
if (armedCard == card) activate()

View file

@ -57,8 +57,8 @@ fun TitleRatings(ratings: List<LibraryRating>, modifier: Modifier = Modifier, st
val desaturated = if (unknownCritic) remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) } else null
Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") P.wordmarkRatio else 1f, markHeight),
alpha = if (unknownCritic) P.unknownCriticOpacity else 1f, colorFilter = desaturated)
if (rating.episode) Text("Ep", color = C.muted, style = textStyle.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), modifier = Modifier.cssLineBox(textStyle))
Text(rating.formatted(), color = C.fg, style = numberStyle, modifier = Modifier.cssLineBox(numberStyle), maxLines = 1)
if (rating.episode) Text("Ep", color = C.muted, style = textStyle.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), modifier = Modifier.exactLineBox(textStyle))
Text(rating.formatted(), color = C.fg, style = numberStyle, modifier = Modifier.exactLineBox(numberStyle), maxLines = 1)
}
}
}
@ -102,6 +102,6 @@ fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifi
val fontSize = TextUnit(fit.second, TextUnitType.Sp)
val style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize,
lineHeight = WolfskinTokens.Type.ratingPlate.lineHeight * (fit.second / WolfskinTokens.Type.ratingPlate.fontSize.value))
TitleRatings(ratings.take(fit.first), modifier = Modifier.cssLineBox(style, top = P.plateTop, bottom = P.plateBottom + P.plateInlineDescent), style = style, plate = true)
TitleRatings(ratings.take(fit.first), modifier = Modifier.exactLineBox(style, top = P.plateTop, bottom = P.plateBottom + P.plateInlineDescent), style = style, plate = true)
}
}

View file

@ -0,0 +1,11 @@
package org.justinvisser.wolfskin.design
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
/** Explicit CSS leading and margins share one fractional layout engine across Wolfskin screens. */
fun Modifier.exactLineBox(style: TextStyle, top: Dp = 0.dp, bottom: Dp = 0.dp): Modifier =
if (style.lineHeight == TextUnit.Unspecified) this else then(CssLineBox(style.lineHeight, top, bottom))

View file

@ -45,16 +45,16 @@ val LocalTitleBitmaps = compositionLocalOf<Map<String, ImageBitmap>> { emptyMap(
/** Decode at the slot size, with no crossfade or oversized poster allocations. */
@Composable
fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null) {
fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) {
val bitmap = LocalTitleBitmaps.current[path]
if (bitmap != null) Image(bitmap, null, modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
else if (path != null) {
val context = LocalContext.current
val request = remember(context, path, width, height, poster) {
LibraryImages.request(context, path, width, height, poster)
WolfskinImages.request(context, path, width, height, poster)
}
AsyncImage(model = request, imageLoader = LibraryImages.loader(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, onError = { onError() })
}
}

View file

@ -0,0 +1,23 @@
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, content: @Composable BoxScope.() -> Unit) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card)) {
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
}
}

View file

@ -38,7 +38,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
val item = state.data.item
val type = if (item.kind == "movie") "Movie" else "Series"
CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
Row(Modifier.cssLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon))
Text(buildString {
append("$type · ${if (item.held) "In your library" else "Not in your library"}")
@ -47,29 +47,29 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
if (state.data.hidden) append(" · Hidden from your picks")
}, color = C.accentText, style = TitleTextStyles.dLine)
}
Text(item.name, color = C.fg, style = TitleTextStyles.displayDetail, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.cssLineBox(TitleTextStyles.displayDetail, bottom = D.titleBottom).testTag("title-heading"))
Text(item.name, color = C.fg, style = TitleTextStyles.displayDetail, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.displayDetail, bottom = D.titleBottom).testTag("title-heading"))
Row(Modifier.padding(bottom = D.factsBottom).height(D.factsHeight).testTag("title-facts"), horizontalArrangement = Arrangement.spacedBy(D.factsGap), verticalAlignment = Alignment.CenterVertically) {
item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body)) }
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body))
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body))
item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) }
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings)
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body))
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
}
state.data.requestedStage?.let { stage ->
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
Row(Modifier.cssLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon))
Text(stage, color = color, style = TitleTextStyles.label)
Text("for you · by ${state.data.requestActor.orEmpty()}", color = C.muted, style = TitleTextStyles.dLine)
}
}
Text(item.overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.cssLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
Text(item.overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body, bottom = D.overviewGap))
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
}
state.data.playTarget?.let { target ->
Row(Modifier.cssLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
if (target.resumeSeconds > 0) {
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f

View file

@ -70,24 +70,24 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
@Composable
private fun VersionChoices(state: TitleState) {
Text("Version", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle))
Text("Version", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle))
SheetSubtitle("${state.target?.name ?: state.data.item.name} · ${state.data.versions.size} files")
state.data.versions.forEachIndexed { index, version ->
PlaybackChoice(version.name, listOf(version.video, version.bitrate, version.size).filter { it.isNotBlank() }.joinToString(" · "), version == state.version, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
}
Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = TitleTextStyles.caption, modifier = Modifier.cssLineBox(TitleTextStyles.caption))
Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption))
}
@Composable
private fun TrackChoices(state: TitleState) {
val version = state.version ?: return
Text("Audio and subtitles", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle))
Text("Audio and subtitles", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle))
SheetSubtitle("${state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name} · ${version.name}")
Text("Audio", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.cssLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
Text("Audio", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.exactLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
version.audio.forEachIndexed { index, track ->
PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
}
Text("Subtitles", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.cssLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
Text("Subtitles", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.exactLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
val start = version.audio.size
PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) }
version.subtitles.forEachIndexed { index, track ->
@ -107,8 +107,8 @@ private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focus
.clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide),
horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
CssColumn(Modifier.weight(1f)) {
Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.cssLineBox(TitleTextStyles.listTitle))
if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption, modifier = Modifier.cssLineBox(TitleTextStyles.caption))
Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.exactLineBox(TitleTextStyles.listTitle))
if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption))
}
TitleRadio(selected, focused)
}
@ -117,7 +117,7 @@ private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focus
@Composable
private fun MediaInformation(state: TitleState) {
val version = state.version ?: return
Text("Media info", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle))
Text("Media info", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle))
SheetSubtitle(state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name)
MediaInfoSection("General", listOf("Container" to version.container, "Size" to version.size.ifEmpty { "not known" }, "Bitrate" to version.bitrate, "Runtime" to (state.target?.runtime ?: "not known")))
MediaInfoSection("Video", listOf("Stream" to version.video))
@ -128,9 +128,9 @@ private fun MediaInformation(state: TitleState) {
@Composable
private fun MediaInfoSection(label: String, values: List<Pair<String, String>>) {
Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.cssLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.exactLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
CssColumn(gap = D.infoRowGap) { values.forEach { (name, value) ->
Row(Modifier.fillMaxWidth().cssLineBox(TitleTextStyles.caption), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) {
Row(Modifier.fillMaxWidth().exactLineBox(TitleTextStyles.caption), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) {
Text(name, color = C.dim, style = TitleTextStyles.infoKey, modifier = Modifier.width(D.infoLabelWidth))
Text(value, color = C.fg, style = TitleTextStyles.caption)
}
@ -140,7 +140,7 @@ private fun MediaInfoSection(label: String, values: List<Pair<String, String>>)
@Composable
private fun WatchedConfirmation(state: TitleState) {
val verb = if (state.watched) "unwatched" else "watched"
Text("Mark the whole series as $verb?", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle))
Text("Mark the whole series as $verb?", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle))
SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.")
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, Modifier.sheetFocused(state.sheetIndex == 0), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
@ -150,7 +150,7 @@ private fun WatchedConfirmation(state: TitleState) {
@Composable
private fun SheetSubtitle(text: String) {
Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body, top = D.sheetSubLift, bottom = D.sheetSubBottom))
Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, top = D.sheetSubLift, bottom = D.sheetSubBottom))
}
@Composable

View file

@ -11,11 +11,11 @@ internal fun TitleRowPrefetch(data: TitlePageData, row: String?) {
val context = LocalContext.current
if (row == null || data.status != TitleReadStatus.Ready) return
if (LocalTitleBitmaps.current.isNotEmpty()) return
val loader = LibraryImages.loader(context)
val loader = WolfskinImages.loader(context)
DisposableEffect(loader, context, row, data.cast, data.episodes, data.related) {
val requests = ArrayList<Disposable>()
fun queue(path: String?, width: Int, height: Int, poster: Boolean = false) {
if (path != null) requests.add(loader.enqueue(LibraryImages.request(context, path, width, height, poster)))
if (path != null) requests.add(loader.enqueue(WolfskinImages.request(context, path, width, height, poster)))
}
when (row) {
"cast" -> for (index in 0 until minOf(data.cast.size, WolfskinTokens.Card.personSlots))

View file

@ -65,8 +65,8 @@ fun TitleCast(state: TitleState, active: Boolean) {
.titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) {
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).clip(CircleShape))
Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) {
Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.cssLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.cssLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.exactLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
}
}
}

View file

@ -0,0 +1,60 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
/** Slot dimensions adapt to the shared exact-pixel artwork request, never another image loader. */
@Composable
private fun SlotImage(source: String, width: Dp, height: Dp, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) {
TitleArtwork(source, (width.value * 2).toInt(), (height.value * 2).toInt(), modifier, poster, colorFilter, onError)
}
/** Poster-only landscapes retain the upright one-sheet above the same dimmed image wash. */
@Composable
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
Box(modifier.background(C.surface2)) {
if (!missing) {
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
for (index in 0 until 15) matrix.values[index] *= brightness
ColorFilter.colorMatrix(matrix)
}
val inset = if (hero) H.heroWashInset else H.cardWashInset
val washModifier = Modifier.wrapContentSize(Alignment.Center, unbounded = true)
.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(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(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) MediaArtworkFallback(title, type == "movie", showTitle = !landscape)
else HomeAmbient(Modifier.fillMaxSize())
}
}

View file

@ -102,12 +102,17 @@ private fun RecoveryPicker(state: ShellState) {
/** Recovery actions remain focusable while profiles cannot be read. */
@Composable
fun PickerButton(label: String, focused: Boolean, primary: Boolean, onClick: () -> Unit) {
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null,
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)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
Text(label, color = if (primary) C.onFocus else C.fg, style = WolfskinTokens.Type.button)
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, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle)
}
}
}

View file

@ -0,0 +1,54 @@
package org.justinvisser.wolfskin.design
import android.content.Context
import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.intercept.Interceptor
import coil3.disk.directory
import coil3.imageLoader
import coil3.request.crossfade
import coil3.memory.MemoryCache
import coil3.request.ImageRequest
import coil3.request.allowRgb565
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
/** 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
private const val IMAGE_READS = 4
private val readPermits = Semaphore(IMAGE_READS)
@Synchronized
fun loader(context: Context): ImageLoader {
val owner = context.applicationContext
if (application !== owner) {
loader?.shutdown()
application = owner
val reads = Dispatchers.IO.limitedParallelism(IMAGE_READS)
loader = owner.imageLoader.newBuilder()
.memoryCache { MemoryCache.Builder().maxSizeBytes((WolfskinTokens.Image.memoryCacheMB * MEBIBYTE).toLong()).build() }
.diskCache { DiskCache.Builder().directory(owner.cacheDir.resolve("wolfskin-images")).maxSizeBytes((WolfskinTokens.Image.diskCacheMB * MEBIBYTE).toLong()).build() }
.components { add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } }) }
.fetcherCoroutineContext(reads).decoderCoroutineContext(reads).crossfade(false).build()
}
return loader!!
}
fun request(context: Context, source: Any, width: Int, height: Int, poster: Boolean): ImageRequest =
ImageRequest.Builder(context).data(source).size(width, height).allowRgb565(poster).build()
fun prefetch(context: Context, row: HomeRow) {
val count = when (row.cardKind) { HomeCardKind.Poster -> 7; HomeCardKind.Tile -> 5; HomeCardKind.Wide -> 4 }
val width = (row.cardWidth.value * 2).toInt()
val height = (row.cardHeight.value * 2).toInt()
row.cards.take(count).forEach { title ->
val poster = row.cardKind == HomeCardKind.Poster
val source = if (poster) title.poster else title.backdrop ?: title.poster
if (source != null) loader(context).enqueue(request(context, source, width, height, poster))
}
}
}

View file

@ -542,6 +542,66 @@ object WolfskinTokens {
val border = 1.0.dp
const val borderOpacity = 0.5f
}
object Home {
val kickerGap = 6.0.dp
val kickerBottom = 5.0.dp
val titleBottom = 8.0.dp
val factsHeight = 19.0.dp
val factsBottom = 7.0.dp
val factsGap = 11.0.dp
val overviewBottom = 15.0.dp
val cardPadding = 11.0.dp
val cardOverlayTop = 27.0.dp
val cardOverlayBottom = 9.0.dp
val sectionTop = 20.0.dp
val sectionAvatar = 28.0.dp
val sectionGap = 11.0.dp
val stateTextBottom = 12.0.dp
val stateTop = 10.0.dp
val statePaddingX = 20.0.dp
val statePaddingY = 17.0.dp
val stateGap = 4.0.dp
val stateIcon = 18.0.dp
val captionWidth = 320.0.dp
val plateTop = 7.0.dp
val plateSide = 8.0.dp
val plateFade = 23.0.dp
val ratingGap = 10.0.dp
val ratingMarkGap = 4.0.dp
val heroSkeletonWidth = 320.0.dp
val heroSkeletonHeight = 42.0.dp
val heroSkeletonFactsWidth = 230.0.dp
val heroSkeletonFactsHeight = 17.0.dp
val heroSkeletonButtonWidth = 130.0.dp
val heroSkeletonGap = 11.0.dp
val heroSkeletonButtonGap = 20.0.dp
val artGlyph = 22.0.dp
val cardSheetLeft = 11.0.dp
val cardSheetTop = 9.0.dp
val cardSheetInset = 18.0.dp
val heroSheetRight = 90.0.dp
val heroSheetTop = 75.0.dp
val cardBlur = 13.0.dp
val heroBlur = 30.0.dp
val progressInset = 9.0.dp
val progressBottom = 5.0.dp
val endTop = 20.0.dp
val frameBottom = 40.0.dp
val toastPadding = 14.0.dp
val toastTop = 75.0.dp
val toastWidth = 380.0.dp
val cardWashInset = 15.0.dp
val heroWashInset = 40.0.dp
val noteTop = 7.0.dp
val noteGap = 8.0.dp
val hairline = 1.0.dp
val savedHeight = 26.0.dp
val savedPadding = 10.0.dp
val savedGap = 5.0.dp
val savedIcon = 14.0.dp
val savedRadius = 14.0.dp
val posterProgressBottom = 6.0.dp
}
object Discover {
val sheetPaddingX = 32.0.dp
val sheetPaddingY = 40.0.dp

View file

@ -1,4 +1,4 @@
<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="M5.5,4 H18.5 A2.5,2.5 0,0 1,21 6.5 V17.5 A2.5,2.5 0,0 1,18.5 20 H5.5 A2.5,2.5 0,0 1,3 17.5 V6.5 A2.5,2.5 0,0 1,5.5 4 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" />
<path android:pathData="M7,4 V20 M17,4 V20 M3,9 H7 M3,15 H7 M17,9 H21 M17,15 H21" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.6" />
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" android:pathData="M5.5,4H18.5Q21,4 21,6.5V17.5Q21,20 18.5,20H5.5Q3,20 3,17.5V6.5Q3,4 5.5,4Z" />
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="1.6" android:pathData="M7,4V20M17,4V20M3,9H7M3,15H7M17,9H21M17,15H21" />
</vector>

View file

@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round" android:pathData="M2.5,9a14,14 0,0 1,19 0M5.5,12.5a9.5,9.5 0,0 1,13 0M8.5,16a5,5 0,0 1,7 0" />
<path android:fillColor="#FFFFFFFF" android:pathData="M13.3,19.5a1.3,1.3 0,1 1,-2.6 0a1.3,1.3 0,1 1,2.6 0" />
</vector>