Integrate real Conductarr TV title and recovery providers
This commit is contained in:
commit
78322f588a
550 changed files with 5029 additions and 293 deletions
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() } }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
3
wolfskin-design/src/main/res/drawable/title_eyeoff.xml
Normal file
3
wolfskin-design/src/main/res/drawable/title_eyeoff.xml
Normal 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>
|
||||
5
wolfskin-design/src/main/res/drawable/title_more.xml
Normal file
5
wolfskin-design/src/main/res/drawable/title_more.xml
Normal 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>
|
||||
4
wolfskin-design/src/main/res/drawable/title_restart.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_restart.xml
Normal 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>
|
||||
4
wolfskin-design/src/main/res/drawable/title_tracks.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_tracks.xml
Normal 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>
|
||||
4
wolfskin-design/src/main/res/drawable/title_trailer.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_trailer.xml
Normal 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>
|
||||
4
wolfskin-design/src/main/res/drawable/title_version.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_version.xml
Normal 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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue