Reuse shared artwork and correct title focus edges
This commit is contained in:
parent
3bfb8cd779
commit
a8951827ba
41 changed files with 581 additions and 438 deletions
|
|
@ -11,38 +11,60 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.unit.constrainHeight
|
||||
|
||||
/** Captions fade without moving the reserved band under the focused poster. */
|
||||
@Composable
|
||||
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false) {
|
||||
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false) {
|
||||
val alpha = remember(title, facts) { Animatable(0f) }
|
||||
LaunchedEffect(title, facts, visible) {
|
||||
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
||||
}
|
||||
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
|
||||
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
val styledFacts = remember(facts, accentLastFact) {
|
||||
buildAnnotatedString {
|
||||
val separator = " · "
|
||||
append(facts)
|
||||
if (accentLastFact) {
|
||||
val suffix = facts.lastIndexOf(separator).takeIf { it >= 0 }?.plus(separator.length) ?: facts.length
|
||||
if (suffix < facts.length) addStyle(SpanStyle(color = WolfskinTokens.Color.accentText), suffix, facts.length)
|
||||
}
|
||||
var start = facts.indexOf(separator)
|
||||
if (start >= 0) {
|
||||
val muted = WolfskinTokens.Color.muted
|
||||
val separatorStyle = SpanStyle(color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity))
|
||||
val dotOffset = separator.indexOf('·')
|
||||
while (start >= 0) {
|
||||
addStyle(separatorStyle, start + dotOffset, start + dotOffset + 1)
|
||||
start = facts.indexOf(separator, start + separator.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(styledFacts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
CaptionFacts(facts, accentFirstFact, accentLastFact)
|
||||
}
|
||||
}
|
||||
|
||||
/** CSS flex facts: only coloured role/ownership spans shrink; ordinary facts and dots keep their width. */
|
||||
@Composable
|
||||
private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean) {
|
||||
val parts = remember(facts) { facts.split(" · ") }
|
||||
val muted = WolfskinTokens.Color.muted
|
||||
val colour = WolfskinTokens.Color.accentText
|
||||
val style = WolfskinTokens.Type.caption
|
||||
Layout(
|
||||
content = {
|
||||
parts.forEachIndexed { index, part ->
|
||||
val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast)
|
||||
Text(part, color = if (accented) colour else muted, style = style, maxLines = 1, overflow = TextOverflow.Clip)
|
||||
if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().clipToBounds(),
|
||||
) { children, constraints ->
|
||||
val gap = WolfskinTokens.Library.captionFactsGap.roundToPx()
|
||||
val basis = IntArray(children.size)
|
||||
var fixed = gap * (children.size - 1).coerceAtLeast(0)
|
||||
var flexible = 0
|
||||
fun shrinks(index: Int) = (index == 0 && accentFirst) || (index == children.lastIndex && accentLast)
|
||||
children.forEachIndexed { index, child ->
|
||||
val width = child.maxIntrinsicWidth(constraints.maxHeight)
|
||||
basis[index] = width
|
||||
if (shrinks(index)) flexible += width else fixed += width
|
||||
}
|
||||
val available = (constraints.maxWidth - fixed).coerceAtLeast(0)
|
||||
val ratio = if (flexible > available) available.toFloat() / flexible else 1f
|
||||
val measured = children.mapIndexed { index, child ->
|
||||
val width = if (shrinks(index)) (basis[index] * ratio).toInt() else basis[index]
|
||||
child.measure(constraints.copy(minWidth = 0, maxWidth = width, minHeight = 0))
|
||||
}
|
||||
layout(constraints.maxWidth, constraints.constrainHeight(measured.maxOfOrNull { it.height } ?: 0)) {
|
||||
var x = 0
|
||||
measured.forEach { child -> child.placeRelative(x, 0); x += child.width + gap }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,23 +6,36 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.tv.material3.Icon
|
||||
|
||||
/** Shell glyph geometry is copied from the locked prototype, not a platform icon font. */
|
||||
/** Shared glyph geometry comes from the locked prototype, never a platform icon font. */
|
||||
@Composable
|
||||
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
||||
val resource = when (name) {
|
||||
"gear" -> R.drawable.wolfskin_gear
|
||||
"requests" -> R.drawable.wolfskin_tray
|
||||
"interface" -> R.drawable.wolfskin_grid
|
||||
"playback" -> R.drawable.wolfskin_play
|
||||
"nextup" -> R.drawable.wolfskin_tv
|
||||
"profile" -> R.drawable.wolfskin_users
|
||||
"play", "playback" -> R.drawable.wolfskin_play
|
||||
"tv", "nextup" -> R.drawable.wolfskin_tv
|
||||
"users", "profile" -> R.drawable.wolfskin_users
|
||||
"about" -> R.drawable.wolfskin_info
|
||||
"seerr" -> R.drawable.wolfskin_sparkle
|
||||
"sparkle", "seerr" -> R.drawable.wolfskin_sparkle
|
||||
"advanced" -> R.drawable.wolfskin_sort
|
||||
"alert" -> R.drawable.wolfskin_alert
|
||||
"film" -> R.drawable.wolfskin_film
|
||||
"eye" -> R.drawable.wolfskin_eye
|
||||
"down" -> R.drawable.wolfskin_down
|
||||
"restart" -> R.drawable.title_restart
|
||||
"version" -> R.drawable.title_version
|
||||
"tracks" -> R.drawable.title_tracks
|
||||
"eyeoff" -> R.drawable.title_eyeoff
|
||||
"trailer" -> R.drawable.title_trailer
|
||||
"more" -> R.drawable.title_more
|
||||
"plus" -> R.drawable.title_plus
|
||||
"clock" -> R.drawable.title_clock
|
||||
"download" -> R.drawable.title_download
|
||||
"wifi" -> R.drawable.title_wifi
|
||||
"check" -> R.drawable.title_check
|
||||
"undo" -> R.drawable.title_undo
|
||||
"left" -> R.drawable.title_left
|
||||
else -> R.drawable.wolfskin_right
|
||||
}
|
||||
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ object LibraryImages {
|
|||
.components { add(Interceptor { chain -> reads.withPermit { chain.proceed() } }) }
|
||||
.crossfade(false).build().also { loader = it }
|
||||
|
||||
fun request(context: Context, poster: String): ImageRequest = ImageRequest.Builder(context).data(poster)
|
||||
.size((WolfskinTokens.Card.posterW.value * 2).toInt(), (WolfskinTokens.Card.posterH.value * 2).toInt())
|
||||
.allowRgb565(true).crossfade(false).build()
|
||||
fun request(context: Context, path: String, width: Int = WolfskinTokens.Image.posterRequestWidth,
|
||||
height: Int = WolfskinTokens.Image.posterRequestHeight, poster: Boolean = true): ImageRequest =
|
||||
ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
|
@ -11,20 +10,10 @@ import androidx.compose.runtime.*
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L
|
||||
|
|
@ -32,92 +21,46 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L
|
|||
/** Poster slots use exact requests and share the shell's draw-only focus treatment. */
|
||||
@Composable
|
||||
fun LibraryPoster(
|
||||
title: LibraryTitle,
|
||||
title: PosterContent,
|
||||
focused: Boolean,
|
||||
ratingsVisible: Boolean,
|
||||
catalogue: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
offlineArt: ((String) -> ImageBitmap?)? = null,
|
||||
) {
|
||||
val poster = title.poster
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Box(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
|
||||
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
|
||||
.testTag("poster-${title.id}")) {
|
||||
Box(Modifier.fillMaxSize().clip(shape).background(C.surface2)) {
|
||||
val bitmap = remember(title.poster) { title.poster?.let { offlineArt?.invoke(it) } }
|
||||
if (bitmap != null) Image(bitmap, title.title, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
else if (title.poster != null && offlineArt == null) {
|
||||
val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } }
|
||||
if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
else if (poster != null && offlineArt == null) {
|
||||
var ready by remember(poster) { mutableStateOf(false) }
|
||||
if (!ready) MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
|
||||
val context = LocalContext.current
|
||||
val request = remember(title.poster, context) { LibraryImages.request(context, title.poster) }
|
||||
AsyncImage(request, title.title, imageLoader = LibraryImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
} else MissingLibraryArt(title)
|
||||
val request = remember(poster, context) { LibraryImages.request(context, poster) }
|
||||
AsyncImage(request, title.posterTitle, imageLoader = LibraryImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop,
|
||||
onSuccess = { ready = true }, onError = { ready = false })
|
||||
} else MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
|
||||
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
|
||||
if (title.progress > 0f && title.progress < 1f) {
|
||||
val progress = title.progress
|
||||
if (progress > 0f && progress < 1f) {
|
||||
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap)
|
||||
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).background(C.progressTrack, CircleShape)) {
|
||||
Box(Modifier.fillMaxHeight().fillMaxWidth(title.progress).background(C.accent, CircleShape))
|
||||
Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape))
|
||||
}
|
||||
}
|
||||
if (catalogue && title.badge != null) Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset)
|
||||
.size(WolfskinTokens.Layout.badge).background(if (title.badge == "held") C.ok else C.badgeRest, CircleShape), contentAlignment = Alignment.Center) {
|
||||
Text(if (title.badge == "held") "✓" else if (title.badge == "failed") "!" else "◷", color = if (title.badge == "held") C.onFocus else C.accent, style = WolfskinTokens.Type.button)
|
||||
.size(WolfskinTokens.Layout.badge).background(if (title.badge == "held") C.ok else C.badgeRest, CircleShape)
|
||||
.then(if (title.badge != "held") Modifier.border(L.badgeBorder, if (title.badge == "failed") C.fail else C.accent, CircleShape) else Modifier),
|
||||
contentAlignment = Alignment.Center) {
|
||||
Glyph(if (title.badge == "held") "check" else if (title.badge == "failed") "alert" else "clock",
|
||||
if (title.badge == "held") C.onFocus else if (title.badge == "failed") C.fail else C.accent, Modifier.size(L.badgeIcon))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MissingLibraryArt(title: LibraryTitle) {
|
||||
val hue = remember(title.title) { title.title.fold(0) { hash, character -> (hash * L.fallbackHueMultiplier.toInt() + character.code) % 360 }.toFloat() }
|
||||
Column(Modifier.fillMaxSize().background(Brush.linearGradient(listOf(
|
||||
Color.hsl(hue, L.fallbackSaturationTop, L.fallbackLightnessTop),
|
||||
Color.hsl(hue, L.fallbackSaturationBottom, L.fallbackLightnessBottom))))
|
||||
.padding(L.fallbackPadding), verticalArrangement = Arrangement.SpaceBetween) {
|
||||
Glyph(if (title.type == "movie") "film" else "nextup", C.fg.copy(alpha = L.fallbackIconOpacity), Modifier.size(L.fallbackIcon))
|
||||
Text(title.title, style = WolfskinTokens.Type.button, color = C.fg, maxLines = 4)
|
||||
}
|
||||
}
|
||||
|
||||
/** A measured prefix stays on one line, shrinking only to the approved type floor. */
|
||||
@Composable
|
||||
fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifier, visible: Boolean = true) {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val available = with(density) { (WolfskinTokens.Card.posterW - L.plateSide * 2).toPx() }
|
||||
val formatted = ratings.map { it.formatted() }
|
||||
fun width(count: Int, size: TextUnit): Float = (0 until count).sumOf { index ->
|
||||
val labelWidth = measurer.measure(formatted[index], WolfskinTokens.Type.ratingPlate.copy(fontSize = size)).size.width
|
||||
val pixels = with(density) { size.toPx() }
|
||||
(labelWidth + pixels * (if (ratings[index].source in listOf("imdb", "tmdb")) L.wordmarkRatio else 1f) + pixels * L.markGapEm + if (index > 0) pixels * L.plateGapEm else 0f).toDouble()
|
||||
}.toFloat()
|
||||
var size = WolfskinTokens.Type.ratingPlate.fontSize
|
||||
var count = ratings.size
|
||||
while (size > L.plateFloor.fontSize && width(count, size) > available) size = (size.value - L.plateStep).coerceAtLeast(L.plateFloor.fontSize.value).sp
|
||||
while (count > 1 && width(count, size) > available) count--
|
||||
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "library-plate")
|
||||
Row(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha }.background(Brush.verticalGradient(
|
||||
0f to C.bg.copy(alpha = L.plateOpacity), L.plateMidStop to C.bg.copy(alpha = L.plateMidOpacity), 1f to Color.Transparent))
|
||||
.padding(start = L.plateSide, end = L.plateSide, top = L.plateTop, bottom = L.plateBottom),
|
||||
horizontalArrangement = Arrangement.spacedBy(with(density) { (size.toPx() * L.plateGapEm).toDp() }), verticalAlignment = Alignment.CenterVertically) {
|
||||
repeat(count) { index ->
|
||||
val rating = ratings[index]
|
||||
val mark = when (rating.source) {
|
||||
"rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh
|
||||
"rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright
|
||||
"imdb" -> R.drawable.ic_rating_imdb
|
||||
else -> R.drawable.ic_rating_tmdb
|
||||
}
|
||||
val markHeight = with(density) { size.toDp() }
|
||||
val unknownCritic = rating.source == "rtCritics" && rating.label != "Fresh" && rating.label != "Certified Fresh" && rating.label != "Rotten"
|
||||
val desaturated = remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(markHeight * L.markGapEm), verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(androidx.compose.ui.res.painterResource(mark), rating.source,
|
||||
Modifier.size(markHeight * if (rating.source in listOf("imdb", "tmdb")) L.wordmarkRatio else 1f, markHeight)
|
||||
.graphicsLayer { this.alpha = if (unknownCritic) L.unknownCriticOpacity else 1f },
|
||||
colorFilter = if (unknownCritic) desaturated else null)
|
||||
Text(formatted[index], color = C.fg, style = WolfskinTokens.Type.ratingPlate.copy(fontSize = size), maxLines = 1, softWrap = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier,
|
|||
.offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2)
|
||||
.height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button))
|
||||
.padding(horizontal = V.side), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph("wifi", C.warn, Modifier.size(V.icon))
|
||||
Glyph("wifi", C.warn, Modifier.size(V.icon))
|
||||
Text("Saved $savedAt", color = C.warn, style = WolfskinTokens.Type.caption)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
|
|||
Row(Modifier.height(D.gridHeight).padding(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||
row.forEachIndexed { column, item ->
|
||||
val index = rowIndex * columns + column
|
||||
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), onFocus = { state.index = index }) { state.click(index) }
|
||||
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.tv.material3.Text
|
||||
import java.util.Locale
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as P
|
||||
|
||||
/** A full facts-line set never shrinks or waits for focused-card dwell. */
|
||||
@Composable
|
||||
fun TitleRatings(ratings: List<LibraryRating>, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.ratingPlate, plate: Boolean = false) {
|
||||
if (ratings.isEmpty()) return
|
||||
val density = LocalDensity.current
|
||||
val markHeight = with(density) { style.fontSize.toDp() }
|
||||
val lineHeight = style.fontSize * (WolfskinTokens.Type.ratingPlate.lineHeight.value / WolfskinTokens.Type.ratingPlate.fontSize.value)
|
||||
val textStyle = if (style.lineHeight == lineHeight) style else style.copy(lineHeight = lineHeight)
|
||||
val numberStyle = remember(textStyle) { textStyle.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold) }
|
||||
val gap = if (plate) with(density) { (style.fontSize * P.plateGapEm).toDp() } else D.ratingGap
|
||||
val markGap = if (plate) with(density) { (style.fontSize * P.markGapEm).toDp() } else D.ratingMarkGap
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
ratings.forEach { rating ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (rating.episode) Text("Ep", color = C.muted, style = textStyle.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
|
||||
val resource = when (rating.source) {
|
||||
"rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh
|
||||
"rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright
|
||||
"imdb" -> R.drawable.ic_rating_imdb
|
||||
else -> R.drawable.ic_rating_tmdb
|
||||
}
|
||||
val unknownCritic = rating.source == "rtCritics" && rating.label != "Fresh" && rating.label != "Certified Fresh" && rating.label != "Rotten"
|
||||
val desaturated = if (unknownCritic) remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) } else null
|
||||
Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") P.wordmarkRatio else 1f, markHeight),
|
||||
alpha = if (unknownCritic) P.unknownCriticOpacity else 1f, colorFilter = desaturated)
|
||||
Text(rating.formatted(), color = C.fg, style = numberStyle, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The plate keeps a prefix of the title set, shrinking to the caption floor before dropping its tail. */
|
||||
@Composable
|
||||
fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifier, visible: Boolean = true) {
|
||||
if (ratings.isEmpty()) return
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate")
|
||||
BoxWithConstraints(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha }
|
||||
.background(Brush.verticalGradient(0f to C.bg.copy(alpha = P.plateOpacity), P.plateMidStop to C.bg.copy(alpha = P.plateMidOpacity), 1f to C.bg.copy(alpha = 0f)))
|
||||
.padding(start = P.plateSide, end = P.plateSide, top = P.plateTop, bottom = P.plateBottom)) {
|
||||
val width = with(density) { maxWidth.toPx() }
|
||||
val fit = remember(ratings, width, density, Locale.getDefault()) {
|
||||
var size = WolfskinTokens.Type.ratingPlate.fontSize.value
|
||||
var count = ratings.size
|
||||
val numbers = ratings.map { it.formatted() }
|
||||
fun measured(): Float {
|
||||
val fontSize = TextUnit(size, TextUnitType.Sp)
|
||||
val style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize, fontFeatureSettings = "tnum")
|
||||
val markGap = with(density) { (fontSize * P.markGapEm).toPx() }
|
||||
val entryGap = with(density) { (fontSize * P.plateGapEm).toPx() }
|
||||
var total = 0f
|
||||
for (index in 0 until count) {
|
||||
val rating = ratings[index]
|
||||
total += measurer.measure(numbers[index], style, maxLines = 1).size.width
|
||||
total += with(density) { fontSize.toPx() } * if (rating.source == "imdb" || rating.source == "tmdb") P.wordmarkRatio else 1f
|
||||
total += markGap
|
||||
if (rating.episode) total += measurer.measure("Ep", style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), maxLines = 1).size.width + markGap
|
||||
if (index > 0) total += entryGap
|
||||
}
|
||||
return total
|
||||
}
|
||||
while (size > P.plateFloor.fontSize.value && measured() > width) size = (size - P.plateStep).coerceAtLeast(P.plateFloor.fontSize.value)
|
||||
while (count > 1 && measured() > width) count--
|
||||
count to size
|
||||
}
|
||||
val fontSize = TextUnit(fit.second, TextUnitType.Sp)
|
||||
TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize,
|
||||
lineHeight = WolfskinTokens.Type.ratingPlate.lineHeight * (fit.second / WolfskinTokens.Type.ratingPlate.fontSize.value)), plate = true)
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,11 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
|
|
@ -28,8 +33,8 @@ import androidx.compose.ui.graphics.ImageBitmap
|
|||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.compose.AsyncImage
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
|
|
@ -46,9 +51,9 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo
|
|||
else if (path != null) {
|
||||
val context = LocalContext.current
|
||||
val request = remember(context, path, width, height, poster) {
|
||||
titleImageRequest(context, path, width, height, poster)
|
||||
LibraryImages.request(context, path, width, height, poster)
|
||||
}
|
||||
AsyncImage(model = request, imageLoader = LocalWolfskinImageLoader.current ?: SingletonImageLoader.get(context),
|
||||
AsyncImage(model = request, imageLoader = LibraryImages.loader(context),
|
||||
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
|
||||
}
|
||||
}
|
||||
|
|
@ -93,40 +98,57 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
|
|||
for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat()
|
||||
ColorFilter.colorMatrix(matrix)
|
||||
}
|
||||
TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight, Modifier.fillMaxSize().blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter)
|
||||
TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight,
|
||||
Modifier.fillMaxSize().artOverscan(D.ambientInset).graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }
|
||||
.blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter)
|
||||
val posterShape = remember { RoundedCornerShape(WolfskinTokens.Radius.heroSheet) }
|
||||
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
|
||||
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
|
||||
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
|
||||
.size(L.heroSheetWidth, L.heroSheetHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)))
|
||||
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape))
|
||||
}
|
||||
Box(Modifier.fillMaxSize().background(horizontal))
|
||||
Box(Modifier.fillMaxSize().background(vertical))
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback art stays behind an image, so a failed decode never leaves an empty card. */
|
||||
/** Library, related and person posters share the exact locked missing-art treatment. */
|
||||
@Composable
|
||||
fun TitleCardArtwork(item: TitleItem, tile: Boolean) {
|
||||
Box(Modifier.fillMaxSize().titleFallbackBackground(item.seriesName ?: item.name, D.cardFallbackGradient)) {
|
||||
TitleGlyph(if (item.kind == "movie") "film" else "tv", D.fallbackGlyphColor, Modifier.offset(D.fallbackInset, D.fallbackInset).size(D.fallbackGlyph))
|
||||
if (!tile) Text(item.name, color = C.fg, style = D.fallbackTitle, maxLines = 4,
|
||||
internal fun MediaArtworkFallback(name: String, movie: Boolean, showTitle: Boolean = true) {
|
||||
Box(Modifier.fillMaxSize().titleFallbackBackground(name, D.cardFallbackGradient)) {
|
||||
Glyph(if (movie) "film" else "tv", D.fallbackGlyphColor, Modifier.offset(D.fallbackInset, D.fallbackInset).size(D.fallbackGlyph))
|
||||
if (showTitle) Text(name, color = C.fg, style = D.fallbackTitle, maxLines = 4, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(D.fallbackInset))
|
||||
if (tile && item.isPosterBackdrop && item.poster != null) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Episode tiles use their backdrop, or the prototype's blurred poster and small one-sheet. */
|
||||
@Composable
|
||||
fun TitleTileArtwork(item: TitleItem) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
MediaArtworkFallback(item.seriesName ?: item.name, item.kind == "movie", showTitle = false)
|
||||
if (item.isPosterBackdrop && item.poster != null) {
|
||||
val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply {
|
||||
for (index in 0 until 15) values[index] *= D.tileAmbientBrightness
|
||||
}) }
|
||||
TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().blur(D.tileAmbientBlur), colorFilter = filter)
|
||||
TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter)
|
||||
val height = WolfskinTokens.Card.tileH - D.tileSheetInset * 2
|
||||
val shape = remember { RoundedCornerShape(D.tileSheetRadius) }
|
||||
val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) }
|
||||
TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(),
|
||||
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).clip(RoundedCornerShape(D.tileSheetRadius)), poster = true)
|
||||
} else {
|
||||
TitleArtwork(if (tile) item.backdrop else item.poster,
|
||||
if (tile) WolfskinTokens.Image.tileRequestWidth else WolfskinTokens.Image.posterRequestWidth,
|
||||
if (tile) WolfskinTokens.Image.tileRequestHeight else WolfskinTokens.Image.posterRequestHeight,
|
||||
Modifier.fillMaxSize(), poster = !tile)
|
||||
}
|
||||
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true)
|
||||
} else TitleArtwork(item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.artOverscan(inset: Dp): Modifier = layout { measurable, constraints ->
|
||||
val pixels = inset.roundToPx()
|
||||
val width = constraints.maxWidth + pixels * 2
|
||||
val height = constraints.maxHeight + pixels * 2
|
||||
val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width, minHeight = height, maxHeight = height))
|
||||
layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(-pixels, -pixels) }
|
||||
}
|
||||
|
||||
private fun Modifier.titleFallbackBackground(name: String, token: String): Modifier = drawWithCache {
|
||||
var hue = 0
|
||||
name.forEach { hue = (hue * 31 + it.code) % 360 }
|
||||
|
|
|
|||
|
|
@ -14,9 +14,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
|
|
@ -33,42 +36,17 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod
|
|||
val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
|
||||
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
|
||||
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
|
||||
.background(background, shape)
|
||||
.then(if (option.on) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
|
||||
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (option.glyph.isNotEmpty()) TitleGlyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))
|
||||
if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))
|
||||
if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Title glyphs are the locked prototype paths, independent of platform icon substitutions. */
|
||||
@Composable
|
||||
fun TitleGlyph(name: String, color: androidx.compose.ui.graphics.Color, modifier: Modifier = Modifier) {
|
||||
val resource = when (name) {
|
||||
"play" -> R.drawable.title_play
|
||||
"restart" -> R.drawable.title_restart
|
||||
"version" -> R.drawable.title_version
|
||||
"tracks" -> R.drawable.title_tracks
|
||||
"eyeoff" -> R.drawable.title_eyeoff
|
||||
"eye" -> R.drawable.title_eye
|
||||
"trailer" -> R.drawable.title_trailer
|
||||
"more" -> R.drawable.title_more
|
||||
"plus" -> R.drawable.title_plus
|
||||
"clock" -> R.drawable.title_clock
|
||||
"users" -> R.drawable.title_users
|
||||
"tv" -> R.drawable.title_tv
|
||||
"film" -> R.drawable.title_film
|
||||
"alert" -> R.drawable.title_alert
|
||||
"wifi" -> R.drawable.title_wifi
|
||||
"check" -> R.drawable.title_check
|
||||
"sparkle" -> R.drawable.title_sparkle
|
||||
"undo" -> R.drawable.title_undo
|
||||
else -> R.drawable.title_left
|
||||
}
|
||||
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)
|
||||
}
|
||||
|
||||
/** A radio remains visibly selected independently of the inverse focused row. */
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.offset
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -38,7 +39,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
val type = if (item.kind == "movie") "Movie" else "Series"
|
||||
Column(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
|
||||
Row(Modifier.padding(bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon))
|
||||
Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon))
|
||||
Text(buildString {
|
||||
append("$type · ${if (item.held) "In your library" else "Not in your library"}")
|
||||
if (item.held && state.watched) append(" · Watched")
|
||||
|
|
@ -57,7 +58,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
state.data.requestedStage?.let { stage ->
|
||||
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
|
||||
Row(Modifier.padding(bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph(if (stage == "Ready") "check" else "clock", color, Modifier.size(D.lineIcon))
|
||||
Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon))
|
||||
Text(stage, color = color, style = WolfskinTokens.Type.label)
|
||||
Text("for you · by ${state.data.requestActor.orEmpty()}", color = C.muted, style = D.dLine)
|
||||
}
|
||||
|
|
@ -79,7 +80,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = WolfskinTokens.Type.label)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index,
|
||||
Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) },
|
||||
onFocus = { state.focus("header", index) }) { state.click("header", index) } }
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import androidx.compose.runtime.Immutable
|
|||
/** Screen data is independent of Jellyfin and Conductarr wire objects. */
|
||||
@Immutable
|
||||
data class TitleItem(
|
||||
val id: String,
|
||||
override val id: String,
|
||||
val name: String,
|
||||
val kind: String,
|
||||
val year: Int? = null,
|
||||
|
|
@ -13,7 +13,7 @@ data class TitleItem(
|
|||
val seasons: Int = 0,
|
||||
val overview: String = "",
|
||||
val genres: List<String> = emptyList(),
|
||||
val poster: String? = null,
|
||||
override val poster: String? = null,
|
||||
val backdrop: String? = null,
|
||||
val held: Boolean = false,
|
||||
val watched: Boolean = false,
|
||||
|
|
@ -22,11 +22,15 @@ data class TitleItem(
|
|||
val seriesName: String? = null,
|
||||
val season: Int = 0,
|
||||
val episode: Int = 0,
|
||||
val ratings: List<TitleRating> = emptyList(),
|
||||
override val ratings: List<LibraryRating> = emptyList(),
|
||||
val character: String? = null,
|
||||
val otherOwners: List<String> = emptyList(),
|
||||
val isPosterBackdrop: Boolean = false,
|
||||
) {
|
||||
) : PosterContent {
|
||||
override val posterTitle: String get() = name
|
||||
override val posterIsMovie: Boolean get() = kind == "movie"
|
||||
override val progress: Float get() = if (runtimeSeconds > 0) resumeSeconds.toFloat() / runtimeSeconds else 0f
|
||||
override val badge: String? get() = if (held) "held" else null
|
||||
val episodeLabel: String get() = "S$season E$episode"
|
||||
val runtime: String get() {
|
||||
val minutes = (runtimeSeconds + 30) / 60
|
||||
|
|
@ -34,10 +38,6 @@ data class TitleItem(
|
|||
}
|
||||
}
|
||||
|
||||
/** A rating is supplied only after sign-in and only when its source observed a valid score. */
|
||||
@Immutable
|
||||
data class TitleRating(val source: String, val score: Double, val mark: String, val episode: Boolean = false)
|
||||
|
||||
/** A cast identity can open a performer page without borrowing a title's identity. */
|
||||
@Immutable
|
||||
data class TitlePerson(val id: String, val name: String, val character: String = "", val portrait: String? = null, val biography: String = "")
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ import androidx.compose.ui.platform.testTag
|
|||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.layout.layout
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
|
@ -40,13 +46,14 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
|||
fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(state.sheet) { focus.requestFocus() }
|
||||
val shadow = remember { Shadow(radius = D.sheetShadowRadius, offset = DpOffset.Zero.copy(x = D.sheetShadowX), color = Color.Black.copy(alpha = D.sheetShadowOpacity)) }
|
||||
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable { }
|
||||
.testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" }
|
||||
.focusRequester(focus).onPreviewKeyEvent { event ->
|
||||
val key = readShellKey(event)
|
||||
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||
}.focusable()) {
|
||||
Column(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().background(C.surface)
|
||||
Column(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface)
|
||||
.verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
|
||||
verticalArrangement = Arrangement.spacedBy(if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap)) {
|
||||
when (state.sheet) {
|
||||
|
|
@ -76,11 +83,11 @@ private fun TrackChoices(state: TitleState) {
|
|||
val version = state.version ?: return
|
||||
Text("Audio and subtitles", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
|
||||
SheetSubtitle("${state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name} · ${version.name}")
|
||||
Text("Audio", color = C.dim, style = WolfskinTokens.Type.label)
|
||||
Text("Audio", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
|
||||
version.audio.forEachIndexed { index, track ->
|
||||
PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
|
||||
}
|
||||
Text("Subtitles", color = C.dim, style = WolfskinTokens.Type.label)
|
||||
Text("Subtitles", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
|
||||
val start = version.audio.size
|
||||
PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) }
|
||||
version.subtitles.forEachIndexed { index, track ->
|
||||
|
|
@ -121,10 +128,10 @@ private fun MediaInformation(state: TitleState) {
|
|||
|
||||
@Composable
|
||||
private fun MediaInfoSection(label: String, values: List<Pair<String, String>>) {
|
||||
Text(label, color = C.dim, style = WolfskinTokens.Type.label)
|
||||
Column { values.forEach { (name, value) ->
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(name, color = C.dim, style = WolfskinTokens.Type.caption, modifier = Modifier.width(D.infoLabelWidth))
|
||||
Text(label, color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(D.infoRowGap)) { values.forEach { (name, value) ->
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) {
|
||||
Text(name, color = C.dim, style = D.infoKey, modifier = Modifier.width(D.infoLabelWidth))
|
||||
Text(value, color = C.fg, style = WolfskinTokens.Type.caption)
|
||||
}
|
||||
} }
|
||||
|
|
@ -143,7 +150,11 @@ private fun WatchedConfirmation(state: TitleState) {
|
|||
|
||||
@Composable
|
||||
private fun SheetSubtitle(text: String) {
|
||||
Text(text, color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.sheetSubBottom))
|
||||
Text(text, color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.layout { measurable, constraints ->
|
||||
val child = measurable.measure(constraints)
|
||||
val lift = D.sheetSubLift.roundToPx()
|
||||
layout(child.width, (child.height + lift).coerceAtLeast(0)) { child.placeRelative(0, lift) }
|
||||
}.padding(bottom = D.sheetSubBottom))
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -151,7 +162,7 @@ private fun HiddenReceipt(state: TitleState) {
|
|||
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.receiptSide, vertical = D.receiptTop),
|
||||
verticalArrangement = Arrangement.spacedBy(D.receiptGap)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph("check", C.fg, Modifier.size(D.receiptIcon))
|
||||
Glyph("check", C.fg, Modifier.size(D.receiptIcon))
|
||||
Text("Hidden from your picks", color = C.fg, style = D.receiptTitle)
|
||||
}
|
||||
Text("${state.data.item.name} leaves every row of ${state.person.name}’s picks. It waits under Hidden picks at the end of Discover.", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,18 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
|||
/** The shared shell remains authoritative; title sheets overlay it and trap all input. */
|
||||
@Composable
|
||||
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
Box(modifier.fillMaxSize()) {
|
||||
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
|
||||
// No content target exists during an initial load; nav Down must not enter a skeleton.
|
||||
if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false
|
||||
else when (readShellKey(event)) {
|
||||
ShellKey.Down -> true
|
||||
ShellKey.Back -> {
|
||||
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) state.back(shell)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}) {
|
||||
val focusOwner = remember(shell) { { shell.navFocused = false } }
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
||||
ShellSurface(shell, handlesContentKeys = false, savedAt = state.data.savedAt) { TitlePage(state, shell, it) }
|
||||
|
|
@ -72,7 +83,7 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism
|
|||
Row(modifier.padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth)
|
||||
.background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph("alert", C.warn, Modifier.size(L.buttonIcon))
|
||||
Glyph("alert", C.warn, Modifier.size(L.buttonIcon))
|
||||
Text(message, color = C.fg, style = WolfskinTokens.Type.body)
|
||||
}
|
||||
}
|
||||
|
|
@ -91,6 +102,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
val displayedRows = remember(state.data) {
|
||||
buildList {
|
||||
add("header")
|
||||
if (state.data.status != TitleReadStatus.Ready) return@buildList
|
||||
if (state.data.item.held && state.data.item.kind == "tv" && state.data.item.seasons > 0) {
|
||||
add("seasons")
|
||||
if (state.data.episodesStatus == TitleReadStatus.Loading || state.data.episodes.isNotEmpty()) add("episodes")
|
||||
|
|
@ -117,7 +129,6 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
|
||||
}
|
||||
val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } }
|
||||
val heroVisible by remember(rowOffsets, list, density) { derivedStateOf { position < with(density) { WolfskinTokens.Shell.stageHeight.toPx() } } }
|
||||
val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } }
|
||||
val nextArtRow = remember(displayedRows, lastVisible) { if (lastVisible < 0) null else displayedRows.drop(lastVisible + 1).firstOrNull { it == "cast" || it == "episodes" || it == "related" } }
|
||||
TitleRowPrefetch(state.data, nextArtRow)
|
||||
|
|
@ -135,10 +146,9 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
}
|
||||
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||
}.focusable()) {
|
||||
if (heroVisible) {
|
||||
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg))
|
||||
} else Box(Modifier.fillMaxSize().background(C.bg))
|
||||
// This remains visible behind the scrolled rows; it is not an off-screen header image.
|
||||
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg))
|
||||
if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state)
|
||||
else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
|
||||
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
|
||||
|
|
@ -193,7 +203,7 @@ private fun TitleReadError(state: TitleState) {
|
|||
.border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.padding(horizontal = D.errorSide, vertical = D.errorVertical)) {
|
||||
Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph("alert", C.warn, Modifier.size(D.receiptIcon))
|
||||
Glyph("alert", C.warn, Modifier.size(D.receiptIcon))
|
||||
Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = D.errorTitle)
|
||||
}
|
||||
Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.errorBodyBottom))
|
||||
|
|
|
|||
|
|
@ -1,33 +1,21 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.Disposable
|
||||
import coil3.request.allowRgb565
|
||||
import coil3.request.crossfade
|
||||
import coil3.ImageLoader
|
||||
|
||||
/** Owned screens share one bounded loader; standalone fixtures can use Coil's local asset loader. */
|
||||
val LocalWolfskinImageLoader = compositionLocalOf<ImageLoader?> { null }
|
||||
|
||||
/** Visible and prefetched images use exactly the same size and decode policy. */
|
||||
internal fun titleImageRequest(context: Context, path: String, width: Int, height: Int, poster: Boolean): ImageRequest =
|
||||
ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build()
|
||||
|
||||
/** Read the next hidden art row into the bounded loader, cancelling work when the window changes. */
|
||||
@Composable
|
||||
internal fun TitleRowPrefetch(data: TitlePageData, row: String?) {
|
||||
val loader = LocalWolfskinImageLoader.current ?: return
|
||||
val context = LocalContext.current
|
||||
if (row == null || data.status != TitleReadStatus.Ready) return
|
||||
if (LocalTitleBitmaps.current.isNotEmpty()) return
|
||||
val loader = LibraryImages.loader(context)
|
||||
DisposableEffect(loader, context, row, data.cast, data.episodes, data.related) {
|
||||
val requests = ArrayList<Disposable>()
|
||||
fun queue(path: String?, width: Int, height: Int, poster: Boolean = false) {
|
||||
if (path != null) requests.add(loader.enqueue(titleImageRequest(context, path, width, height, poster)))
|
||||
if (path != null) requests.add(loader.enqueue(LibraryImages.request(context, path, width, height, poster)))
|
||||
}
|
||||
when (row) {
|
||||
"cast" -> for (index in 0 until minOf(data.cast.size, WolfskinTokens.Card.personSlots))
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.tv.material3.Text
|
||||
import java.util.Locale
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
||||
/** Rating formatting follows the TV locale; Rotten Tomatoes remains an integer percentage. */
|
||||
fun formatTitleRating(rating: TitleRating, locale: Locale = Locale.getDefault()): String =
|
||||
if (rating.source.startsWith("rt")) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score)
|
||||
|
||||
/** A full facts-line set never shrinks or waits for focused-card dwell. */
|
||||
@Composable
|
||||
fun TitleRatings(ratings: List<TitleRating>, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.button, plate: Boolean = false) {
|
||||
val density = LocalDensity.current
|
||||
val markHeight = with(density) { style.fontSize.toDp() }
|
||||
val gap = if (plate) with(density) { (style.fontSize * D.ratingPlateEntryEm).toDp() } else D.ratingGap
|
||||
val markGap = if (plate) with(density) { (style.fontSize * D.ratingPlateMarkEm).toDp() } else D.ratingMarkGap
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
ratings.forEach { rating ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (rating.episode) Text("Ep", color = C.muted, style = style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
|
||||
val resource = when (rating.mark) {
|
||||
"rotten" -> R.drawable.ic_rotten_tomatoes_rotten
|
||||
"upright" -> R.drawable.ic_rt_upright
|
||||
"spilled" -> R.drawable.ic_rt_spilled
|
||||
"imdb" -> R.drawable.ic_rating_imdb
|
||||
"tmdb" -> R.drawable.ic_rating_tmdb
|
||||
else -> R.drawable.ic_rotten_tomatoes_fresh
|
||||
}
|
||||
Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") D.ratingWordmarkAspect else 1f, markHeight),
|
||||
alpha = if (rating.mark == "tomato") D.mutedTomatoOpacity else 1f,
|
||||
colorFilter = if (rating.mark == "tomato") ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
|
||||
Text(formatTitleRating(rating), color = C.fg, style = style.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold), maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The plate keeps a prefix of the title set, shrinking to the caption floor before dropping its tail. */
|
||||
@Composable
|
||||
fun TitleRatingPlate(ratings: List<TitleRating>, modifier: Modifier = Modifier) {
|
||||
if (ratings.isEmpty()) return
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
BoxWithConstraints(modifier.background(Brush.verticalGradient(listOf(C.bg.copy(alpha = .9f), C.bg.copy(alpha = .72f), C.bg.copy(alpha = 0f))))
|
||||
.padding(start = D.plateSide, end = D.plateSide, top = D.plateTop, bottom = D.plateFade)) {
|
||||
val width = with(density) { maxWidth.toPx() }
|
||||
val fit = remember(ratings, width, density, Locale.getDefault()) {
|
||||
var size = WolfskinTokens.Type.ratingPlate.fontSize.value
|
||||
var count = ratings.size
|
||||
val numbers = ratings.map { formatTitleRating(it) }
|
||||
fun measured(): Float {
|
||||
val fontSize = TextUnit(size, TextUnitType.Sp)
|
||||
val style = WolfskinTokens.Type.button.copy(fontSize = fontSize, fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold)
|
||||
val markGap = with(density) { (fontSize * D.ratingPlateMarkEm).toPx() }
|
||||
val entryGap = with(density) { (fontSize * D.ratingPlateEntryEm).toPx() }
|
||||
var total = 0f
|
||||
for (index in 0 until count) {
|
||||
val rating = ratings[index]
|
||||
total += measurer.measure(numbers[index], style, maxLines = 1).size.width
|
||||
total += with(density) { fontSize.toPx() } * if (rating.source == "imdb" || rating.source == "tmdb") D.ratingWordmarkAspect else 1f
|
||||
total += markGap
|
||||
if (rating.episode) total += measurer.measure("Ep", style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), maxLines = 1).size.width + markGap
|
||||
if (index > 0) total += entryGap
|
||||
}
|
||||
return total
|
||||
}
|
||||
while (size > WolfskinTokens.Type.caption.fontSize.value && measured() > width) size = (size - D.ratingPlateStepSp).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value)
|
||||
while (count > 1 && measured() > width) count--
|
||||
count to size
|
||||
}
|
||||
TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.button.copy(fontSize = TextUnit(fit.second, TextUnitType.Sp)), plate = true)
|
||||
}
|
||||
}
|
||||
|
|
@ -103,40 +103,50 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
|||
|
||||
/** Posters draw art at rest, with facts only in the reserved focused-caption band. */
|
||||
@Composable
|
||||
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
|
||||
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, catalogue: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
|
||||
val focusOwner = LocalTitleFocusOwner.current
|
||||
val width = if (tile) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
|
||||
val height = if (tile) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
|
||||
Column(modifier.zIndex(if (focused) 1f else 0f)) {
|
||||
Box(Modifier.size(width, height).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
if (!tile) {
|
||||
val bitmaps = LocalTitleBitmaps.current
|
||||
val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } }
|
||||
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
|
||||
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
|
||||
} else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
|
||||
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
|
||||
TitleCardArtwork(item, tile)
|
||||
if (tile) {
|
||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
|
||||
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
|
||||
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(if (item.kind == "episode") "${item.episodeLabel} · ${item.name}" else "${if (item.kind == "movie") "Movie" else "Series"} · ${item.year ?: ""}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
TitleTileArtwork(item)
|
||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
|
||||
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
|
||||
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
val progress = if (item.runtimeSeconds > 0) item.resumeSeconds.toFloat() / item.runtimeSeconds else 0f
|
||||
val progress = item.progress
|
||||
if (progress > 0f && progress < 1f) {
|
||||
Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = if (tile) D.tileProgressBottom else D.cardProgressBottom)
|
||||
Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = D.tileProgressBottom)
|
||||
.fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
|
||||
Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent))
|
||||
}
|
||||
}
|
||||
if (item.held && !tile) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) {
|
||||
TitleGlyph("check", C.onFocus, Modifier.size(L.buttonIcon))
|
||||
}
|
||||
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) {
|
||||
TitleRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
||||
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
if (!tile) {
|
||||
val availability = androidx.compose.runtime.remember(item, catalogue) {
|
||||
if (catalogue) {
|
||||
if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }
|
||||
} else if (item.watched) "Watched"
|
||||
else if (item.resumeSeconds > 0) "${((item.runtimeSeconds - item.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left"
|
||||
else null
|
||||
}
|
||||
val facts = androidx.compose.runtime.remember(item, availability) {
|
||||
listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(),
|
||||
if (item.kind == "movie") { if (item.runtimeSeconds > 0) item.runtime else "Movie" } else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series",
|
||||
availability).joinToString(" · ")
|
||||
}
|
||||
Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) {
|
||||
Caption(item.name, listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(), if (item.kind == "movie") item.runtime.takeIf { item.runtimeSeconds > 0 } ?: "Movie" else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series", if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }).joinToString(" · "), focused,
|
||||
Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth))
|
||||
Caption(item.name, facts, focused, Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth),
|
||||
accentLastFact = availability != null, accentFirstFact = !item.character.isNullOrBlank())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ class TitleState(
|
|||
}
|
||||
if (sheet != null) { sheetKey(key); return true }
|
||||
when (key) {
|
||||
ShellKey.Back -> onAction(TitleAction.Pop)
|
||||
ShellKey.Back -> back(shell)
|
||||
ShellKey.Up -> if (row == "header") { shell.focusNav(shell.currentTab) } else moveRow(-1)
|
||||
ShellKey.Down -> moveRow(1)
|
||||
ShellKey.Left -> focus(row, (column - 1).coerceAtLeast(0))
|
||||
|
|
@ -135,8 +135,14 @@ class TitleState(
|
|||
return true
|
||||
}
|
||||
|
||||
/** Platform Back shares the sheet-first ladder with remote Back. */
|
||||
fun back() { if (sheet != null) sheet = null else onAction(TitleAction.Pop) }
|
||||
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
|
||||
fun back(shell: ShellState) {
|
||||
when {
|
||||
sheet != null -> sheet = null
|
||||
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
||||
else -> onAction(TitleAction.Pop)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pointer selection stays inside the modal's row order. */
|
||||
fun clickSheet(index: Int) { sheetIndex = index; sheetKey(ShellKey.Ok) }
|
||||
|
|
@ -227,7 +233,6 @@ class TitleState(
|
|||
}
|
||||
else -> sheet = null
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -249,8 +254,8 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
|
|||
val target = state.target
|
||||
if (data.status != TitleReadStatus.Ready) {
|
||||
if (data.status == TitleReadStatus.Unavailable) {
|
||||
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
||||
add(TitleOption("back", "Back", "left", icon = false))
|
||||
add(TitleOption("retry", "Try again", "", primary = true, icon = false))
|
||||
add(TitleOption("back", "Back", "", icon = false))
|
||||
}
|
||||
return@buildList
|
||||
}
|
||||
|
|
@ -271,9 +276,16 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
|
|||
} else if (data.connection != TitleConnection.SignedIn) {
|
||||
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
||||
} else {
|
||||
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", if (data.requestedStage == null) "plus" else "clock", primary = true, icon = false))
|
||||
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false))
|
||||
add(TitleOption("other", "For someone else", "users", icon = false))
|
||||
if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false))
|
||||
else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun titleStageGlyph(stage: String): String = when (stage) {
|
||||
"Ready" -> "check"
|
||||
"Failed" -> "alert"
|
||||
"Downloading", "Almost ready" -> "download"
|
||||
else -> "clock"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -310,6 +310,9 @@ object WolfskinTokens {
|
|||
val chipIcon = 13.0.dp
|
||||
val captionWidth = 320.0.dp
|
||||
const val captionSeparatorOpacity = 0.4f
|
||||
val captionFactsGap = 9.0.dp
|
||||
val badgeIcon = 14.0.dp
|
||||
val badgeBorder = 1.5.dp
|
||||
val plateTop = 7.0.dp
|
||||
val plateSide = 8.0.dp
|
||||
val plateBottom = 23.0.dp
|
||||
|
|
@ -376,6 +379,7 @@ object WolfskinTokens {
|
|||
val sheetGap = 9.0.dp
|
||||
val sheetSubLift = -4.0.dp
|
||||
val sheetSubBottom = 7.0.dp
|
||||
val sheetLabelTop = 4.0.dp
|
||||
val sheetRowSide = 13.0.dp
|
||||
val versionRowVertical = 10.0.dp
|
||||
val trackHeight = 42.0.dp
|
||||
|
|
@ -384,14 +388,14 @@ object WolfskinTokens {
|
|||
val radioDot = 8.0.dp
|
||||
val radioBorder = 1.5.dp
|
||||
val infoGap = 7.0.dp
|
||||
val infoLabelWidth = 80.0.dp
|
||||
val infoLabelWidth = 100.0.dp
|
||||
val infoRowGap = 3.0.dp
|
||||
val infoColumnGap = 10.0.dp
|
||||
val infoKey = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
|
||||
val gridHeight = 244.0.dp
|
||||
val minimumEndPadding = 40.0.dp
|
||||
val ratingGap = 10.0.dp
|
||||
val ratingMarkGap = 4.0.dp
|
||||
val plateSide = 8.0.dp
|
||||
val plateTop = 7.0.dp
|
||||
val plateFade = 23.0.dp
|
||||
val initialSize = TextStyle(fontSize = 32.0.sp, fontWeight = FontWeight(800), lineHeight = 41.6.sp, letterSpacing = 0.0.em)
|
||||
val personInitialSize = TextStyle(fontSize = 48.0.sp, fontWeight = FontWeight(800), lineHeight = 62.400000000000006.sp, letterSpacing = 0.0.em)
|
||||
const val fallbackHue = 260.0f
|
||||
|
|
@ -413,9 +417,6 @@ object WolfskinTokens {
|
|||
val chipBorder = 1.0.dp
|
||||
val heroSheetRight = 90.0.dp
|
||||
val heroSheetTop = 75.0.dp
|
||||
const val mutedTomatoOpacity = 0.55f
|
||||
const val ratingWordmarkAspect = 1.8f
|
||||
const val ratingPlateStepSp = 0.25f
|
||||
val gridHeadingTop = 12.0.dp
|
||||
val castName = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
|
||||
val receiptSide = 15.0.dp
|
||||
|
|
@ -441,6 +442,10 @@ object WolfskinTokens {
|
|||
val tileSheetLeft = 11.0.dp
|
||||
val tileSheetRadius = 5.0.dp
|
||||
val tileAmbientBlur = 13.0.dp
|
||||
val tileAmbientInset = 15.0.dp
|
||||
val tileShadowRadius = 15.0.dp
|
||||
val tileShadowY = 5.0.dp
|
||||
const val tileShadowOpacity = 0.5f
|
||||
const val tileAmbientBrightness = 0.5f
|
||||
const val cardFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))"
|
||||
const val personFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))"
|
||||
|
|
@ -452,10 +457,15 @@ object WolfskinTokens {
|
|||
val tileOverlayBottom = 9.0.dp
|
||||
const val cardOverlayOpacity = 0.92f
|
||||
val cardProgressInset = 9.0.dp
|
||||
val cardProgressBottom = 6.0.dp
|
||||
val tileProgressBottom = 5.0.dp
|
||||
const val ratingPlateEntryEm = 0.64f
|
||||
const val ratingPlateMarkEm = 0.29f
|
||||
val ambientInset = 40.0.dp
|
||||
const val ambientScale = 1.1f
|
||||
val posterShadowRadius = 40.0.dp
|
||||
val posterShadowY = 15.0.dp
|
||||
const val posterShadowOpacity = 0.6f
|
||||
val sheetShadowRadius = 40.0.dp
|
||||
val sheetShadowX = -15.0.dp
|
||||
const val sheetShadowOpacity = 0.5f
|
||||
}
|
||||
object SavedIndicator {
|
||||
val height = 26.0.dp
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M12,3 L2,20 H22 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M12,10 V14.5 M12,17 V17.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
|
||||
</vector>
|
||||
3
wolfskin-design/src/main/res/drawable/title_download.xml
Normal file
3
wolfskin-design/src/main/res/drawable/title_download.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="M12 4v11m-5-5 5 5 5-5M5 20h14" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
|
||||
<path android:pathData="M9.0,12.0a3.0,3.0 0 1,0 6.0,0a3.0,3.0 0 1,0 -6.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
|
||||
</vector>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M5.5,4 H18.5 A2.5,2.5 0,0 1,21 6.5 V17.5 A2.5,2.5 0,0 1,18.5 20 H5.5 A2.5,2.5 0,0 1,3 17.5 V6.5 A2.5,2.5 0,0 1,5.5 4 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" />
|
||||
<path android:pathData="M7,4 V20 M17,4 V20 M3,9 H7 M3,15 H7 M17,9 H21 M17,15 H21" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.6" />
|
||||
</vector>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M8 5v14l11-7z" android:fillColor="#FFFFFFFF" />
|
||||
</vector>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M12 3c.6 4.2 2.8 6.4 7 7-4.2.6-6.4 2.8-7 7-.6-4.2-2.8-6.4-7-7 4.2-.6 6.4-2.8 7-7z" android:fillColor="#FFFFFFFF" />
|
||||
</vector>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M5.5,5.0H18.5Q21.0,5.0 21.0,7.5V14.5Q21.0,17.0 18.5,17.0H5.5Q3.0,17.0 3.0,14.5V7.5Q3.0,5.0 5.5,5.0Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
|
||||
<path android:pathData="M8 21h8M12 17v4" android:fillColor="#FFFFFFFF" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" android:strokeLineCap="round" android:strokeLineJoin="miter" />
|
||||
</vector>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M5.5,8.5a3.5,3.5 0 1,0 7.0,0a3.5,3.5 0 1,0 -7.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
|
||||
<path android:pathData="M2.5 19.5c.8-3.4 3.4-5 6.5-5s5.7 1.6 6.5 5M15.5 5.2a3.4 3.4 0 0 1 0 6.6M18 14.8c1.8.7 3 2.2 3.5 4.7" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="miter" />
|
||||
</vector>
|
||||
|
|
@ -1 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:pathData="M5.5,4 L18.5,4 Q21,4 21,6.5 L21,17.5 Q21,20 18.5,20 L5.5,20 Q3,20 3,17.5 L3,6.5 Q3,4 5.5,4 M7,4 L7,20 M17,4 L17,20 M3,9 L7,9 M3,15 L7,15 M17,9 L21,9 M17,15 L21,15" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round"/></vector>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M5.5,4 H18.5 A2.5,2.5 0,0 1,21 6.5 V17.5 A2.5,2.5 0,0 1,18.5 20 H5.5 A2.5,2.5 0,0 1,3 17.5 V6.5 A2.5,2.5 0,0 1,5.5 4 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" />
|
||||
<path android:pathData="M7,4 V20 M17,4 V20 M3,9 H7 M3,15 H7 M17,9 H21 M17,15 H21" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.6" />
|
||||
</vector>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue