Reuse shared artwork and correct title focus edges

This commit is contained in:
justinzeus 2026-11-05 19:58:36 +01:00
parent 3bfb8cd779
commit a8951827ba
41 changed files with 581 additions and 438 deletions

View file

@ -11,38 +11,60 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
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 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) {
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false) {
val alpha = remember(title, facts) { Animatable(0f) }
LaunchedEffect(title, facts, visible) {
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
}
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
val styledFacts = remember(facts, accentLastFact) {
buildAnnotatedString {
val separator = " · "
append(facts)
if (accentLastFact) {
val suffix = facts.lastIndexOf(separator).takeIf { it >= 0 }?.plus(separator.length) ?: facts.length
if (suffix < facts.length) addStyle(SpanStyle(color = WolfskinTokens.Color.accentText), suffix, facts.length)
}
var start = facts.indexOf(separator)
if (start >= 0) {
val muted = WolfskinTokens.Color.muted
val separatorStyle = SpanStyle(color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity))
val dotOffset = separator.indexOf('·')
while (start >= 0) {
addStyle(separatorStyle, start + dotOffset, start + dotOffset + 1)
start = facts.indexOf(separator, start + separator.length)
}
}
}
}
Text(styledFacts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
CaptionFacts(facts, accentFirstFact, accentLastFact)
}
}
/** 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) {
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, maxLines = 1, overflow = TextOverflow.Clip)
if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = 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 }
}
}
}

View file

@ -6,23 +6,36 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.tv.material3.Icon
/** Shell glyph geometry is copied from the locked prototype, not a platform icon font. */
/** Shared glyph geometry comes from the locked prototype, never a platform icon font. */
@Composable
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
val resource = when (name) {
"gear" -> R.drawable.wolfskin_gear
"requests" -> R.drawable.wolfskin_tray
"interface" -> R.drawable.wolfskin_grid
"playback" -> R.drawable.wolfskin_play
"nextup" -> R.drawable.wolfskin_tv
"profile" -> R.drawable.wolfskin_users
"play", "playback" -> R.drawable.wolfskin_play
"tv", "nextup" -> R.drawable.wolfskin_tv
"users", "profile" -> R.drawable.wolfskin_users
"about" -> R.drawable.wolfskin_info
"seerr" -> R.drawable.wolfskin_sparkle
"sparkle", "seerr" -> R.drawable.wolfskin_sparkle
"advanced" -> R.drawable.wolfskin_sort
"alert" -> R.drawable.wolfskin_alert
"film" -> R.drawable.wolfskin_film
"eye" -> R.drawable.wolfskin_eye
"down" -> R.drawable.wolfskin_down
"restart" -> R.drawable.title_restart
"version" -> R.drawable.title_version
"tracks" -> R.drawable.title_tracks
"eyeoff" -> R.drawable.title_eyeoff
"trailer" -> R.drawable.title_trailer
"more" -> R.drawable.title_more
"plus" -> R.drawable.title_plus
"clock" -> R.drawable.title_clock
"download" -> R.drawable.title_download
"wifi" -> R.drawable.title_wifi
"check" -> R.drawable.title_check
"undo" -> R.drawable.title_undo
"left" -> R.drawable.title_left
else -> R.drawable.wolfskin_right
}
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)

View file

@ -5,26 +5,41 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.derivedStateOf
import java.util.Locale
import androidx.compose.runtime.Immutable
private val leadingArticle = Regex("^(the|a|an)\\s+", RegexOption.IGNORE_CASE)
/** Art-only card data shared without per-card adapter allocations. */
@Immutable
interface PosterContent {
val id: String
val posterTitle: String
val posterIsMovie: Boolean
val poster: String?
val ratings: List<LibraryRating>
val progress: Float
val badge: String?
}
/** Immutable title facts cross the provider boundary without SDK objects. */
data class LibraryTitle(
val id: String,
override val id: String,
val title: String,
val type: String,
val year: Int? = null,
val runtimeSeconds: Long? = null,
val seasons: Int? = null,
val poster: String? = null,
override val poster: String? = null,
val played: Boolean = false,
val progress: Float = 0f,
override val progress: Float = 0f,
val addedAt: String = "",
val ratings: List<LibraryRating> = emptyList(),
override val ratings: List<LibraryRating> = emptyList(),
val availability: String = "",
val badge: String? = null,
override val badge: String? = null,
val tmdbId: String? = null,
) {
) : PosterContent {
override val posterTitle: String get() = title
override val posterIsMovie: Boolean get() = type == "movie"
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' } ?: '#'
@ -40,7 +55,8 @@ data class LibraryTitle(
}
/** Ordered rating entries are formatted after the active TV locale is known. */
data class LibraryRating(val source: String, val score: Double, val label: String? = null) {
@Immutable
data class LibraryRating(val source: String, val score: Double, val label: String? = null, val episode: Boolean = false) {
fun formatted(locale: Locale = Locale.getDefault()): String = if (source.startsWith("rt")) "${score.toInt()}%" else String.format(locale, "%.1f", score)
}

View file

@ -26,7 +26,7 @@ object LibraryImages {
.components { add(Interceptor { chain -> reads.withPermit { chain.proceed() } }) }
.crossfade(false).build().also { loader = it }
fun request(context: Context, poster: String): ImageRequest = ImageRequest.Builder(context).data(poster)
.size((WolfskinTokens.Card.posterW.value * 2).toInt(), (WolfskinTokens.Card.posterH.value * 2).toInt())
.allowRgb565(true).crossfade(false).build()
fun request(context: Context, path: String, width: Int = WolfskinTokens.Image.posterRequestWidth,
height: Int = WolfskinTokens.Image.posterRequestHeight, poster: Boolean = true): ImageRequest =
ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build()
}

View file

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

View file

@ -105,7 +105,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier,
.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), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph("wifi", C.warn, Modifier.size(V.icon))
Glyph("wifi", C.warn, Modifier.size(V.icon))
Text("Saved $savedAt", color = C.warn, style = WolfskinTokens.Type.caption)
}
}

View file

@ -145,7 +145,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
Row(Modifier.height(D.gridHeight).padding(horizontal = 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), onFocus = { state.index = index }) { state.click(index) }
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) }
}
}
}

View file

@ -0,0 +1,105 @@
package org.justinvisser.wolfskin.design
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.remember
import androidx.compose.runtime.getValue
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.graphics.graphicsLayer
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.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.tv.material3.Text
import java.util.Locale
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as P
/** A full facts-line set never shrinks or waits for focused-card dwell. */
@Composable
fun TitleRatings(ratings: List<LibraryRating>, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.ratingPlate, plate: Boolean = false) {
if (ratings.isEmpty()) return
val density = LocalDensity.current
val markHeight = with(density) { style.fontSize.toDp() }
val lineHeight = style.fontSize * (WolfskinTokens.Type.ratingPlate.lineHeight.value / WolfskinTokens.Type.ratingPlate.fontSize.value)
val textStyle = if (style.lineHeight == lineHeight) style else style.copy(lineHeight = lineHeight)
val numberStyle = remember(textStyle) { textStyle.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold) }
val gap = if (plate) with(density) { (style.fontSize * P.plateGapEm).toDp() } else D.ratingGap
val markGap = if (plate) with(density) { (style.fontSize * P.markGapEm).toDp() } else D.ratingMarkGap
Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
ratings.forEach { rating ->
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
if (rating.episode) Text("Ep", color = C.muted, style = textStyle.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
val resource = when (rating.source) {
"rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh
"rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright
"imdb" -> R.drawable.ic_rating_imdb
else -> R.drawable.ic_rating_tmdb
}
val unknownCritic = rating.source == "rtCritics" && rating.label != "Fresh" && rating.label != "Certified Fresh" && rating.label != "Rotten"
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)
Text(rating.formatted(), color = C.fg, style = numberStyle, maxLines = 1)
}
}
}
}
/** The plate keeps a prefix of the title set, shrinking to the caption floor before dropping its tail. */
@Composable
fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifier, visible: Boolean = true) {
if (ratings.isEmpty()) return
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate")
BoxWithConstraints(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha }
.background(Brush.verticalGradient(0f to C.bg.copy(alpha = P.plateOpacity), P.plateMidStop to C.bg.copy(alpha = P.plateMidOpacity), 1f to C.bg.copy(alpha = 0f)))
.padding(start = P.plateSide, end = P.plateSide, top = P.plateTop, bottom = P.plateBottom)) {
val width = with(density) { maxWidth.toPx() }
val fit = remember(ratings, width, density, Locale.getDefault()) {
var size = WolfskinTokens.Type.ratingPlate.fontSize.value
var count = ratings.size
val numbers = ratings.map { it.formatted() }
fun measured(): Float {
val fontSize = TextUnit(size, TextUnitType.Sp)
val style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize, fontFeatureSettings = "tnum")
val markGap = with(density) { (fontSize * P.markGapEm).toPx() }
val entryGap = with(density) { (fontSize * P.plateGapEm).toPx() }
var total = 0f
for (index in 0 until count) {
val rating = ratings[index]
total += measurer.measure(numbers[index], style, maxLines = 1).size.width
total += with(density) { fontSize.toPx() } * if (rating.source == "imdb" || rating.source == "tmdb") P.wordmarkRatio else 1f
total += markGap
if (rating.episode) total += measurer.measure("Ep", style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), maxLines = 1).size.width + markGap
if (index > 0) total += entryGap
}
return total
}
while (size > P.plateFloor.fontSize.value && measured() > width) size = (size - P.plateStep).coerceAtLeast(P.plateFloor.fontSize.value)
while (count > 1 && measured() > width) count--
count to size
}
val fontSize = TextUnit(fit.second, TextUnitType.Sp)
TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize,
lineHeight = WolfskinTokens.Type.ratingPlate.lineHeight * (fit.second / WolfskinTokens.Type.ratingPlate.fontSize.value)), plate = true)
}
}

View file

@ -15,6 +15,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.ShaderBrush
@ -28,8 +33,8 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
import coil3.SingletonImageLoader
import coil3.compose.AsyncImage
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
@ -46,9 +51,9 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo
else if (path != null) {
val context = LocalContext.current
val request = remember(context, path, width, height, poster) {
titleImageRequest(context, path, width, height, poster)
LibraryImages.request(context, path, width, height, poster)
}
AsyncImage(model = request, imageLoader = LocalWolfskinImageLoader.current ?: SingletonImageLoader.get(context),
AsyncImage(model = request, imageLoader = LibraryImages.loader(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
}
}
@ -93,40 +98,57 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat()
ColorFilter.colorMatrix(matrix)
}
TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight, Modifier.fillMaxSize().blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter)
TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight,
Modifier.fillMaxSize().artOverscan(D.ambientInset).graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }
.blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter)
val posterShape = remember { RoundedCornerShape(WolfskinTokens.Radius.heroSheet) }
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
.size(L.heroSheetWidth, L.heroSheetHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)))
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape))
}
Box(Modifier.fillMaxSize().background(horizontal))
Box(Modifier.fillMaxSize().background(vertical))
}
}
/** Fallback art stays behind an image, so a failed decode never leaves an empty card. */
/** Library, related and person posters share the exact locked missing-art treatment. */
@Composable
fun TitleCardArtwork(item: TitleItem, tile: Boolean) {
Box(Modifier.fillMaxSize().titleFallbackBackground(item.seriesName ?: item.name, D.cardFallbackGradient)) {
TitleGlyph(if (item.kind == "movie") "film" else "tv", D.fallbackGlyphColor, Modifier.offset(D.fallbackInset, D.fallbackInset).size(D.fallbackGlyph))
if (!tile) Text(item.name, color = C.fg, style = D.fallbackTitle, maxLines = 4,
internal fun MediaArtworkFallback(name: String, movie: Boolean, showTitle: Boolean = true) {
Box(Modifier.fillMaxSize().titleFallbackBackground(name, D.cardFallbackGradient)) {
Glyph(if (movie) "film" else "tv", D.fallbackGlyphColor, Modifier.offset(D.fallbackInset, D.fallbackInset).size(D.fallbackGlyph))
if (showTitle) Text(name, color = C.fg, style = D.fallbackTitle, maxLines = 4, overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.BottomStart).padding(D.fallbackInset))
if (tile && item.isPosterBackdrop && item.poster != null) {
}
}
/** Episode tiles use their backdrop, or the prototype's blurred poster and small one-sheet. */
@Composable
fun TitleTileArtwork(item: TitleItem) {
Box(Modifier.fillMaxSize()) {
MediaArtworkFallback(item.seriesName ?: item.name, item.kind == "movie", showTitle = false)
if (item.isPosterBackdrop && item.poster != null) {
val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply {
for (index in 0 until 15) values[index] *= D.tileAmbientBrightness
}) }
TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().blur(D.tileAmbientBlur), colorFilter = filter)
TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter)
val height = WolfskinTokens.Card.tileH - D.tileSheetInset * 2
val shape = remember { RoundedCornerShape(D.tileSheetRadius) }
val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) }
TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(),
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).clip(RoundedCornerShape(D.tileSheetRadius)), poster = true)
} else {
TitleArtwork(if (tile) item.backdrop else item.poster,
if (tile) WolfskinTokens.Image.tileRequestWidth else WolfskinTokens.Image.posterRequestWidth,
if (tile) WolfskinTokens.Image.tileRequestHeight else WolfskinTokens.Image.posterRequestHeight,
Modifier.fillMaxSize(), poster = !tile)
}
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true)
} else TitleArtwork(item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize())
}
}
private fun Modifier.artOverscan(inset: Dp): Modifier = layout { measurable, constraints ->
val pixels = inset.roundToPx()
val width = constraints.maxWidth + pixels * 2
val height = constraints.maxHeight + pixels * 2
val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width, minHeight = height, maxHeight = height))
layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(-pixels, -pixels) }
}
private fun Modifier.titleFallbackBackground(name: String, token: String): Modifier = drawWithCache {
var hue = 0
name.forEach { hue = (hue * 31 + it.code) % 360 }

View file

@ -14,9 +14,12 @@ 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.res.painterResource
import androidx.compose.ui.platform.testTag
import androidx.tv.material3.Icon
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
@ -33,42 +36,17 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod
val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
.background(background, shape)
.then(if (option.on) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
if (option.glyph.isNotEmpty()) TitleGlyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))
if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))
if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1)
}
}
/** Title glyphs are the locked prototype paths, independent of platform icon substitutions. */
@Composable
fun TitleGlyph(name: String, color: androidx.compose.ui.graphics.Color, modifier: Modifier = Modifier) {
val resource = when (name) {
"play" -> R.drawable.title_play
"restart" -> R.drawable.title_restart
"version" -> R.drawable.title_version
"tracks" -> R.drawable.title_tracks
"eyeoff" -> R.drawable.title_eyeoff
"eye" -> R.drawable.title_eye
"trailer" -> R.drawable.title_trailer
"more" -> R.drawable.title_more
"plus" -> R.drawable.title_plus
"clock" -> R.drawable.title_clock
"users" -> R.drawable.title_users
"tv" -> R.drawable.title_tv
"film" -> R.drawable.title_film
"alert" -> R.drawable.title_alert
"wifi" -> R.drawable.title_wifi
"check" -> R.drawable.title_check
"sparkle" -> R.drawable.title_sparkle
"undo" -> R.drawable.title_undo
else -> R.drawable.title_left
}
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)
}
/** A radio remains visibly selected independently of the inverse focused row. */
@Composable

View file

@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -38,7 +39,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
val type = if (item.kind == "movie") "Movie" else "Series"
Column(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
Row(Modifier.padding(bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon))
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"}")
if (item.held && state.watched) append(" · Watched")
@ -57,7 +58,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
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.padding(bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph(if (stage == "Ready") "check" else "clock", color, Modifier.size(D.lineIcon))
Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon))
Text(stage, color = color, style = WolfskinTokens.Type.label)
Text("for you · by ${state.data.requestActor.orEmpty()}", color = C.muted, style = D.dLine)
}
@ -79,7 +80,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = WolfskinTokens.Type.label)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index,
Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) },
onFocus = { state.focus("header", index) }) { state.click("header", index) } }

View file

@ -5,7 +5,7 @@ import androidx.compose.runtime.Immutable
/** Screen data is independent of Jellyfin and Conductarr wire objects. */
@Immutable
data class TitleItem(
val id: String,
override val id: String,
val name: String,
val kind: String,
val year: Int? = null,
@ -13,7 +13,7 @@ data class TitleItem(
val seasons: Int = 0,
val overview: String = "",
val genres: List<String> = emptyList(),
val poster: String? = null,
override val poster: String? = null,
val backdrop: String? = null,
val held: Boolean = false,
val watched: Boolean = false,
@ -22,11 +22,15 @@ data class TitleItem(
val seriesName: String? = null,
val season: Int = 0,
val episode: Int = 0,
val ratings: List<TitleRating> = emptyList(),
override val ratings: List<LibraryRating> = emptyList(),
val character: String? = null,
val otherOwners: List<String> = emptyList(),
val isPosterBackdrop: Boolean = false,
) {
) : PosterContent {
override val posterTitle: String get() = name
override val posterIsMovie: Boolean get() = kind == "movie"
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"
val runtime: String get() {
val minutes = (runtimeSeconds + 30) / 60
@ -34,10 +38,6 @@ data class TitleItem(
}
}
/** A rating is supplied only after sign-in and only when its source observed a valid score. */
@Immutable
data class TitleRating(val source: String, val score: Double, val mark: String, val episode: Boolean = false)
/** A cast identity can open a performer page without borrowing a title's identity. */
@Immutable
data class TitlePerson(val id: String, val name: String, val character: String = "", val portrait: String? = null, val biography: String = "")

View file

@ -31,6 +31,12 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.tv.material3.Text
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.layout.layout
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
@ -40,13 +46,14 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
val focus = remember { FocusRequester() }
LaunchedEffect(state.sheet) { focus.requestFocus() }
val shadow = remember { Shadow(radius = D.sheetShadowRadius, offset = DpOffset.Zero.copy(x = D.sheetShadowX), color = Color.Black.copy(alpha = D.sheetShadowOpacity)) }
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable { }
.testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" }
.focusRequester(focus).onPreviewKeyEvent { event ->
val key = readShellKey(event)
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
}.focusable()) {
Column(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().background(C.surface)
Column(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface)
.verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
verticalArrangement = Arrangement.spacedBy(if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap)) {
when (state.sheet) {
@ -76,11 +83,11 @@ private fun TrackChoices(state: TitleState) {
val version = state.version ?: return
Text("Audio and subtitles", color = C.fg, style = WolfskinTokens.Type.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 = WolfskinTokens.Type.label)
Text("Audio", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(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 = WolfskinTokens.Type.label)
Text("Subtitles", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(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 ->
@ -121,10 +128,10 @@ private fun MediaInformation(state: TitleState) {
@Composable
private fun MediaInfoSection(label: String, values: List<Pair<String, String>>) {
Text(label, color = C.dim, style = WolfskinTokens.Type.label)
Column { values.forEach { (name, value) ->
Row(Modifier.fillMaxWidth()) {
Text(name, color = C.dim, style = WolfskinTokens.Type.caption, modifier = Modifier.width(D.infoLabelWidth))
Text(label, color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
Column(verticalArrangement = Arrangement.spacedBy(D.infoRowGap)) { values.forEach { (name, value) ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) {
Text(name, color = C.dim, style = D.infoKey, modifier = Modifier.width(D.infoLabelWidth))
Text(value, color = C.fg, style = WolfskinTokens.Type.caption)
}
} }
@ -143,7 +150,11 @@ private fun WatchedConfirmation(state: TitleState) {
@Composable
private fun SheetSubtitle(text: String) {
Text(text, color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.sheetSubBottom))
Text(text, color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.layout { measurable, constraints ->
val child = measurable.measure(constraints)
val lift = D.sheetSubLift.roundToPx()
layout(child.width, (child.height + lift).coerceAtLeast(0)) { child.placeRelative(0, lift) }
}.padding(bottom = D.sheetSubBottom))
}
@Composable
@ -151,7 +162,7 @@ private fun HiddenReceipt(state: TitleState) {
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.receiptSide, vertical = D.receiptTop),
verticalArrangement = Arrangement.spacedBy(D.receiptGap)) {
Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph("check", C.fg, Modifier.size(D.receiptIcon))
Glyph("check", C.fg, Modifier.size(D.receiptIcon))
Text("Hidden from your picks", color = C.fg, style = D.receiptTitle)
}
Text("${state.data.item.name} leaves every row of ${state.person.name}’s picks. It waits under Hidden picks at the end of Discover.", color = C.muted, style = WolfskinTokens.Type.body)

View file

@ -52,7 +52,18 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** The shared shell remains authoritative; title sheets overlay it and trap all input. */
@Composable
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
Box(modifier.fillMaxSize()) {
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
// No content target exists during an initial load; nav Down must not enter a skeleton.
if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false
else when (readShellKey(event)) {
ShellKey.Down -> true
ShellKey.Back -> {
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) state.back(shell)
true
}
else -> false
}
}) {
val focusOwner = remember(shell) { { shell.navFocused = false } }
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
ShellSurface(shell, handlesContentKeys = false, savedAt = state.data.savedAt) { TitlePage(state, shell, it) }
@ -72,7 +83,7 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism
Row(modifier.padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth)
.background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop),
horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph("alert", C.warn, Modifier.size(L.buttonIcon))
Glyph("alert", C.warn, Modifier.size(L.buttonIcon))
Text(message, color = C.fg, style = WolfskinTokens.Type.body)
}
}
@ -91,6 +102,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
val displayedRows = remember(state.data) {
buildList {
add("header")
if (state.data.status != TitleReadStatus.Ready) return@buildList
if (state.data.item.held && state.data.item.kind == "tv" && state.data.item.seasons > 0) {
add("seasons")
if (state.data.episodesStatus == TitleReadStatus.Loading || state.data.episodes.isNotEmpty()) add("episodes")
@ -117,7 +129,6 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
}
val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } }
val heroVisible by remember(rowOffsets, list, density) { derivedStateOf { position < with(density) { WolfskinTokens.Shell.stageHeight.toPx() } } }
val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } }
val nextArtRow = remember(displayedRows, lastVisible) { if (lastVisible < 0) null else displayedRows.drop(lastVisible + 1).firstOrNull { it == "cast" || it == "episodes" || it == "related" } }
TitleRowPrefetch(state.data, nextArtRow)
@ -135,10 +146,9 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
}
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
}.focusable()) {
if (heroVisible) {
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg))
} else Box(Modifier.fillMaxSize().background(C.bg))
// This remains visible behind the scrolled rows; it is not an off-screen header image.
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg))
if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state)
else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
@ -193,7 +203,7 @@ private fun TitleReadError(state: TitleState) {
.border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(horizontal = D.errorSide, vertical = D.errorVertical)) {
Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph("alert", C.warn, Modifier.size(D.receiptIcon))
Glyph("alert", C.warn, Modifier.size(D.receiptIcon))
Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = D.errorTitle)
}
Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.errorBodyBottom))

View file

@ -1,33 +1,21 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.compositionLocalOf
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
import coil3.request.ImageRequest
import coil3.request.Disposable
import coil3.request.allowRgb565
import coil3.request.crossfade
import coil3.ImageLoader
/** Owned screens share one bounded loader; standalone fixtures can use Coil's local asset loader. */
val LocalWolfskinImageLoader = compositionLocalOf<ImageLoader?> { null }
/** Visible and prefetched images use exactly the same size and decode policy. */
internal fun titleImageRequest(context: Context, path: String, width: Int, height: Int, poster: Boolean): ImageRequest =
ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build()
/** Read the next hidden art row into the bounded loader, cancelling work when the window changes. */
@Composable
internal fun TitleRowPrefetch(data: TitlePageData, row: String?) {
val loader = LocalWolfskinImageLoader.current ?: return
val context = LocalContext.current
if (row == null || data.status != TitleReadStatus.Ready) return
if (LocalTitleBitmaps.current.isNotEmpty()) return
val loader = LibraryImages.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(titleImageRequest(context, path, width, height, poster)))
if (path != null) requests.add(loader.enqueue(LibraryImages.request(context, path, width, height, poster)))
}
when (row) {
"cast" -> for (index in 0 until minOf(data.cast.size, WolfskinTokens.Card.personSlots))

View file

@ -1,96 +0,0 @@
package org.justinvisser.wolfskin.design
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.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.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.tv.material3.Text
import java.util.Locale
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** Rating formatting follows the TV locale; Rotten Tomatoes remains an integer percentage. */
fun formatTitleRating(rating: TitleRating, locale: Locale = Locale.getDefault()): String =
if (rating.source.startsWith("rt")) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score)
/** A full facts-line set never shrinks or waits for focused-card dwell. */
@Composable
fun TitleRatings(ratings: List<TitleRating>, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.button, plate: Boolean = false) {
val density = LocalDensity.current
val markHeight = with(density) { style.fontSize.toDp() }
val gap = if (plate) with(density) { (style.fontSize * D.ratingPlateEntryEm).toDp() } else D.ratingGap
val markGap = if (plate) with(density) { (style.fontSize * D.ratingPlateMarkEm).toDp() } else D.ratingMarkGap
Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
ratings.forEach { rating ->
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
if (rating.episode) Text("Ep", color = C.muted, style = style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
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
}
Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") D.ratingWordmarkAspect else 1f, markHeight),
alpha = if (rating.mark == "tomato") D.mutedTomatoOpacity else 1f,
colorFilter = if (rating.mark == "tomato") ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
Text(formatTitleRating(rating), color = C.fg, style = style.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold), maxLines = 1)
}
}
}
}
/** The plate keeps a prefix of the title set, shrinking to the caption floor before dropping its tail. */
@Composable
fun TitleRatingPlate(ratings: List<TitleRating>, modifier: Modifier = Modifier) {
if (ratings.isEmpty()) return
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
BoxWithConstraints(modifier.background(Brush.verticalGradient(listOf(C.bg.copy(alpha = .9f), C.bg.copy(alpha = .72f), C.bg.copy(alpha = 0f))))
.padding(start = D.plateSide, end = D.plateSide, top = D.plateTop, bottom = D.plateFade)) {
val width = with(density) { maxWidth.toPx() }
val fit = remember(ratings, width, density, Locale.getDefault()) {
var size = WolfskinTokens.Type.ratingPlate.fontSize.value
var count = ratings.size
val numbers = ratings.map { formatTitleRating(it) }
fun measured(): Float {
val fontSize = TextUnit(size, TextUnitType.Sp)
val style = WolfskinTokens.Type.button.copy(fontSize = fontSize, fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold)
val markGap = with(density) { (fontSize * D.ratingPlateMarkEm).toPx() }
val entryGap = with(density) { (fontSize * D.ratingPlateEntryEm).toPx() }
var total = 0f
for (index in 0 until count) {
val rating = ratings[index]
total += measurer.measure(numbers[index], style, maxLines = 1).size.width
total += with(density) { fontSize.toPx() } * if (rating.source == "imdb" || rating.source == "tmdb") D.ratingWordmarkAspect else 1f
total += markGap
if (rating.episode) total += measurer.measure("Ep", style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), maxLines = 1).size.width + markGap
if (index > 0) total += entryGap
}
return total
}
while (size > WolfskinTokens.Type.caption.fontSize.value && measured() > width) size = (size - D.ratingPlateStepSp).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value)
while (count > 1 && measured() > width) count--
count to size
}
TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.button.copy(fontSize = TextUnit(fit.second, TextUnitType.Sp)), plate = true)
}
}

View file

@ -103,40 +103,50 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
/** Posters draw art at rest, with facts only in the reserved focused-caption band. */
@Composable
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, catalogue: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
val focusOwner = LocalTitleFocusOwner.current
val width = if (tile) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
val height = if (tile) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
Column(modifier.zIndex(if (focused) 1f else 0f)) {
Box(Modifier.size(width, height).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
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,
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
} else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
TitleCardArtwork(item, tile)
if (tile) {
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(if (item.kind == "episode") "${item.episodeLabel} · ${item.name}" else "${if (item.kind == "movie") "Movie" else "Series"} · ${item.year ?: ""}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
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)
}
val progress = if (item.runtimeSeconds > 0) item.resumeSeconds.toFloat() / item.runtimeSeconds else 0f
val progress = item.progress
if (progress > 0f && progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = if (tile) D.tileProgressBottom else D.cardProgressBottom)
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 (item.held && !tile) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) {
TitleGlyph("check", C.onFocus, Modifier.size(L.buttonIcon))
}
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) {
TitleRatingPlate(item.ratings, Modifier.fillMaxWidth())
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, listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(), if (item.kind == "movie") item.runtime.takeIf { item.runtimeSeconds > 0 } ?: "Movie" else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series", if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }).joinToString(" · "), focused,
Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth))
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())
}
}
}

View file

@ -125,7 +125,7 @@ class TitleState(
}
if (sheet != null) { sheetKey(key); return true }
when (key) {
ShellKey.Back -> onAction(TitleAction.Pop)
ShellKey.Back -> back(shell)
ShellKey.Up -> if (row == "header") { shell.focusNav(shell.currentTab) } else moveRow(-1)
ShellKey.Down -> moveRow(1)
ShellKey.Left -> focus(row, (column - 1).coerceAtLeast(0))
@ -135,8 +135,14 @@ class TitleState(
return true
}
/** Platform Back shares the sheet-first ladder with remote Back. */
fun back() { if (sheet != null) sheet = null else onAction(TitleAction.Pop) }
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
fun back(shell: ShellState) {
when {
sheet != null -> sheet = null
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
else -> onAction(TitleAction.Pop)
}
}
/** Pointer selection stays inside the modal's row order. */
fun clickSheet(index: Int) { sheetIndex = index; sheetKey(ShellKey.Ok) }
@ -227,7 +233,6 @@ class TitleState(
}
else -> sheet = null
}
else -> Unit
}
}
@ -249,8 +254,8 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
val target = state.target
if (data.status != TitleReadStatus.Ready) {
if (data.status == TitleReadStatus.Unavailable) {
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
add(TitleOption("back", "Back", "left", icon = false))
add(TitleOption("retry", "Try again", "", primary = true, icon = false))
add(TitleOption("back", "Back", "", icon = false))
}
return@buildList
}
@ -271,9 +276,16 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
} else if (data.connection != TitleConnection.SignedIn) {
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
} else {
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", if (data.requestedStage == null) "plus" else "clock", primary = true, icon = false))
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false))
add(TitleOption("other", "For someone else", "users", icon = false))
if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false))
else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false))
}
}
internal fun titleStageGlyph(stage: String): String = when (stage) {
"Ready" -> "check"
"Failed" -> "alert"
"Downloading", "Almost ready" -> "download"
else -> "clock"
}

View file

@ -310,6 +310,9 @@ object WolfskinTokens {
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 badgeBorder = 1.5.dp
val plateTop = 7.0.dp
val plateSide = 8.0.dp
val plateBottom = 23.0.dp
@ -376,6 +379,7 @@ object WolfskinTokens {
val sheetGap = 9.0.dp
val sheetSubLift = -4.0.dp
val sheetSubBottom = 7.0.dp
val sheetLabelTop = 4.0.dp
val sheetRowSide = 13.0.dp
val versionRowVertical = 10.0.dp
val trackHeight = 42.0.dp
@ -384,14 +388,14 @@ object WolfskinTokens {
val radioDot = 8.0.dp
val radioBorder = 1.5.dp
val infoGap = 7.0.dp
val infoLabelWidth = 80.0.dp
val infoLabelWidth = 100.0.dp
val infoRowGap = 3.0.dp
val infoColumnGap = 10.0.dp
val infoKey = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
val gridHeight = 244.0.dp
val minimumEndPadding = 40.0.dp
val ratingGap = 10.0.dp
val ratingMarkGap = 4.0.dp
val plateSide = 8.0.dp
val plateTop = 7.0.dp
val plateFade = 23.0.dp
val initialSize = TextStyle(fontSize = 32.0.sp, fontWeight = FontWeight(800), lineHeight = 41.6.sp, letterSpacing = 0.0.em)
val personInitialSize = TextStyle(fontSize = 48.0.sp, fontWeight = FontWeight(800), lineHeight = 62.400000000000006.sp, letterSpacing = 0.0.em)
const val fallbackHue = 260.0f
@ -413,9 +417,6 @@ object WolfskinTokens {
val chipBorder = 1.0.dp
val heroSheetRight = 90.0.dp
val heroSheetTop = 75.0.dp
const val mutedTomatoOpacity = 0.55f
const val ratingWordmarkAspect = 1.8f
const val ratingPlateStepSp = 0.25f
val gridHeadingTop = 12.0.dp
val castName = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
val receiptSide = 15.0.dp
@ -441,6 +442,10 @@ object WolfskinTokens {
val tileSheetLeft = 11.0.dp
val tileSheetRadius = 5.0.dp
val tileAmbientBlur = 13.0.dp
val tileAmbientInset = 15.0.dp
val tileShadowRadius = 15.0.dp
val tileShadowY = 5.0.dp
const val tileShadowOpacity = 0.5f
const val tileAmbientBrightness = 0.5f
const val cardFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))"
const val personFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))"
@ -452,10 +457,15 @@ object WolfskinTokens {
val tileOverlayBottom = 9.0.dp
const val cardOverlayOpacity = 0.92f
val cardProgressInset = 9.0.dp
val cardProgressBottom = 6.0.dp
val tileProgressBottom = 5.0.dp
const val ratingPlateEntryEm = 0.64f
const val ratingPlateMarkEm = 0.29f
val ambientInset = 40.0.dp
const val ambientScale = 1.1f
val posterShadowRadius = 40.0.dp
val posterShadowY = 15.0.dp
const val posterShadowOpacity = 0.6f
val sheetShadowRadius = 40.0.dp
val sheetShadowX = -15.0.dp
const val sheetShadowOpacity = 0.5f
}
object SavedIndicator {
val height = 26.0.dp

View file

@ -1,4 +0,0 @@
<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="M12,3 L2,20 H22 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
<path android:pathData="M12,10 V14.5 M12,17 V17.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,3 @@
<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="M12 4v11m-5-5 5 5 5-5M5 20h14" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -1,4 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
<path android:pathData="M9.0,12.0a3.0,3.0 0 1,0 6.0,0a3.0,3.0 0 1,0 -6.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
</vector>

View file

@ -1,4 +0,0 @@
<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" />
</vector>

View file

@ -1,3 +0,0 @@
<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="M8 5v14l11-7z" android:fillColor="#FFFFFFFF" />
</vector>

View file

@ -1,3 +0,0 @@
<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="M12 3c.6 4.2 2.8 6.4 7 7-4.2.6-6.4 2.8-7 7-.6-4.2-2.8-6.4-7-7 4.2-.6 6.4-2.8 7-7z" android:fillColor="#FFFFFFFF" />
</vector>

View file

@ -1,4 +0,0 @@
<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,5.0H18.5Q21.0,5.0 21.0,7.5V14.5Q21.0,17.0 18.5,17.0H5.5Q3.0,17.0 3.0,14.5V7.5Q3.0,5.0 5.5,5.0Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
<path android:pathData="M8 21h8M12 17v4" android:fillColor="#FFFFFFFF" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" android:strokeLineCap="round" android:strokeLineJoin="miter" />
</vector>

View file

@ -1,4 +0,0 @@
<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,8.5a3.5,3.5 0 1,0 7.0,0a3.5,3.5 0 1,0 -7.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
<path android:pathData="M2.5 19.5c.8-3.4 3.4-5 6.5-5s5.7 1.6 6.5 5M15.5 5.2a3.4 3.4 0 0 1 0 6.6M18 14.8c1.8.7 3 2.2 3.5 4.7" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="miter" />
</vector>

View file

@ -1 +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 L18.5,4 Q21,4 21,6.5 L21,17.5 Q21,20 18.5,20 L5.5,20 Q3,20 3,17.5 L3,6.5 Q3,4 5.5,4 M7,4 L7,20 M17,4 L17,20 M3,9 L7,9 M3,15 L7,15 M17,9 L21,9 M17,15 L21,15" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round"/></vector>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="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" />
</vector>