Build the Wolfskin Home feed
This commit is contained in:
parent
9be55c570a
commit
0fd0ec035d
683 changed files with 2561 additions and 18 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
wolfskin-design/src/main/res/drawable/ic_rating_imdb.xml
Normal file
22
wolfskin-design/src/main/res/drawable/ic_rating_imdb.xml
Normal 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>
|
||||
16
wolfskin-design/src/main/res/drawable/ic_rating_tmdb.xml
Normal file
16
wolfskin-design/src/main/res/drawable/ic_rating_tmdb.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
73
wolfskin-design/src/main/res/drawable/ic_rt_spilled.xml
Normal file
73
wolfskin-design/src/main/res/drawable/ic_rt_spilled.xml
Normal 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>
|
||||
52
wolfskin-design/src/main/res/drawable/ic_rt_upright.xml
Normal file
52
wolfskin-design/src/main/res/drawable/ic_rt_upright.xml
Normal 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>
|
||||
3
wolfskin-design/src/main/res/drawable/wolfskin_eye.xml
Normal file
3
wolfskin-design/src/main/res/drawable/wolfskin_eye.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: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>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_film.xml
Normal file
4
wolfskin-design/src/main/res/drawable/wolfskin_film.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android: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>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_wifi.xml
Normal file
4
wolfskin-design/src/main/res/drawable/wolfskin_wifi.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android: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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue