Implement option B card metadata and solid bottom edges
Some checks failed
review/code-claude Blocking: CardCaption focus-only caption invisible after first focus move in a shelf
review/design-claude Design review: 1 blocking (unframed skeleton caption bars); other differences are renderer/fixture artifacts

This commit is contained in:
justinzeus 2026-11-07 03:06:26 +01:00
parent fdff85c8a1
commit fe44a3c24f
27 changed files with 536 additions and 234 deletions

View file

@ -10,18 +10,11 @@ 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
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.constrainHeight
/** Captions rise inside the reserved band without changing measured geometry. */
@Composable
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) {
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) {
val alpha = remember(title, facts) { Animatable(0f) }
val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) }
val reduced = reducedMotion()
@ -32,47 +25,10 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
if (visible) entryProgress.floatValue = value
}
}
val titleStyle = WolfskinTokens.Type.button
val factsStyle = WolfskinTokens.Type.caption
Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) }) {
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, style: TextStyle) {
val parts = remember(facts) { facts.split(" · ") }
val muted = WolfskinTokens.Color.muted
val colour = WolfskinTokens.Color.accentText
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.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(),
) { children, constraints ->
val gap = WolfskinTokens.Library.captionFactsGap.roundToPx()
val basis = IntArray(children.size)
var fixed = gap * (children.size - 1).coerceAtLeast(0)
var flexible = 0
fun shrinks(index: Int) = (index == 0 && accentFirst) || (index == children.lastIndex && accentLast)
children.forEachIndexed { index, child ->
val width = child.maxIntrinsicWidth(constraints.maxHeight)
basis[index] = width
if (shrinks(index)) flexible += width else fixed += width
}
val available = (constraints.maxWidth - fixed).coerceAtLeast(0)
val ratio = if (flexible > available) available.toFloat() / flexible else 1f
val measured = children.mapIndexed { index, child ->
val width = if (shrinks(index)) (basis[index] * ratio).toInt() else basis[index]
child.measure(constraints.copy(minWidth = 0, maxWidth = width, minHeight = 0))
}
layout(constraints.maxWidth, constraints.constrainHeight(measured.maxOfOrNull { it.height } ?: 0)) {
var x = 0
measured.forEach { child -> child.placeRelative(x, 0); x += child.width + gap }
}
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(facts, color = WolfskinTokens.Color.muted, style = factsStyle, modifier = Modifier.exactLineBox(factsStyle), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}

View file

@ -0,0 +1,81 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.res.painterResource
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.CardMetadata as M
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
/** Card-width titles never displace runtime or watched metadata into a neighbouring slot. */
@Composable
fun CardCaption(title: String, year: Int?, duration: String?, watched: Boolean, focused: Boolean,
modifier: Modifier = Modifier, focusOnly: Boolean = false, showTitle: Boolean = true) {
val transition = if (focusOnly) {
val alpha = remember(title) { Animatable(0f) }
val entryProgress = remember(title) { mutableFloatStateOf(0f) }
val reduced = reducedMotion()
val rise = with(LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() }
LaunchedEffect(focused, reduced) {
if (focused && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong())
alpha.animateTo(if (focused) 1f else 0f, tween(motionDuration(if (focused) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) {
if (focused) entryProgress.floatValue = value
}
}
Modifier.graphicsLayer {
this.alpha = alpha.value
translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue)
}
} else Modifier
Column(modifier.then(transition)) {
if (showTitle) {
val style = if (focused || focusOnly) M.titleFocus else M.titleRest
Text(title, color = if (focused || focusOnly) C.fg else C.muted, style = style,
modifier = Modifier.fillMaxWidth().exactLineBox(style), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
CardMetadata(year, duration, watched)
}
}
/** Measured gaps tighten before any fixed-size year, runtime or check can be clipped. */
@Composable
fun CardMetadata(year: Int?, duration: String?, watched: Boolean, modifier: Modifier = Modifier) {
Layout(content = {
if (year != null) Text(year.toString(), color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1)
if (duration != null) Text(duration, color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1)
if (watched) Icon(painterResource(R.drawable.card_watched_check), "Watched", Modifier.size(M.check), tint = C.muted)
}, modifier = modifier.fillMaxWidth().clipToBounds()) { children, constraints ->
val sizes = IntArray(children.size) { children[it].maxIntrinsicWidth(constraints.maxHeight) }
val gaps = (children.size - 1).coerceAtLeast(0)
val textWidth = sizes.sum()
val gap = when {
textWidth + gaps * M.gap.roundToPx() <= constraints.maxWidth -> M.gap.roundToPx()
textWidth + gaps * M.compactGap.roundToPx() <= constraints.maxWidth -> M.compactGap.roundToPx()
else -> M.minimumGap.roundToPx()
}
val measured = Array(children.size) { children[it].measure(Constraints.fixedWidth(sizes[it])) }
val height = with(this) { M.subtitle.lineHeight.toPx().toInt() }
layout(constraints.maxWidth, height) {
var left = 0
measured.forEach { child ->
child.placeRelative(left, (height - child.height) / 2)
left += child.width + gap
}
}
}
}

View file

@ -55,7 +55,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
LazyRow(state = cards, userScrollEnabled = false,
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) {
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) {
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
@ -66,7 +66,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
}
} else if (lane.pending) {
items(slots, key = { "pending:$it" }) {
Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
PosterCaptionSkeleton()
}
} else {
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
@ -80,15 +80,16 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
}
}
}
if (!services && active && !lane.pending) {
if (!services && !lane.pending) {
val item = lane.titles.getOrNull(column)
Caption(
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth),
accentFirstFact = lane.hidden,
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
)
val captionModifier = Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(K.posterW)
if (item != null) {
val card = item.libraryCard(state)
CardCaption(item.title, card.year, card.duration, false, active, captionModifier, focusOnly = true)
} else Caption(
if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
active, captionModifier)
}
}
}
@ -108,7 +109,3 @@ internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: ()
else CatalogueRecoveryCard(lane.tail == DiscoverTail.Retry, focused, onClick)
}
internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String {
val card = title.libraryCard(state)
return (if (hidden) "Hidden from your picks · " else "") + card.facts(true)
}

View file

@ -38,7 +38,6 @@ data class HomeTitle(
}
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. */

View file

@ -119,7 +119,7 @@ fun HomeScreen(
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.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand) + L.rowGapAfter
HomeRowKind.Section -> L.row_section
HomeRowKind.State -> L.row_state
HomeRowKind.Note -> L.row_note

View file

@ -8,7 +8,6 @@ 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
@ -20,7 +19,6 @@ 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
@ -36,7 +34,7 @@ import org.justinvisser.wolfskin.design.HomeType as T
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 caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand
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) } }
@ -50,39 +48,34 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
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).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + caption + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), 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 = !row.skeleton && 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) {
if (row.skeleton) {
PosterCaptionSkeleton(width, height, caption, row.cardKind == HomeCardKind.Poster)
} else if (title == null) {
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.surface2)) 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())
}
} else TitleCard(selected, cardModifier.clickable { onCard(index) }, width, height,
watched = !row.catalogue && title.played,
progress = if (row.catalogue || title.runtimeSeconds == null || title.runtimeSeconds <= 0 || title.resumeSeconds <= 0) 0f else (title.resumeSeconds / title.runtimeSeconds).toFloat()) {
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) }
HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth())
}
}
}
if (row.cardKind == HomeCardKind.Poster && focused && !row.skeleton) {
if (!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)
val captionModifier = Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(width)
if (title == null) {
if (row.cardKind == HomeCardKind.Poster) Caption("See all ${row.title}", "${row.total} titles", focused, captionModifier)
} else CardCaption(title.displayTitle, if (title.isEpisode) null else title.year,
title.takeIf { it.type == "movie" || it.isEpisode }?.runtimeSeconds?.takeIf { it > 0 }?.let { title.runtime },
!row.catalogue && title.played && title.resumeSeconds <= 0, focused, captionModifier,
focusOnly = true, showTitle = row.cardKind == HomeCardKind.Poster)
}
}
}
@ -90,9 +83,11 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
@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 metadata = WolfskinTokens.CardMetadata
Column(Modifier.padding(bottom = metadata.bottom + metadata.separator + metadata.resumeLine).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(metadata.titleFocus), color = C.fg, style = metadata.titleFocus, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}

View file

@ -18,6 +18,7 @@ interface PosterContent {
val poster: String?
val ratings: List<LibraryRating>
val progress: Float
val posterWatched: Boolean
val badge: String?
}
@ -40,18 +41,15 @@ data class LibraryTitle(
) : PosterContent {
override val posterTitle: String get() = title
override val posterIsMovie: Boolean get() = type == "movie"
override val posterWatched: Boolean get() = played
val duration: String? get() = if (type == "movie" || type == "episode") runtimeSeconds?.takeIf { it > 0 }?.let {
val minutes = (it + 30) / 60
if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m"
} else null
val sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT)
val catalogueKey: String = "$type:${tmdbId ?: id}"
val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#'
val leadScore: Double get() = ratings.firstOrNull()?.let { if (it.source.startsWith("rt")) it.score else it.score * 10 } ?: -1.0
fun facts(catalogue: Boolean): String = buildList {
year?.let { add(it.toString()) }
add(if (type == "movie") runtimeSeconds?.let { val minutes = (it + 30) / 60; if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" } ?: "Movie" else seasons?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series")
if (catalogue) { if (availability.isNotEmpty()) add(availability) }
else if (played) add("Watched")
else if (progress > 0f && runtimeSeconds != null) add("${((runtimeSeconds * (1 - progress) + 30) / 60).toInt().coerceAtLeast(1)} min left")
}.joinToString(" · ")
}
/** Ordered rating entries are formatted after the active TV locale is known. */

View file

@ -25,9 +25,12 @@ fun LibraryPoster(
catalogue: Boolean,
modifier: Modifier = Modifier,
offlineArt: ((String) -> ImageBitmap?)? = null,
showWatchState: Boolean = !catalogue,
) {
val poster = title.poster
TitleCard(focused, modifier.testTag("poster-${title.id}")) {
val progress = if (showWatchState) title.progress else 0f
TitleCard(focused, modifier.testTag("poster-${title.id}"), watched = showWatchState && title.posterWatched,
progress = if (progress >= 1f) 0f else progress) {
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) {
@ -39,13 +42,6 @@ fun LibraryPoster(
onSuccess = { ready = true }, onError = { ready = false })
} else MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
val progress = title.progress
if (progress > 0f && progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Title.cardProgressInset, vertical = WolfskinTokens.Layout.navItemGap)
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).testTag("poster-progress-${title.id}").background(C.progressTrack, CircleShape)) {
Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape))
}
}
if (catalogue && title.badge != null) {
val held = title.badge == "held"
val color = if (title.badge == "failed") C.fail else C.accent

View file

@ -121,7 +121,7 @@ fun LibraryScreen(
LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) {
Box(Modifier.fillMaxWidth().height(rowHeight)) {
Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) }
repeat(grid.columns) { PosterCaptionSkeleton() }
}
}
}
@ -139,6 +139,7 @@ fun LibraryScreen(
val title = items.getOrNull(index) ?: break
key(if (grid.catalogue) title.catalogueKey else title.id) {
val isFocused = active && !grid.toolbar && !grid.inRail && grid.index == index
Box(Modifier.width(K.posterW)) {
LibraryPoster(title, isFocused, plateKey == title.id && isFocused, grid.catalogue,
Modifier.zIndex(if (isFocused) 1f else 0f).pointerInput(index, grid) {
awaitPointerEventScope {
@ -150,15 +151,13 @@ fun LibraryScreen(
}
}
}.clickable { grid.index = index; grid.toolbar = false; shell.navFocused = false; grid.onTitle(title) }, offlineArt)
CardCaption(title.title, if (title.type == "episode") null else title.year, title.duration,
!grid.catalogue && title.played && !(title.progress > 0f && title.progress < 1f), isFocused,
Modifier.offset(y = K.posterH + L.captionOffset).width(K.posterW))
}
}
}
}
if (active && !grid.toolbar && !grid.inRail && grid.index / grid.columns == row) {
val title = items.getOrNull(grid.index)
if (title != null) Caption(title.title, title.facts(grid.catalogue), true,
Modifier.offset(L.safeSide + K.posterPitch * (grid.index % grid.columns), K.posterH + L.captionOffset).width(G.captionWidth),
accentLastFact = if (grid.catalogue) title.availability.isNotEmpty() else title.played || title.progress > 0f)
}
}
}
if (grid.tail != GridTail.None) item(key = "tail", contentType = "grid") {
@ -240,7 +239,7 @@ private fun LibraryTail(grid: LibraryGridState, focused: Boolean) {
Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) {
CatalogueRecoveryCard(retry, focused, { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }, Modifier.offset(L.safeSide))
Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused,
Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth))
Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(K.posterW))
}
}

View file

@ -46,7 +46,7 @@ private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) {
Column {
Spacer(Modifier.height(heading))
Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
repeat(K.posterSlots) { SkeletonBlock(K.posterW, K.posterH) }
repeat(K.posterSlots) { PosterCaptionSkeleton() }
}
}
}
@ -55,3 +55,19 @@ private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) {
private fun SkeletonBlock(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp) {
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
}
/** Loading and real grid rows reserve the identical two-line caption band. */
@Composable
internal fun PosterCaptionSkeleton(width: androidx.compose.ui.unit.Dp = K.posterW,
height: androidx.compose.ui.unit.Dp = K.posterH, band: androidx.compose.ui.unit.Dp = K.posterCaptionBand,
showTitle: Boolean = true) {
Column(Modifier.width(width)) {
SkeletonBlock(width, height)
Box(Modifier.height(band)) {
Column(Modifier.offset(y = L.captionOffset)) {
if (showTitle) SkeletonBlock(width, with(androidx.compose.ui.platform.LocalDensity.current) { WolfskinTokens.CardMetadata.titleRest.lineHeight.toDp() })
SkeletonBlock(width / 2, with(androidx.compose.ui.platform.LocalDensity.current) { WolfskinTokens.CardMetadata.subtitle.lineHeight.toDp() })
}
}
}
}

View file

@ -148,7 +148,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
row.forEachIndexed { column, item ->
val index = rowIndex * columns + column
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) }
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), alwaysShowCaption = true, onFocus = { state.index = index }) { state.click(index) }
}
}
}

View file

@ -9,15 +9,51 @@ 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.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
// Precompose before packing into Color: packing first loses the browser's truncated channel fractions.
private val opaqueProgressTrack = WolfskinTokens.Color.progressTrack.let { track ->
val backing = WolfskinTokens.Color.bg
val inverse = 1f - track.alpha
Color(
((track.red * track.alpha + backing.red * inverse) * 255).toInt(),
((track.green * track.alpha + backing.green * inverse) * 255).toInt(),
((track.blue * track.alpha + backing.blue * inverse) * 255).toInt(),
)
}
/** 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) {
height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f,
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.ringCardRing, FocusLift.Card)) {
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2)
.drawWithCache {
val edge = WolfskinTokens.CardMetadata.bottom.toPx()
val fill = when {
progress > 0f -> WolfskinTokens.CardMetadata.resumeLine.toPx()
watched -> WolfskinTokens.CardMetadata.watchedLine.toPx()
else -> 0f
}
val backing = edge + if (fill > 0f) fill + WolfskinTokens.CardMetadata.separator.toPx() else 0f
onDrawWithContent {
clipRect(bottom = size.height - backing) { this@onDrawWithContent.drawContent() }
drawRect(WolfskinTokens.Color.bg, Offset(0f, size.height - backing), Size(size.width, backing))
if (fill > 0f) {
val top = Offset(0f, size.height - edge - fill)
if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill))
drawRect(WolfskinTokens.Color.accent, top,
Size(size.width * if (progress > 0f) progress.coerceIn(0f, 1f) else 1f, fill))
}
}
}, content = content)
}
}

View file

@ -30,6 +30,7 @@ data class TitleItem(
) : PosterContent {
override val posterTitle: String get() = name
override val posterIsMovie: Boolean get() = kind == "movie"
override val posterWatched: Boolean get() = watched
override val progress: Float get() = if (runtimeSeconds > 0) resumeSeconds.toFloat() / runtimeSeconds else 0f
override val badge: String? get() = if (held) "held" else null
val episodeLabel: String get() = "S$season E$episode"

View file

@ -4,7 +4,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -107,53 +106,36 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
}
}
/** Posters draw art at rest, with facts only in the reserved focused-caption band. */
/** Shelf captions are focus-only; filmography keeps card-width metadata visible at rest. */
@Composable
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, catalogue: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, alwaysShowCaption: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
val focusOwner = LocalTitleFocusOwner.current
Column(modifier.zIndex(if (focused) 1f else 0f)) {
if (!tile) {
val bitmaps = LocalTitleBitmaps.current
val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } }
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true,
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
} else Box(Modifier.size(WolfskinTokens.Card.tileW, D.episodeCardHeight).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
} else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() },
WolfskinTokens.Card.tileW, D.episodeCardHeight, watched = item.watched,
progress = item.progress.takeIf { it < 1f } ?: 0f) {
TitleTileArtwork(item)
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
val progress = item.progress
if (progress > 0f && progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = D.tileProgressBottom)
.fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent))
}
if (focused) CardMetadata(null, if (item.runtimeSeconds > 0) item.runtime else null, item.watched && item.resumeSeconds <= 0)
}
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) {
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
}
}
if (!tile) {
val availability = androidx.compose.runtime.remember(item, catalogue) {
if (catalogue) {
if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }
} else if (item.watched) "Watched"
else if (item.resumeSeconds > 0) "${((item.runtimeSeconds - item.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left"
else null
}
val facts = androidx.compose.runtime.remember(item, availability) {
listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(),
if (item.kind == "movie") { if (item.runtimeSeconds > 0) item.runtime else "Movie" } else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series",
availability).joinToString(" · ")
}
Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) {
Caption(item.name, facts, focused, Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth),
accentLastFact = availability != null, accentFirstFact = !item.character.isNullOrBlank())
Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand).width(WolfskinTokens.Card.posterW)) {
CardCaption(item.name, item.year, if (item.kind == "movie" && item.runtimeSeconds > 0) item.runtime else null,
item.watched && item.resumeSeconds <= 0, focused,
Modifier.offset(y = L.captionOffset).width(WolfskinTokens.Card.posterW), focusOnly = !alwaysShowCaption)
}
}
}

View file

@ -11,6 +11,20 @@ import androidx.compose.ui.unit.sp
/** The locked TV design, converted at the source density. */
object WolfskinTokens {
object CardMetadata {
val titleRest = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(500), lineHeight = 16.0.sp, letterSpacing = 0.0.em)
val titleFocus = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.0.sp, letterSpacing = 0.0.em)
val subtitle = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 13.0.sp, letterSpacing = 0.0.em)
val check = 12.0.dp
val gap = 6.0.dp
val compactGap = 4.0.dp
val minimumGap = 2.0.dp
val bottom = 1.0.dp
val separator = 1.0.dp
val watchedLine = 1.5.dp
val resumeLine = 3.0.dp
val landscapeBand = 32.0.dp
}
object Color {
val bg = ComposeColor(0xFF07070A)
val surface = ComposeColor(0xFF13131A)
@ -173,13 +187,13 @@ object WolfskinTokens {
val tileH = 112.5.dp
val tileGap = 20.0.dp
val tilePitch = 220.0.dp
val tileCaptionBand = 0.0.dp
val tileCaptionBand = 32.0.dp
const val tileSlots = 4
val wideW = 272.0.dp
val wideH = 153.0.dp
val wideGap = 20.0.dp
val widePitch = 292.0.dp
val wideCaptionBand = 0.0.dp
val wideCaptionBand = 32.0.dp
const val wideSlots = 3
val entryW = 128.0.dp
val entryH = 72.0.dp
@ -379,9 +393,6 @@ object WolfskinTokens {
val chipGap = 8.0.dp
val chipTop = 8.0.dp
val chipIcon = 13.0.dp
val captionWidth = 320.0.dp
const val captionSeparatorOpacity = 0.4f
val captionFactsGap = 9.0.dp
val badgeIcon = 14.0.dp
val plateTop = 7.0.dp
val plateSide = 8.0.dp
@ -465,7 +476,6 @@ object WolfskinTokens {
val personGap = 24.0.dp
val personNameBottom = 5.0.dp
val personNoteBottom = 3.0.dp
val captionWidth = 320.0.dp
val sheetPaddingTop = 40.0.dp
val sheetPaddingSide = 32.0.dp
val sheetGap = 9.0.dp
@ -552,8 +562,6 @@ object WolfskinTokens {
val tileOverlaySide = 11.0.dp
val tileOverlayBottom = 9.0.dp
const val cardOverlayOpacity = 0.92f
val cardProgressInset = 9.0.dp
val tileProgressBottom = 5.0.dp
val ambientInset = 40.0.dp
const val ambientScale = 1.1f
val posterShadowRadius = 40.0.dp
@ -591,7 +599,6 @@ object WolfskinTokens {
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
@ -612,8 +619,6 @@ object WolfskinTokens {
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
@ -634,7 +639,6 @@ object WolfskinTokens {
val savedGap = 5.0.dp
val savedIcon = 14.0.dp
val savedRadius = 14.0.dp
val posterProgressBottom = 6.0.dp
}
object Discover {
val shelfHeadingBand = 52.0.dp

View file

@ -0,0 +1,4 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<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 12l4 4l10 -10" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.5" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>