Build native title and person surfaces
This commit is contained in:
parent
9be55c570a
commit
0db05174b1
535 changed files with 3088 additions and 17 deletions
|
|
@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.tv.material3.Text
|
||||
|
||||
/** Captions fade without moving the reserved band under the focused poster. */
|
||||
|
|
@ -15,7 +16,7 @@ import androidx.tv.material3.Text
|
|||
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)
|
||||
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(facts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
||||
/** Filmography retains a real credit key and requests more credits before focus reaches the tail. */
|
||||
class PersonState(val person: TitlePerson, initialCredits: List<TitleItem>, private val onAction: (TitleAction) -> Unit, private val onReadMore: () -> Unit = {}) {
|
||||
var credits by mutableStateOf(initialCredits)
|
||||
var index by mutableStateOf(0)
|
||||
private var armed: String? = null
|
||||
|
||||
/** Pointer activation and remote key-up open the same selected credit. */
|
||||
fun click(position: Int) { index = position; onAction(TitleAction.OpenTitle(credits[index])) }
|
||||
|
||||
/** The first grid row exits upward into the existing shell nav. */
|
||||
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
|
||||
if (credits.isEmpty()) { if (down && key == ShellKey.Back) onAction(TitleAction.Pop); return true }
|
||||
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
|
||||
if (key == ShellKey.Ok) {
|
||||
if (repeat > 0) return true
|
||||
if (down) armed = credits[index].id else { if (armed == credits[index].id) click(index); armed = null }
|
||||
return true
|
||||
}
|
||||
if (!down) return true
|
||||
armed = null
|
||||
when (key) {
|
||||
ShellKey.Back -> onAction(TitleAction.Pop)
|
||||
ShellKey.Up -> if (index < columns) shell.focusNav(shell.currentTab) else index -= columns
|
||||
ShellKey.Down -> if (index / columns < credits.lastIndex / columns) index = (index + columns).coerceAtMost(credits.lastIndex)
|
||||
ShellKey.Left -> if (index % columns > 0) index--
|
||||
ShellKey.Right -> if (index % columns < columns - 1 && index < credits.lastIndex) index++
|
||||
else -> Unit
|
||||
}
|
||||
if (index >= credits.size - WolfskinTokens.Behaviour.nextPageCards.toInt()) onReadMore()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Portrait, biography and the full continuous grid use the shell's remembered frame. */
|
||||
@Composable
|
||||
fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } }
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
|
||||
ShellSurface(shell, modifier, handlesContentKeys = false) { PersonPage(state, shell, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val density = LocalDensity.current
|
||||
val list = shell.view.listState
|
||||
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
|
||||
LaunchedEffect(state.index / columns, shell.navFocused) {
|
||||
if (shell.navFocused || state.index < columns) list.animateScrollToItem(0)
|
||||
else list.animateScrollToItem(2 + state.index / columns, -with(density) { L.pinnedLine.roundToPx() })
|
||||
}
|
||||
Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" }
|
||||
.onPreviewKeyEvent { event -> readShellKey(event)?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false }.focusable()) {
|
||||
LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
|
||||
item(key = "person-header", contentType = "header") {
|
||||
Box(Modifier.height(L.row_phead)) {
|
||||
Row(Modifier.offset(L.safeSide, D.personTop), horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
PersonPortrait(state.person, Modifier.size(L.personPortrait).clip(CircleShape), true)
|
||||
Column(Modifier.width(L.contentWidth - L.personPortrait - D.personGap)) {
|
||||
Text(state.person.name, color = C.fg, style = WolfskinTokens.Type.screenTitle, modifier = Modifier.padding(bottom = D.personNameBottom))
|
||||
Text("Acting · ${state.credits.size} credit${if (state.credits.size == 1) "" else "s"} among this household’s titles", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
if (state.person.biography.isNotBlank()) Text(state.person.biography, color = C.dim, style = WolfskinTokens.Type.caption, maxLines = 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "filmography", contentType = "header") {
|
||||
Text("Filmography", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
|
||||
}
|
||||
itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row ->
|
||||
Row(Modifier.height(D.gridHeight).padding(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||
row.forEachIndexed { column, item ->
|
||||
val index = rowIndex * columns + column
|
||||
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), onFocus = { state.index = index }) { state.click(index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "end-padding", contentType = "spacer") { Spacer(Modifier.height(WolfskinTokens.Shell.stageHeight - L.pinnedLine - D.gridHeight)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -31,17 +31,20 @@ import androidx.tv.material3.Text
|
|||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
|
||||
/** Screens and the shell interpret the same Android directional and activation keys. */
|
||||
fun readShellKey(event: KeyEvent): ShellKey? = when (event.key) {
|
||||
Key.DirectionUp -> ShellKey.Up
|
||||
Key.DirectionDown -> ShellKey.Down
|
||||
Key.DirectionLeft -> ShellKey.Left
|
||||
Key.DirectionRight -> ShellKey.Right
|
||||
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok
|
||||
Key.Back, Key.Escape -> ShellKey.Back
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Map Android key events once, preserving repeat count and key-up semantics. */
|
||||
fun ShellState.handleKey(event: KeyEvent): Boolean {
|
||||
val key = when (event.key) {
|
||||
Key.DirectionUp -> ShellKey.Up
|
||||
Key.DirectionDown -> ShellKey.Down
|
||||
Key.DirectionLeft -> ShellKey.Left
|
||||
Key.DirectionRight -> ShellKey.Right
|
||||
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok
|
||||
Key.Back, Key.Escape -> ShellKey.Back
|
||||
else -> return false
|
||||
}
|
||||
val key = readShellKey(event) ?: return false
|
||||
return this.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.allowRgb565
|
||||
import coil3.request.crossfade
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
||||
/** Local decoded fixture art keeps screenshot tests deterministic and completely offline. */
|
||||
val LocalTitleBitmaps = compositionLocalOf<Map<String, ImageBitmap>> { emptyMap() }
|
||||
|
||||
/** Decode at the slot size, with no crossfade or oversized poster allocations. */
|
||||
@Composable
|
||||
fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null) {
|
||||
val bitmap = LocalTitleBitmaps.current[path]
|
||||
if (bitmap != null) Image(bitmap, null, modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
|
||||
else if (path != null) {
|
||||
val context = LocalContext.current
|
||||
val request = remember(context, path, width, height, poster) {
|
||||
ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build()
|
||||
}
|
||||
AsyncImage(model = request, imageLoader = LocalWolfskinImageLoader.current ?: SingletonImageLoader.get(context),
|
||||
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
|
||||
}
|
||||
}
|
||||
|
||||
/** Initials follow the same title-hashed hue as the locked art fallback. */
|
||||
@Composable
|
||||
fun PersonPortrait(person: TitlePerson, modifier: Modifier = Modifier, large: Boolean = false) {
|
||||
val brush = remember(person.name) { initialsBrush(person.name) }
|
||||
Box(modifier.background(brush), contentAlignment = Alignment.Center) {
|
||||
if (person.portrait != null) TitleArtwork(person.portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight, Modifier.fillMaxSize())
|
||||
else Text(person.name.split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2).joinToString("") { it.take(1) }.uppercase(),
|
||||
color = C.fg, style = if (large) D.personInitialSize else D.initialSize, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
/** The original gradient is parsed once so stops stay derived from the canonical token. */
|
||||
@Composable
|
||||
fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
|
||||
val horizontal = remember {
|
||||
val stops = Regex("rgb\\(7 7 10 / ([.\\d]+)\\) (\\d+)%").findAll(WolfskinTokens.Gradient.detailScrim).take(3)
|
||||
.map { it.groupValues[2].toFloat() / 100 to C.bg.copy(alpha = it.groupValues[1].toFloat()) }.toList()
|
||||
Brush.horizontalGradient(*stops.toTypedArray())
|
||||
}
|
||||
val vertical = remember {
|
||||
val token = WolfskinTokens.Gradient.detailScrim.substringAfter("linear-gradient(0deg,")
|
||||
val stops = mutableListOf(1f to C.bg)
|
||||
Regex("rgb\\(7 7 10 / ([.\\d]+)\\) (\\d+)%").findAll(token).forEach {
|
||||
stops.add(1f - it.groupValues[2].toFloat() / 100 to C.bg.copy(alpha = it.groupValues[1].toFloat()))
|
||||
}
|
||||
Brush.verticalGradient(*stops.sortedBy { it.first }.toTypedArray())
|
||||
}
|
||||
Box(modifier.titleAmbientBackground()) {
|
||||
if (item.backdrop != null && !item.isPosterBackdrop) {
|
||||
TitleArtwork(item.backdrop, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight, Modifier.fillMaxSize())
|
||||
} else if (item.poster != null) {
|
||||
val filter = remember {
|
||||
val token = WolfskinTokens.Gradient.heroAmbientFilter
|
||||
Regex("blur\\(([\\d.]+)px\\).*saturate\\(([\\d.]+)\\).*brightness\\(([\\d.]+)\\)").find(token)!!.groupValues
|
||||
}
|
||||
val colorFilter = remember {
|
||||
val matrix = ColorMatrix().apply { setToSaturation(filter[2].toFloat()) }
|
||||
for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat()
|
||||
ColorFilter.colorMatrix(matrix)
|
||||
}
|
||||
TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight, Modifier.fillMaxSize().blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter)
|
||||
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
|
||||
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
|
||||
.size(L.heroSheetWidth, L.heroSheetHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)))
|
||||
}
|
||||
Box(Modifier.fillMaxSize().background(horizontal))
|
||||
Box(Modifier.fillMaxSize().background(vertical))
|
||||
}
|
||||
}
|
||||
|
||||
private fun initialsBrush(name: String): Brush {
|
||||
var hue = 0
|
||||
name.forEach { hue = (hue * 31 + it.code) % 360 }
|
||||
return Brush.linearGradient(listOf(Color.hsl(hue.toFloat(), .24f, .30f), Color.hsl(hue.toFloat(), .26f, .14f)))
|
||||
}
|
||||
|
||||
private fun Modifier.titleAmbientBackground(): Modifier = drawWithCache {
|
||||
val values = Regex("([\\d.]+)%").findAll(D.heroAmbientBackground).map { it.groupValues[1].toFloat() / 100 }.toList()
|
||||
val shader = android.graphics.RadialGradient(0f, 0f, 1f, intArrayOf(C.surface3.toArgb(), C.bg.toArgb()), null, android.graphics.Shader.TileMode.CLAMP)
|
||||
shader.setLocalMatrix(android.graphics.Matrix().apply {
|
||||
setScale(size.width * values[0], size.height * values[1])
|
||||
postTranslate(size.width * values[2], size.height * values[3])
|
||||
})
|
||||
val brush = ShaderBrush(shader)
|
||||
onDrawBehind { drawRect(brush) }
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
||||
/** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */
|
||||
@Composable
|
||||
fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
|
||||
val focusOwner = LocalTitleFocusOwner.current
|
||||
val background = if (option.primary) C.fg else if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary
|
||||
val foreground = if (option.primary) C.onFocus else C.fg
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
|
||||
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
|
||||
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
|
||||
.background(background, shape)
|
||||
.then(if (option.on) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
|
||||
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (option.glyph.isNotEmpty()) TitleGlyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))
|
||||
if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Title glyphs are the locked prototype paths, independent of platform icon substitutions. */
|
||||
@Composable
|
||||
fun TitleGlyph(name: String, color: androidx.compose.ui.graphics.Color, modifier: Modifier = Modifier) {
|
||||
val resource = when (name) {
|
||||
"play" -> R.drawable.title_play
|
||||
"restart" -> R.drawable.title_restart
|
||||
"version" -> R.drawable.title_version
|
||||
"tracks" -> R.drawable.title_tracks
|
||||
"eyeoff" -> R.drawable.title_eyeoff
|
||||
"eye" -> R.drawable.title_eye
|
||||
"trailer" -> R.drawable.title_trailer
|
||||
"more" -> R.drawable.title_more
|
||||
"plus" -> R.drawable.title_plus
|
||||
"clock" -> R.drawable.title_clock
|
||||
"users" -> R.drawable.title_users
|
||||
"tv" -> R.drawable.title_tv
|
||||
"wifi" -> R.drawable.title_wifi
|
||||
"check" -> R.drawable.title_check
|
||||
"sparkle" -> R.drawable.title_sparkle
|
||||
"undo" -> R.drawable.title_undo
|
||||
else -> R.drawable.title_left
|
||||
}
|
||||
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)
|
||||
}
|
||||
|
||||
/** A radio remains visibly selected independently of the inverse focused row. */
|
||||
@Composable
|
||||
fun TitleRadio(selected: Boolean, focused: Boolean) {
|
||||
val color = if (focused) C.onFocus else C.fg
|
||||
Box(Modifier.size(D.radioSize).background(if (selected) C.accent else androidx.compose.ui.graphics.Color.Transparent, CircleShape)
|
||||
.then(if (!selected) Modifier.border(D.radioBorder, color.copy(alpha = .35f), CircleShape) else Modifier), contentAlignment = Alignment.Center) {
|
||||
if (selected) Box(Modifier.size(D.radioDot).background(C.onFocus, CircleShape))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
||||
/** Film, series and episode share one copy block and one fixed options-caption band. */
|
||||
@Composable
|
||||
fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) {
|
||||
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
|
||||
val item = state.data.item
|
||||
val type = if (item.kind == "movie") "Movie" else "Series"
|
||||
Column(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
|
||||
Row(Modifier.padding(bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon))
|
||||
Text(buildString {
|
||||
append("$type · ${if (item.held) "In your library" else "Not in your library"}")
|
||||
if (item.held && state.watched) append(" · Watched")
|
||||
if (!item.held && item.otherOwners.isNotEmpty()) append(" · In ${item.otherOwners.joinToString(" and ")}’s library")
|
||||
if (state.data.hidden) append(" · Hidden from your picks")
|
||||
}, color = C.accentText, style = D.dLine)
|
||||
}
|
||||
Text(item.name, color = C.fg, style = WolfskinTokens.Type.displayDetail, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(bottom = D.titleBottom))
|
||||
Row(Modifier.heightIn(min = L.iconCaptionLine + D.factsBottom).padding(bottom = D.factsBottom), horizontalArrangement = Arrangement.spacedBy(D.factsGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
item.year?.let { Text(it.toString(), color = C.muted, style = WolfskinTokens.Type.body) }
|
||||
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = WolfskinTokens.Type.body)
|
||||
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings)
|
||||
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = WolfskinTokens.Type.body)
|
||||
}
|
||||
state.data.requestedStage?.let { stage ->
|
||||
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
|
||||
Row(Modifier.padding(bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph(if (stage == "Ready") "check" else "clock", color, Modifier.size(D.lineIcon))
|
||||
Text(stage, color = color, style = WolfskinTokens.Type.label)
|
||||
Text("for you · by ${state.data.requestActor.orEmpty()}", color = C.muted, style = D.dLine)
|
||||
}
|
||||
}
|
||||
Text(item.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(bottom = D.overviewGap))
|
||||
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
|
||||
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
||||
color = C.warn, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.overviewGap))
|
||||
}
|
||||
state.data.playTarget?.let { target ->
|
||||
Row(Modifier.offset(y = D.progressLift).padding(bottom = D.progressBottom), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (target.resumeSeconds > 0) {
|
||||
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
|
||||
val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f
|
||||
Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(C.accent))
|
||||
}
|
||||
Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = WolfskinTokens.Type.label)
|
||||
} else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = WolfskinTokens.Type.label)
|
||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = WolfskinTokens.Type.label)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index, onFocus = { state.focus("header", index) }) { state.click("header", index) } }
|
||||
}
|
||||
if (state.options.any { it.icon }) {
|
||||
val option = state.options.getOrNull(state.column)?.takeIf { active && it.icon }
|
||||
Text(buildAnnotatedString {
|
||||
if (option != null) {
|
||||
withStyle(SpanStyle(color = C.fg, fontWeight = WolfskinTokens.Type.label.fontWeight)) { append(option.label) }
|
||||
if (option.note.isNotEmpty()) append(" · ${option.note}")
|
||||
}
|
||||
}, color = C.muted, style = D.iconCaption, modifier = Modifier.padding(top = D.captionTop).height(L.iconCaptionLine))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A no-copy load has no focusable content and never draws a spinner. */
|
||||
@Composable
|
||||
fun TitleHeaderSkeleton(modifier: Modifier = Modifier) {
|
||||
Column(modifier.offset(L.safeSide, L.detailCopyTop), verticalArrangement = Arrangement.spacedBy(D.factsBottom)) {
|
||||
Box(Modifier.size(D.skeletonKickerWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
|
||||
Box(Modifier.size(D.skeletonTitleWidth, D.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||
Box(Modifier.size(D.skeletonFactsWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
|
||||
Box(Modifier.size(D.skeletonOverviewWidth, D.skeletonOverviewHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
Box(Modifier.size(D.skeletonPlayWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button)))
|
||||
repeat(2) { Box(Modifier.size(L.iconButton).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/** Screen data is independent of Jellyfin and Conductarr wire objects. */
|
||||
@Immutable
|
||||
data class TitleItem(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val kind: String,
|
||||
val year: Int? = null,
|
||||
val runtimeSeconds: Long = 0,
|
||||
val seasons: Int = 0,
|
||||
val overview: String = "",
|
||||
val genres: List<String> = emptyList(),
|
||||
val poster: String? = null,
|
||||
val backdrop: String? = null,
|
||||
val held: Boolean = false,
|
||||
val watched: Boolean = false,
|
||||
val resumeSeconds: Long = 0,
|
||||
val seriesId: String? = null,
|
||||
val seriesName: String? = null,
|
||||
val season: Int = 0,
|
||||
val episode: Int = 0,
|
||||
val ratings: List<TitleRating> = emptyList(),
|
||||
val character: String? = null,
|
||||
val otherOwners: List<String> = emptyList(),
|
||||
val isPosterBackdrop: Boolean = false,
|
||||
) {
|
||||
val episodeLabel: String get() = "S$season E$episode"
|
||||
val runtime: String get() {
|
||||
val minutes = (runtimeSeconds + 30) / 60
|
||||
return if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m"
|
||||
}
|
||||
}
|
||||
|
||||
/** A rating is supplied only after sign-in and only when its source observed a valid score. */
|
||||
@Immutable
|
||||
data class TitleRating(val source: String, val score: Double, val mark: String, val episode: Boolean = false)
|
||||
|
||||
/** A cast identity can open a performer page without borrowing a title's identity. */
|
||||
@Immutable
|
||||
data class TitlePerson(val id: String, val name: String, val character: String = "", val portrait: String? = null, val biography: String = "")
|
||||
|
||||
/** Stream indexes remain the server's indexes, including non-contiguous track lists. */
|
||||
@Immutable
|
||||
data class TitleTrack(val index: Int, val label: String, val isDefault: Boolean = false)
|
||||
|
||||
/** Playback options describe the actual play target, not a season or recommendation stub. */
|
||||
@Immutable
|
||||
data class TitleVersion(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val video: String,
|
||||
val bitrate: String,
|
||||
val size: String,
|
||||
val container: String,
|
||||
val audio: List<TitleTrack>,
|
||||
val subtitles: List<TitleTrack>,
|
||||
)
|
||||
|
||||
enum class TitleReadStatus { Ready, Loading, Unavailable }
|
||||
enum class TitleConnection { SignedIn, SignedOut, Offline }
|
||||
enum class TitleSheet { Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt }
|
||||
|
||||
/** A title read includes related titles and the selected season; reads happen outside composition. */
|
||||
@Immutable
|
||||
data class TitlePageData(
|
||||
val item: TitleItem,
|
||||
val playTarget: TitleItem? = null,
|
||||
val watchTarget: TitleItem = item,
|
||||
val versions: List<TitleVersion> = emptyList(),
|
||||
val selectedVersion: String? = null,
|
||||
val selectedAudio: Int? = null,
|
||||
val selectedSubtitle: Int = -1,
|
||||
val trailerUrl: String? = null,
|
||||
val cast: List<TitlePerson> = emptyList(),
|
||||
val castStatus: TitleReadStatus = TitleReadStatus.Ready,
|
||||
val related: List<TitleItem> = emptyList(),
|
||||
val episodes: List<TitleItem> = emptyList(),
|
||||
val status: TitleReadStatus = TitleReadStatus.Ready,
|
||||
val connection: TitleConnection = TitleConnection.SignedOut,
|
||||
val requestedStage: String? = null,
|
||||
val requestActor: String? = null,
|
||||
val hidden: Boolean = false,
|
||||
val pick: Boolean = false,
|
||||
val savedAt: String? = null,
|
||||
val seasonNumbers: List<Int> = List(item.seasons) { it + 1 },
|
||||
)
|
||||
|
||||
/** Effects are handled by the app's authenticated facade and navigation layer. */
|
||||
sealed interface TitleAction {
|
||||
data class Play(val item: TitleItem, val restart: Boolean = false) : TitleAction
|
||||
data class Version(val item: TitleItem, val version: TitleVersion) : TitleAction
|
||||
data class Track(val item: TitleItem, val version: TitleVersion, val audio: Int?, val subtitle: Int) : TitleAction
|
||||
data class Watched(val item: TitleItem, val watched: Boolean) : TitleAction
|
||||
data class Trailer(val url: String) : TitleAction
|
||||
data class Person(val person: TitlePerson) : TitleAction
|
||||
data class OpenTitle(val item: TitleItem) : TitleAction
|
||||
data class Season(val number: Int) : TitleAction
|
||||
data class Request(val item: TitleItem, val forSomeoneElse: Boolean) : TitleAction
|
||||
data class Hide(val item: TitleItem, val hidden: Boolean) : TitleAction
|
||||
data class Requests(val item: TitleItem) : TitleAction
|
||||
data object Retry : TitleAction
|
||||
data object Pop : TitleAction
|
||||
}
|
||||
|
||||
/** The options row distinguishes compact icons from labelled primary and secondary pills. */
|
||||
@Immutable
|
||||
data class TitleOption(val id: String, val label: String, val glyph: String, val note: String = "", val primary: Boolean = false, val icon: Boolean = true, val on: Boolean = false)
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
||||
/** Sheets sit above navigation and consume pointer and key input across the entire scrim. */
|
||||
@Composable
|
||||
fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(state.sheet) { focus.requestFocus() }
|
||||
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable { }
|
||||
.testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" }
|
||||
.focusRequester(focus).onPreviewKeyEvent { event ->
|
||||
val key = readShellKey(event)
|
||||
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||
}.focusable()) {
|
||||
Column(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().background(C.surface)
|
||||
.verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
|
||||
verticalArrangement = Arrangement.spacedBy(if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap)) {
|
||||
when (state.sheet) {
|
||||
TitleSheet.Version -> VersionChoices(state)
|
||||
TitleSheet.Tracks -> TrackChoices(state)
|
||||
TitleSheet.MediaInfo -> MediaInformation(state)
|
||||
TitleSheet.ConfirmWatched -> WatchedConfirmation(state)
|
||||
TitleSheet.HiddenReceipt -> HiddenReceipt(state)
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VersionChoices(state: TitleState) {
|
||||
Text("Version", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
|
||||
SheetSubtitle("${state.target?.name ?: state.data.item.name} · ${state.data.versions.size} files")
|
||||
state.data.versions.forEachIndexed { index, version ->
|
||||
PlaybackChoice(version.name, listOf(version.video, version.bitrate, version.size).filter { it.isNotBlank() }.joinToString(" · "), version == state.version, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
|
||||
}
|
||||
Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = WolfskinTokens.Type.caption)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackChoices(state: TitleState) {
|
||||
val version = state.version ?: return
|
||||
Text("Audio and subtitles", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
|
||||
SheetSubtitle("${state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name} · ${version.name}")
|
||||
Text("Audio", color = C.dim, style = WolfskinTokens.Type.label)
|
||||
version.audio.forEachIndexed { index, track ->
|
||||
PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
|
||||
}
|
||||
Text("Subtitles", color = C.dim, style = WolfskinTokens.Type.label)
|
||||
val start = version.audio.size
|
||||
PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) }
|
||||
version.subtitles.forEachIndexed { index, track ->
|
||||
val position = start + index + 1
|
||||
PlaybackChoice(track.label, null, track.index == state.subtitleIndex, state.sheetIndex == position, { state.sheetIndex = position }) { state.clickSheet(position) }
|
||||
}
|
||||
val done = start + version.subtitles.size + 1
|
||||
TitleControl(TitleOption("done", "Done", "", primary = true, icon = false), state.sheetIndex == done, Modifier.padding(top = D.trackGap), onFocus = { state.sheetIndex = done }) { state.clickSheet(done) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focused: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
|
||||
val foreground = if (focused) C.onFocus else C.fg
|
||||
val sizing = if (why == null) Modifier.height(D.trackHeight) else Modifier.padding(vertical = D.versionRowVertical)
|
||||
Row(Modifier.fillMaxWidth().titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
||||
.clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(label, color = foreground, style = WolfskinTokens.Type.listTitle)
|
||||
if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = WolfskinTokens.Type.caption)
|
||||
}
|
||||
TitleRadio(selected, focused)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaInformation(state: TitleState) {
|
||||
val version = state.version ?: return
|
||||
Text("Media info", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
|
||||
SheetSubtitle(state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name)
|
||||
MediaInfoSection("General", listOf("Container" to version.container, "Size" to version.size.ifEmpty { "not known" }, "Bitrate" to version.bitrate, "Runtime" to (state.target?.runtime ?: "not known")))
|
||||
MediaInfoSection("Video", listOf("Stream" to version.video))
|
||||
MediaInfoSection("Audio", version.audio.mapIndexed { index, track -> "Track ${index + 1}" to (track.label + if (track.isDefault) " (default)" else "") })
|
||||
MediaInfoSection("Subtitles", version.subtitles.mapIndexed { index, track -> "Track ${index + 1}" to track.label })
|
||||
TitleControl(TitleOption("close", "Close", "", primary = true, icon = false), true, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaInfoSection(label: String, values: List<Pair<String, String>>) {
|
||||
Text(label, color = C.dim, style = WolfskinTokens.Type.label)
|
||||
Column { values.forEach { (name, value) ->
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(name, color = C.dim, style = WolfskinTokens.Type.caption, modifier = Modifier.width(D.infoLabelWidth))
|
||||
Text(value, color = C.fg, style = WolfskinTokens.Type.caption)
|
||||
}
|
||||
} }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchedConfirmation(state: TitleState) {
|
||||
val verb = if (state.watched) "unwatched" else "watched"
|
||||
Text("Mark the whole series as $verb?", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
|
||||
SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
|
||||
TitleControl(TitleOption("cancel", "Cancel", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }) { state.clickSheet(1) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetSubtitle(text: String) {
|
||||
Text(text, color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.sheetSubBottom))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HiddenReceipt(state: TitleState) {
|
||||
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.receiptSide, vertical = D.receiptTop),
|
||||
verticalArrangement = Arrangement.spacedBy(D.receiptGap)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph("check", C.fg, Modifier.size(D.receiptIcon))
|
||||
Text("Hidden from your picks", color = C.fg, style = D.receiptTitle)
|
||||
}
|
||||
Text("${state.data.item.name} leaves every row of ${state.person.name}’s picks. It waits under Hidden picks at the end of Discover.", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
TitleControl(TitleOption("undo", "Undo", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
|
||||
TitleControl(TitleOption("done", "Done", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }) { state.clickSheet(1) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
||||
/** The shared shell remains authoritative; title sheets overlay it and trap all input. */
|
||||
@Composable
|
||||
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
LaunchedEffect(state.toast) {
|
||||
if (state.toast != null) { delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null }
|
||||
}
|
||||
Box(modifier.fillMaxSize()) {
|
||||
val focusOwner = remember(shell) { { shell.navFocused = false } }
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
||||
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
||||
}
|
||||
if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell)
|
||||
if (!shell.pickerVisible) state.toast?.let { message ->
|
||||
Row(Modifier.align(Alignment.TopEnd).padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth)
|
||||
.background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph("wifi", C.warn, Modifier.size(L.buttonIcon))
|
||||
Text(message, color = C.fg, style = WolfskinTokens.Type.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One keyed vertical frame renders the exact header, season, cast and related order. */
|
||||
@Composable
|
||||
fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val density = LocalDensity.current
|
||||
val list = shell.view.listState
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
|
||||
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
||||
}
|
||||
val displayedRows = remember(state.data) {
|
||||
buildList {
|
||||
add("header")
|
||||
if (state.data.item.held && state.data.item.kind == "tv" && state.data.item.seasons > 0) {
|
||||
add("seasons")
|
||||
if (state.data.episodes.isNotEmpty()) add("episodes")
|
||||
}
|
||||
if (state.data.castStatus == TitleReadStatus.Loading || state.data.cast.isNotEmpty()) add("cast")
|
||||
if (state.data.related.isNotEmpty()) add("related")
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.row, shell.navFocused, state.data.status) {
|
||||
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
|
||||
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
|
||||
val y = displayedRows.take(index).fold(WolfskinTokens.Shell.zero) { total, row -> total + titleRowHeight(row) }
|
||||
val height = titleRowHeight(state.row)
|
||||
if (index == 0 || (y < L.windowAhead && y + height <= WolfskinTokens.Shell.stageHeight - L.safeTopPicker)) list.animateScrollToItem(0)
|
||||
else list.animateScrollToItem(index, -with(density) { L.pinnedLine.roundToPx() })
|
||||
}
|
||||
val dim by remember { derivedStateOf {
|
||||
val before = displayedRows.take(list.firstVisibleItemIndex).sumOf { with(density) { titleRowHeight(it).toPx().toDouble() } }
|
||||
((before + list.firstVisibleItemScrollOffset) / D.dimScrollPx).toFloat().coerceAtMost(D.dimMaximum)
|
||||
} }
|
||||
Box(modifier.focusRequester(focus).testTag("title-page").semantics { stateDescription = "${state.data.item.id}:${state.row}:${state.column}:${state.sheet}" }
|
||||
.onPreviewKeyEvent { event ->
|
||||
val key = readShellKey(event)
|
||||
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||
}.focusable()) {
|
||||
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().background(C.bg.copy(alpha = dim)))
|
||||
if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state)
|
||||
else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
|
||||
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
|
||||
val active = state.row == id && !shell.navFocused && state.sheet == null
|
||||
when (id) {
|
||||
"header" -> Box(Modifier.height(L.row_dhead)) { TitleHeader(state, active) }
|
||||
"seasons" -> TitleSeasons(state, active)
|
||||
"episodes" -> TitleMediaShelf(state, true, active)
|
||||
"cast" -> TitleCast(state, active)
|
||||
"related" -> TitleMediaShelf(state, false, active)
|
||||
}
|
||||
} }
|
||||
item(key = "end-padding", contentType = "spacer") {
|
||||
Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last())).coerceAtLeast(D.minimumEndPadding)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun titleRowHeight(row: String): Dp = when (row) {
|
||||
"header" -> L.row_dhead
|
||||
"seasons" -> L.row_chips
|
||||
"episodes" -> L.rowHeadingBand + WolfskinTokens.Card.tileH + L.rowGapAfter
|
||||
"cast" -> D.castHeight
|
||||
else -> L.rowHeadingBand + WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + L.rowGapAfter
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TitleSeasons(state: TitleState, active: Boolean) {
|
||||
val focusOwner = LocalTitleFocusOwner.current
|
||||
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
|
||||
items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index ->
|
||||
val number = state.data.seasonNumbers[index]
|
||||
val focused = active && state.column == index
|
||||
val selected = state.season == number
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.chip)
|
||||
Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape)
|
||||
.then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier)
|
||||
.clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
|
||||
Text("Season $number", color = if (focused) C.onFocus else if (selected) C.accentText else C.fg, style = WolfskinTokens.Type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TitleReadError(state: TitleState) {
|
||||
Column(Modifier.offset(L.safeSide, L.navBand).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(D.overviewGap), verticalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
Text("This title can’t be read", color = C.warn, style = WolfskinTokens.Type.stateTitle)
|
||||
Text("Conductarr didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }) { state.click("header", index) } } }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
|
||||
/** Pointing at owned content exits nav focus through the same shell state as a remote Down. */
|
||||
val LocalTitleFocusOwner = compositionLocalOf<(() -> Unit)?> { null }
|
||||
|
||||
/** Hover changes logical focus without activating a button or arming an OK press. */
|
||||
@Composable
|
||||
fun Modifier.titlePointerFocus(onFocus: (() -> Unit)?): Modifier {
|
||||
if (onFocus == null) return this
|
||||
val latest by rememberUpdatedState(onFocus)
|
||||
val owner = LocalTitleFocusOwner.current
|
||||
return pointerInput(owner) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
if (event.type == PointerEventType.Enter) { owner?.invoke(); latest?.invoke() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.tv.material3.Text
|
||||
import java.util.Locale
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
||||
/** Rating formatting follows the TV locale; Rotten Tomatoes remains an integer percentage. */
|
||||
fun formatTitleRating(rating: TitleRating, locale: Locale = Locale.getDefault()): String =
|
||||
if (rating.source.startsWith("rt")) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score)
|
||||
|
||||
/** A full facts-line set never shrinks or waits for focused-card dwell. */
|
||||
@Composable
|
||||
fun TitleRatings(ratings: List<TitleRating>, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.button) {
|
||||
val density = LocalDensity.current
|
||||
val markHeight = with(density) { style.fontSize.toDp() }
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(D.ratingGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
ratings.forEach { rating ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(D.ratingMarkGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (rating.episode) Text("Ep", color = C.muted, style = style)
|
||||
val resource = when (rating.mark) {
|
||||
"rotten" -> R.drawable.ic_rotten_tomatoes_rotten
|
||||
"upright" -> R.drawable.ic_rt_upright
|
||||
"spilled" -> R.drawable.ic_rt_spilled
|
||||
"imdb" -> R.drawable.ic_rating_imdb
|
||||
"tmdb" -> R.drawable.ic_rating_tmdb
|
||||
else -> R.drawable.ic_rotten_tomatoes_fresh
|
||||
}
|
||||
Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") 1.8f else 1f, markHeight),
|
||||
alpha = if (rating.mark == "tomato") .55f else 1f,
|
||||
colorFilter = if (rating.mark == "tomato") ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
|
||||
Text(formatTitleRating(rating), color = C.fg, style = style.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold), maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The plate keeps a prefix of the title set, shrinking to the caption floor before dropping its tail. */
|
||||
@Composable
|
||||
fun TitleRatingPlate(ratings: List<TitleRating>, modifier: Modifier = Modifier) {
|
||||
if (ratings.isEmpty()) return
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
BoxWithConstraints(modifier.background(Brush.verticalGradient(listOf(C.bg.copy(alpha = .9f), C.bg.copy(alpha = .72f), C.bg.copy(alpha = 0f))))
|
||||
.padding(start = D.plateSide, end = D.plateSide, top = D.plateTop, bottom = D.plateFade)) {
|
||||
val width = with(density) { maxWidth.toPx() }
|
||||
val fit = remember(ratings, width, density, Locale.getDefault()) {
|
||||
var size = WolfskinTokens.Type.ratingPlate.fontSize.value
|
||||
var count = ratings.size
|
||||
fun measured(): Float {
|
||||
val text = ratings.take(count).joinToString("") { (if (it.episode) "Ep " else "") + formatTitleRating(it) }
|
||||
val textWidth = measurer.measure(text, WolfskinTokens.Type.button.copy(fontSize = TextUnit(size, TextUnitType.Sp))).size.width
|
||||
val marks = ratings.take(count).sumOf { if (it.source == "imdb" || it.source == "tmdb") 1.8 else 1.0 }.toFloat()
|
||||
return textWidth + with(density) { TextUnit(size * marks, TextUnitType.Sp).toPx() + D.ratingMarkGap.toPx() * count + D.ratingGap.toPx() * (count - 1) }
|
||||
}
|
||||
while (size > WolfskinTokens.Type.caption.fontSize.value && measured() > width) size = (size - .25f).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value)
|
||||
while (count > 1 && measured() > width) count--
|
||||
count to size
|
||||
}
|
||||
TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.button.copy(fontSize = TextUnit(fit.second, TextUnitType.Sp)))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
||||
/** Cast occupies the first shelf after films, and follows season episodes on series. */
|
||||
@Composable
|
||||
fun TitleCast(state: TitleState, active: Boolean) {
|
||||
val track = rememberLazyListState()
|
||||
val focusOwner = LocalTitleFocusOwner.current
|
||||
LaunchedEffect(state.column, active) { if (active) track.animateScrollToItem((state.column - 5).coerceAtLeast(0)) }
|
||||
Column(Modifier.height(D.castHeight)) {
|
||||
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
||||
if (state.data.castStatus == TitleReadStatus.Loading) {
|
||||
items(7, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||
Box(Modifier.size(L.castCircle).background(C.surface, CircleShape))
|
||||
}
|
||||
} else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person ->
|
||||
val focused = active && state.column == index
|
||||
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f)
|
||||
.titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) {
|
||||
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).clip(CircleShape))
|
||||
Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(person.name, color = C.fg, style = D.castName, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
|
||||
Text(person.character, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Episode and related shelves are lazy, keyed and keep their focused card above adjacent art. */
|
||||
@Composable
|
||||
fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
||||
val titles = if (episodes) state.data.episodes else state.data.related
|
||||
val id = if (episodes) "episodes" else "related"
|
||||
val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
|
||||
val height = if (episodes) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
|
||||
val track = rememberLazyListState()
|
||||
val visible = if (episodes) 4 else 6
|
||||
LaunchedEffect(state.column, active) { if (active) track.animateScrollToItem((state.column - (visible - 2)).coerceAtLeast(0)) }
|
||||
Column(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
|
||||
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||
itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
|
||||
TitlePoster(item, active && state.column == index, Modifier.width(width), episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Posters draw art at rest, with facts only in the reserved focused-caption band. */
|
||||
@Composable
|
||||
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
|
||||
val focusOwner = LocalTitleFocusOwner.current
|
||||
val width = if (tile) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
|
||||
val height = if (tile) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
|
||||
Column(modifier.zIndex(if (focused) 1f else 0f)) {
|
||||
Box(Modifier.size(width, height).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
|
||||
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
|
||||
TitleArtwork(if (tile) item.backdrop ?: item.poster else item.poster, (width.value * 2).toInt(), (height.value * 2).toInt(), Modifier.fillMaxSize(), !tile)
|
||||
if (item.poster == null && item.backdrop == null) Text(item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 4, modifier = Modifier.padding(D.lineGap))
|
||||
if (tile) {
|
||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg))).padding(D.lineGap)) {
|
||||
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(if (item.kind == "episode") "${item.episodeLabel} · ${item.name}" else "${if (item.kind == "movie") "Movie" else "Series"} · ${item.year ?: ""}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (item.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) {
|
||||
TitleGlyph("check", C.onFocus, Modifier.size(L.buttonIcon))
|
||||
}
|
||||
if (focused && ratingsVisible) TitleRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
||||
}
|
||||
if (!tile) {
|
||||
Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) {
|
||||
Caption(item.name, listOfNotNull(item.character?.let { "as $it" }, item.year?.toString(), if (item.kind == "movie") item.runtime else if (item.seasons > 0) "${item.seasons} seasons" else "Series", if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }).joinToString(" · "), focused,
|
||||
Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/** One focus controller drives the drawn screen and Android key-event replays. */
|
||||
class TitleState(
|
||||
initial: TitlePageData,
|
||||
val person: ShellPerson,
|
||||
private val onAction: (TitleAction) -> Unit,
|
||||
) {
|
||||
var data by mutableStateOf(initial)
|
||||
private set
|
||||
var row by mutableStateOf("header")
|
||||
var column by mutableStateOf(0)
|
||||
val columns = mutableStateMapOf<String, Int>()
|
||||
var sheet by mutableStateOf<TitleSheet?>(null)
|
||||
private set
|
||||
var sheetIndex by mutableStateOf(0)
|
||||
var season by mutableStateOf(initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
|
||||
var versionId by mutableStateOf(initial.selectedVersion)
|
||||
var audioIndex by mutableStateOf(initial.selectedAudio)
|
||||
var subtitleIndex by mutableStateOf(initial.selectedSubtitle)
|
||||
var watched by mutableStateOf(initial.watchTarget.watched)
|
||||
var toast by mutableStateOf<String?>(null)
|
||||
private var armedCard: String? = null
|
||||
private var okHeld = false
|
||||
val target: TitleItem? get() = data.playTarget ?: data.item.takeIf { it.kind == "movie" && it.held }
|
||||
val version: TitleVersion? get() = data.versions.firstOrNull { it.id == versionId } ?: data.versions.firstOrNull()
|
||||
val audio: TitleTrack? get() = version?.audio?.firstOrNull { it.index == audioIndex } ?: version?.audio?.firstOrNull { it.isDefault } ?: version?.audio?.firstOrNull()
|
||||
val subtitle: TitleTrack? get() = version?.subtitles?.firstOrNull { it.index == subtitleIndex }
|
||||
val rows: List<String> by derivedStateOf { buildList {
|
||||
add("header")
|
||||
if (data.status != TitleReadStatus.Ready) return@buildList
|
||||
if (data.item.held && data.item.kind == "tv" && data.item.seasons > 0) {
|
||||
add("seasons")
|
||||
if (data.episodes.isNotEmpty()) add("episodes")
|
||||
}
|
||||
if (data.castStatus == TitleReadStatus.Ready && data.cast.isNotEmpty()) add("cast")
|
||||
if (data.related.isNotEmpty()) add("related")
|
||||
} }
|
||||
val options: List<TitleOption> by derivedStateOf { titleOptions(this) }
|
||||
|
||||
/** Refresh keeps the selected row and file when they still exist. */
|
||||
fun update(value: TitlePageData) {
|
||||
val wasLoading = data.status == TitleReadStatus.Loading
|
||||
data = value
|
||||
if (wasLoading) season = value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
|
||||
watched = value.watchTarget.watched
|
||||
versionId = value.selectedVersion
|
||||
audioIndex = value.selectedAudio
|
||||
subtitleIndex = value.selectedSubtitle
|
||||
if (row !in rows) { row = "header"; column = 0 }
|
||||
column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
/** Pointer focus and D-pad use the same row memory. */
|
||||
fun focus(targetRow: String, targetColumn: Int) {
|
||||
columns[row] = column
|
||||
row = targetRow
|
||||
column = targetColumn
|
||||
columns[row] = column
|
||||
}
|
||||
/** Pointer clicks activate the same state transition as a completed remote press. */
|
||||
fun click(targetRow: String, targetColumn: Int) {
|
||||
focus(targetRow, targetColumn)
|
||||
activate()
|
||||
}
|
||||
|
||||
|
||||
/** Film and episode toggles are optimistic; the facade restores this value on failure. */
|
||||
fun restoreWatched(value: Boolean) { watched = value }
|
||||
|
||||
/** Key-up can only activate the same card that armed the first key-down. */
|
||||
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
|
||||
if (key == ShellKey.Ok) {
|
||||
if (!down) {
|
||||
okHeld = false
|
||||
val card = "$row:$column"
|
||||
if (armedCard == card && sheet == null && !shell.navFocused) activate()
|
||||
armedCard = null
|
||||
return true
|
||||
}
|
||||
if (repeat > 0 || okHeld) return true
|
||||
okHeld = true
|
||||
if (sheet == null && row in listOf("cast", "related", "episodes")) {
|
||||
armedCard = "$row:$column"
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if (!down) return true
|
||||
armedCard = null
|
||||
}
|
||||
if (sheet != null) { sheetKey(key); return true }
|
||||
when (key) {
|
||||
ShellKey.Back -> onAction(TitleAction.Pop)
|
||||
ShellKey.Up -> if (row == "header") { shell.focusNav(shell.currentTab) } else moveRow(-1)
|
||||
ShellKey.Down -> moveRow(1)
|
||||
ShellKey.Left -> focus(row, (column - 1).coerceAtLeast(0))
|
||||
ShellKey.Right -> focus(row, (column + 1).coerceAtMost((rowSize(row) - 1).coerceAtLeast(0)))
|
||||
ShellKey.Ok -> activate()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Platform Back shares the sheet-first ladder with remote Back. */
|
||||
fun back() { if (sheet != null) sheet = null else onAction(TitleAction.Pop) }
|
||||
|
||||
/** Pointer selection stays inside the modal's row order. */
|
||||
fun clickSheet(index: Int) { sheetIndex = index; sheetKey(ShellKey.Ok) }
|
||||
|
||||
private fun moveRow(delta: Int) {
|
||||
val index = (rows.indexOf(row) + delta).coerceIn(0, rows.lastIndex)
|
||||
val next = rows[index]
|
||||
focus(next, (columns[next] ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0)))
|
||||
}
|
||||
|
||||
private fun rowSize(id: String): Int = when (id) {
|
||||
"header" -> options.size
|
||||
"seasons" -> data.seasonNumbers.size
|
||||
"episodes" -> data.episodes.size
|
||||
"cast" -> data.cast.size
|
||||
"related" -> data.related.size
|
||||
else -> 0
|
||||
}
|
||||
|
||||
private fun activate() {
|
||||
when (row) {
|
||||
"cast" -> data.cast.getOrNull(column)?.let { onAction(TitleAction.Person(it)) }
|
||||
"related" -> data.related.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) }
|
||||
"episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) }
|
||||
"seasons" -> { season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) }
|
||||
"header" -> options.getOrNull(column)?.let { activateOption(it.id) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun activateOption(id: String) {
|
||||
when (id) {
|
||||
"play", "restart" -> target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
||||
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
|
||||
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
||||
"more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo }
|
||||
"watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched()
|
||||
"trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) }
|
||||
"seasons" -> focus("seasons", data.seasonNumbers.indexOf(season).coerceAtLeast(0))
|
||||
"retry" -> onAction(TitleAction.Retry)
|
||||
"back" -> onAction(TitleAction.Pop)
|
||||
"request", "other", "add" -> onAction(TitleAction.Request(data.item, id != "request"))
|
||||
"status" -> onAction(TitleAction.Requests(data.item))
|
||||
"hide", "unhide" -> {
|
||||
val hidden = id == "hide"
|
||||
data = data.copy(hidden = hidden)
|
||||
onAction(TitleAction.Hide(data.item, hidden))
|
||||
if (hidden) { sheetIndex = 0; sheet = TitleSheet.HiddenReceipt } else toast = "Back in your picks."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleWatched() {
|
||||
watched = !watched
|
||||
onAction(TitleAction.Watched(data.watchTarget, watched))
|
||||
}
|
||||
|
||||
private fun sheetKey(key: ShellKey) {
|
||||
if (key == ShellKey.Back) { sheet = null; return }
|
||||
val current = version
|
||||
val size = when (sheet) {
|
||||
TitleSheet.Version -> data.versions.size
|
||||
TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2
|
||||
TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2
|
||||
else -> 1
|
||||
}
|
||||
when (key) {
|
||||
ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0)
|
||||
ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1)
|
||||
ShellKey.Ok -> when (sheet) {
|
||||
TitleSheet.Version -> {
|
||||
val selected = data.versions[sheetIndex]
|
||||
versionId = selected.id
|
||||
audioIndex = null
|
||||
subtitleIndex = -1
|
||||
target?.let { onAction(TitleAction.Version(it, selected)) }
|
||||
sheet = null
|
||||
}
|
||||
TitleSheet.Tracks -> if (current != null) selectTrack(current)
|
||||
TitleSheet.ConfirmWatched -> { if (sheetIndex == 0) toggleWatched(); sheet = null }
|
||||
TitleSheet.HiddenReceipt -> {
|
||||
sheet = null
|
||||
if (sheetIndex == 0) {
|
||||
data = data.copy(hidden = false)
|
||||
onAction(TitleAction.Hide(data.item, false))
|
||||
} else onAction(TitleAction.Pop)
|
||||
}
|
||||
else -> sheet = null
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectTrack(current: TitleVersion) {
|
||||
when {
|
||||
sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index
|
||||
sheetIndex == current.audio.size -> subtitleIndex = -1
|
||||
sheetIndex <= current.audio.size + current.subtitles.size -> subtitleIndex = current.subtitles[sheetIndex - current.audio.size - 1].index
|
||||
else -> { sheet = null; return }
|
||||
}
|
||||
target?.let { onAction(TitleAction.Track(it, current, audio?.index, subtitleIndex)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Button order follows the file, resume, ownership and authentication facts, never synthetic availability. */
|
||||
fun titleOptions(state: TitleState): List<TitleOption> = buildList {
|
||||
val data = state.data
|
||||
val item = data.item
|
||||
val target = state.target
|
||||
if (data.status != TitleReadStatus.Ready) {
|
||||
if (data.status == TitleReadStatus.Unavailable) {
|
||||
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
||||
add(TitleOption("back", "Back", "left", icon = false))
|
||||
}
|
||||
return@buildList
|
||||
}
|
||||
if (item.held) {
|
||||
if (target != null) {
|
||||
val verb = if (target.resumeSeconds > 0) "Resume" else "Play"
|
||||
add(TitleOption("play", verb + if (target.kind == "episode") " ${target.episodeLabel}" else "", "play", primary = true, icon = false))
|
||||
if (target.resumeSeconds > 0) add(TitleOption("restart", "Restart", "restart", "play from the beginning"))
|
||||
if (data.versions.size > 1) add(TitleOption("version", "Version", "version", state.version?.name.orEmpty()))
|
||||
add(TitleOption("tracks", "Audio and subtitles", "tracks", "${state.audio?.label.orEmpty()} · subtitles ${state.subtitle?.label ?: "off"}"))
|
||||
}
|
||||
val series = data.watchTarget.kind == "tv"
|
||||
add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched))
|
||||
if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer"))
|
||||
if (target != null) add(TitleOption("more", "More", "more", "Media info"))
|
||||
if (item.kind == "tv" && item.seasons > 0) add(TitleOption("seasons", "Seasons", "tv", icon = false))
|
||||
if (data.connection == TitleConnection.SignedIn) add(TitleOption("add", "Add for someone", "users", icon = false))
|
||||
} else if (data.connection != TitleConnection.SignedIn) {
|
||||
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
||||
} else {
|
||||
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", if (data.requestedStage == null) "plus" else "clock", primary = true, icon = false))
|
||||
add(TitleOption("other", "For someone else", "users", icon = false))
|
||||
if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false))
|
||||
else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import coil3.ImageLoader
|
||||
|
||||
/** Owned screens share one bounded loader; standalone fixtures can use Coil's local asset loader. */
|
||||
val LocalWolfskinImageLoader = compositionLocalOf<ImageLoader?> { null }
|
||||
|
|
@ -212,8 +212,20 @@ object WolfskinTokens {
|
|||
const val reducedMotion = "all transitions instant"
|
||||
}
|
||||
object Image {
|
||||
const val posterRequestWidth = 256
|
||||
const val posterRequestHeight = 384
|
||||
const val tileRequestWidth = 416
|
||||
const val tileRequestHeight = 234
|
||||
const val wideRequestWidth = 560
|
||||
const val wideRequestHeight = 315
|
||||
const val heroRequestWidth = 1280
|
||||
const val heroRequestHeight = 720
|
||||
const val castRequestWidth = 200
|
||||
const val castRequestHeight = 200
|
||||
const val memoryCacheMB = 64.0f
|
||||
const val diskCacheMB = 128.0f
|
||||
const val concurrentReadsCatalogue = 2
|
||||
const val concurrentReadsImage = 4
|
||||
}
|
||||
object Behaviour {
|
||||
const val rowLimit = 20.0f
|
||||
|
|
@ -279,4 +291,85 @@ object WolfskinTokens {
|
|||
const val navBandOpacity = 0.72f
|
||||
const val navSolidStop = 0.84f
|
||||
}
|
||||
object Title {
|
||||
val lineGap = 11.0.dp
|
||||
val lineLift = -2.0.dp
|
||||
val overviewGap = 17.0.dp
|
||||
val factsGap = 11.0.dp
|
||||
val factsBottom = 7.0.dp
|
||||
val titleBottom = 8.0.dp
|
||||
val iconGap = 7.0.dp
|
||||
val iconSize = 16.0.dp
|
||||
val captionTop = 8.0.dp
|
||||
val progressWidth = 160.0.dp
|
||||
val progressBottom = 15.0.dp
|
||||
val progressGap = 9.0.dp
|
||||
val progressLift = -7.0.dp
|
||||
val castHeight = 197.0.dp
|
||||
val castNameTop = 108.0.dp
|
||||
val castNameSide = 6.0.dp
|
||||
val personTop = 85.0.dp
|
||||
val personGap = 24.0.dp
|
||||
val personNameBottom = 5.0.dp
|
||||
val personNoteBottom = 3.0.dp
|
||||
val captionWidth = 320.0.dp
|
||||
val sheetPaddingTop = 40.0.dp
|
||||
val sheetPaddingSide = 32.0.dp
|
||||
val sheetGap = 9.0.dp
|
||||
val sheetSubLift = -4.0.dp
|
||||
val sheetSubBottom = 7.0.dp
|
||||
val sheetRowSide = 13.0.dp
|
||||
val versionRowVertical = 10.0.dp
|
||||
val trackHeight = 42.0.dp
|
||||
val trackGap = 6.0.dp
|
||||
val radioSize = 20.0.dp
|
||||
val radioDot = 8.0.dp
|
||||
val radioBorder = 1.5.dp
|
||||
val infoGap = 7.0.dp
|
||||
val infoLabelWidth = 80.0.dp
|
||||
val gridHeight = 244.0.dp
|
||||
val minimumEndPadding = 40.0.dp
|
||||
val ratingGap = 10.0.dp
|
||||
val ratingMarkGap = 4.0.dp
|
||||
val plateSide = 8.0.dp
|
||||
val plateTop = 7.0.dp
|
||||
val plateFade = 23.0.dp
|
||||
val initialSize = TextStyle(fontSize = 32.0.sp, fontWeight = FontWeight(800), lineHeight = 41.6.sp, letterSpacing = 0.0.em)
|
||||
val personInitialSize = TextStyle(fontSize = 48.0.sp, fontWeight = FontWeight(800), lineHeight = 62.400000000000006.sp, letterSpacing = 0.0.em)
|
||||
const val fallbackHue = 260.0f
|
||||
val skeletonKickerWidth = 210.0.dp
|
||||
val skeletonKickerHeight = 17.0.dp
|
||||
val skeletonTitleWidth = 380.0.dp
|
||||
val skeletonTitleHeight = 46.0.dp
|
||||
val skeletonFactsWidth = 280.0.dp
|
||||
val skeletonOverviewWidth = 490.0.dp
|
||||
val skeletonOverviewHeight = 55.0.dp
|
||||
val skeletonPlayWidth = 110.0.dp
|
||||
val dLine = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(400), lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
|
||||
val iconCaption = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(400), lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
|
||||
val lineIcon = 14.0.dp
|
||||
val chipHeight = 34.0.dp
|
||||
val chipPadding = 14.0.dp
|
||||
val chipGap = 8.0.dp
|
||||
val chipTop = 8.0.dp
|
||||
val chipBorder = 1.0.dp
|
||||
val heroSheetRight = 90.0.dp
|
||||
val heroSheetTop = 75.0.dp
|
||||
const val mutedTomatoOpacity = 0.55f
|
||||
const val ratingWordmarkAspect = 1.8f
|
||||
const val ratingPlateStepSp = 0.25f
|
||||
val gridHeadingTop = 12.0.dp
|
||||
val castName = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(700), lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
|
||||
val receiptSide = 15.0.dp
|
||||
val receiptTop = 14.0.dp
|
||||
val receiptGap = 5.0.dp
|
||||
val receiptIcon = 18.0.dp
|
||||
val receiptTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(700), lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em)
|
||||
val toastMaxWidth = 380.0.dp
|
||||
val toastSide = 14.0.dp
|
||||
val toastTop = 11.0.dp
|
||||
const val heroAmbientBackground = "radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg))"
|
||||
const val dimScrollPx = 1400.0f
|
||||
const val dimMaximum = 0.7f
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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/title_check.xml
Normal file
3
wolfskin-design/src/main/res/drawable/title_check.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M5 12.5l4.5 4.5L19 7.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/title_clock.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_clock.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M3.5,12.0a8.5,8.5 0 1,0 17.0,0a8.5,8.5 0 1,0 -17.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
|
||||
<path android:pathData="M12 7.5V12l3 2" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round" android:strokeLineJoin="miter" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/title_eye.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_eye.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
|
||||
<path android:pathData="M9.0,12.0a3.0,3.0 0 1,0 6.0,0a3.0,3.0 0 1,0 -6.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
|
||||
</vector>
|
||||
3
wolfskin-design/src/main/res/drawable/title_eyeoff.xml
Normal file
3
wolfskin-design/src/main/res/drawable/title_eyeoff.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M3 3l18 18M10.6 5.1A10.6 10.6 0 0 1 12 5c6.4 0 10 7 10 7a17 17 0 0 1-3.2 4M6.6 6.6C3.8 8.4 2 12 2 12s3.6 7 10 7c1.6 0 3-.4 4.3-1" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="miter" />
|
||||
</vector>
|
||||
3
wolfskin-design/src/main/res/drawable/title_left.xml
Normal file
3
wolfskin-design/src/main/res/drawable/title_left.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="m15 6-6 6 6 6" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
5
wolfskin-design/src/main/res/drawable/title_more.xml
Normal file
5
wolfskin-design/src/main/res/drawable/title_more.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M3.6,12.0a1.9,1.9 0 1,0 3.8,0a1.9,1.9 0 1,0 -3.8,0" android:fillColor="#FFFFFFFF" />
|
||||
<path android:pathData="M10.1,12.0a1.9,1.9 0 1,0 3.8,0a1.9,1.9 0 1,0 -3.8,0" android:fillColor="#FFFFFFFF" />
|
||||
<path android:pathData="M16.6,12.0a1.9,1.9 0 1,0 3.8,0a1.9,1.9 0 1,0 -3.8,0" android:fillColor="#FFFFFFFF" />
|
||||
</vector>
|
||||
3
wolfskin-design/src/main/res/drawable/title_play.xml
Normal file
3
wolfskin-design/src/main/res/drawable/title_play.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M8 5v14l11-7z" android:fillColor="#FFFFFFFF" />
|
||||
</vector>
|
||||
3
wolfskin-design/src/main/res/drawable/title_plus.xml
Normal file
3
wolfskin-design/src/main/res/drawable/title_plus.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M12 5v14M5 12h14" android:fillColor="#FFFFFFFF" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:strokeLineJoin="miter" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/title_restart.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_restart.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M4.5 12a7.5 7.5 0 1 0 2.2-5.3" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:strokeLineCap="round" android:strokeLineJoin="miter" />
|
||||
<path android:pathData="M4 4.5v4h4" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
3
wolfskin-design/src/main/res/drawable/title_sparkle.xml
Normal file
3
wolfskin-design/src/main/res/drawable/title_sparkle.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M12 3c.6 4.2 2.8 6.4 7 7-4.2.6-6.4 2.8-7 7-.6-4.2-2.8-6.4-7-7 4.2-.6 6.4-2.8 7-7z" android:fillColor="#FFFFFFFF" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/title_tracks.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_tracks.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M5.5,5.0H18.5Q21.0,5.0 21.0,7.5V16.5Q21.0,19.0 18.5,19.0H5.5Q3.0,19.0 3.0,16.5V7.5Q3.0,5.0 5.5,5.0Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
|
||||
<path android:pathData="M7 12h3M12.5 12h4.5M7 15.5h6M15 15.5h2" android:fillColor="#FFFFFFFF" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="miter" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/title_trailer.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_trailer.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M5.5,5.0H18.5Q21.5,5.0 21.5,8.0V16.0Q21.5,19.0 18.5,19.0H5.5Q2.5,19.0 2.5,16.0V8.0Q2.5,5.0 5.5,5.0Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
|
||||
<path android:pathData="m10 9 5 3-5 3z" android:fillColor="#FFFFFFFF" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/title_tv.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_tv.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M5.5,5.0H18.5Q21.0,5.0 21.0,7.5V14.5Q21.0,17.0 18.5,17.0H5.5Q3.0,17.0 3.0,14.5V7.5Q3.0,5.0 5.5,5.0Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
|
||||
<path android:pathData="M8 21h8M12 17v4" android:fillColor="#FFFFFFFF" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" android:strokeLineCap="round" android:strokeLineJoin="miter" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/title_undo.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_undo.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M9 7 4.5 11.5 9 16" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M5 11.5h9a5.5 5.5 0 0 1 0 11h-2" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="miter" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/title_users.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_users.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M5.5,8.5a3.5,3.5 0 1,0 7.0,0a3.5,3.5 0 1,0 -7.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
|
||||
<path android:pathData="M2.5 19.5c.8-3.4 3.4-5 6.5-5s5.7 1.6 6.5 5M15.5 5.2a3.4 3.4 0 0 1 0 6.6M18 14.8c1.8.7 3 2.2 3.5 4.7" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="miter" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/title_version.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_version.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M6 3h8l4 4v14H6z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="round" />
|
||||
<path android:pathData="m10.5 11 4 2.5-4 2.5z" android:fillColor="#FFFFFFFF" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/title_wifi.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_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: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" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round" android:strokeLineJoin="miter" />
|
||||
<path android:pathData="M10.7,19.5a1.3,1.3 0 1,0 2.6,0a1.3,1.3 0 1,0 -2.6,0" android:fillColor="#FFFFFFFF" />
|
||||
</vector>
|
||||
Loading…
Add table
Add a link
Reference in a new issue