Build the Wolfskin Home feed

This commit is contained in:
justinzeus 2026-11-05 16:15:40 +01:00
parent 9be55c570a
commit 0fd0ec035d
683 changed files with 2561 additions and 18 deletions

View file

@ -1,21 +1,33 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.tv.material3.Text
/** Captions fade without moving the reserved band under the focused poster. */
@Composable
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) {
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "caption-fade")
Column(modifier.graphicsLayer { this.alpha = alpha }) {
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1)
Text(facts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1)
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, availability: String? = null) {
val alpha = remember(title, facts, availability) { Animatable(0f) }
LaunchedEffect(alpha, 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)
Row(Modifier.clipToBounds(), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Layout.captionOffset)) {
val entries = facts.split(" · ") + listOfNotNull(availability)
entries.forEachIndexed { index, fact ->
if (index > 0) Text("·", color = WolfskinTokens.Color.fg.copy(alpha = .4f), style = WolfskinTokens.Type.caption)
Text(fact, color = if (availability != null && index == entries.lastIndex) WolfskinTokens.Color.accentText else WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false)
}
}
}
}

View file

@ -5,6 +5,12 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
@ -39,3 +45,13 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color:
}
}
}
/** Pointing changes the same logical focus as the D-pad without activating an action. */
@Composable
fun Modifier.focusOnPointerEnter(onEnter: () -> Unit): Modifier {
val action by rememberUpdatedState(onEnter)
val interactions = remember { MutableInteractionSource() }
val hovered by interactions.collectIsHoveredAsState()
LaunchedEffect(hovered) { if (hovered) action() }
return hoverable(interactions)
}

View file

@ -20,6 +20,9 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
"seerr" -> R.drawable.wolfskin_sparkle
"advanced" -> R.drawable.wolfskin_sort
"alert" -> R.drawable.wolfskin_alert
"wifi" -> R.drawable.wolfskin_wifi
"film" -> R.drawable.wolfskin_film
"eye" -> R.drawable.wolfskin_eye
else -> R.drawable.wolfskin_right
}
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)

View file

@ -0,0 +1,32 @@
package org.justinvisser.wolfskin.design
import android.graphics.Matrix
import android.graphics.RadialGradient
import android.graphics.Shader
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.toArgb
/** Empty hero art keeps the locked elliptical ambient wash rather than flashing a black frame. */
@Composable
fun HomeAmbient(modifier: Modifier = Modifier) {
val brush = remember {
object : ShaderBrush() {
override fun createShader(size: Size): Shader {
val centerX = size.width * .7f
val centerY = size.height * .2f
val radiusX = size.width * 1.2f
val radiusY = size.height * .9f
return RadialGradient(centerX, centerY, radiusX, intArrayOf(WolfskinTokens.Color.surface3.toArgb(), WolfskinTokens.Color.bg.toArgb()), null, Shader.TileMode.CLAMP).apply {
setLocalMatrix(Matrix().apply { setScale(1f, radiusY / radiusX, centerX, centerY) })
}
}
}
}
Box(modifier.background(brush))
}

View file

@ -0,0 +1,86 @@
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.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.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.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
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
/** 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) {
HomeImages.request(context, source, (width.value * 2).toInt(), (height.value * 2).toInt(), poster)
}
AsyncImage(request, imageLoader = HomeImages.loader(context), contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, onError = { onError() })
}
/** Poster-only landscapes use the upright one-sheet over a dimmed wash of the same image. */
@Composable
fun HomeArt(title: HomeTitle, width: Dp, height: Dp, modifier: Modifier = Modifier, landscape: Boolean = false, hero: Boolean = false) {
val source = if (landscape) title.backdrop ?: title.poster else title.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.title, width, height, density) {
val hue = title.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 && (title.posterOnly || title.backdrop == null) && title.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(title.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(title.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 (title.type == "movie") "film" else "nextup", C.fg.copy(alpha = .45f), Modifier.offset(H.cardPadding, H.cardPadding).size(H.artGlyph))
if (!landscape) Text(title.title, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 4, modifier = Modifier.align(Alignment.BottomStart).padding(H.cardPadding))
} else HomeAmbient(Modifier.fillMaxSize())
}
}

View file

@ -0,0 +1,92 @@
package org.justinvisser.wolfskin.design
/** Home navigation mutates the shell's per-person view, including each row's remembered column. */
class HomeFocus(
private val shell: ShellState,
private val rows: List<HomeRow>,
private val onAction: (HomeAction) -> Unit,
) {
private var armed: Pair<String, Int>? = null
val row: HomeRow? get() = rows.getOrNull(shell.view.row)
val column: Int get() = row?.let { shell.view.cards[it.id] ?: 0 } ?: 0
fun focus(index: Int, column: Int) {
armed = null
shell.navFocused = false
shell.view.row = index
shell.view.home.focusedKey = rows[index].id
shell.view.cards[rows[index].id] = column.coerceIn(0, (rows[index].count - 1).coerceAtLeast(0))
}
fun key(key: ShellKey, down: Boolean, repeat: Int): Boolean {
val current = row ?: return false
if (key == ShellKey.Back) { armed = null; return false }
if (key == ShellKey.Ok) {
if (repeat > 0) return true
if (current.kind == HomeRowKind.Shelf) {
if (down) armed = current.id to column else {
if (armed == (current.id to column)) activate(current)
armed = null
}
} else if (down) activate(current)
return true
}
if (!down) return true
armed = null
if (reenterVisible()) return true
when (key) {
ShellKey.Left -> focus(shell.view.row, (column - 1).coerceAtLeast(0))
ShellKey.Right -> focus(shell.view.row, (column + 1).coerceAtMost((current.count - 1).coerceAtLeast(0)))
ShellKey.Up, ShellKey.Down -> {
val direction = if (key == ShellKey.Up) -1 else 1
var next = shell.view.row + direction
while (next in rows.indices && !rows[next].focusable) next += direction
if (next in rows.indices) {
val target = rows[next]
val nearest = (column * pitch(current) / pitch(target)).toInt()
focus(next, shell.view.cards[target.id] ?: nearest)
} else if (direction < 0) {
shell.view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
shell.view.home.resetScroll++
shell.navFocused = true
shell.navIndex = shell.currentTab
}
}
}
return true
}
private fun reenterVisible(): Boolean {
val view = shell.view
val layout = view.listState.layoutInfo
if (layout.totalItemsCount == 0 || view.home.positionedKey != row?.id || view.listState.isScrollInProgress) return false
val density = layout.viewportSize.width / WolfskinTokens.Shell.stageWidth.value
val current = layout.visibleItemsInfo.firstOrNull { it.index == view.row }
if (current != null && current.offset + current.size >= WolfskinTokens.Layout.navSolidAfter.value * density &&
current.offset <= layout.viewportSize.height - WolfskinTokens.Layout.rowHeadingBand.value * 2 * density) return false
val first = layout.visibleItemsInfo.firstOrNull {
rows[it.index].focusable && it.offset + it.size > WolfskinTokens.Layout.rowHeadingBand.value * density &&
it.offset < layout.viewportSize.height - WolfskinTokens.Layout.row_endcta.value * density
} ?: return false
focus(first.index, view.cards[rows[first.index].id] ?: 0)
return true
}
private fun activate(row: HomeRow) {
when (row.kind) {
HomeRowKind.Hero -> row.cards.firstOrNull()?.let { title ->
onAction(if (column == 0 && row.heroKind != "pick") HomeAction.Play(title, row.heroKind == "resume") else HomeAction.Details(title))
}
HomeRowKind.Shelf -> if (column < row.cards.size) onAction(HomeAction.Details(row.cards[column])) else onAction(HomeAction.SeeAll(row))
HomeRowKind.State -> onAction(HomeAction.Retry)
HomeRowKind.Explore -> onAction(HomeAction.Discover)
else -> Unit
}
}
private fun pitch(row: HomeRow): Float = when (row.cardKind) {
HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value
HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value
HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value
}
}

View file

@ -0,0 +1,92 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
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.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
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.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Type as T
/** The hero occupies one row while its backdrop extends beneath the first shelves. */
@Composable
fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) {
Box(Modifier.fillMaxWidth().height(L.row_hero)) {
if (!row.skeleton) {
val title = row.cards.first()
val artModifier = Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)
HomeArt(title, WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight, artModifier, landscape = true, hero = true)
Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f))))
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) {
Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f)))
Text(when (row.heroKind) { "resume" -> "Continue watching"; "next" -> "Up next"; "new" -> "New in your library"; else -> "For you" }, color = C.accentText, style = T.label)
}
Spacer(Modifier.height(H.kickerBottom))
Text(title.displayTitle, color = C.fg, style = T.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(H.titleBottom))
Row(Modifier.heightIn(min = H.factsHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.factsGap)) {
Text(if (title.isEpisode) title.subtitle else title.year?.toString().orEmpty(), color = C.muted, style = T.body, maxLines = 1)
if (row.heroKind == "resume" || title.runtimeSeconds != null) Text(if (row.heroKind == "resume") title.remaining else title.runtime, color = C.muted, style = T.body, maxLines = 1)
HomeRatingsRow(ratings)
}
Spacer(Modifier.height(H.factsBottom))
Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax))
Spacer(Modifier.height(H.overviewBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }) { onButton(0) }
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }) { onButton(1) }
}
}
} else {
HomeAmbient(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom)) {
Box(Modifier.size(H.heroSkeletonWidth, H.heroSkeletonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
Spacer(Modifier.height(H.heroSkeletonGap))
Box(Modifier.size(H.heroSkeletonFactsWidth, H.heroSkeletonFactsHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
Spacer(Modifier.height(H.heroSkeletonButtonGap))
Box(Modifier.size(H.heroSkeletonButtonWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button)))
}
}
}
}
/** Discover continues in the same scroller, under the active person's identity. */
@Composable
fun HomeSection(person: ShellPerson, savedAt: String?) {
Box(Modifier.fillMaxWidth().height(L.row_section)) {
Row(Modifier.padding(start = L.safeSide, top = H.sectionTop), horizontalArrangement = Arrangement.spacedBy(H.sectionGap), verticalAlignment = Alignment.CenterVertically) {
Text("Discover", color = C.fg, style = T.sectionTitle)
Avatar(person, H.sectionAvatar, T.avatarLetterSectionHeader)
Text("Picks for you first, then what’s out there", color = C.muted, style = T.body)
if (savedAt != null) Text("Saved $savedAt", color = C.warn, style = T.caption)
}
}
}
/** Failures keep a focusable recovery action without covering working library shelves. */
@Composable
fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> Unit, onHover: () -> Unit) {
Box(Modifier.fillMaxWidth().height(L.row_state)) {
Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(WolfskinTokens.Focus.ringCardRing / 2, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX, vertical = H.statePaddingY)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.plateTop)) {
Glyph(if (row.calm) "profile" else "alert", if (row.calm) C.accentText else C.warn, Modifier.size(H.stateIcon))
Text(row.title, color = C.fg, style = T.stateTitle.copy(fontWeight = FontWeight.Bold))
}
Spacer(Modifier.height(H.stateGap))
Text(row.text, color = C.muted, style = T.body)
Spacer(Modifier.height(H.stateTextBottom))
PickerButton("Try again", focused, row.calm, onFocus = onHover, onClick = retry)
}
}
}

View file

@ -0,0 +1,49 @@
package org.justinvisser.wolfskin.design
import android.content.Context
import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.imageLoader
import coil3.request.crossfade
import coil3.memory.MemoryCache
import coil3.request.ImageRequest
import coil3.request.allowRgb565
import kotlinx.coroutines.Dispatchers
/** Home shares one bounded loader; upstream's hidden-switch screens retain their existing loader. */
object HomeImages {
private var application: Context? = null
private var loader: ImageLoader? = null
private const val MEBIBYTE = 1024L * 1024L
private const val IMAGE_READS = 4
@Synchronized
fun loader(context: Context): ImageLoader {
val owner = context.applicationContext
if (application !== owner) {
loader?.shutdown()
application = owner
val reads = Dispatchers.IO.limitedParallelism(IMAGE_READS)
loader = owner.imageLoader.newBuilder()
.memoryCache { MemoryCache.Builder().maxSizeBytes((WolfskinTokens.Image.memoryCacheMB * MEBIBYTE).toLong()).build() }
.diskCache { DiskCache.Builder().directory(owner.cacheDir.resolve("wolfskin-images")).maxSizeBytes((WolfskinTokens.Image.diskCacheMB * MEBIBYTE).toLong()).build() }
.fetcherCoroutineContext(reads).decoderCoroutineContext(reads).crossfade(false).build()
}
return loader!!
}
fun request(context: Context, source: String, width: Int, height: Int, poster: Boolean): ImageRequest =
ImageRequest.Builder(context).data(source).size(width, height).allowRgb565(poster).build()
fun prefetch(context: Context, row: HomeRow) {
val count = when (row.cardKind) { HomeCardKind.Poster -> 7; HomeCardKind.Tile -> 5; HomeCardKind.Wide -> 4 }
val width = (row.cardWidth.value * 2).toInt()
val height = (row.cardHeight.value * 2).toInt()
row.cards.take(count).forEach { title ->
val poster = row.cardKind == HomeCardKind.Poster
val source = if (poster) title.poster else title.backdrop ?: title.poster
if (source != null) loader(context).enqueue(request(context, source, width, height, poster))
}
}
}

View file

@ -0,0 +1,91 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.Immutable
import java.util.Locale
import kotlin.math.roundToInt
/** A screen-owned media projection, independent of Jellyfin and catalogue DTOs. */
@Immutable
data class HomeTitle(
val id: String,
val title: String,
val type: String,
val year: Int? = null,
val runtimeSeconds: Int? = null,
val seasons: Int? = null,
val overview: String = "",
val poster: String? = null,
val backdrop: String? = null,
val posterOnly: Boolean = false,
val seriesTitle: String? = null,
val season: Int? = null,
val episode: Int? = null,
val resumeSeconds: Double = 0.0,
val played: Boolean = false,
val addedAt: String = "",
val genres: List<String> = emptyList(),
val ratings: List<HomeRating> = emptyList(),
val held: Boolean = false,
val otherOwner: String? = null,
val tmdbId: Int? = null,
) {
val isEpisode: Boolean get() = type == "episode"
val displayTitle: String get() = seriesTitle ?: title
val episodeLabel: String get() = "S$season E$episode"
val runtime: String get() {
val minutes = ((runtimeSeconds ?: 0) / 60.0).roundToInt()
return if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m"
}
val remaining: String get() = "${(((runtimeSeconds ?: 0) - resumeSeconds) / 60).roundToInt().coerceAtLeast(1)} min left"
val subtitle: String get() = if (isEpisode) "$episodeLabel · $title" else "${if (type == "movie") "Movie" else "Series"} · ${year ?: ""}"
val facts: String get() = listOfNotNull(year?.toString(), if (type == "movie") runtimeSeconds?.let { runtime } ?: "Movie" else seasons?.let { "$it ${if (it == 1) "season" else "seasons"}" } ?: "Series").joinToString(" · ")
}
/** Only observed, range-checked scores reach this model; formatting follows the TV locale. */
@Immutable
data class HomeRating(val mark: String, val score: Double, val percentage: Boolean = false, val episode: Boolean = false) {
fun format(locale: Locale = Locale.getDefault()): String = if (percentage) "${score.toInt()}%" else String.format(locale, "%.1f", score)
}
enum class HomeCardKind { Poster, Tile, Wide }
enum class HomeRowKind { Hero, Shelf, Section, State, Note, Explore }
enum class HomeAvailability { Ready, Loading, SignedOut, Unavailable, Saved }
/** Rows retain stable keys while a refresh replaces their immutable contents. */
@Immutable
data class HomeRow(
val id: String,
val kind: HomeRowKind,
val title: String = "",
val cards: List<HomeTitle> = emptyList(),
val cardKind: HomeCardKind = HomeCardKind.Poster,
val catalogue: Boolean = false,
val total: Int = cards.size,
val target: String? = null,
val text: String = "",
val heroKind: String = "next",
val skeleton: Boolean = false,
val calm: Boolean = false,
) {
val hasSeeAll: Boolean get() = total > cards.size
val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick") 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 }
val focusable: Boolean get() = !skeleton && count > 0
}
/** One snapshot paints immediately; source status never discards a saved view. */
@Immutable
data class HomeSnapshot(
val rows: List<HomeRow>,
val conductarr: HomeAvailability = HomeAvailability.SignedOut,
val jellyfinAvailable: Boolean = true,
val savedAt: String? = null,
)
/** Home emits integration intents, never owns the upstream player or later-slice pages. */
sealed interface HomeAction {
data class Play(val title: HomeTitle, val resume: Boolean) : HomeAction
data class Details(val title: HomeTitle) : HomeAction
data class SeeAll(val row: HomeRow) : HomeAction
data object Discover : HomeAction
data object Retry : HomeAction
}

View file

@ -0,0 +1,28 @@
package org.justinvisser.wolfskin.design
/** The screen's ratings cache uses an injected clock and never caches failed reads. */
class HomeRatingCache(private val clock: () -> Long = System::currentTimeMillis) {
private data class Entry(val ratings: List<HomeRating>, val readAt: Long)
private val entries = linkedMapOf<String, Entry>()
fun read(title: HomeTitle): List<HomeRating>? {
val key = key(title)
val entry = entries[key] ?: return null
if (clock() - entry.readAt >= lifetimeMillis) { entries.remove(key); return null }
return entry.ratings
}
fun save(title: HomeTitle, ratings: List<HomeRating>) {
val key = key(title)
entries.remove(key)
entries[key] = Entry(ratings, clock())
while (entries.size > capacity) entries.remove(entries.keys.first())
}
private fun key(title: HomeTitle): String = if (title.isEpisode) "episode:${title.id}" else "${title.type}:${title.tmdbId ?: title.id}"
companion object {
private const val capacity = 300
private val lifetimeMillis = (WolfskinTokens.Behaviour.cacheRatingsHours * 60 * 60 * 1000).toLong()
}
}

View file

@ -0,0 +1,92 @@
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.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.getValue
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.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.tv.material3.Text
import java.util.Locale
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Type as T
/** Formatting is measured before fitting, so decimal commas obey the same one-line rule. */
@Composable
fun HomeRatingsRow(ratings: List<HomeRating>, modifier: Modifier = Modifier, fontSize: TextUnit = T.ratingPlate.fontSize, plate: Boolean = false) {
val density = LocalDensity.current
val size = with(density) { fontSize.toDp() }
val entryGap = if (plate) size * .64f else H.ratingGap
val markGap = if (plate) size * .29f else H.ratingMarkGap
Row(modifier, horizontalArrangement = Arrangement.spacedBy(entryGap), verticalAlignment = Alignment.CenterVertically) {
ratings.forEach { rating ->
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
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
}
val muted = rating.mark == "tomato"
Image(painterResource(resource), null, Modifier.size(size * if (rating.mark == "imdb" || rating.mark == "tmdb") 1.8f else 1f, size).graphicsLayer { alpha = if (muted) .55f else 1f }, colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
if (rating.episode) Text("Ep", color = C.muted, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontWeight = FontWeight.SemiBold), maxLines = 1)
Text(rating.format(), color = C.fg, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontFeatureSettings = "tnum"), maxLines = 1, softWrap = false)
}
}
}
}
/** The plate is a prefix of the full ratings set; its first entry is never discarded. */
@Composable
fun HomeRatingPlate(ratings: List<HomeRating>, visible: Boolean, modifier: Modifier = Modifier) {
val alpha by animateFloatAsState(if (visible && ratings.isNotEmpty()) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate")
if (ratings.isEmpty()) return
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val locale = Locale.getDefault()
BoxWithConstraints(modifier.graphicsLayer { this.alpha = alpha }.background(Brush.verticalGradient(0f to C.bg.copy(alpha = .9f), .52f to C.bg.copy(alpha = .72f), 1f to C.bg.copy(alpha = 0f))).padding(start = H.plateSide, end = H.plateSide, top = H.plateTop, bottom = H.plateFade)) {
val available = with(density) { maxWidth.toPx() }
val fitted = remember(ratings, available, density, locale) {
val base = T.ratingPlate.fontSize
val floor = T.caption.fontSize
fun width(size: TextUnit, entries: List<HomeRating>): Float {
val em = with(density) { size.toPx() }
return entries.sumOf { rating ->
val mark = em * if (rating.mark == "imdb" || rating.mark == "tmdb") 1.8f else 1f
val number = measurer.measure(rating.format(locale), T.ratingPlate.copy(fontSize = size)).size.width
val episode = if (rating.episode) measurer.measure("Ep", T.ratingPlate.copy(fontSize = size, fontWeight = FontWeight.SemiBold)).size.width + em * .29f else 0f
(mark + em * .29f + number + episode).toDouble()
}.toFloat() + (entries.size - 1).coerceAtLeast(0) * em * .64f
}
var size = base
val initialWidth = width(base, ratings)
if (initialWidth > available) size = base * (available / initialWidth).coerceAtLeast(floor.value / base.value)
val step = (base.value - floor.value) / 8
while (size > floor && width(size, ratings) > available) size = base * ((size.value - step).coerceAtLeast(floor.value) / base.value)
var count = ratings.size
while (count > 1 && width(size, ratings.take(count)) > available) count--
size to ratings.take(count)
}
HomeRatingsRow(fitted.second, fontSize = fitted.first, plate = true)
}
}

View file

@ -0,0 +1,55 @@
package org.justinvisser.wolfskin.design
/** Source reads preserve their order; the hero is removed by stable item identity. */
data class HomeLibrary(
val resume: List<HomeTitle> = emptyList(),
val nextUp: List<HomeTitle> = emptyList(),
val newest: List<HomeTitle> = emptyList(),
val destinations: Map<String, List<HomeTitle>> = emptyMap(),
val destinationTotals: Map<String, Int> = emptyMap(),
)
/** Merge the two sources into the single Home frame without inventing playable items. */
fun buildHomeRows(
library: HomeLibrary,
continuation: List<HomeRow>,
libraryAvailable: Boolean = true,
loading: Boolean = false,
): List<HomeRow> = buildList {
if (loading) {
add(HomeRow("hero-sk", HomeRowKind.Hero, skeleton = true))
listOf(HomeCardKind.Tile, HomeCardKind.Tile, HomeCardKind.Poster, HomeCardKind.Poster).forEachIndexed { index, kind ->
add(HomeRow("sk$index", HomeRowKind.Shelf, cardKind = kind, skeleton = true))
}
return@buildList
}
val choice = when {
library.resume.isNotEmpty() -> "resume" to library.resume.first()
library.nextUp.isNotEmpty() -> "next" to library.nextUp.first()
library.newest.isNotEmpty() -> "new" to library.newest.first()
else -> continuation.firstNotNullOfOrNull { it.cards.firstOrNull() }?.let { "pick" to it }
}
val hero = choice?.second
if (hero != null) add(HomeRow("hero", HomeRowKind.Hero, cards = listOf(hero), heroKind = choice.first))
if (!libraryAvailable) {
add(HomeRow("jf-down", HomeRowKind.State, "Your library can’t be reached", text = "Jellyfin isn’t answering, so nothing can play right now."))
} else {
listOf(Triple("cw", "Continue watching", library.resume), Triple("nu", "Next up", library.nextUp), Triple("new", "New in your library", library.newest)).forEach { (id, title, source) ->
val cards = source.filter { it.id != hero?.id }
if (cards.isNotEmpty()) add(HomeRow(id, HomeRowKind.Shelf, title, cards, if (id == "cw") HomeCardKind.Wide else HomeCardKind.Tile))
}
listOf("movies", "shows", "anime").forEach { destination ->
val titles = library.destinations[destination].orEmpty().sortedWith(compareBy<HomeTitle> { it.played }.thenByDescending { it.addedAt })
if (titles.isNotEmpty()) add(HomeRow("lib-$destination", HomeRowKind.Shelf, if (destination == "anime") "Anime" else "Your $destination", titles.take(WolfskinTokens.Behaviour.rowLimit.toInt()), total = library.destinationTotals[destination] ?: titles.size, target = destination))
}
}
add(HomeRow("disc-h", HomeRowKind.Section, "Discover"))
addAll(continuation)
}
/** Authentication failure is distinct from an unreachable signed-in catalogue. */
fun homeDiscoverState(status: HomeAvailability): HomeRow = if (status == HomeAvailability.SignedOut) {
HomeRow("signin", HomeRowKind.State, "Discover needs sign-in", text = "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual.", calm = true)
} else {
HomeRow("cond-down", HomeRowKind.State, "Discover is resting", text = "Conductarr can’t be reached. Your library still plays.")
}

View file

@ -0,0 +1,111 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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 kotlinx.coroutines.delay
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
/** One collector owns the focused card dwell; cached scores appear on re-entry without another read. */
@Composable
fun HomeScreen(
shell: ShellState,
snapshot: HomeSnapshot,
modifier: Modifier = Modifier,
onAction: (HomeAction) -> Unit,
) {
val view = shell.view
LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt }
val action by rememberUpdatedState(onAction)
val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } }
DisposableEffect(shell, focus) {
shell.contentInput = focus::key
onDispose { shell.contentInput = null }
}
LaunchedEffect(snapshot.rows, view) {
val remembered = snapshot.rows.indexOfFirst { it.id == view.home.focusedKey && it.focusable }
if (remembered >= 0) view.row = remembered
if (snapshot.rows.none { it.focusable }) { shell.navFocused = true; shell.navIndex = shell.currentTab }
else if (snapshot.rows.getOrNull(view.row)?.focusable != true) view.row = snapshot.rows.indexOfFirst { it.focusable }
view.home.focusedKey = snapshot.rows.getOrNull(view.row)?.id
}
val density = LocalDensity.current
val heights = remember(snapshot.rows) { snapshot.rows.map { rowHeight(it) } }
val topInset = if (snapshot.rows.firstOrNull()?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine
val context = LocalContext.current
LaunchedEffect(view.row, snapshot.rows) {
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { HomeImages.prefetch(context, it) }
}
LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) {
val key = snapshot.rows.getOrNull(view.row)?.id
if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect
val topRequested = shell.navFocused && view.home.resetScroll > 0 && view.home.positionedReset != view.home.resetScroll
val target = if (topRequested) 0f else with(density) {
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
}.coerceAtLeast(0f)
view.home.positionedKey = key
view.home.positionedReset = view.home.resetScroll
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
}
val cache = view.home.ratings
val row = snapshot.rows.getOrNull(view.row)
val title = row?.cards?.getOrNull(focus.column)
val hero = snapshot.rows.firstOrNull { it.kind == HomeRowKind.Hero }?.cards?.firstOrNull()
var heroRatings by remember(hero?.id) { mutableStateOf(hero?.let(cache::read).orEmpty()) }
var focusedRatings by remember(title?.id, shell.navFocused) { mutableStateOf(title?.let(cache::read).orEmpty()) }
LaunchedEffect(title?.id, hero?.id, shell.navFocused, shell.pickerVisible, snapshot.conductarr) {
if (hero != null && snapshot.conductarr == HomeAvailability.Ready) {
heroRatings = hero.ratings
cache.save(hero, heroRatings)
}
if (title != null && !shell.navFocused && !shell.pickerVisible && snapshot.conductarr == HomeAvailability.Ready) {
val saved = cache.read(title)
if (saved == null && row.kind != HomeRowKind.Hero) delay(WolfskinTokens.Motion.ratingDwell.toLong())
focusedRatings = saved ?: title.ratings.also { cache.save(title, it) }
}
}
val showRatings = snapshot.conductarr != HomeAvailability.SignedOut
val trailingSpace = maxOf(H.frameBottom, WolfskinTokens.Shell.stageHeight - L.pinnedLine - (heights.lastOrNull() ?: WolfskinTokens.Shell.zero))
LazyColumn(state = view.listState, modifier = modifier.testTag("wolfskin-home").semantics { stateDescription = "${row?.id}:${focus.column}" }, contentPadding = PaddingValues(top = topInset, bottom = trailingSpace)) {
itemsIndexed(snapshot.rows, key = { _, item -> item.id }, contentType = { _, item -> item.kind.name.lowercase() }) { index, item ->
val selected = index == view.row && !shell.navFocused
fun activate(column: Int) { focus.focus(index, column); focus.key(ShellKey.Ok, true, 0); focus.key(ShellKey.Ok, false, 0) }
when (item.kind) {
HomeRowKind.Hero -> HomeHero(item, selected, focus.column, if (showRatings) heroRatings else emptyList(), ::activate) { focus.focus(index, it) }
HomeRowKind.Shelf -> HomeShelf(item, selected, view.cards[item.id] ?: 0, if (showRatings && selected) focusedRatings else emptyList(), ::activate) { focus.focus(index, it) }
HomeRowKind.Section -> HomeSection(shell.person, snapshot.savedAt)
HomeRowKind.State -> HomeRecovery(item, selected, { activate(0) }) { focus.focus(index, 0) }
HomeRowKind.Note -> Row(Modifier.fillMaxWidth().height(L.row_note).padding(start = L.safeSide, top = H.noteTop), horizontalArrangement = Arrangement.spacedBy(H.noteGap)) {
Glyph(if (item.calm) "eye" else "alert", if (item.calm) C.muted else C.warn, Modifier.size(L.buttonIcon))
Text(item.text, color = if (item.calm) C.muted else C.warn, style = WolfskinTokens.Type.body)
}
HomeRowKind.Explore -> Box(Modifier.fillMaxWidth().height(L.row_endcta).padding(start = L.safeSide, top = H.endTop)) {
PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }) { activate(0) }
}
}
}
}
}
private fun rowHeight(row: HomeRow) = when (row.kind) {
HomeRowKind.Hero -> L.row_hero
HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.Shell.zero) + L.rowGapAfter
HomeRowKind.Section -> L.row_section
HomeRowKind.State -> L.row_state
HomeRowKind.Note -> L.row_note
HomeRowKind.Explore -> L.row_endcta
}

View file

@ -0,0 +1,106 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
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.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
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.Type as T
/** Every lazy row and card uses a stable source identity and an explicit content type. */
@Composable
fun HomeShelf(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) {
val width = row.cardWidth
val height = row.cardHeight
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero
val slots = if (row.cardKind == HomeCardKind.Poster) 6 else if (row.cardKind == HomeCardKind.Tile) 4 else 3
val first = (column - (slots - 2)).coerceIn(0, (row.count - slots).coerceAtLeast(0))
val track = rememberLazyListState(first)
val density = LocalDensity.current
LaunchedEffect(first) {
val pitch = with(density) { (width + K.posterGap).toPx() }
track.animateScrollBy((first - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base))
}
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))
val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList()
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title ->
val selected = focused && column == index
Box(Modifier.size(width, height).zIndex(if (selected) 2f else 0f).focusScale(selected, WolfskinTokens.Focus.scaleCard).focusRing(selected, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).testTag("home-card:${row.id}:$index").then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) }.clickable { onCard(index) } else Modifier)) {
Box(Modifier.fillMaxSize().clip(RoundedCornerShape(WolfskinTokens.Radius.card))) {
when {
row.skeleton -> Box(Modifier.fillMaxSize().background(C.surface))
title == null -> SeeAll(row.total)
else -> {
HomeArt(title, width, height, Modifier.fillMaxSize(), landscape = row.cardKind != HomeCardKind.Poster)
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)
if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) }
if (title.resumeSeconds > 0 && title.runtimeSeconds != null) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = H.progressInset).padding(bottom = H.progressBottom).fillMaxWidth().height(L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
Box(Modifier.fillMaxHeight().fillMaxWidth((title.resumeSeconds / title.runtimeSeconds).toFloat().coerceIn(0f, 1f)).background(C.accent))
}
HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth())
}
}
}
}
}
}
if (row.cardKind == HomeCardKind.Poster && focused && !row.skeleton) {
val title = row.cards.getOrNull(column)
val facts = title?.facts ?: "${row.total} titles"
val availability = when {
title == null -> null
row.catalogue -> if (title.held) "In your library" else title.otherOwner?.let { "In $it’s library" }
title.played -> "Watched"
title.resumeSeconds > 0 -> title.remaining
else -> null
}
Caption(title?.title ?: "See all ${row.title}", facts, true, Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(H.captionWidth), availability)
}
}
}
@Composable
private fun TileOverlay(title: HomeTitle) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
Column(Modifier.fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))).padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) {
Text(title.displayTitle, color = C.fg, style = T.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(title.subtitle, color = C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
@Composable
private fun SeeAll(total: Int) {
Column(Modifier.fillMaxSize().background(C.surface).border(WolfskinTokens.Focus.ringCardRing, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)), verticalArrangement = Arrangement.spacedBy(H.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph("interface", C.accent, Modifier.size(H.seeAllIcon))
Text("See all", color = C.fg, style = T.listTitle)
Text("$total", color = C.muted, style = T.label)
}
}
val HomeRow.cardWidth: Dp get() = when (cardKind) { HomeCardKind.Poster -> K.posterW; HomeCardKind.Tile -> K.tileW; HomeCardKind.Wide -> K.wideW }
val HomeRow.cardHeight: Dp get() = when (cardKind) { HomeCardKind.Poster -> K.posterH; HomeCardKind.Tile -> K.tileH; HomeCardKind.Wide -> K.wideH }

View file

@ -0,0 +1,14 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
/** Focus-follow runs on a new row, never on re-entry to an already-positioned saved viewport. */
class HomeViewMemory {
val ratings = HomeRatingCache()
var focusedKey: String? = null
var positionedKey: String? = null
var resetScroll by mutableIntStateOf(0)
var positionedReset = -1
}

View file

@ -6,6 +6,11 @@ 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.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -82,6 +87,14 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
}
}
}
state.view.savedAt?.let { time ->
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide - L.navAvatar - L.buttonGap, S.navItemTop + (L.navItemHeight - S.settingsPillHeight) / 2)
.height(S.settingsPillHeight).border(WolfskinTokens.Focus.ringCardRing / 2, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill))
.padding(horizontal = S.settingsPillPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsPillGap)) {
Glyph("wifi", C.warn, Modifier.size(S.settingsPillHeight - S.settingsPillGap))
Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption)
}
}
val avatar = boxes.last()
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
Modifier.offset(avatar.left, avatar.top)

View file

@ -27,6 +27,8 @@ class RememberedView {
val listState = LazyListState()
var row by mutableStateOf(0)
val cards = mutableStateMapOf<String, Int>()
var savedAt by mutableStateOf<String?>(null)
val home by lazy { HomeViewMemory() }
}
/** Focus and Back transitions are independent of drawing and server availability. */
@ -54,6 +56,7 @@ class ShellState(
private var pickerReturnRoute = "home"
private val views = mutableStateMapOf<Pair<String, String>, RememberedView>()
private var armedCard: String? = null
var contentInput: ((ShellKey, Boolean, Int) -> Boolean)? = null
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
@ -71,6 +74,8 @@ class ShellState(
/** Repeats are consumed, and a row activation is cancelled when focus moves. */
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = null): Boolean {
if (!pickerVisible && !navFocused && !requestsVisible && route != "settings" &&
contentInput?.invoke(key, down, repeat) == true) return true
if (key == ShellKey.Ok) {
if (repeat > 0) return true
if (card != null) {

View file

@ -102,12 +102,16 @@ private fun RecoveryPicker(state: ShellState) {
/** Recovery actions remain focusable while profiles cannot be read. */
@Composable
fun PickerButton(label: String, focused: Boolean, primary: Boolean, onClick: () -> Unit) {
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
Text(label, color = if (primary) C.onFocus else C.fg, style = WolfskinTokens.Type.button)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) {
if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
Text(label, color = if (primary) C.onFocus else C.fg, style = WolfskinTokens.Type.button)
}
}
}

View file

@ -279,4 +279,59 @@ object WolfskinTokens {
const val navBandOpacity = 0.72f
const val navSolidStop = 0.84f
}
object Home {
val kickerGap = 6.0.dp
val kickerBottom = 5.0.dp
val titleBottom = 8.0.dp
val factsHeight = 19.0.dp
val factsBottom = 7.0.dp
val factsGap = 11.0.dp
val overviewBottom = 15.0.dp
val cardPadding = 11.0.dp
val cardOverlayTop = 27.0.dp
val cardOverlayBottom = 9.0.dp
val sectionTop = 20.0.dp
val sectionAvatar = 28.0.dp
val sectionGap = 11.0.dp
val stateTextBottom = 12.0.dp
val stateTop = 10.0.dp
val statePaddingX = 20.0.dp
val statePaddingY = 17.0.dp
val stateGap = 4.0.dp
val stateIcon = 18.0.dp
val captionWidth = 320.0.dp
val plateTop = 7.0.dp
val plateSide = 8.0.dp
val plateFade = 23.0.dp
val ratingGap = 10.0.dp
val ratingMarkGap = 4.0.dp
val heroSkeletonWidth = 320.0.dp
val heroSkeletonHeight = 42.0.dp
val heroSkeletonFactsWidth = 230.0.dp
val heroSkeletonFactsHeight = 17.0.dp
val heroSkeletonButtonWidth = 130.0.dp
val heroSkeletonGap = 11.0.dp
val heroSkeletonButtonGap = 20.0.dp
val artGlyph = 22.0.dp
val cardSheetLeft = 11.0.dp
val cardSheetTop = 9.0.dp
val cardSheetInset = 18.0.dp
val heroSheetRight = 90.0.dp
val heroSheetTop = 75.0.dp
val cardBlur = 13.0.dp
val heroBlur = 30.0.dp
val progressInset = 9.0.dp
val progressBottom = 5.0.dp
val endTop = 20.0.dp
val frameBottom = 40.0.dp
val seeAllGap = 4.0.dp
val seeAllIcon = 24.0.dp
val toastPadding = 14.0.dp
val toastTop = 75.0.dp
val toastWidth = 380.0.dp
val cardWashInset = 15.0.dp
val heroWashInset = 40.0.dp
val noteTop = 7.0.dp
val noteGap = 8.0.dp
}
}

View file

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="575dp"
android:height="289.83dp"
android:viewportWidth="575"
android:viewportHeight="289.83">
<path
android:pathData="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z"
android:fillColor="#f6c700"/>
<path
android:pathData="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z"
android:fillColor="#000000"/>
<path
android:pathData="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z"
android:fillColor="#000000"/>
<path
android:pathData="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z"
android:fillColor="#000000"/>
<path
android:pathData="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z"
android:fillColor="#000000"/>
</vector>

View file

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="185.04dp"
android:height="133.4dp"
android:viewportWidth="185.04"
android:viewportHeight="133.4">
<path android:pathData="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear" android:startX="0" android:startY="66.7" android:endX="185.04" android:endY="66.7">
<item android:offset="0" android:color="#90cea1"/>
<item android:offset="0.56" android:color="#3cbec9"/>
<item android:offset="1" android:color="#00b3e5"/>
</gradient>
</aapt:attr>
</path>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="138.75dp"
android:height="141.25dp"
android:viewportWidth="138.75"
android:viewportHeight="141.25">
<path
android:pathData="m20.15,40.83c-28.15,27.62 -13.66,61.01 -5.73,71.93 35.25,41.95 92.79,25.34 111.89,-5.91 4.76,-8.2 22.55,-53.47 -23.98,-78.01z"
android:fillColor="#f93208"/>
<path
android:pathData="m39.61,39.26 l4.78,-8.86 28.41,-5.04 11.12,9.21z"
android:fillColor="#f93208"/>
<path
android:pathData="m39.44,8.57 l8.97,-5.28 6.76,15.48c3.79,-6.32 13.79,-16.32 24.94,-4.67 -4.73,1.26 -7.52,3.86 -7.74,8.48 15.15,-4.17 31.34,3.21 33.54,9.09 -10.95,-4.31 -27.69,10.38 -41.77,2.33 0.01,15.05 -12.62,16.64 -19.9,17.08 2.08,-5 5.59,-9.99 1.47,-14.99 -7.62,8.17 -13.87,10.67 -33.17,4.67 4.88,-1.68 14.84,-11.39 24.45,-11.43 -6.78,-2.47 -12.29,-2.09 -17.81,-1.48 2.92,-3.96 12.15,-15.2 28.63,-8.48z"
android:fillColor="#02902e"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="145dp"
android:height="140dp"
android:viewportWidth="145"
android:viewportHeight="140">
<path
android:pathData="M47.4,35.34c-13.61,-7.93 -12.32,-25.2 2.1,-31.88 26.12,-6.53 29.12,13.78 22.65,30.41 -6.54,24.11 18.09,23.66 19.92,10.07 3.61,-18.41 19.39,-26.69 31.67,-16.36 12.6,12.14 7.07,36.58 -17.83,34.19 -16.03,-1.54 -19.55,19.58 0.84,21.18 32.23,1.91 42.49,22.17 31.04,35.87 -15.99,15.15 -37.69,-4.44 -45.51,-19.5 -6.8,-9.31 -17.32,0.11 -13.42,6.5 12.98,19.47 2.92,31.23 -10.91,30.62 -13.37,-0.85 -20.96,-9.06 -13.21,-29.15 3.9,-12.48 -8.6,-15.39 -16.57,-5.45 -11.71,19.61 -28.86,13.68 -33.98,4.19 -3.24,-7.62 -2.92,-25.85 24.12,-23.7 16.69,4.14 11.78,-12.56 -0.63,-13.63 -9.24,-0.44 -30.5,-7.3 -22.86,-24.54 7.34,-11.06 24.96,-11.77 33.35,6.29 3.04,4.23 8.36,11.04 18.04,5.03 3.51,-5.2 1.21,-13.9 -8.81,-20.14z"
android:fillColor="#0fc755"/>
</vector>

View file

@ -0,0 +1,73 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="143.75dp"
android:height="108.75dp"
android:viewportWidth="143.75"
android:viewportHeight="108.75">
<path
android:pathData="m96.641 2.9657c28.149 1.101 27.459 97.814 0.825 97.194l-74.45-9.973c-25.51-7.211-25.922-69.313-0.534-76.178z"
android:fillColor="#07a23b"/>
<path
android:pathData="m85.419 8.8789-63.171 8.9751c-4.2681 3.3648-6.9679 8.2192-7.5687 9.3296l65.017-6.963c1.3226-3.3762 4.3015-9.5395 5.7202-11.342z"
android:fillColor="#fff"/>
<path
android:pathData="m78.042 28.008-65.329 5.5498c-2.494 4.757-3.169 10.65-3.3147 15.17l66.739-1.5147c0.0074-3.2891 0.55003-13.318 1.9052-19.206z"
android:fillColor="#fff"/>
<path
android:pathData="m85.595 94.456-63.251-8.403c-4.2975-3.326-7.0398-8.1557-7.6503-9.2607l65.082 6.3737c1.3522 3.3644 4.3852 9.4999 5.8189 11.29z"
android:fillColor="#fff"/>
<path
android:pathData="m78.051 75.394-65.375-4.957c-2.536-4.734-3.2627-10.621-3.4481-15.14l66.749 0.9101c0.03629 3.2889 0.66694 13.312 2.0737 19.188z"
android:fillColor="#fff"/>
<path
android:pathData="m100.36 10.836c-13.099 0.685-19.878 48.223-11.732 71.195l21.342-4.561c8.39-24.044 1.28-66.986-9.61-66.634z"
android:fillColor="#03621e"/>
<path
android:pathData="m99.087 78.942a6.1255 6.1255 0 0 1 -6.1238 6.1255 6.1255 6.1255 0 0 1 -6.1273 -6.122 6.1255 6.1255 0 0 1 6.1202 -6.1291 6.1255 6.1255 0 0 1 6.1309 6.1184"
android:fillColor="#fdd600"/>
<path
android:pathData="m112.96 75.406a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"
android:fillColor="#fdd600"/>
<path
android:pathData="m120.39 74.743a4.9323 4.9323 0 0 1 -4.9309 4.9323 4.9323 4.9323 0 0 1 -4.9337 -4.9294 4.9323 4.9323 0 0 1 4.928 -4.9352 4.9323 4.9323 0 0 1 4.9366 4.9266"
android:fillColor="#fdd600"/>
<path
android:pathData="m124.1 78.942a3.7391 3.7391 0 0 1 -3.738 3.7391 3.7391 3.7391 0 0 1 -3.7402 -3.7369 3.7391 3.7391 0 0 1 3.7358 -3.7412 3.7391 3.7391 0 0 1 3.7423 3.7347"
android:fillColor="#fdd600"/>
<path
android:pathData="m131.08 83.14a5.0207 5.0207 0 0 1 -5.0192 5.0207 5.0207 5.0207 0 0 1 -5.0222 -5.0178 5.0207 5.0207 0 0 1 5.0163 -5.0236 5.0207 5.0207 0 0 1 5.0251 5.0148"
android:fillColor="#fdd600"/>
<path
android:pathData="m135.86 91.67a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"
android:fillColor="#fdd600"/>
<path
android:pathData="m140.36 97.327a3.9158 3.9158 0 0 1 -3.9147 3.9158 3.9158 3.9158 0 0 1 -3.917 -3.9136 3.9158 3.9158 0 0 1 3.9124 -3.9181 3.9158 3.9158 0 0 1 3.9192 3.9113"
android:fillColor="#fdd600"/>
<path
android:pathData="m140.45 99.625a4.181 4.181 0 0 1 -4.1798 4.181 4.181 4.181 0 0 1 -4.1822 -4.1786 4.181 4.181 0 0 1 4.1773 -4.1834 4.181 4.181 0 0 1 4.1846 4.1761"
android:fillColor="#fdd600"/>
<path
android:pathData="m134.44 100.55a4.8439 4.8439 0 0 1 -4.8425 4.8439 4.8439 4.8439 0 0 1 -4.8453 -4.8411 4.8439 4.8439 0 0 1 4.8397 -4.8467 4.8439 4.8439 0 0 1 4.8481 4.8383"
android:fillColor="#fdd600"/>
<path
android:pathData="m126.84 100.24a4.0042 4.0042 0 0 1 -4.0031 4.0042 4.0042 4.0042 0 0 1 -4.0054 -4.0019 4.0042 4.0042 0 0 1 4.0007 -4.0065 4.0042 4.0042 0 0 1 4.0077 3.9996"
android:fillColor="#fdd600"/>
<path
android:pathData="m125.43 97.636a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"
android:fillColor="#fdd600"/>
<path
android:pathData="m117.12 98.078a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"
android:fillColor="#fdd600"/>
<path
android:pathData="m110.49 97.459a3.6065 3.6065 0 0 1 -3.6054 3.6065 3.6065 3.6065 0 0 1 -3.6075 -3.6044 3.6065 3.6065 0 0 1 3.6033 -3.6086 3.6065 3.6065 0 0 1 3.6096 3.6023"
android:fillColor="#fdd600"/>
<path
android:pathData="m105.72 96.929a4.0484 4.0484 0 0 1 -4.0472 4.0484 4.0484 4.0484 0 0 1 -4.0496 -4.0461 4.0484 4.0484 0 0 1 4.0449 -4.0508 4.0484 4.0484 0 0 1 4.052 4.0437"
android:fillColor="#fdd600"/>
<path
android:pathData="m94.71 80.271c2.1568-1.7217 5.4319-2.8842 9.5881-3.6062l11.579 0.61872 15.203 13.612-2.0329 9.6343-27.047-2.5633-4.7288 1.4584-11.183-17.899z"
android:fillColor="#fdd600"/>
<path
android:pathData="m85.913 71.627c3.2472 12.036 7.0507 22.57 12.64 28.284l-9.9879-1.591s-5.5685-25.456-4.8614-25.544c0.70711-0.08839 2.2097-1.149 2.2097-1.149z"
android:fillColor="#09a339"/>
</vector>

View file

@ -0,0 +1,52 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="106.25dp"
android:height="140dp"
android:viewportWidth="106.25"
android:viewportHeight="140">
<path
android:pathData="M2.727 39.537c-.471-21.981 100.88-25.089 100.88-.42L92.91 117.56c-7.605 26.86-72.064 27.007-79.07.21z"
android:fillColor="#fa3106"/>
<path
android:pathData="M8.809 51.911l9.018 66.639c3.472 4.515 8.498 7.384 9.648 8.022l-6.921-68.576c-3.498-1.41-9.881-4.579-11.745-6.083zM28.629 59.776l5.453 68.898c4.926 2.652 11.04 3.391 15.73 3.566l-1.258-70.366c-3.414-.024-13.82-.642-19.925-2.098zM97.632 52.121l-9.019 66.643c-3.472 4.515-8.498 7.384-9.647 8.022l6.92-68.583c3.5-1.41 9.882-4.579 11.746-6.082zM77.812 59.986l-5.453 68.898c-4.926 2.652-11.04 3.391-15.73 3.566l1.258-70.366c3.414-.024 13.82-.642 19.925-2.098z"
android:fillColor="#fff"/>
<path
android:pathData="M6.397,31.252a6.816,6.816 0 1,0 13.632,0a6.816,6.816 0 1,0 -13.632,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M15.415,27.687a6.607,6.607 0 1,0 13.214,0a6.607,6.607 0 1,0 -13.214,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M24.434,19.769a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M28.943,15.155a6.03,6.03 0 1,0 12.06,0a6.03,6.03 0 1,0 -12.06,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M40.164,17.095a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M44.883,9.597a6.24,6.24 0 1,0 12.48,0a6.24,6.24 0 1,0 -12.48,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M54.636,9.387a6.554,6.554 0 1,0 13.108,0a6.554,6.554 0 1,0 -13.108,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M63.025,13.635a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M70.156,17.672a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M74.561,22.706a4.352,4.352 0 1,0 8.704,0a4.352,4.352 0 1,0 -8.704,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M78.232,26.324a5.243,5.243 0 1,0 10.486,0a5.243,5.243 0 1,0 -10.486,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M82.426,34.398a5.768,5.768 0 1,0 11.536,0a5.768,5.768 0 1,0 -11.536,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M87.355 35.447c5.79 2.799 1.352-2.213 10.696 2.097-9.574 15.338-74.774 16.892-90.291.525l-.21-3.985L38.59 16.99l22.863-6.606 15.52 9.962z"
android:fillColor="#ffd600"/>
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:pathData="M2,12s3.6,-7 10,-7s10,7 10,7s-3.6,7 -10,7S2,12 2,12Z M15,12a3,3 0,1 1,-6 0a3,3 0,1 1,6 0" />
</vector>

View file

@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" android:pathData="M5.5,4H18.5Q21,4 21,6.5V17.5Q21,20 18.5,20H5.5Q3,20 3,17.5V6.5Q3,4 5.5,4Z" />
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="1.6" android:pathData="M7,4V20M17,4V20M3,9H7M3,15H7M17,9H21M17,15H21" />
</vector>

View file

@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round" android:pathData="M2.5,9a14,14 0,0 1,19 0M5.5,12.5a9.5,9.5 0,0 1,13 0M8.5,16a5,5 0,0 1,7 0" />
<path android:fillColor="#FFFFFFFF" android:pathData="M13.3,19.5a1.3,1.3 0,1 1,-2.6 0a1.3,1.3 0,1 1,2.6 0" />
</vector>