Integrate real Conductarr TV title and recovery providers

This commit is contained in:
justinzeus 2026-11-06 00:10:27 +01:00
commit 78322f588a
550 changed files with 5029 additions and 293 deletions

View file

@ -9,12 +9,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.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 fade without moving the reserved band under the focused poster. */
@Composable
@ -25,32 +26,47 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
LaunchedEffect(alpha, visible) {
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
}
val styledFacts = remember(facts, accentLastFact, accentFirstFact) {
buildAnnotatedString {
val separator = " · "
append(facts)
if (accentFirstFact) {
val end = facts.indexOf(separator).takeIf { it >= 0 } ?: facts.length
addStyle(SpanStyle(color = WolfskinTokens.Color.accentText), 0, end)
}
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)
}
}
}
}
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
Text(title, modifier = Modifier.exactLineBox(titleStyle), color = WolfskinTokens.Color.fg, style = titleStyle, maxLines = 1, overflow = titleOverflow)
Text(styledFacts, modifier = Modifier.exactLineBox(factsStyle), color = WolfskinTokens.Color.muted, style = factsStyle, maxLines = 1)
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 }
}
}
}

View file

@ -0,0 +1,68 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.LayoutModifier
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import kotlin.math.roundToInt
internal data class CssLineBox(val lineHeight: TextUnit, val top: Dp, val bottom: Dp) : LayoutModifier, ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any = parentData ?: this@CssLineBox
fun Density.exactHeight(height: Int): Float {
val margins = top.toPx() + bottom.toPx()
return ((height - margins) / lineHeight.toPx()).roundToInt().coerceAtLeast(1) * lineHeight.toPx() + margins
}
override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints) = with(measurable.measure(constraints.copy(minHeight = 0))) {
val line = lineHeight.toPx()
val lines = ((this[LastBaseline] - this[FirstBaseline]) / line).roundToInt().coerceAtLeast(0) + 1
val textHeight = lines * line
val offset = top.toPx() + (textHeight - height) / 2f
layout(width, (textHeight + top.toPx() + bottom.toPx()).roundToInt()) {
placeRelative(0, offset.roundToInt())
}
}
}
private class CssColumnHeight : ParentDataModifier {
var pixels = 0f
override fun Density.modifyParentData(parentData: Any?): Any = parentData ?: this@CssColumnHeight
}
/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
@Composable
internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) {
val exact = remember { CssColumnHeight() }
Layout(content, modifier.then(exact)) { children, constraints ->
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) }
fun height(index: Int): Float = when (val data = children[index].parentData) {
is CssLineBox -> with(data) { exactHeight(measured[index].height) }
is CssColumnHeight -> data.pixels
else -> measured[index].height.toFloat()
}
val space = gap.toPx()
var total = 0f
var width = constraints.minWidth
for (index in measured.indices) {
total += height(index) + if (index == measured.lastIndex) 0f else space
width = maxOf(width, measured[index].width)
}
exact.pixels = total
layout(width.coerceIn(constraints.minWidth, constraints.maxWidth), total.roundToInt().coerceIn(constraints.minHeight, constraints.maxHeight)) {
var y = 0f
for (index in measured.indices) {
measured[index].placeRelative(0, y.roundToInt())
y += height(index) + space
}
}
}
}

View file

@ -13,7 +13,7 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = {
}
private val pool = profiles.flatMap { it.getJSONArray("discover").objects() }.distinctBy(::identity).map(::title)
val services = listOf("Netflix", "Prime Video", "Disney+", "HBO Max", "Apple TV+", "Videoland", "Hulu", "NPO Start", "Crunchyroll")
.mapIndexed { index, name -> DiscoverService(index.toLong(), name, listOf(355f, 200f, 230f, 265f, 0f, 20f, 140f, 25f, 30f)[index]) }
.mapIndexed { index, name -> DiscoverService(index.toLong(), name, discoverServiceHue(name)) }
fun snapshot(person: String): DiscoverSnapshot {
val profile = profiles.first { it.getString("key") == person }

View file

@ -32,6 +32,19 @@ data class DiscoverSeed(val title: String, val items: List<DiscoverTitle>)
/** Services carry their catalogue id and a text wordmark, never third-party logos. */
data class DiscoverService(val id: Long, val name: String, val hue: Float)
/** Locked service accents are shared by fixture and live catalogue providers. */
fun discoverServiceHue(name: String): Float = when (name) {
"Netflix" -> 355f
"Prime Video", "Amazon Prime Video" -> 200f
"Disney+", "Disney Plus" -> 230f
"HBO Max", "Max" -> 265f
"Videoland" -> 20f
"Hulu" -> 140f
"NPO Start" -> 25f
"Crunchyroll" -> 30f
else -> 0f
}
enum class DiscoverStatus { Ready, NeedsSignIn, Unavailable, Loading, NoSeeds, PicksUnavailable }
enum class DiscoverTail { None, SeeAll, Retry, ShowMore }

View file

@ -131,9 +131,9 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) {
val phase = sheet.phase
val name = sheet.selectedPerson.name
val actor = sheet.result?.actor ?: state.shell.person.name
val actor = sheet.result?.actor
val text = when (phase) {
RequestPhase.Receipt -> "for ${if (sheet.selectedPerson.key == state.shell.person.key) "you" else name} · by $actor"
RequestPhase.Receipt -> "for ${if (sheet.selectedPerson.key == state.shell.person.key) "you" else name}" + actor?.let { " · by $it" }.orEmpty()
RequestPhase.NotConfirmed -> "The request service didn’t answer. Nothing is assumed filed."
RequestPhase.Pending -> "Seerr hasn’t confirmed it yet. Checking again looks first, so nothing is filed twice."
RequestPhase.Refused -> "$name can’t receive requests from this TV."

View file

@ -6,25 +6,24 @@ 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
"wifi" -> R.drawable.wolfskin_wifi
"clock" -> R.drawable.wolfskin_clock
"plus" -> R.drawable.wolfskin_plus
"check" -> R.drawable.wolfskin_check
"clock" -> R.drawable.wolfskin_clock
"hide" -> R.drawable.wolfskin_hide
"undo" -> R.drawable.wolfskin_undo
"left" -> R.drawable.wolfskin_left
@ -32,6 +31,12 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
"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
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

@ -1,7 +1,5 @@
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
@ -10,43 +8,42 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L
/** Poster slots use exact requests and share the shell's draw-only focus treatment. */
@Composable
fun LibraryPoster(
title: LibraryTitle,
title: PosterContent,
focused: Boolean,
ratingsVisible: Boolean,
catalogue: Boolean,
modifier: Modifier = Modifier,
offlineArt: ((String) -> ImageBitmap?)? = null,
) {
val poster = title.poster
TitleCard(focused, modifier.testTag("poster-${title.id}")) {
val bitmap = remember(title.poster) { title.poster?.let { offlineArt?.invoke(it) } }
if (bitmap != null) Image(bitmap, title.title, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else if (offlineArt == null) TitleArtwork(title.title, title.type, title.poster,
WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.fillMaxSize())
else TitleArtwork(title.title, title.type, null, WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.fillMaxSize())
val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } }
if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else if (poster != null && offlineArt == null) {
var ready by remember(poster) { mutableStateOf(false) }
if (!ready) MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
val context = LocalContext.current
val request = remember(poster, context) { WolfskinImages.request(context, poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true) }
AsyncImage(request, title.posterTitle, imageLoader = WolfskinImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop,
onSuccess = { ready = true }, onError = { ready = false })
} else MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
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) {
@ -54,7 +51,7 @@ fun LibraryPoster(
val color = if (title.badge == "failed") C.fail else C.accent
Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset)
.size(WolfskinTokens.Layout.badge).background(if (held) C.ok else C.badgeRest, CircleShape)
.border(if (held) WolfskinTokens.Shell.zero else L.badgeRing, color, CircleShape), contentAlignment = Alignment.Center) {
.then(if (held) Modifier else Modifier.border(L.badgeRing, color, CircleShape)), contentAlignment = Alignment.Center) {
Glyph(if (held) "check" else if (title.badge == "failed") "alert" else "clock",
if (held) C.onFocus else color, Modifier.size(L.badgeIcon))
}
@ -63,45 +60,3 @@ fun LibraryPoster(
}
/** 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

@ -7,12 +7,12 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -33,9 +33,9 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V
/** Blob bounds retain their measurement while their draw coordinates spring between targets. */
data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp)
@ -101,10 +101,11 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
}
}
state.navSavedAt?.let { time ->
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide - L.navAvatar - L.navItemGap, S.navItemTop + (L.navItemHeight - H.savedHeight) / 2)
.height(H.savedHeight).border(H.hairline, C.warn.copy(alpha = .5f), RoundedCornerShape(H.savedRadius))
.padding(horizontal = H.savedPadding + H.hairline), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.savedGap)) {
Glyph("wifi", C.warn, Modifier.size(H.savedIcon))
Row(Modifier.align(Alignment.TopEnd)
.offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2)
.height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button))
.padding(horizontal = V.side + V.border), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) {
Glyph("wifi", C.warn, Modifier.size(V.icon))
Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption)
}
}

View file

@ -0,0 +1,158 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.tv.material3.Text
import 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
/** Filmography retains a real credit key and requests more credits before focus reaches the tail. */
class PersonState(initialPerson: TitlePerson, initialCredits: List<TitleItem>, private val onAction: (TitleAction) -> Unit, private val onReadMore: () -> Unit = {}) {
var person by mutableStateOf(initialPerson)
private set
var credits by mutableStateOf(initialCredits)
var savedAt by mutableStateOf<String?>(null)
var toast by mutableStateOf<String?>(null)
fun update(actor: TitlePerson, titles: List<TitleItem>, saved: String? = null) {
person = actor; credits = titles; savedAt = saved
index = index.coerceAtMost((credits.size - 1).coerceAtLeast(0))
}
var index by mutableStateOf(0)
private var armed: String? = null
/** Pointer activation and remote key-up open the same selected credit. */
fun click(position: Int) { index = position; onAction(TitleAction.OpenTitle(credits[index])) }
/** The first grid row exits upward into the existing shell nav. */
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
if (credits.isEmpty()) {
if (down && key == ShellKey.Back) onAction(TitleAction.Pop)
else if (down && key == ShellKey.Up) shell.focusNav(shell.currentTab)
return true
}
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
if (key == ShellKey.Ok) {
if (repeat > 0) return true
if (down) armed = credits[index].id else { if (armed == credits[index].id) click(index); armed = null }
return true
}
if (!down) return true
armed = null
when (key) {
ShellKey.Back -> onAction(TitleAction.Pop)
ShellKey.Up -> if (index < columns) shell.focusNav(shell.currentTab) else index -= columns
ShellKey.Down -> if (index / columns < credits.lastIndex / columns) index = (index + columns).coerceAtMost(credits.lastIndex)
ShellKey.Left -> if (index % columns > 0) index--
ShellKey.Right -> if (index % columns < columns - 1 && index < credits.lastIndex) index++
else -> Unit
}
if (index >= credits.size - WolfskinTokens.Behaviour.nextPageCards.toInt()) onReadMore()
return true
}
}
/** Portrait, biography and the full continuous grid use the shell's remembered frame. */
@Composable
fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
val view = shell.view
LaunchedEffect(view, state.savedAt) { view.savedAt = state.savedAt }
val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } }
LaunchedEffect(state) { shell.navFocused = false }
Box(modifier.fillMaxSize().background(C.bg)) {
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
ShellSurface(shell, handlesContentKeys = false) { PersonPage(state, shell, it) }
}
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
}
}
@Composable
private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
val density = LocalDensity.current
val list = shell.view.listState
var wheelPending by remember { mutableStateOf(false) }
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
LaunchedEffect(state.index / columns, shell.navFocused) {
val target = if (shell.navFocused || state.index < columns) 0f
else with(density) { (L.row_phead + L.row_gh + D.gridHeight * (state.index / columns) - L.pinnedLine).toPx() }
val first = list.firstVisibleItemIndex
val before = when (first) {
0 -> WolfskinTokens.Shell.zero
1 -> L.row_phead
else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2)
}
val current = with(density) { before.toPx() } + list.firstVisibleItemScrollOffset
if (target != current) list.animateScrollBy(target - current, titleFrameScrollSpec)
}
Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" }
.titleWheel { wheelPending = true; shell.navFocused = false }
.onPreviewKeyEvent { event ->
val key = readShellKey(event)
if (key != null && key != ShellKey.Back && event.type == KeyEventType.KeyDown && wheelPending) {
val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index >= 2 && it.key != "end-padding" && it.offset + it.size > with(density) { L.pinnedLine.roundToPx() } }
state.index = ((visible?.index?.minus(2) ?: 0) * columns).coerceAtMost((state.credits.size - 1).coerceAtLeast(0))
wheelPending = false
if (key != ShellKey.Ok) return@onPreviewKeyEvent true
}
key?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false
}.focusable()) {
LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
item(key = "person-header", contentType = "header") {
Box(Modifier.height(L.row_phead)) {
Row(Modifier.offset(L.safeSide, D.personTop), horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
PersonPortrait(state.person, Modifier.size(L.personPortrait).clip(CircleShape), true)
Column(Modifier.width(L.contentWidth - L.personPortrait - D.personGap)) {
Text(state.person.name, color = C.fg, style = TitleTextStyles.screenTitle, modifier = Modifier.padding(bottom = D.personNameBottom))
Text("Acting · ${state.credits.size} credit${if (state.credits.size == 1) "" else "s"} among this household’s titles", color = C.muted, style = TitleTextStyles.body)
if (state.person.biography.isNotBlank()) Text(state.person.biography, color = C.dim, style = TitleTextStyles.caption, maxLines = 3)
}
}
}
}
item(key = "filmography", contentType = "header") {
Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
}
itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row ->
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), catalogue = true, onFocus = { state.index = index }) { state.click(index) }
}
}
}
item(key = "end-padding", contentType = "spacer") { Spacer(Modifier.height(WolfskinTokens.Shell.stageHeight - L.pinnedLine - D.gridHeight)) }
}
}
}

View file

@ -12,6 +12,6 @@ val LocalScreenRatings = compositionLocalOf<ScreenRatings?> { null }
/** Convert supplier marks without inferring a Rotten Tomatoes label from a percentage. */
fun LibraryRating.homeRating(): HomeRating = HomeRating(
mark = when (label) { "Fresh", "Certified Fresh" -> "fresh"; "Rotten" -> "rotten"; "Upright" -> "upright"; "Spilled" -> "spilled"; else -> when (source) { "rtCritics" -> "tomato"; "rtAudience" -> "popcorn"; "episode" -> "tmdb"; else -> source } },
score = score, percentage = source.startsWith("rt"), episode = source == "episode",
mark = when (label) { "Fresh", "Certified Fresh" -> "fresh"; "Rotten" -> "rotten"; "Upright" -> "upright"; "Spilled" -> "spilled"; else -> when (source) { "rtCritics" -> "tomato"; "rtAudience" -> "popcorn"; else -> source } },
score = score, percentage = source.startsWith("rt"), episode = episode,
)

View file

@ -9,8 +9,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@ -34,17 +32,20 @@ import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Screens and the shell interpret the same Android directional and activation keys. */
fun readShellKey(event: KeyEvent): ShellKey? = when (event.key) {
Key.DirectionUp -> ShellKey.Up
Key.DirectionDown -> ShellKey.Down
Key.DirectionLeft -> ShellKey.Left
Key.DirectionRight -> ShellKey.Right
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok
Key.Back, Key.Escape -> ShellKey.Back
else -> null
}
/** Map Android key events once, preserving repeat count and key-up semantics. */
fun ShellState.handleKey(event: KeyEvent): Boolean {
val key = when (event.key) {
Key.DirectionUp -> ShellKey.Up
Key.DirectionDown -> ShellKey.Down
Key.DirectionLeft -> ShellKey.Left
Key.DirectionRight -> ShellKey.Right
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok
Key.Back, Key.Escape -> ShellKey.Back
else -> return false
}
val key = readShellKey(event) ?: return false
return this.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount)
}

View file

@ -0,0 +1,107 @@
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.platform.testTag
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) {
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)
if (rating.episode) Text("Ep", color = C.muted, style = textStyle.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), modifier = Modifier.exactLineBox(textStyle))
Text(rating.formatted(), color = C.fg, style = numberStyle, modifier = Modifier.exactLineBox(numberStyle), maxLines = 1)
}
}
}
}
/** 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)))
.testTag("rating-plate").padding(horizontal = P.plateSide)) {
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)
val style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize,
lineHeight = WolfskinTokens.Type.ratingPlate.lineHeight * (fit.second / WolfskinTokens.Type.ratingPlate.fontSize.value))
TitleRatings(ratings.take(fit.first), modifier = Modifier.exactLineBox(style, top = P.plateTop, bottom = P.plateBottom + P.plateInlineDescent), style = style, plate = true)
}
}

View file

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

View file

@ -1,87 +1,176 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.runtime.Composable
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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.graphicsLayer
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.toArgb
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** Exact decode dimensions and no crossfade keep card focus independent of image work. */
@Composable
private fun SlotImage(source: String, width: Dp, height: Dp, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) {
val context = LocalContext.current
val request = remember(context, source, width, height, poster) {
WolfskinImages.request(context, source, (width.value * 2).toInt(), (height.value * 2).toInt(), poster)
}
AsyncImage(request, imageLoader = WolfskinImages.loader(context), contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, onError = { onError() })
}
/** Local decoded fixture art keeps screenshot tests deterministic and completely offline. */
val LocalTitleBitmaps = compositionLocalOf<Map<String, ImageBitmap>> { emptyMap() }
/** Poster-only landscapes use the upright one-sheet over a dimmed wash of the same image. */
/** Decode at the slot size, with no crossfade or oversized poster allocations. */
@Composable
fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height: Dp, modifier: Modifier = Modifier,
backdrop: String? = null, posterOnly: Boolean = false, landscape: Boolean = false, hero: Boolean = false) {
val source = if (landscape) backdrop ?: poster else poster
val failed = remember(source) { androidx.compose.runtime.mutableStateOf(false) }
val missing = source == null || failed.value
val density = LocalDensity.current
val fallback = if (missing && !hero) remember(title, width, height, density) {
val hue = title.fold(0) { hash, character -> (hash * 31 + character.code) % 360 }.toFloat()
val radians = Math.toRadians(160.0)
val directionX = kotlin.math.sin(radians).toFloat()
val directionY = -kotlin.math.cos(radians).toFloat()
val size = with(density) { Offset(width.toPx(), height.toPx()) }
val extent = size.x * directionX + size.y * directionY
val center = size / 2f
val offset = Offset(directionX * extent / 2f, directionY * extent / 2f)
Brush.linearGradient(listOf(Color.hsl(hue, .22f, .22f), Color.hsl(hue, .26f, .10f)), center - offset, center + offset)
} else null
Box(modifier.then(if (fallback != null) Modifier.background(fallback) else Modifier.background(C.surface2))) {
if (!missing) {
if (landscape && (posterOnly || backdrop == null) && poster != null) {
val wash = remember(hero) {
val matrix = ColorMatrix().apply { setToSaturation(if (hero) 1.2f else 1f) }
val brightness = if (hero) .55f else .5f
for (index in 0 until 15) matrix.values[index] *= brightness
ColorFilter.colorMatrix(matrix)
}
val inset = if (hero) H.heroWashInset else H.cardWashInset
val washModifier = Modifier.wrapContentSize(Alignment.Center, unbounded = true)
.requiredSize(width + inset * 2, height + inset * 2)
.blur(if (hero) H.heroBlur else H.cardBlur)
.graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } }
SlotImage(poster, width, height, washModifier, colorFilter = wash, onError = { failed.value = true })
val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset
val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f)
val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop)
SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true })
} else SlotImage(source, if (hero) WolfskinTokens.Card.posterW * 5 else width, if (hero) WolfskinTokens.Card.posterW * 5 * (9f / 16f) else height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true })
} else if (!hero) {
Glyph(if (type == "movie") "film" else "nextup", C.fg.copy(alpha = .45f), Modifier.offset(H.cardPadding, H.cardPadding).size(H.artGlyph))
if (!landscape) Text(title, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 4, modifier = Modifier.align(Alignment.BottomStart).padding(H.cardPadding))
} else HomeAmbient(Modifier.fillMaxSize())
fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) {
val bitmap = LocalTitleBitmaps.current[path]
if (bitmap != null) Image(bitmap, null, modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
else if (path != null) {
val context = LocalContext.current
val request = remember(context, path, width, height, poster) {
WolfskinImages.request(context, path, width, height, poster)
}
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, onError = { onError() })
}
}
/** Initials follow the same title-hashed hue as the locked art fallback. */
@Composable
fun PersonPortrait(person: TitlePerson, modifier: Modifier = Modifier, large: Boolean = false) {
val initials = remember(person.name) { person.name.split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2).joinToString("") { it.take(1) }.uppercase() }
val size = if (large) (L.personPortrait.value * 2).toInt() else WolfskinTokens.Image.castRequestWidth
Box(modifier.titleFallbackBackground(person.name, D.personFallbackGradient), contentAlignment = Alignment.Center) {
Text(initials, color = D.initialsColor, style = if (large) D.personInitialSize else D.initialSize, textAlign = TextAlign.Center)
if (person.portrait != null) TitleArtwork(person.portrait, size, size, Modifier.fillMaxSize())
}
}
/** The original gradient is parsed once so stops stay derived from the canonical token. */
@Composable
fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
val horizontal = remember {
val stops = Regex("rgb\\(7 7 10 / ([.\\d]+)\\) (\\d+)%").findAll(WolfskinTokens.Gradient.detailScrim).take(3)
.map { it.groupValues[2].toFloat() / 100 to C.bg.copy(alpha = it.groupValues[1].toFloat()) }.toList()
Brush.horizontalGradient(*stops.toTypedArray())
}
val vertical = remember {
val token = WolfskinTokens.Gradient.detailScrim.substringAfter("linear-gradient(0deg,")
val stops = mutableListOf(1f to C.bg)
Regex("rgb\\(7 7 10 / ([.\\d]+)\\) (\\d+)%").findAll(token).forEach {
stops.add(1f - it.groupValues[2].toFloat() / 100 to C.bg.copy(alpha = it.groupValues[1].toFloat()))
}
Brush.verticalGradient(*stops.sortedBy { it.first }.toTypedArray())
}
Box(modifier.titleAmbientBackground()) {
if (item.backdrop != null && !item.isPosterBackdrop) {
TitleArtwork(item.backdrop, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight, Modifier.fillMaxSize())
} else if (item.poster != null) {
val filter = remember {
val token = WolfskinTokens.Gradient.heroAmbientFilter
Regex("blur\\(([\\d.]+)px\\).*saturate\\(([\\d.]+)\\).*brightness\\(([\\d.]+)\\)").find(token)!!.groupValues
}
val colorFilter = remember {
val matrix = ColorMatrix().apply { setToSaturation(filter[2].toFloat()) }
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().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).dropShadow(posterShape, posterShadow).clip(posterShape))
}
Box(Modifier.fillMaxSize().background(horizontal))
Box(Modifier.fillMaxSize().background(vertical))
}
}
/** Library, related and person posters share the exact locked missing-art treatment. */
@Composable
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))
}
}
/** 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().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).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 }
val values = Regex("([\\d.]+)%").findAll(token).map { it.groupValues[1].toFloat() / 100 }.toList()
val angle = Math.toRadians(Regex("(\\d+)deg").find(token)!!.groupValues[1].toDouble())
val dx = kotlin.math.sin(angle).toFloat()
val dy = -kotlin.math.cos(angle).toFloat()
val half = (kotlin.math.abs(size.width * dx) + kotlin.math.abs(size.height * dy)) / 2
val vector = Offset(dx * half, dy * half)
val middle = Offset(size.width / 2, size.height / 2)
val brush = Brush.linearGradient(listOf(Color.hsl(hue.toFloat(), values[0], values[1]), Color.hsl(hue.toFloat(), values[2], values[3])),
start = middle - vector, end = middle + vector)
onDrawBehind { drawRect(brush) }
}
private fun Modifier.titleAmbientBackground(): Modifier = drawWithCache {
val values = Regex("([\\d.]+)%").findAll(D.heroAmbientBackground).map { it.groupValues[1].toFloat() / 100 }.toList()
val shader = android.graphics.RadialGradient(0f, 0f, 1f, intArrayOf(C.surface3.toArgb(), C.bg.toArgb()), null, android.graphics.Shader.TileMode.CLAMP)
shader.setLocalMatrix(android.graphics.Matrix().apply {
setScale(size.width * values[0], size.height * values[1])
postTranslate(size.width * values[2], size.height * values[3])
})
val brush = ShaderBrush(shader)
onDrawBehind { drawRect(brush) }
}

View file

@ -0,0 +1,59 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.platform.testTag
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
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */
@Composable
fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, onClick: () -> Unit) {
val focusOwner = LocalTitleFocusOwner.current
val background = if (option.primary) C.fg else if (overArtwork) {
if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary
} else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
val foreground = if (option.primary) C.onFocus else C.fg
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, FocusLift.Button)
.background(background, shape)
.then(if (option.on && !focused) 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()) 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)
}
}
/** A radio remains visibly selected independently of the inverse focused row. */
@Composable
fun TitleRadio(selected: Boolean, focused: Boolean) {
val color = if (focused) C.onFocus else C.fg
Box(Modifier.size(D.radioSize).background(if (selected) C.accent else androidx.compose.ui.graphics.Color.Transparent, CircleShape)
.then(if (focused || !selected) Modifier.border(D.radioBorder, color.copy(alpha = if (focused) .5f else .35f), CircleShape) else Modifier), contentAlignment = Alignment.Center) {
if (selected) Box(Modifier.size(D.radioDot).background(C.onFocus, CircleShape))
}
}

View file

@ -0,0 +1,117 @@
package org.justinvisser.wolfskin.design
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.platform.testTag
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** Film, series and episode share one copy block and one fixed options-caption band. */
@Composable
fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) {
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
val item = state.data.item
val type = if (item.kind == "movie") "Movie" else "Series"
CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon))
Text(buildString {
append("$type · ${if (item.held) "In your library" else "Not in your library"}")
if (item.held && state.watched) append(" · Watched")
if (!item.held && item.otherOwners.isNotEmpty()) append(" · In ${item.otherOwners.joinToString(" and ")}’s library")
if (state.data.hidden) append(" · Hidden from your picks")
}, color = C.accentText, style = TitleTextStyles.dLine)
}
Text(item.name, color = C.fg, style = TitleTextStyles.displayDetail, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.displayDetail, bottom = D.titleBottom).testTag("title-heading"))
Row(Modifier.padding(bottom = D.factsBottom).height(D.factsHeight).testTag("title-facts"), horizontalArrangement = Arrangement.spacedBy(D.factsGap), verticalAlignment = Alignment.CenterVertically) {
item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) }
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings)
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
}
state.data.requestedStage?.let { stage ->
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon))
Text(stage, color = color, style = TitleTextStyles.label)
Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine)
}
}
Text(item.overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
}
state.data.playTarget?.let { target ->
Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
if (target.resumeSeconds > 0) {
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f
Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(C.accent))
}
Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress)
} else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress)
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = TitleTextStyles.progress)
}
}
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), 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) } }
}
if (state.options.any { it.icon }) {
val option = state.options.getOrNull(state.column)?.takeIf { active && it.icon }
Text(buildAnnotatedString {
if (option != null) {
withStyle(SpanStyle(color = C.fg, fontWeight = WolfskinTokens.Type.label.fontWeight)) { append(option.label) }
if (option.note.isNotEmpty()) append(" · ${option.note}")
}
}, color = C.muted, style = D.iconCaption, modifier = Modifier.padding(top = D.captionTop).height(L.iconCaptionLine))
}
}
}
/** A no-copy load has no focusable content and never draws a spinner. */
@Composable
fun TitleHeaderSkeleton(modifier: Modifier = Modifier) {
Column(modifier.offset(L.safeSide, L.detailCopyTop)) {
Box(Modifier.size(D.skeletonKickerWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
Spacer(Modifier.height(D.skeletonTitleGap))
Box(Modifier.size(D.skeletonTitleWidth, D.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
Spacer(Modifier.height(D.skeletonFactsGap))
Box(Modifier.size(D.skeletonFactsWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
Spacer(Modifier.height(D.skeletonOverviewGap))
Box(Modifier.size(D.skeletonOverviewWidth, D.skeletonOverviewHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
Spacer(Modifier.height(D.overviewGap))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
Box(Modifier.size(D.skeletonPlayWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button)))
repeat(2) { Box(Modifier.size(L.iconButton).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) }
}
}
}

View file

@ -0,0 +1,115 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.Immutable
/** Screen data is independent of Jellyfin and Conductarr wire objects. */
@Immutable
data class TitleItem(
override val id: String,
val name: String,
val kind: String,
val year: Int? = null,
val runtimeSeconds: Long = 0,
val seasons: Int = 0,
val overview: String = "",
val genres: List<String> = emptyList(),
override val poster: String? = null,
val backdrop: String? = null,
val held: Boolean = false,
val watched: Boolean = false,
val resumeSeconds: Long = 0,
val seriesId: String? = null,
val seriesName: String? = null,
val season: Int = 0,
val episode: Int = 0,
override val ratings: List<LibraryRating> = emptyList(),
val character: String? = null,
val otherOwners: List<String> = emptyList(),
val isPosterBackdrop: Boolean = false,
val tmdbId: Long? = null,
) : 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
return if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m"
}
}
/** 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 = "")
/** Stream indexes remain the server's indexes, including non-contiguous track lists. */
@Immutable
data class TitleTrack(val index: Int, val label: String, val isDefault: Boolean = false)
/** Playback options describe the actual play target, not a season or recommendation stub. */
@Immutable
data class TitleVersion(
val id: String,
val name: String,
val video: String,
val bitrate: String,
val size: String,
val container: String,
val audio: List<TitleTrack>,
val subtitles: List<TitleTrack>,
)
enum class TitleReadStatus { Ready, Loading, Unavailable }
enum class TitleConnection { SignedIn, SignedOut, Offline }
enum class TitleSheet { Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt }
/** A title read includes related titles and the selected season; reads happen outside composition. */
@Immutable
data class TitlePageData(
val item: TitleItem,
val playTarget: TitleItem? = null,
val watchTarget: TitleItem = item,
val versions: List<TitleVersion> = emptyList(),
val selectedVersion: String? = null,
val selectedAudio: Int? = null,
val selectedSubtitle: Int = -1,
val trailerUrl: String? = null,
val cast: List<TitlePerson> = emptyList(),
val castStatus: TitleReadStatus = TitleReadStatus.Ready,
val related: List<TitleItem> = emptyList(),
val episodes: List<TitleItem> = emptyList(),
val status: TitleReadStatus = TitleReadStatus.Ready,
val connection: TitleConnection = TitleConnection.SignedOut,
val requestedStage: String? = null,
val requestActor: String? = null,
val hidden: Boolean = false,
val pick: Boolean = false,
val savedAt: String? = null,
val seasonNumbers: List<Int> = List(item.seasons) { it + 1 },
val selectedSeason: Int? = null,
val episodesStatus: TitleReadStatus = TitleReadStatus.Ready,
val requestsAllowed: Boolean = true,
val requestFor: String? = null,
)
/** Effects are handled by the app's authenticated facade and navigation layer. */
sealed interface TitleAction {
data class Play(val item: TitleItem, val restart: Boolean = false) : TitleAction
data class Version(val item: TitleItem, val version: TitleVersion) : TitleAction
data class Track(val item: TitleItem, val version: TitleVersion, val audio: Int?, val subtitle: Int) : TitleAction
data class Watched(val item: TitleItem, val watched: Boolean) : TitleAction
data class Trailer(val url: String) : TitleAction
data class Person(val person: TitlePerson) : TitleAction
data class OpenTitle(val item: TitleItem) : TitleAction
data class Season(val number: Int) : TitleAction
data class Request(val item: TitleItem, val forSomeoneElse: Boolean) : TitleAction
data class Hide(val item: TitleItem, val hidden: Boolean) : TitleAction
data class Requests(val item: TitleItem) : TitleAction
data object Retry : TitleAction
data object Pop : TitleAction
}
/** The options row distinguishes compact icons from labelled primary and secondary pills. */
@Immutable
data class TitleOption(val id: String, val label: String, val glyph: String, val note: String = "", val primary: Boolean = false, val icon: Boolean = true, val on: Boolean = false)

View file

@ -0,0 +1,170 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
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
/** Sheets sit above navigation and consume pointer and key input across the entire scrim. */
@Composable
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()) {
CssColumn(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface)
.verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
gap = if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) {
when (state.sheet) {
TitleSheet.Version -> VersionChoices(state)
TitleSheet.Tracks -> TrackChoices(state)
TitleSheet.MediaInfo -> MediaInformation(state)
TitleSheet.ConfirmWatched -> WatchedConfirmation(state)
TitleSheet.HiddenReceipt -> HiddenReceipt(state)
null -> Unit
}
}
}
}
@Composable
private fun VersionChoices(state: TitleState) {
Text("Version", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle))
SheetSubtitle("${state.target?.name ?: state.data.item.name} · ${state.data.versions.size} files")
state.data.versions.forEachIndexed { index, version ->
PlaybackChoice(version.name, listOf(version.video, version.bitrate, version.size).filter { it.isNotBlank() }.joinToString(" · "), version == state.version, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
}
Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption))
}
@Composable
private fun TrackChoices(state: TitleState) {
val version = state.version ?: return
Text("Audio and subtitles", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle))
SheetSubtitle("${state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name} · ${version.name}")
Text("Audio", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.exactLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
version.audio.forEachIndexed { index, track ->
PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
}
Text("Subtitles", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.exactLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
val start = version.audio.size
PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) }
version.subtitles.forEachIndexed { index, track ->
val position = start + index + 1
PlaybackChoice(track.label, null, track.index == state.subtitleIndex, state.sheetIndex == position, { state.sheetIndex = position }) { state.clickSheet(position) }
}
val done = start + version.subtitles.size + 1
TitleControl(TitleOption("done", "Done", "", primary = true, icon = false), state.sheetIndex == done, Modifier.padding(top = D.sheetButtonTop).sheetFocused(state.sheetIndex == done), onFocus = { state.sheetIndex = done }) { state.clickSheet(done) }
}
@Composable
private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focused: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
val foreground = if (focused) C.onFocus else C.fg
val sizing = if (why == null) Modifier.height(D.trackHeight) else Modifier.padding(vertical = D.versionRowVertical)
Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
.clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide),
horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
CssColumn(Modifier.weight(1f)) {
Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.exactLineBox(TitleTextStyles.listTitle))
if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption))
}
TitleRadio(selected, focused)
}
}
@Composable
private fun MediaInformation(state: TitleState) {
val version = state.version ?: return
Text("Media info", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle))
SheetSubtitle(state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name)
MediaInfoSection("General", listOf("Container" to version.container, "Size" to version.size.ifEmpty { "not known" }, "Bitrate" to version.bitrate, "Runtime" to (state.target?.runtime ?: "not known")))
MediaInfoSection("Video", listOf("Stream" to version.video))
MediaInfoSection("Audio", version.audio.mapIndexed { index, track -> "Track ${index + 1}" to (track.label + if (track.isDefault) " (default)" else "") })
MediaInfoSection("Subtitles", version.subtitles.mapIndexed { index, track -> "Track ${index + 1}" to track.label })
TitleControl(TitleOption("close", "Close", "", primary = true, icon = false), true, Modifier.padding(top = D.sheetButtonTop).sheetFocused(true), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
}
@Composable
private fun MediaInfoSection(label: String, values: List<Pair<String, String>>) {
Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.exactLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
CssColumn(gap = D.infoRowGap) { values.forEach { (name, value) ->
Row(Modifier.fillMaxWidth().exactLineBox(TitleTextStyles.caption), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) {
Text(name, color = C.dim, style = TitleTextStyles.infoKey, modifier = Modifier.width(D.infoLabelWidth))
Text(value, color = C.fg, style = TitleTextStyles.caption)
}
} }
}
@Composable
private fun WatchedConfirmation(state: TitleState) {
val verb = if (state.watched) "unwatched" else "watched"
Text("Mark the whole series as $verb?", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle))
SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.")
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, Modifier.sheetFocused(state.sheetIndex == 0), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
TitleControl(TitleOption("cancel", "Cancel", "", icon = false), state.sheetIndex == 1, Modifier.sheetFocused(state.sheetIndex == 1), onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) }
}
}
@Composable
private fun SheetSubtitle(text: String) {
Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, top = D.sheetSubLift, bottom = D.sheetSubBottom))
}
@Composable
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) {
Glyph("check", C.fg, Modifier.size(D.receiptIcon))
Text("Hidden from your picks", color = C.fg, style = TitleTextStyles.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 = TitleTextStyles.body)
}
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
TitleControl(TitleOption("undo", "Undo", "", primary = true, icon = false), state.sheetIndex == 0, Modifier.sheetFocused(state.sheetIndex == 0), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
TitleControl(TitleOption("done", "Done", "", icon = false), state.sheetIndex == 1, Modifier.sheetFocused(state.sheetIndex == 1), onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) }
}
}

View file

@ -0,0 +1,209 @@
package org.justinvisser.wolfskin.design
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import kotlinx.coroutines.delay
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.Dp
import androidx.tv.material3.Text
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
/** The shared shell remains authoritative; title sheets overlay it and trap all input. */
@Composable
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
val view = shell.view
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
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) { TitlePage(state, shell, it) }
}
if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell)
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
}
}
/** Playback and person reads share one calm, timed notice above the persistent nav. */
@Composable
internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDismiss: () -> Unit) {
LaunchedEffect(message) {
if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() }
}
if (message != null) RecoveryToast(message, modifier)
}
/** One keyed vertical frame renders the exact header, season, cast and related order. */
@Composable
fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
val density = LocalDensity.current
val list = shell.view.listState
var wheelPending by remember { mutableStateOf(false) }
val focus = remember { FocusRequester() }
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
}
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")
}
if (state.data.castStatus == TitleReadStatus.Loading || state.data.cast.isNotEmpty()) add("cast")
if (state.data.related.isNotEmpty()) add("related")
}
}
val rowOffsets = remember(displayedRows, density) {
FloatArray(displayedRows.size + 1).apply {
displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row).toPx() } }
}
}
val position by remember(list, rowOffsets) { derivedStateOf {
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
} }
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows) {
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
val y = rowOffsets[index]
val height = with(density) { titleRowHeight(state.row).toPx() }
val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f
else y - with(density) { L.pinnedLine.toPx() }
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
}
val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } }
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)
Box(modifier.focusRequester(focus).testTag("title-page").semantics { stateDescription = "${state.data.item.id}:${state.row}:${state.column}:${state.sheet}" }
.titleWheel { if (state.data.status == TitleReadStatus.Ready) { wheelPending = true; shell.navFocused = false } }
.onPreviewKeyEvent { event ->
val key = readShellKey(event)
if (key != null && key != ShellKey.Back && event.type == KeyEventType.KeyDown && wheelPending) {
val visible = list.layoutInfo.visibleItemsInfo.firstOrNull {
it.key in state.rows && it.offset + it.size > with(density) { L.pinnedLine.roundToPx() }
}?.key as? String ?: "header"
state.focus(visible, state.columns[visible] ?: 0)
wheelPending = false
if (key != ShellKey.Ok) return@onPreviewKeyEvent true
}
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
}.focusable()) {
// 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") {
val active = state.row == id && !shell.navFocused && state.sheet == null
when (id) {
"header" -> Box(Modifier.height(L.row_dhead)) { TitleHeader(state, active) }
"seasons" -> TitleSeasons(state, active)
"episodes" -> TitleMediaShelf(state, true, active)
"cast" -> TitleCast(state, active)
"related" -> TitleMediaShelf(state, false, active)
}
} }
item(key = "end-padding", contentType = "spacer") {
Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last())).coerceAtLeast(D.minimumEndPadding)))
}
}
}
}
private fun titleRowHeight(row: String): Dp = when (row) {
"header" -> L.row_dhead
"seasons" -> L.row_chips
"episodes" -> L.rowHeadingBand + WolfskinTokens.Card.tileH + L.rowGapAfter
"cast" -> D.castHeight
else -> L.rowHeadingBand + WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + L.rowGapAfter
}
@Composable
private fun TitleSeasons(state: TitleState, active: Boolean) {
val focusOwner = LocalTitleFocusOwner.current
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index ->
val number = state.data.seasonNumbers[index]
val focused = active && state.column == index
val selected = state.season == number
val shape = RoundedCornerShape(WolfskinTokens.Radius.chip)
Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl)
.onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) }
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape)
.then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier)
.clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
Text("Season $number", color = if (focused) C.onFocus else if (selected) C.accentText else C.fg, style = WolfskinTokens.Type.label)
}
}
}
}
@Composable
private fun TitleReadError(state: TitleState) {
Column(Modifier.offset(L.safeSide, D.errorTop).width(L.stateWidth)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
.border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(WolfskinTokens.SavedIndicator.border)
.padding(horizontal = D.errorSide, vertical = D.errorVertical)) {
Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
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 = TitleTextStyles.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 = TitleTextStyles.body, modifier = Modifier.padding(bottom = D.errorBodyBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }, overArtwork = false) { state.click("header", index) } } }
}
}
internal val titleFrameScrollSpec = tween<Float>(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))

View file

@ -0,0 +1,41 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
/** Pointing at owned content exits nav focus through the same shell state as a remote Down. */
val LocalTitleFocusOwner = compositionLocalOf<(() -> Unit)?> { null }
/** Hover changes logical focus without activating a button or arming an OK press. */
@Composable
fun Modifier.titlePointerFocus(onFocus: (() -> Unit)?): Modifier {
if (onFocus == null) return this
val latest by rememberUpdatedState(onFocus)
val owner = LocalTitleFocusOwner.current
return pointerInput(owner) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.type == PointerEventType.Enter) { owner?.invoke(); latest() }
}
}
}
}
/** Observe wheel input without consuming the frame's native scroll gesture. */
@Composable
fun Modifier.titleWheel(onScroll: () -> Unit): Modifier {
val latest by rememberUpdatedState(onScroll)
return pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
if (awaitPointerEvent().type == PointerEventType.Scroll) latest()
}
}
}
}

View file

@ -0,0 +1,32 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
import coil3.request.Disposable
/** 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 context = LocalContext.current
if (row == null || data.status != TitleReadStatus.Ready) return
if (LocalTitleBitmaps.current.isNotEmpty()) return
val loader = WolfskinImages.loader(context)
DisposableEffect(loader, context, row, data.cast, data.episodes, data.related) {
val requests = ArrayList<Disposable>()
fun queue(path: String?, width: Int, height: Int, poster: Boolean = false) {
if (path != null) requests.add(loader.enqueue(WolfskinImages.request(context, path, width, height, poster)))
}
when (row) {
"cast" -> for (index in 0 until minOf(data.cast.size, WolfskinTokens.Card.personSlots))
queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight)
"episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) {
val item = data.episodes[index]
queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight)
}
"related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots))
queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true)
}
onDispose { requests.forEach { it.dispose() } }
}
}

View file

@ -0,0 +1,16 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
/** Logical remote focus must reveal its row even though Android focus stays on the modal owner. */
@Composable
internal fun Modifier.sheetFocused(focused: Boolean): Modifier {
val request = remember { BringIntoViewRequester() }
LaunchedEffect(focused) { if (focused) request.bringIntoView() }
return bringIntoViewRequester(request)
}

View file

@ -0,0 +1,166 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.gestures.animateScrollBy
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
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.lazy.LazyRow
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
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
/** Cast occupies the first shelf after films, and follows season episodes on series. */
@Composable
fun TitleCast(state: TitleState, active: Boolean) {
val track = rememberLazyListState()
val focusOwner = LocalTitleFocusOwner.current
val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() }
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) }
Column(Modifier.height(D.castHeight)) {
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
if (state.data.castStatus == TitleReadStatus.Loading) {
items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
Box(Modifier.size(L.castCircle).background(C.surface, CircleShape))
}
} else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person ->
val focused = active && state.column == index
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f)
.onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) }
.titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) {
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).clip(CircleShape))
Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) {
Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.exactLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
}
}
}
}
}
}
/** Episode and related shelves are lazy, keyed and keep their focused card above adjacent art. */
@Composable
fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
val titles = if (episodes) state.data.episodes else state.data.related
val id = if (episodes) "episodes" else "related"
val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
val height = if (episodes) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
val track = rememberLazyListState()
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() }
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) }
Column(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
}
} else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
TitlePoster(item, active && state.column == index, Modifier.width(width)
.onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) },
episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
}
}
}
}
/** 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, catalogue: 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,
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() }) {
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 = 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))
}
}
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) {
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())
}
}
}
}
/** Keep a full lookahead card, but do not recenter a row when reversing direction. */
private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float) {
val first = firstVisibleItemIndex
val next = when {
column < first -> column
column >= first + slots - 1 -> (column - slots + 2).coerceAtLeast(0)
else -> first
}
if (next != first) animateScrollBy((next - first) * pitch - firstVisibleItemScrollOffset, titleTrackScrollSpec)
}
private val titleTrackScrollSpec = tween<Float>(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))

View file

@ -0,0 +1,60 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
/** Slot dimensions adapt to the shared exact-pixel artwork request, never another image loader. */
@Composable
private fun SlotImage(source: String, width: Dp, height: Dp, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) {
TitleArtwork(source, (width.value * 2).toInt(), (height.value * 2).toInt(), modifier, poster, colorFilter, onError)
}
/** Poster-only landscapes retain the upright one-sheet above the same dimmed image wash. */
@Composable
fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height: Dp, modifier: Modifier = Modifier,
backdrop: String? = null, posterOnly: Boolean = false, landscape: Boolean = false, hero: Boolean = false) {
val source = if (landscape) backdrop ?: poster else poster
val failed = remember(source) { androidx.compose.runtime.mutableStateOf(false) }
val missing = source == null || failed.value
Box(modifier.background(C.surface2)) {
if (!missing) {
if (landscape && (posterOnly || backdrop == null) && poster != null) {
val wash = remember(hero) {
val matrix = ColorMatrix().apply { setToSaturation(if (hero) 1.2f else 1f) }
val brightness = if (hero) .55f else .5f
for (index in 0 until 15) matrix.values[index] *= brightness
ColorFilter.colorMatrix(matrix)
}
val inset = if (hero) H.heroWashInset else H.cardWashInset
val washModifier = Modifier.wrapContentSize(Alignment.Center, unbounded = true)
.requiredSize(width + inset * 2, height + inset * 2)
.blur(if (hero) H.heroBlur else H.cardBlur)
.graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } }
SlotImage(poster, width, height, washModifier, colorFilter = wash, onError = { failed.value = true })
val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset
val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f)
val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop)
SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true })
} else SlotImage(source, if (hero) WolfskinTokens.Card.posterW * 5 else width, if (hero) WolfskinTokens.Card.posterW * 5 * (9f / 16f) else height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true })
} else if (!hero) MediaArtworkFallback(title, type == "movie", showTitle = !landscape)
else HomeAmbient(Modifier.fillMaxSize())
}
}

View file

@ -0,0 +1,291 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/** One focus controller drives the drawn screen and Android key-event replays. */
class TitleState(
initial: TitlePageData,
val person: ShellPerson,
private val onAction: (TitleAction) -> Unit,
) {
var data by mutableStateOf(initial)
private set
var row by mutableStateOf("header")
var column by mutableStateOf(0)
val columns = mutableStateMapOf<String, Int>()
private val centers = HashMap<String, MutableMap<Int, Float>>()
fun recordCenter(row: String, index: Int, x: Float) { centers.getOrPut(row) { HashMap() }[index] = x }
fun showSeasonLoading() { if (data.episodesStatus != TitleReadStatus.Loading) data = data.copy(episodesStatus = TitleReadStatus.Loading) }
fun restoreSeason() { season = data.selectedSeason ?: season; data = data.copy(episodesStatus = TitleReadStatus.Ready) }
var sheet by mutableStateOf<TitleSheet?>(null)
private set
var sheetIndex by mutableStateOf(0)
var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
var versionId by mutableStateOf(initial.selectedVersion)
var audioIndex by mutableStateOf(initial.selectedAudio)
var subtitleIndex by mutableStateOf(initial.selectedSubtitle)
var watched by mutableStateOf(initial.watchTarget.watched)
var toast by mutableStateOf<String?>(null)
private var armedCard: String? = null
private var okHeld = false
val target: TitleItem? get() = data.playTarget ?: data.item.takeIf { it.kind == "movie" && it.held }
val version: TitleVersion? get() = data.versions.firstOrNull { it.id == versionId } ?: data.versions.firstOrNull()
val audio: TitleTrack? get() = version?.audio?.firstOrNull { it.index == audioIndex } ?: version?.audio?.firstOrNull { it.isDefault } ?: version?.audio?.firstOrNull()
val subtitle: TitleTrack? get() = version?.subtitles?.firstOrNull { it.index == subtitleIndex }
val rows: List<String> by derivedStateOf { buildList {
add("header")
if (data.status != TitleReadStatus.Ready) return@buildList
if (data.item.held && data.item.kind == "tv" && data.item.seasons > 0) {
add("seasons")
if (data.episodesStatus == TitleReadStatus.Ready && data.episodes.isNotEmpty()) add("episodes")
}
if (data.castStatus == TitleReadStatus.Ready && data.cast.isNotEmpty()) add("cast")
if (data.related.isNotEmpty()) add("related")
} }
val options: List<TitleOption> by derivedStateOf { titleOptions(this) }
/** Refresh keeps the selected row and file when they still exist. */
fun update(value: TitlePageData) {
val wasLoading = data.status == TitleReadStatus.Loading
val pendingWatched = watched != data.watchTarget.watched
val pendingVersion = versionId != data.selectedVersion
val pendingAudio = audioIndex != data.selectedAudio
val pendingSubtitle = subtitleIndex != data.selectedSubtitle
data = value
if (wasLoading || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
if (!pendingWatched) watched = value.watchTarget.watched
if (!pendingVersion) versionId = value.selectedVersion
if (!pendingAudio) audioIndex = value.selectedAudio
if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle
if (row !in rows) { row = "header"; column = 0 }
column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0))
}
/** Pointer focus and D-pad use the same row memory. */
fun focus(targetRow: String, targetColumn: Int) {
columns[row] = column
row = targetRow
column = targetColumn
columns[row] = column
}
/** Pointer clicks activate the same state transition as a completed remote press. */
fun click(targetRow: String, targetColumn: Int) {
focus(targetRow, targetColumn)
activate()
}
/** Keep the last confirmed selections separate from immediate, optimistic UI feedback. */
fun confirmPlayback(action: TitleAction) {
data = when (action) {
is TitleAction.Version -> data.copy(selectedVersion = action.version.id, selectedAudio = null, selectedSubtitle = -1)
is TitleAction.Track -> data.copy(selectedVersion = action.version.id, selectedAudio = action.audio, selectedSubtitle = action.subtitle)
is TitleAction.Watched -> data.copy(watchTarget = data.watchTarget.copy(watched = action.watched))
else -> data
}
}
/** A failed older write cannot undo a newer selection still waiting to save. */
fun rejectPlayback(action: TitleAction) {
when (action) {
is TitleAction.Version -> if (versionId == action.version.id) {
versionId = data.selectedVersion; audioIndex = data.selectedAudio; subtitleIndex = data.selectedSubtitle
}
is TitleAction.Track -> if (audioIndex == action.audio && subtitleIndex == action.subtitle) {
audioIndex = data.selectedAudio; subtitleIndex = data.selectedSubtitle
}
is TitleAction.Watched -> if (watched == action.watched) watched = data.watchTarget.watched
else -> Unit
}
}
/** Key-up can only activate the same card that armed the first key-down. */
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
if (key == ShellKey.Ok) {
if (!down) {
okHeld = false
val card = "$row:$column"
if (armedCard == card && sheet == null && !shell.navFocused) activate()
armedCard = null
return true
}
if (repeat > 0 || okHeld) return true
okHeld = true
if (sheet == null && (row == "cast" || row == "related" || row == "episodes")) {
armedCard = "$row:$column"
return true
}
} else {
if (!down) return true
armedCard = null
}
if (sheet != null) { sheetKey(key); return true }
when (key) {
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))
ShellKey.Right -> focus(row, (column + 1).coerceAtMost((rowSize(row) - 1).coerceAtLeast(0)))
ShellKey.Ok -> activate()
}
return true
}
/** 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) }
private fun moveRow(delta: Int) {
val index = (rows.indexOf(row) + delta).coerceIn(0, rows.lastIndex)
val next = rows[index]
val x = centers[row]?.get(column)
val nearest = if (x != null) centers[next]?.entries?.minByOrNull { kotlin.math.abs(it.value - x) }?.key else null
focus(next, (columns[next] ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0)))
}
private fun rowSize(id: String): Int = when (id) {
"header" -> options.size
"seasons" -> data.seasonNumbers.size
"episodes" -> data.episodes.size
"cast" -> data.cast.size
"related" -> data.related.size
else -> 0
}
private fun activate() {
when (row) {
"cast" -> data.cast.getOrNull(column)?.let { onAction(TitleAction.Person(it)) }
"related" -> data.related.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) }
"episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) }
"seasons" -> { season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) }
"header" -> options.getOrNull(column)?.let { activateOption(it.id) }
}
}
private fun activateOption(id: String) {
when (id) {
"play", "restart" -> target?.let { onAction(TitleAction.Play(it, id == "restart")) }
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
"more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo }
"watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched()
"trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) }
"seasons" -> focus("seasons", data.seasonNumbers.indexOf(season).coerceAtLeast(0))
"retry" -> onAction(TitleAction.Retry)
"back" -> onAction(TitleAction.Pop)
"request", "other", "add" -> onAction(TitleAction.Request(data.item, id != "request"))
"status" -> onAction(TitleAction.Requests(data.item))
"hide", "unhide" -> {
val hidden = id == "hide"
data = data.copy(hidden = hidden)
onAction(TitleAction.Hide(data.item, hidden))
if (hidden) { sheetIndex = 0; sheet = TitleSheet.HiddenReceipt } else toast = "Back in your picks."
}
}
}
private fun toggleWatched() {
watched = !watched
onAction(TitleAction.Watched(data.watchTarget, watched))
}
private fun sheetKey(key: ShellKey) {
if (key == ShellKey.Back) { sheet = null; return }
val current = version
val size = when (sheet) {
TitleSheet.Version -> data.versions.size
TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2
TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2
else -> 1
}
when (key) {
ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0)
ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1)
ShellKey.Ok -> when (sheet) {
TitleSheet.Version -> {
val selected = data.versions[sheetIndex]
versionId = selected.id
audioIndex = null
subtitleIndex = -1
target?.let { onAction(TitleAction.Version(it, selected)) }
sheet = null
}
TitleSheet.Tracks -> if (current != null) selectTrack(current)
TitleSheet.ConfirmWatched -> { if (sheetIndex == 0) toggleWatched(); sheet = null }
TitleSheet.HiddenReceipt -> {
sheet = null
if (sheetIndex == 0) {
data = data.copy(hidden = false)
onAction(TitleAction.Hide(data.item, false))
} else onAction(TitleAction.Pop)
}
else -> sheet = null
}
}
}
private fun selectTrack(current: TitleVersion) {
when {
sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index
sheetIndex == current.audio.size -> subtitleIndex = -1
sheetIndex <= current.audio.size + current.subtitles.size -> subtitleIndex = current.subtitles[sheetIndex - current.audio.size - 1].index
else -> { sheet = null; return }
}
target?.let { onAction(TitleAction.Track(it, current, audio?.index, subtitleIndex)) }
}
}
/** Button order follows the file, resume, ownership and authentication facts, never synthetic availability. */
fun titleOptions(state: TitleState): List<TitleOption> = buildList {
val data = state.data
val item = data.item
val target = state.target
if (data.status != TitleReadStatus.Ready) {
if (data.status == TitleReadStatus.Unavailable) {
add(TitleOption("retry", "Try again", "", primary = true, icon = false))
add(TitleOption("back", "Back", "", icon = false))
}
return@buildList
}
if (item.held) {
if (target != null) {
val verb = if (target.resumeSeconds > 0) "Resume" else "Play"
add(TitleOption("play", verb + if (target.kind == "episode") " ${target.episodeLabel}" else "", "play", primary = true, icon = false))
if (target.resumeSeconds > 0) add(TitleOption("restart", "Restart", "restart", "play from the beginning"))
if (data.versions.size > 1) add(TitleOption("version", "Version", "version", state.version?.name.orEmpty()))
add(TitleOption("tracks", "Audio and subtitles", "tracks", "${state.audio?.label.orEmpty()} · subtitles ${state.subtitle?.label ?: "off"}"))
}
val series = data.watchTarget.kind == "tv"
add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched))
if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer"))
if (target != null) add(TitleOption("more", "More", "more", "Media info"))
if (item.kind == "tv" && item.seasons > 0) add(TitleOption("seasons", "Seasons", "tv", icon = false))
if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) add(TitleOption("add", "Add for someone", "users", icon = false))
} else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) {
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", 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

@ -0,0 +1,25 @@
package org.justinvisser.wolfskin.design
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
/** Explicit styles retain CSS leading without changing shared nav or library typography. */
internal object TitleTextStyles {
private val lineBoxes = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
private fun TextStyle.withLineBoxes() = copy(lineHeightStyle = lineBoxes)
val displayDetail = WolfskinTokens.Type.displayDetail.withLineBoxes()
val dLine = WolfskinTokens.Title.dLine.withLineBoxes()
val progress = WolfskinTokens.Title.dLine.withLineBoxes()
val castName = WolfskinTokens.Title.castName.withLineBoxes()
val body = WolfskinTokens.Type.body.withLineBoxes()
val caption = WolfskinTokens.Type.caption.withLineBoxes()
val label = WolfskinTokens.Type.label.withLineBoxes()
val sheetTitle = WolfskinTokens.Type.sheetTitle.withLineBoxes()
val listTitle = WolfskinTokens.Type.listTitle.withLineBoxes()
val screenTitle = WolfskinTokens.Type.screenTitle.withLineBoxes()
val rowHeading = WolfskinTokens.Type.rowHeading.withLineBoxes()
val errorTitle = WolfskinTokens.Title.errorTitle.withLineBoxes()
val receiptTitle = WolfskinTokens.Title.receiptTitle.withLineBoxes()
val infoKey = WolfskinTokens.Title.infoKey.withLineBoxes()
}

View file

@ -166,26 +166,31 @@ object WolfskinTokens {
val posterGap = 12.0.dp
val posterPitch = 140.0.dp
val posterCaptionBand = 38.0.dp
const val posterSlots = 6
val tileW = 208.0.dp
val tileH = 117.0.dp
val tileGap = 12.0.dp
val tilePitch = 220.0.dp
val tileCaptionBand = 0.0.dp
const val tileSlots = 4
val wideW = 280.0.dp
val wideH = 157.5.dp
val wideGap = 12.0.dp
val widePitch = 292.0.dp
val wideCaptionBand = 0.0.dp
const val wideSlots = 3
val entryW = 128.0.dp
val entryH = 72.0.dp
val entryGap = 12.0.dp
val entryPitch = 140.0.dp
val entryCaptionBand = 0.0.dp
const val entrySlots = 6
val personW = 100.0.dp
val personH = 145.0.dp
val personGap = 14.0.dp
val personPitch = 114.0.dp
val personCaptionBand = 0.0.dp
const val personSlots = 7
}
object Focus {
const val scaleCard = 1.08f
@ -212,8 +217,20 @@ object WolfskinTokens {
const val reducedMotion = "all transitions instant"
}
object Image {
const val posterRequestWidth = 256
const val posterRequestHeight = 384
const val tileRequestWidth = 416
const val tileRequestHeight = 234
const val wideRequestWidth = 560
const val wideRequestHeight = 315
const val heroRequestWidth = 1280
const val heroRequestHeight = 720
const val castRequestWidth = 200
const val castRequestHeight = 200
const val memoryCacheMB = 64.0f
const val diskCacheMB = 128.0f
const val concurrentReadsCatalogue = 2
const val concurrentReadsImage = 4
}
object Behaviour {
const val rowLimit = 20.0f
@ -337,9 +354,12 @@ 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 plateTop = 7.0.dp
val plateSide = 8.0.dp
val plateBottom = 23.0.dp
val plateInlineDescent = 3.3.dp
val plateFloor = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
const val plateStep = 0.25f
const val plateGapEm = 0.64f
@ -366,7 +386,6 @@ object WolfskinTokens {
val tailPadding = 10.0.dp
val tailGap = 5.0.dp
val badgeRing = 1.5.dp
val badgeIcon = 14.0.dp
val seeAllGap = 4.0.dp
val tailDescription = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(500), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
val headerSubtitle = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(400), lineHeight = 19.5.sp, letterSpacing = 0.0.em)
@ -395,6 +414,134 @@ object WolfskinTokens {
val sheetX = -15.0.dp
const val sheetOpacity = 0.5f
}
object Title {
val lineGap = 11.0.dp
val lineLift = -2.0.dp
val overviewGap = 17.0.dp
val factsGap = 11.0.dp
val factsBottom = 7.0.dp
val factsHeight = 19.0.dp
val titleBottom = 8.0.dp
val iconGap = 7.0.dp
val iconSize = 16.0.dp
val captionTop = 8.0.dp
val progressWidth = 160.0.dp
val progressBottom = 15.0.dp
val progressGap = 9.0.dp
val progressLift = -7.0.dp
val castHeight = 197.0.dp
val castNameTop = 108.0.dp
val castNameSide = 6.0.dp
val personTop = 85.0.dp
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
val sheetSubLift = -4.0.dp
val sheetSubBottom = 7.0.dp
val sheetLabelTop = 4.0.dp
val sheetButtonTop = 6.0.dp
val sheetRowSide = 13.0.dp
val versionRowVertical = 10.0.dp
val trackHeight = 42.0.dp
val trackGap = 6.0.dp
val radioSize = 20.0.dp
val radioDot = 8.0.dp
val radioBorder = 1.5.dp
val infoGap = 7.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 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
val skeletonKickerWidth = 210.0.dp
val skeletonKickerHeight = 17.0.dp
val skeletonTitleGap = 13.0.dp
val skeletonFactsGap = 12.0.dp
val skeletonOverviewGap = 15.0.dp
val skeletonTitleWidth = 380.0.dp
val skeletonTitleHeight = 46.0.dp
val skeletonFactsWidth = 280.0.dp
val skeletonOverviewWidth = 490.0.dp
val skeletonOverviewHeight = 55.0.dp
val skeletonPlayWidth = 110.0.dp
val dLine = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(400), lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
val iconCaption = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(400), lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
val lineIcon = 14.0.dp
val chipHeight = 34.0.dp
val chipPadding = 14.0.dp
val chipGap = 8.0.dp
val chipTop = 8.0.dp
val chipBorder = 1.0.dp
val heroSheetRight = 90.0.dp
val heroSheetTop = 75.0.dp
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
val receiptTop = 14.0.dp
val receiptGap = 5.0.dp
val receiptIcon = 18.0.dp
val receiptTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight.Bold, lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em)
val toastMaxWidth = 380.0.dp
val toastSide = 14.0.dp
val toastTop = 11.0.dp
const val heroAmbientBackground = "radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg))"
const val dimScrollPx = 1400.0f
const val dimMaximum = 0.7f
val errorTop = 85.0.dp
val errorSide = 20.0.dp
val errorVertical = 17.0.dp
val errorHeadingBottom = 4.0.dp
val errorBodyBottom = 12.0.dp
val errorTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight.Bold, lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em)
val fallbackGlyph = 22.0.dp
val fallbackInset = 11.0.dp
val tileSheetInset = 9.0.dp
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%))"
val initialsColor = ComposeColor(0xD1FFFFFF)
val fallbackGlyphColor = ComposeColor(0x73FFFFFF)
val fallbackTitle = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.099999999999998.sp, letterSpacing = 0.0.em)
val tileOverlayTop = 27.0.dp
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
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
val side = 10.0.dp
val gap = 5.0.dp
val icon = 14.0.dp
val border = 1.0.dp
const val borderOpacity = 0.5f
}
object Home {
val kickerGap = 6.0.dp
val kickerBottom = 5.0.dp

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="M3 3l18 18M10.6 5.1A10.6 10.6 0 0 1 12 5c6.4 0 10 7 10 7a17 17 0 0 1-3.2 4M6.6 6.6C3.8 8.4 2 12 2 12s3.6 7 10 7c1.6 0 3-.4 4.3-1" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="miter" />
</vector>

View file

@ -0,0 +1,5 @@
<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="M3.6,12.0a1.9,1.9 0 1,0 3.8,0a1.9,1.9 0 1,0 -3.8,0" android:fillColor="#FFFFFFFF" />
<path android:pathData="M10.1,12.0a1.9,1.9 0 1,0 3.8,0a1.9,1.9 0 1,0 -3.8,0" android:fillColor="#FFFFFFFF" />
<path android:pathData="M16.6,12.0a1.9,1.9 0 1,0 3.8,0a1.9,1.9 0 1,0 -3.8,0" android:fillColor="#FFFFFFFF" />
</vector>

View file

@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M4.5 12a7.5 7.5 0 1 0 2.2-5.3" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:strokeLineCap="round" android:strokeLineJoin="miter" />
<path android:pathData="M4 4.5v4h4" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5.5,5.0H18.5Q21.0,5.0 21.0,7.5V16.5Q21.0,19.0 18.5,19.0H5.5Q3.0,19.0 3.0,16.5V7.5Q3.0,5.0 5.5,5.0Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
<path android:pathData="M7 12h3M12.5 12h4.5M7 15.5h6M15 15.5h2" android:fillColor="#FFFFFFFF" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="miter" />
</vector>

View file

@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5.5,5.0H18.5Q21.5,5.0 21.5,8.0V16.0Q21.5,19.0 18.5,19.0H5.5Q2.5,19.0 2.5,16.0V8.0Q2.5,5.0 5.5,5.0Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
<path android:pathData="m10 9 5 3-5 3z" android:fillColor="#FFFFFFFF" />
</vector>

View file

@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M6 3h8l4 4v14H6z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="round" />
<path android:pathData="m10.5 11 4 2.5-4 2.5z" android:fillColor="#FFFFFFFF" />
</vector>

View file

@ -61,7 +61,7 @@ class RequestsKeysTest {
@Test fun readinessWindowAndTitleRoundTripPreserveRequest() {
lateinit var state: ShellState
state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("request-title/${action.request.tmdbId}") }
state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("title/${action.request.mediaType}:${action.request.tmdbId}") }
mount(state)
send(Key.DirectionDown, Key.DirectionRight)
rule.onNodeWithText("Download · Downloading").assertExists()
@ -76,7 +76,7 @@ class RequestsKeysTest {
assertEquals("x6", state.requests.focusedId)
send(Key.DirectionCenter)
assertFalse(state.requestsVisible)
assertTrue(state.route.startsWith("request-title/"))
assertTrue(state.route.startsWith("title/"))
send(Key.Back)
rule.onNodeWithTag("coming-row-x6").assertExists()
assertEquals("x6", state.requests.focusedId)
@ -123,7 +123,7 @@ class RequestsKeysTest {
@Test fun selectingARootAfterARequestTitleDiscardsTheSheetReturnLink() {
lateinit var state: ShellState
state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("request-title/${action.request.tmdbId}") }
state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("title/${action.request.mediaType}:${action.request.tmdbId}") }
mount(state)
send(Key.DirectionCenter, Key.DirectionUp, Key.DirectionCenter)
assertEquals("home", state.route)