Integrate native Home and replay full title launch paths
This commit is contained in:
commit
fc02ce8646
697 changed files with 2956 additions and 146 deletions
|
|
@ -10,6 +10,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
|
|
@ -18,30 +19,31 @@ import androidx.compose.ui.unit.constrainHeight
|
|||
|
||||
/** Captions fade without moving the reserved band under the focused poster. */
|
||||
@Composable
|
||||
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false) {
|
||||
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false,
|
||||
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
|
||||
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
|
||||
val alpha = remember(title, facts) { Animatable(0f) }
|
||||
LaunchedEffect(title, facts, visible) {
|
||||
LaunchedEffect(alpha, visible) {
|
||||
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
||||
}
|
||||
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
|
||||
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, modifier = Modifier.cssLineBox(WolfskinTokens.Type.button), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
CaptionFacts(facts, accentFirstFact, accentLastFact)
|
||||
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow)
|
||||
CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle)
|
||||
}
|
||||
}
|
||||
|
||||
/** CSS flex facts: only coloured role/ownership spans shrink; ordinary facts and dots keep their width. */
|
||||
@Composable
|
||||
private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean) {
|
||||
private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean, style: TextStyle) {
|
||||
val parts = remember(facts) { facts.split(" · ") }
|
||||
val muted = WolfskinTokens.Color.muted
|
||||
val colour = WolfskinTokens.Color.accentText
|
||||
val style = WolfskinTokens.Type.caption
|
||||
Layout(
|
||||
content = {
|
||||
parts.forEachIndexed { index, part ->
|
||||
val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast)
|
||||
Text(part, color = if (accented) colour else muted, style = style, modifier = Modifier.cssLineBox(style), maxLines = 1, overflow = TextOverflow.Clip)
|
||||
if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style, modifier = Modifier.cssLineBox(style))
|
||||
Text(part, color = if (accented) colour else muted, style = style, modifier = Modifier.exactLineBox(style), maxLines = 1, overflow = TextOverflow.Clip)
|
||||
if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style, modifier = Modifier.exactLineBox(style))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().clipToBounds(),
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
|||
|
||||
@Composable
|
||||
private fun SheetButton(label: String, primary: Boolean = false, onClick: () -> Unit) {
|
||||
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, true, primary, onClick) }
|
||||
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, true, primary, onClick = onClick) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -124,7 +124,9 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
|
|||
val foreground = if (focused) C.onFocus else C.fg
|
||||
val secondary = if (focused) C.onFocus else C.muted
|
||||
val context = LocalContext.current
|
||||
val request = remember(item.poster, context) { LibraryImages.request(context, item.poster, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt()) }
|
||||
val request = remember(item.poster, context) {
|
||||
item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) }
|
||||
}
|
||||
val posterReady = remember(request) { mutableStateOf(false) }
|
||||
Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
.background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
|
|
@ -135,7 +137,7 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
|
|||
}.clickable(onClick = onClick)
|
||||
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
|
||||
AsyncImage(model = request, imageLoader = LibraryImages.loader(context), contentDescription = null,
|
||||
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
|
||||
modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
|
||||
.background(if (posterReady.value) Color.Transparent else C.surface3),
|
||||
onState = { posterReady.value = it is AsyncImagePainter.State.Success },
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import androidx.compose.ui.layout.LayoutModifier
|
|||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasureScope
|
||||
import androidx.compose.ui.layout.ParentDataModifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
|
@ -18,11 +17,7 @@ import androidx.compose.ui.unit.TextUnit
|
|||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** CSS leading constrains the line box, even when the platform font's natural box is taller. */
|
||||
internal fun Modifier.cssLineBox(style: TextStyle, top: Dp = 0.dp, bottom: Dp = 0.dp): Modifier =
|
||||
then(CssLineBox(style.lineHeight, top, bottom))
|
||||
|
||||
private data class CssLineBox(val lineHeight: TextUnit, val top: Dp, val bottom: Dp) : LayoutModifier, ParentDataModifier {
|
||||
internal data class CssLineBox(val lineHeight: TextUnit, val top: Dp, val bottom: Dp) : LayoutModifier, ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any = parentData ?: this@CssLineBox
|
||||
fun Density.exactHeight(height: Int): Float {
|
||||
val margins = top.toPx() + bottom.toPx()
|
||||
|
|
|
|||
|
|
@ -31,8 +31,6 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
|
|||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.allowRgb565
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
|
|
@ -52,12 +50,12 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
|
|||
}) else null }
|
||||
val context = LocalContext.current
|
||||
val request = remember(context, path, poster) {
|
||||
ImageRequest.Builder(context).data(path).size(if (poster) 256 else 1280, if (poster) 384 else 720).allowRgb565(poster).build()
|
||||
WolfskinImages.request(context, path, if (poster) 256 else 1280, if (poster) 384 else 720, poster)
|
||||
}
|
||||
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
|
||||
else AsyncImage(
|
||||
model = request,
|
||||
imageLoader = LibraryImages.loader(context),
|
||||
imageLoader = WolfskinImages.loader(context),
|
||||
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ import androidx.compose.animation.core.animateFloatAsState
|
|||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
|
@ -58,3 +63,13 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color:
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Pointing changes the same logical focus as the D-pad without activating an action. */
|
||||
@Composable
|
||||
fun Modifier.focusOnPointerEnter(onEnter: () -> Unit): Modifier {
|
||||
val action by rememberUpdatedState(onEnter)
|
||||
val interactions = remember { MutableInteractionSource() }
|
||||
val hovered by interactions.collectIsHoveredAsState()
|
||||
LaunchedEffect(hovered) { if (hovered) action() }
|
||||
return hoverable(interactions)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
|||
"sparkle", "seerr" -> R.drawable.wolfskin_sparkle
|
||||
"advanced" -> R.drawable.wolfskin_sort
|
||||
"alert" -> R.drawable.wolfskin_alert
|
||||
"wifi" -> R.drawable.wolfskin_wifi
|
||||
"clock" -> R.drawable.wolfskin_clock
|
||||
"plus" -> R.drawable.wolfskin_plus
|
||||
"check" -> R.drawable.wolfskin_check
|
||||
"clock" -> R.drawable.wolfskin_clock
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.Shader
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
|
||||
/** Empty hero art keeps the locked elliptical ambient wash rather than flashing a black frame. */
|
||||
@Composable
|
||||
fun HomeAmbient(modifier: Modifier = Modifier) {
|
||||
val brush = remember {
|
||||
object : ShaderBrush() {
|
||||
override fun createShader(size: Size): Shader {
|
||||
val centerX = size.width * .7f
|
||||
val centerY = size.height * .2f
|
||||
val radiusX = size.width * 1.2f
|
||||
val radiusY = size.height * .9f
|
||||
return RadialGradient(centerX, centerY, radiusX, intArrayOf(WolfskinTokens.Color.surface3.toArgb(), WolfskinTokens.Color.bg.toArgb()), null, Shader.TileMode.CLAMP).apply {
|
||||
setLocalMatrix(Matrix().apply { setScale(1f, radiusY / radiusX, centerX, centerY) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(modifier.background(brush))
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Home navigation mutates the shell's per-person view, including each row's remembered column. */
|
||||
class HomeFocus(
|
||||
private val shell: ShellState,
|
||||
private val rows: List<HomeRow>,
|
||||
private val onAction: (HomeAction) -> Unit,
|
||||
) : ShellContentFocus {
|
||||
private val view = shell.rememberedView("home")
|
||||
override val cardKey: String? get() = row?.let { if (it.kind == HomeRowKind.Shelf) it.cards.getOrNull(column)?.id ?: it.id else null }
|
||||
val row: HomeRow? get() = rows.getOrNull(view.row)
|
||||
val column: Int get() = row?.let { view.cards[it.id] ?: 0 } ?: 0
|
||||
|
||||
fun focus(index: Int, column: Int) {
|
||||
shell.navFocused = false
|
||||
view.row = index
|
||||
view.home.focusedKey = rows[index].id
|
||||
val target = rows[index]
|
||||
val bounded = column.coerceIn(0, (target.count - 1).coerceAtLeast(0))
|
||||
view.cards[target.id] = bounded
|
||||
if (target.kind == HomeRowKind.Shelf) view.home.firstCards[target.id] = target.adjustedFirst(bounded, view.home.firstCards[target.id] ?: 0)
|
||||
}
|
||||
|
||||
override fun key(key: ShellKey): Boolean {
|
||||
val current = row ?: return false
|
||||
if (key == ShellKey.Back) return false
|
||||
if (key == ShellKey.Ok) { activate(current); return true }
|
||||
if (reenterVisible()) return true
|
||||
when (key) {
|
||||
ShellKey.Left -> focus(view.row, (column - 1).coerceAtLeast(0))
|
||||
ShellKey.Right -> focus(view.row, (column + 1).coerceAtMost((current.count - 1).coerceAtLeast(0)))
|
||||
ShellKey.Up, ShellKey.Down -> {
|
||||
val direction = if (key == ShellKey.Up) -1 else 1
|
||||
var next = view.row + direction
|
||||
while (next in rows.indices && !rows[next].focusable) next += direction
|
||||
if (next in rows.indices) {
|
||||
val target = rows[next]
|
||||
val nearest = nearest(current, target)
|
||||
focus(next, view.cards[target.id] ?: nearest)
|
||||
} else if (direction < 0) {
|
||||
view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
|
||||
view.home.resetScroll++
|
||||
shell.navFocused = true
|
||||
shell.navIndex = shell.currentTab
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun reenterVisible(): Boolean {
|
||||
val layout = view.listState.layoutInfo
|
||||
if (layout.totalItemsCount == 0 || view.home.positionedKey != row?.id || view.listState.isScrollInProgress) return false
|
||||
val density = layout.viewportSize.width / WolfskinTokens.Shell.stageWidth.value
|
||||
val current = layout.visibleItemsInfo.firstOrNull { it.index == view.row }
|
||||
if (current != null && current.offset + current.size >= WolfskinTokens.Layout.navSolidAfter.value * density &&
|
||||
current.offset <= layout.viewportSize.height - WolfskinTokens.Layout.rowHeadingBand.value * 2 * density) return false
|
||||
val first = layout.visibleItemsInfo.firstOrNull {
|
||||
rows[it.index].focusable && it.offset + it.size > WolfskinTokens.Layout.rowHeadingBand.value * density &&
|
||||
it.offset < layout.viewportSize.height - WolfskinTokens.Layout.row_endcta.value * density
|
||||
} ?: return false
|
||||
focus(first.index, view.cards[rows[first.index].id] ?: 0)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun activate(row: HomeRow) {
|
||||
when (row.kind) {
|
||||
HomeRowKind.Hero -> row.cards.firstOrNull()?.let { title ->
|
||||
onAction(if (column == 0 && row.heroKind != "pick") HomeAction.Play(title, row.heroKind == "resume") else HomeAction.Details(title))
|
||||
}
|
||||
HomeRowKind.Shelf -> if (column < row.cards.size) onAction(HomeAction.Details(row.cards[column])) else onAction(HomeAction.SeeAll(row))
|
||||
HomeRowKind.State -> onAction(HomeAction.Retry)
|
||||
HomeRowKind.Explore -> onAction(HomeAction.Discover)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun nearest(from: HomeRow, to: HomeRow): Int {
|
||||
if (to.kind != HomeRowKind.Shelf) return (column * pitch(from) / pitch(to)).toInt()
|
||||
val fromFirst = if (from.kind == HomeRowKind.Shelf) view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0
|
||||
val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2
|
||||
val first = (view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0))
|
||||
val last = minOf(to.count - 1, first + to.focusSlots - 1)
|
||||
var best = first
|
||||
var distance = Float.POSITIVE_INFINITY
|
||||
for (index in first..last) {
|
||||
val candidate = abs((index - first) * pitch(to) + to.cardWidth.value / 2 - x)
|
||||
if (candidate < distance) { best = index; distance = candidate }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
private fun pitch(row: HomeRow): Float = when (row.cardKind) {
|
||||
HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value
|
||||
HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value
|
||||
HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value
|
||||
}
|
||||
}
|
||||
|
||||
internal val HomeRow.focusSlots: Int get() = when (cardKind) {
|
||||
HomeCardKind.Poster -> 6
|
||||
HomeCardKind.Tile -> 4
|
||||
HomeCardKind.Wide -> 3
|
||||
}
|
||||
|
||||
/** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */
|
||||
internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int {
|
||||
var first = previous
|
||||
if (column > first + focusSlots - 2) first = column - (focusSlots - 2)
|
||||
if (column < first + 1) first = (column - 1).coerceAtLeast(0)
|
||||
return first.coerceIn(0, (count - focusSlots).coerceAtLeast(0))
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.HomeType as T
|
||||
|
||||
/** The hero occupies one row while its backdrop extends beneath the first shelves. */
|
||||
@Composable
|
||||
fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) {
|
||||
Box(Modifier.fillMaxWidth().height(L.row_hero)) {
|
||||
if (!row.skeleton) {
|
||||
val title = row.cards.first()
|
||||
val artModifier = Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)
|
||||
TitleArtwork(title.title, title.type, title.poster, WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight, artModifier, backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = true, hero = true)
|
||||
Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
|
||||
Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f))))
|
||||
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) {
|
||||
Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f)))
|
||||
Text(when (row.heroKind) { "resume" -> "Continue watching"; "next" -> "Up next"; "new" -> "New in your library"; else -> "For you" }, modifier = Modifier.exactLineBox(T.label), color = C.accentText, style = T.label)
|
||||
}
|
||||
Spacer(Modifier.height(H.kickerBottom))
|
||||
Text(title.displayTitle, modifier = Modifier.exactLineBox(T.displayHero), color = C.fg, style = T.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.height(H.titleBottom))
|
||||
Row(Modifier.heightIn(min = H.factsHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.factsGap)) {
|
||||
Text(if (title.isEpisode) title.subtitle else title.year?.toString().orEmpty(), modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1)
|
||||
if (row.heroKind == "resume" || title.runtimeSeconds != null) Text(if (row.heroKind == "resume") title.remaining else title.runtime, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1)
|
||||
HomeRatingsRow(ratings)
|
||||
}
|
||||
Spacer(Modifier.height(H.factsBottom))
|
||||
Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
|
||||
Spacer(Modifier.height(H.overviewBottom))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
||||
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
|
||||
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HomeAmbient(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
|
||||
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom)) {
|
||||
Box(Modifier.size(H.heroSkeletonWidth, H.heroSkeletonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||
Spacer(Modifier.height(H.heroSkeletonGap))
|
||||
Box(Modifier.size(H.heroSkeletonFactsWidth, H.heroSkeletonFactsHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
|
||||
Spacer(Modifier.height(H.heroSkeletonButtonGap))
|
||||
Box(Modifier.size(H.heroSkeletonButtonWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Discover continues in the same scroller, under the active person's identity. */
|
||||
@Composable
|
||||
fun HomeSection(person: ShellPerson, savedAt: String?) {
|
||||
Box(Modifier.fillMaxWidth().height(L.row_section)) {
|
||||
Row(Modifier.padding(start = L.safeSide, top = H.sectionTop), horizontalArrangement = Arrangement.spacedBy(H.sectionGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Discover", color = C.fg, style = T.sectionTitle)
|
||||
Avatar(person, H.sectionAvatar, T.avatarLetterSectionHeader)
|
||||
Text("Picks for you first, then what’s out there", color = C.muted, style = T.body)
|
||||
if (savedAt != null) Box(Modifier.height(WolfskinTokens.Shell.settingsPillHeight)
|
||||
.border(H.hairline, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill))
|
||||
.padding(horizontal = WolfskinTokens.Shell.settingsPillPadding + H.hairline), contentAlignment = Alignment.Center) {
|
||||
Text("Saved $savedAt", color = C.warn, style = T.caption.copy(fontWeight = T.label.fontWeight))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Failures keep a focusable recovery action without covering working library shelves. */
|
||||
@Composable
|
||||
fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> Unit, onHover: () -> Unit) {
|
||||
Box(Modifier.fillMaxWidth().height(L.row_state)) {
|
||||
Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX + H.hairline, vertical = H.statePaddingY + H.hairline)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.plateTop)) {
|
||||
Glyph(if (row.calm) "profile" else "alert", if (row.calm) C.accentText else C.warn, Modifier.size(H.stateIcon))
|
||||
Text(row.title, modifier = Modifier.exactLineBox(T.stateTitle), color = C.fg, style = T.stateTitle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
Spacer(Modifier.height(H.stateGap))
|
||||
Text(row.text, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body)
|
||||
Spacer(Modifier.height(H.stateTextBottom))
|
||||
PickerButton("Try again", focused, row.calm, onFocus = onHover, textStyle = T.button, onClick = retry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** A screen-owned media projection, independent of Jellyfin and catalogue DTOs. */
|
||||
@Immutable
|
||||
data class HomeTitle(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val type: String,
|
||||
val year: Int? = null,
|
||||
val runtimeSeconds: Int? = null,
|
||||
val seasons: Int? = null,
|
||||
val overview: String = "",
|
||||
val poster: String? = null,
|
||||
val backdrop: String? = null,
|
||||
val posterOnly: Boolean = false,
|
||||
val seriesTitle: String? = null,
|
||||
val season: Int? = null,
|
||||
val episode: Int? = null,
|
||||
val resumeSeconds: Double = 0.0,
|
||||
val played: Boolean = false,
|
||||
val addedAt: String = "",
|
||||
val genres: List<String> = emptyList(),
|
||||
val ratings: List<HomeRating> = emptyList(),
|
||||
val held: Boolean = false,
|
||||
val otherOwner: String? = null,
|
||||
val tmdbId: Int? = null,
|
||||
) {
|
||||
val isEpisode: Boolean get() = type == "episode"
|
||||
val displayTitle: String get() = seriesTitle ?: title
|
||||
val episodeLabel: String get() = "S$season E$episode"
|
||||
val runtime: String get() {
|
||||
val minutes = ((runtimeSeconds ?: 0) / 60.0).roundToInt()
|
||||
return if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m"
|
||||
}
|
||||
val remaining: String get() = "${(((runtimeSeconds ?: 0) - resumeSeconds) / 60).roundToInt().coerceAtLeast(1)} min left"
|
||||
val subtitle: String get() = if (isEpisode) "$episodeLabel · $title" else "${if (type == "movie") "Movie" else "Series"} · ${year ?: ""}"
|
||||
val facts: String get() = listOfNotNull(year?.toString(), if (type == "movie") runtimeSeconds?.let { runtime } ?: "Movie" else seasons?.let { "$it ${if (it == 1) "season" else "seasons"}" } ?: "Series").joinToString(" · ")
|
||||
}
|
||||
|
||||
/** Only observed, range-checked scores reach this model; formatting follows the TV locale. */
|
||||
@Immutable
|
||||
data class HomeRating(val mark: String, val score: Double, val percentage: Boolean = false, val episode: Boolean = false) {
|
||||
fun format(locale: Locale = Locale.getDefault()): String = if (percentage) "${score.toInt()}%" else String.format(locale, "%.1f", score)
|
||||
}
|
||||
|
||||
enum class HomeCardKind { Poster, Tile, Wide }
|
||||
enum class HomeRowKind { Hero, Shelf, Section, State, Note, Explore }
|
||||
enum class HomeAvailability { Ready, Loading, SignedOut, Unavailable, Saved }
|
||||
|
||||
/** Rows retain stable keys while a refresh replaces their immutable contents. */
|
||||
@Immutable
|
||||
data class HomeRow(
|
||||
val id: String,
|
||||
val kind: HomeRowKind,
|
||||
val title: String = "",
|
||||
val cards: List<HomeTitle> = emptyList(),
|
||||
val cardKind: HomeCardKind = HomeCardKind.Poster,
|
||||
val catalogue: Boolean = false,
|
||||
val total: Int = cards.size,
|
||||
val target: String? = null,
|
||||
val text: String = "",
|
||||
val heroKind: String = "next",
|
||||
val skeleton: Boolean = false,
|
||||
val calm: Boolean = false,
|
||||
val allCards: List<HomeTitle> = cards,
|
||||
) {
|
||||
val hasSeeAll: Boolean get() = total > cards.size
|
||||
val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick") 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 }
|
||||
val focusable: Boolean get() = !skeleton && count > 0
|
||||
}
|
||||
|
||||
/** One snapshot paints immediately; source status never discards a saved view. */
|
||||
@Immutable
|
||||
data class HomeSnapshot(
|
||||
val rows: List<HomeRow>,
|
||||
val conductarr: HomeAvailability = HomeAvailability.SignedOut,
|
||||
val jellyfinAvailable: Boolean = true,
|
||||
val savedAt: String? = null,
|
||||
)
|
||||
|
||||
/** Home emits integration intents, never owns the upstream player or later-slice pages. */
|
||||
sealed interface HomeAction {
|
||||
data class Play(val title: HomeTitle, val resume: Boolean) : HomeAction
|
||||
data class Details(val title: HomeTitle) : HomeAction
|
||||
data class SeeAll(val row: HomeRow) : HomeAction
|
||||
data object Discover : HomeAction
|
||||
data object Retry : HomeAction
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
/** The screen's ratings cache uses an injected clock and never caches failed reads. */
|
||||
class HomeRatingCache(private val clock: () -> Long = System::currentTimeMillis) {
|
||||
private data class Entry(val ratings: List<HomeRating>, val readAt: Long)
|
||||
private val entries = linkedMapOf<String, Entry>()
|
||||
|
||||
fun read(title: HomeTitle): List<HomeRating>? {
|
||||
val key = key(title)
|
||||
val entry = entries[key] ?: return null
|
||||
if (clock() - entry.readAt >= lifetimeMillis) { entries.remove(key); return null }
|
||||
return entry.ratings
|
||||
}
|
||||
|
||||
fun save(title: HomeTitle, ratings: List<HomeRating>) {
|
||||
val key = key(title)
|
||||
entries.remove(key)
|
||||
entries[key] = Entry(ratings, clock())
|
||||
while (entries.size > capacity) entries.remove(entries.keys.first())
|
||||
}
|
||||
|
||||
private fun key(title: HomeTitle): String = if (title.isEpisode) "episode:${title.id}" else "${title.type}:${title.tmdbId ?: title.id}"
|
||||
|
||||
companion object {
|
||||
private const val capacity = 300
|
||||
private val lifetimeMillis = (WolfskinTokens.Behaviour.cacheRatingsHours * 60 * 60 * 1000).toLong()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.tv.material3.Text
|
||||
import java.util.Locale
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
import org.justinvisser.wolfskin.design.HomeType as T
|
||||
|
||||
/** Formatting is measured before fitting, so decimal commas obey the same one-line rule. */
|
||||
@Composable
|
||||
fun HomeRatingsRow(ratings: List<HomeRating>, modifier: Modifier = Modifier, fontSize: TextUnit = T.ratingPlate.fontSize, plate: Boolean = false) {
|
||||
val density = LocalDensity.current
|
||||
val size = with(density) { fontSize.toDp() }
|
||||
val entryGap = if (plate) size * .64f else H.ratingGap
|
||||
val markGap = if (plate) size * .29f else H.ratingMarkGap
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(entryGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
ratings.forEach { rating ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
val resource = when (rating.mark) {
|
||||
"rotten" -> R.drawable.ic_rotten_tomatoes_rotten
|
||||
"upright" -> R.drawable.ic_rt_upright
|
||||
"spilled" -> R.drawable.ic_rt_spilled
|
||||
"imdb" -> R.drawable.ic_rating_imdb
|
||||
"tmdb" -> R.drawable.ic_rating_tmdb
|
||||
else -> R.drawable.ic_rotten_tomatoes_fresh
|
||||
}
|
||||
val muted = rating.mark == "tomato"
|
||||
Image(painterResource(resource), null, Modifier.size(size * if (rating.mark == "imdb" || rating.mark == "tmdb") 1.8f else 1f, size).graphicsLayer { alpha = if (muted) .55f else 1f }, colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
|
||||
if (rating.episode) Text("Ep", color = C.muted, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontWeight = T.label.fontWeight), maxLines = 1)
|
||||
Text(rating.format(), color = C.fg, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontFeatureSettings = "tnum"), maxLines = 1, softWrap = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The plate is a prefix of the full ratings set; its first entry is never discarded. */
|
||||
@Composable
|
||||
fun HomeRatingPlate(ratings: List<HomeRating>, visible: Boolean, modifier: Modifier = Modifier) {
|
||||
val alpha by animateFloatAsState(if (visible && ratings.isNotEmpty()) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate")
|
||||
if (ratings.isEmpty()) return
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val locale = Locale.getDefault()
|
||||
BoxWithConstraints(modifier.graphicsLayer { this.alpha = alpha }.background(Brush.verticalGradient(0f to C.bg.copy(alpha = .9f), .52f to C.bg.copy(alpha = .72f), 1f to C.bg.copy(alpha = 0f))).padding(start = H.plateSide, end = H.plateSide, top = H.plateTop, bottom = H.plateFade)) {
|
||||
val available = with(density) { maxWidth.toPx() }
|
||||
val fitted = remember(ratings, available, density, locale) {
|
||||
val base = T.ratingPlate.fontSize
|
||||
val floor = T.caption.fontSize
|
||||
fun width(size: TextUnit, entries: List<HomeRating>): Float {
|
||||
val em = with(density) { size.toPx() }
|
||||
return entries.sumOf { rating ->
|
||||
val mark = em * if (rating.mark == "imdb" || rating.mark == "tmdb") 1.8f else 1f
|
||||
val number = measurer.measure(rating.format(locale), T.ratingPlate.copy(fontSize = size)).size.width
|
||||
val episode = if (rating.episode) measurer.measure("Ep", T.ratingPlate.copy(fontSize = size, fontWeight = T.label.fontWeight)).size.width + em * .29f else 0f
|
||||
(mark + em * .29f + number + episode).toDouble()
|
||||
}.toFloat() + (entries.size - 1).coerceAtLeast(0) * em * .64f
|
||||
}
|
||||
var size = base
|
||||
val initialWidth = width(base, ratings)
|
||||
if (initialWidth > available) size = base * (available / initialWidth).coerceAtLeast(floor.value / base.value)
|
||||
val step = (base.value - floor.value) / 8
|
||||
while (size > floor && width(size, ratings) > available) size = base * ((size.value - step).coerceAtLeast(floor.value) / base.value)
|
||||
var count = ratings.size
|
||||
while (count > 1 && width(size, ratings.take(count)) > available) count--
|
||||
size to ratings.take(count)
|
||||
}
|
||||
HomeRatingsRow(fitted.second, fontSize = fitted.first, plate = true)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
/** Source reads preserve their order; the hero is removed by stable item identity. */
|
||||
data class HomeLibrary(
|
||||
val resume: List<HomeTitle> = emptyList(),
|
||||
val nextUp: List<HomeTitle> = emptyList(),
|
||||
val newest: List<HomeTitle> = emptyList(),
|
||||
val destinations: Map<String, List<HomeTitle>> = emptyMap(),
|
||||
val destinationTotals: Map<String, Int> = emptyMap(),
|
||||
)
|
||||
|
||||
/** Merge the two sources into the single Home frame without inventing playable items. */
|
||||
fun buildHomeRows(
|
||||
library: HomeLibrary,
|
||||
continuation: List<HomeRow>,
|
||||
libraryAvailable: Boolean = true,
|
||||
loading: Boolean = false,
|
||||
): List<HomeRow> = buildList {
|
||||
if (loading) {
|
||||
add(HomeRow("hero-sk", HomeRowKind.Hero, skeleton = true))
|
||||
listOf(HomeCardKind.Tile, HomeCardKind.Tile, HomeCardKind.Poster, HomeCardKind.Poster).forEachIndexed { index, kind ->
|
||||
add(HomeRow("sk$index", HomeRowKind.Shelf, cardKind = kind, skeleton = true))
|
||||
}
|
||||
return@buildList
|
||||
}
|
||||
val choice = when {
|
||||
library.resume.isNotEmpty() -> "resume" to library.resume.first()
|
||||
library.nextUp.isNotEmpty() -> "next" to library.nextUp.first()
|
||||
library.newest.isNotEmpty() -> "new" to library.newest.first()
|
||||
else -> continuation.firstNotNullOfOrNull { it.cards.firstOrNull() }?.let { "pick" to it }
|
||||
}
|
||||
val hero = choice?.second
|
||||
if (hero != null) add(HomeRow("hero", HomeRowKind.Hero, cards = listOf(hero), heroKind = choice.first))
|
||||
if (!libraryAvailable) {
|
||||
add(HomeRow("jf-down", HomeRowKind.State, "Your library can’t be reached", text = "Jellyfin isn’t answering, so nothing can play right now."))
|
||||
} else {
|
||||
listOf(Triple("cw", "Continue watching", library.resume), Triple("nu", "Next up", library.nextUp), Triple("new", "New in your library", library.newest)).forEach { (id, title, source) ->
|
||||
val cards = source.filter { it.id != hero?.id }
|
||||
if (cards.isNotEmpty()) add(HomeRow(id, HomeRowKind.Shelf, title, cards, if (id == "cw") HomeCardKind.Wide else HomeCardKind.Tile))
|
||||
}
|
||||
listOf("movies", "shows", "anime").forEach { destination ->
|
||||
val titles = library.destinations[destination].orEmpty().sortedWith(compareBy<HomeTitle> { it.played }.thenByDescending { it.addedAt })
|
||||
if (titles.isNotEmpty()) add(HomeRow("lib-$destination", HomeRowKind.Shelf, if (destination == "anime") "Anime" else "Your $destination", titles.take(WolfskinTokens.Behaviour.rowLimit.toInt()), total = library.destinationTotals[destination] ?: titles.size, target = destination))
|
||||
}
|
||||
}
|
||||
add(HomeRow("disc-h", HomeRowKind.Section, "Discover"))
|
||||
addAll(continuation)
|
||||
}
|
||||
|
||||
/** Authentication failure is distinct from an unreachable signed-in catalogue. */
|
||||
fun homeDiscoverState(status: HomeAvailability): HomeRow = if (status == HomeAvailability.SignedOut) {
|
||||
HomeRow("signin", HomeRowKind.State, "Discover needs sign-in", text = "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual.", calm = true)
|
||||
} else {
|
||||
HomeRow("cond-down", HomeRowKind.State, "Discover is resting", text = "Conductarr can’t be reached. Your library still plays.")
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.delay
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
|
||||
/** One collector owns the focused card dwell; cached scores appear on re-entry without another read. */
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
shell: ShellState,
|
||||
snapshot: HomeSnapshot,
|
||||
modifier: Modifier = Modifier,
|
||||
onAction: (HomeAction) -> Unit,
|
||||
) {
|
||||
val view = shell.rememberedView("home")
|
||||
LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt }
|
||||
val action by rememberUpdatedState(onAction)
|
||||
val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } }
|
||||
DisposableEffect(shell, focus) {
|
||||
shell.contentFocus = focus
|
||||
onDispose { if (shell.contentFocus === focus) shell.contentFocus = null }
|
||||
}
|
||||
LaunchedEffect(snapshot.rows, view) {
|
||||
snapshot.rows.forEach { item ->
|
||||
view.cards[item.id]?.let { view.cards[item.id] = it.coerceIn(0, (item.count - 1).coerceAtLeast(0)) }
|
||||
}
|
||||
val remembered = snapshot.rows.indexOfFirst { it.id == view.home.focusedKey && it.focusable }
|
||||
if (remembered >= 0) view.row = remembered
|
||||
if (snapshot.rows.none { it.focusable }) {
|
||||
if (view.home.focusedKey == null && !shell.navFocused) view.home.loadingEntryRevision = shell.inputRevision
|
||||
shell.navFocused = true
|
||||
shell.navIndex = shell.currentTab
|
||||
} else {
|
||||
if (view.home.loadingEntryRevision == shell.inputRevision) focus.focus(snapshot.rows.indexOfFirst { it.focusable }, 0)
|
||||
view.home.loadingEntryRevision = null
|
||||
if (snapshot.rows.getOrNull(view.row)?.focusable != true) view.row = snapshot.rows.indexOfFirst { it.focusable }
|
||||
}
|
||||
view.home.focusedKey = snapshot.rows.getOrNull(view.row)?.id
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val heights = remember(snapshot.rows) { snapshot.rows.map { rowHeight(it) } }
|
||||
val topInset = if (snapshot.rows.firstOrNull()?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(view.row, snapshot.rows) {
|
||||
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) }
|
||||
}
|
||||
LaunchedEffect(view) {
|
||||
if (view.home.positionedKey != null) {
|
||||
view.home.focusedKey?.let { view.home.tracks[it] }?.let { track ->
|
||||
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
|
||||
}
|
||||
view.listState.scrollToItem(view.listState.firstVisibleItemIndex, view.listState.firstVisibleItemScrollOffset)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) {
|
||||
val key = snapshot.rows.getOrNull(view.row)?.id
|
||||
if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect
|
||||
val topRequested = shell.navFocused && view.home.resetScroll > 0 && view.home.positionedReset != view.home.resetScroll
|
||||
val target = if (topRequested) 0f else with(density) {
|
||||
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
|
||||
}.coerceAtLeast(0f)
|
||||
view.home.positionedKey = key
|
||||
view.home.positionedReset = view.home.resetScroll
|
||||
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
|
||||
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
|
||||
}
|
||||
val cache = view.home.ratings
|
||||
val row = snapshot.rows.getOrNull(view.row)
|
||||
val title = row?.cards?.getOrNull(focus.column)
|
||||
val hero = snapshot.rows.firstOrNull { it.kind == HomeRowKind.Hero }?.cards?.firstOrNull()
|
||||
var heroRatings by remember(hero?.id) { mutableStateOf(hero?.let(cache::read).orEmpty()) }
|
||||
var focusedRatings by remember(title?.id, shell.navFocused) { mutableStateOf(title?.let(cache::read).orEmpty()) }
|
||||
LaunchedEffect(title?.id, hero?.id, shell.navFocused, shell.pickerVisible, snapshot.conductarr) {
|
||||
if (hero != null && snapshot.conductarr == HomeAvailability.Ready) {
|
||||
heroRatings = hero.ratings
|
||||
cache.save(hero, heroRatings)
|
||||
}
|
||||
if (title != null && !shell.navFocused && !shell.pickerVisible && snapshot.conductarr == HomeAvailability.Ready) {
|
||||
val saved = cache.read(title)
|
||||
if (saved == null && row.kind != HomeRowKind.Hero) delay(WolfskinTokens.Motion.ratingDwell.toLong())
|
||||
focusedRatings = saved ?: title.ratings.also { cache.save(title, it) }
|
||||
}
|
||||
}
|
||||
val showRatings = snapshot.conductarr != HomeAvailability.SignedOut
|
||||
val trailingSpace = maxOf(H.frameBottom, WolfskinTokens.Shell.stageHeight - L.pinnedLine - (heights.lastOrNull() ?: WolfskinTokens.Shell.zero))
|
||||
LazyColumn(state = view.listState, modifier = modifier.testTag("wolfskin-home").semantics { stateDescription = "${row?.id}:${focus.column}" }, contentPadding = PaddingValues(top = topInset, bottom = trailingSpace)) {
|
||||
itemsIndexed(snapshot.rows, key = { _, item -> item.id }, contentType = { _, item -> item.kind.name.lowercase() }) { index, item ->
|
||||
val selected = index == view.row && !shell.navFocused
|
||||
fun activate(column: Int) { focus.focus(index, column); focus.key(ShellKey.Ok) }
|
||||
when (item.kind) {
|
||||
HomeRowKind.Hero -> HomeHero(item, selected, focus.column, if (showRatings) heroRatings else emptyList(), ::activate) { focus.focus(index, it) }
|
||||
HomeRowKind.Shelf -> HomeShelf(item, view.home, selected, view.cards[item.id] ?: 0, if (showRatings && selected) focusedRatings else emptyList(), ::activate) { focus.focus(index, it) }
|
||||
HomeRowKind.Section -> HomeSection(shell.person, snapshot.savedAt)
|
||||
HomeRowKind.State -> HomeRecovery(item, selected, { activate(0) }) { focus.focus(index, 0) }
|
||||
HomeRowKind.Note -> Row(Modifier.fillMaxWidth().height(L.row_note).padding(start = L.safeSide, top = H.noteTop), horizontalArrangement = Arrangement.spacedBy(H.noteGap)) {
|
||||
Glyph(if (item.calm) "eye" else "alert", if (item.calm) C.muted else C.warn, Modifier.size(L.buttonIcon))
|
||||
Text(item.text, color = if (item.calm) C.muted else C.warn, style = HomeType.body)
|
||||
}
|
||||
HomeRowKind.Explore -> Box(Modifier.fillMaxWidth().height(L.row_endcta).padding(start = L.safeSide, top = H.endTop)) {
|
||||
PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }, textStyle = HomeType.button) { activate(0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rowHeight(row: HomeRow) = when (row.kind) {
|
||||
HomeRowKind.Hero -> L.row_hero
|
||||
HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.Shell.zero) + L.rowGapAfter
|
||||
HomeRowKind.Section -> L.row_section
|
||||
HomeRowKind.State -> L.row_state
|
||||
HomeRowKind.Note -> L.row_note
|
||||
HomeRowKind.Explore -> L.row_endcta
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
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.runtime.SideEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.HomeType as T
|
||||
|
||||
/** Every lazy row and card uses a stable source identity and an explicit content type. */
|
||||
@Composable
|
||||
fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) {
|
||||
val width = row.cardWidth
|
||||
val height = row.cardHeight
|
||||
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero
|
||||
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
|
||||
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
|
||||
val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
|
||||
val targetFirst by rememberUpdatedState(first)
|
||||
val density = LocalDensity.current
|
||||
LaunchedEffect(track) {
|
||||
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
|
||||
var positionedFirst = targetFirst
|
||||
snapshotFlow { targetFirst }.collectLatest { target ->
|
||||
if (target != positionedFirst) {
|
||||
positionedFirst = target
|
||||
val pitch = with(density) { (width + K.posterGap).toPx() }
|
||||
track.animateScrollBy((target - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base))
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
|
||||
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))
|
||||
val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList()
|
||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
|
||||
itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title ->
|
||||
val selected = focused && column == index
|
||||
val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index")
|
||||
.then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier)
|
||||
if (title == null && !row.skeleton) {
|
||||
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label)
|
||||
} else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height) {
|
||||
if (title == null) Box(Modifier.fillMaxSize().background(C.surface)) else {
|
||||
TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster)
|
||||
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)
|
||||
if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) }
|
||||
if (title.resumeSeconds > 0 && title.runtimeSeconds != null) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = H.progressInset).padding(bottom = if (row.cardKind == HomeCardKind.Poster) H.posterProgressBottom else H.progressBottom).fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
|
||||
Box(Modifier.fillMaxHeight().fillMaxWidth((title.resumeSeconds / title.runtimeSeconds).toFloat().coerceIn(0f, 1f)).background(C.accent))
|
||||
}
|
||||
HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (row.cardKind == HomeCardKind.Poster && focused && !row.skeleton) {
|
||||
val title = row.cards.getOrNull(column)
|
||||
val availability = when {
|
||||
title == null -> null
|
||||
row.catalogue -> if (title.held) "In your library" else title.otherOwner?.let { "In $it’s library" }
|
||||
title.played -> "Watched"
|
||||
title.resumeSeconds > 0 -> title.remaining
|
||||
else -> null
|
||||
}
|
||||
val facts = remember(title, row.total, availability) {
|
||||
(title?.facts ?: "${row.total} titles").replace(" · ", " · ") + availability?.let { " · $it" }.orEmpty()
|
||||
}
|
||||
Caption(title?.title ?: "See all ${row.title}", facts, true, Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(H.captionWidth), accentLastFact = availability != null, titleStyle = T.button, factsStyle = T.caption, titleOverflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TileOverlay(title: HomeTitle) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
|
||||
Column(Modifier.fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))).padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) {
|
||||
Text(title.displayTitle, modifier = Modifier.exactLineBox(T.button), color = C.fg, style = T.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(title.subtitle, modifier = Modifier.exactLineBox(T.caption), color = C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val HomeRow.cardWidth: Dp get() = when (cardKind) { HomeCardKind.Poster -> K.posterW; HomeCardKind.Tile -> K.tileW; HomeCardKind.Wide -> K.wideW }
|
||||
val HomeRow.cardHeight: Dp get() = when (cardKind) { HomeCardKind.Poster -> K.posterH; HomeCardKind.Tile -> K.tileH; HomeCardKind.Wide -> K.wideH }
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.ui.text.PlatformTextStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
|
||||
/** Home keeps full CSS line boxes without moving retained shell or library typography. */
|
||||
object HomeType {
|
||||
private fun fullBox(style: TextStyle): TextStyle = style.copy(
|
||||
platformStyle = PlatformTextStyle(includeFontPadding = false),
|
||||
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None, LineHeightStyle.Mode.Tight),
|
||||
)
|
||||
val body = fullBox(WolfskinTokens.Type.body)
|
||||
val button = fullBox(WolfskinTokens.Type.button)
|
||||
val caption = fullBox(WolfskinTokens.Type.caption)
|
||||
val label = fullBox(WolfskinTokens.Type.label)
|
||||
val displayHero = fullBox(WolfskinTokens.Type.displayHero)
|
||||
val stateTitle = fullBox(WolfskinTokens.Type.stateTitle)
|
||||
val sectionTitle = fullBox(WolfskinTokens.Type.sectionTitle)
|
||||
val rowHeading = WolfskinTokens.Type.rowHeading
|
||||
val ratingPlate = WolfskinTokens.Type.ratingPlate
|
||||
val listTitle = fullBox(WolfskinTokens.Type.listTitle)
|
||||
val avatarLetterSectionHeader = WolfskinTokens.Type.avatarLetterSectionHeader
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/** Focus-follow runs on a new row, never on re-entry to an already-positioned saved viewport. */
|
||||
class HomeViewMemory {
|
||||
val ratings = HomeRatingCache()
|
||||
val firstCards = mutableStateMapOf<String, Int>()
|
||||
val tracks = mutableMapOf<String, LazyListState>()
|
||||
var loadingEntryRevision: Long? = null
|
||||
var focusedKey: String? = null
|
||||
var positionedKey: String? = null
|
||||
var resetScroll by mutableIntStateOf(0)
|
||||
var positionedReset = -1
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.content.Context
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.allowRgb565
|
||||
import coil3.request.crossfade
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import coil3.disk.directory
|
||||
|
||||
/** Every grid shares one bounded cache and four concurrent image reads. */
|
||||
object LibraryImages {
|
||||
private var loader: ImageLoader? = null
|
||||
private val reads = Semaphore(WolfskinTokens.Library.imageReads.toInt())
|
||||
|
||||
@Synchronized
|
||||
fun loader(context: Context): ImageLoader = loader ?: SingletonImageLoader.get(context).newBuilder()
|
||||
.memoryCache(MemoryCache.Builder().maxSizeBytes((WolfskinTokens.Image.memoryCacheMB * 1024 * 1024).toLong()).build())
|
||||
.diskCache(DiskCache.Builder().directory(context.cacheDir.resolve("wolfskin-library-art"))
|
||||
.maxSizeBytes((WolfskinTokens.Image.diskCacheMB * 1024 * 1024).toLong()).build())
|
||||
.components { add(Interceptor { chain -> reads.withPermit { chain.proceed() } }) }
|
||||
.crossfade(false).build().also { loader = it }
|
||||
|
||||
fun request(context: Context, data: Any?, width: Int = WolfskinTokens.Image.posterRequestWidth,
|
||||
height: Int = WolfskinTokens.Image.posterRequestHeight, poster: Boolean = true): ImageRequest =
|
||||
ImageRequest.Builder(context).data(data).size(width, height).allowRgb565(poster).crossfade(false).build()
|
||||
}
|
||||
|
|
@ -5,11 +5,9 @@ import androidx.compose.foundation.background
|
|||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
|
@ -29,20 +27,15 @@ fun LibraryPoster(
|
|||
offlineArt: ((String) -> ImageBitmap?)? = null,
|
||||
) {
|
||||
val poster = title.poster
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Box(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
|
||||
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
|
||||
.testTag("poster-${title.id}")) {
|
||||
Box(Modifier.fillMaxSize().clip(shape).background(C.surface2)) {
|
||||
TitleCard(focused, modifier.testTag("poster-${title.id}")) {
|
||||
val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } }
|
||||
if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
else if (poster != null && offlineArt == null) {
|
||||
var ready by remember(poster) { mutableStateOf(false) }
|
||||
if (!ready) MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
|
||||
val context = LocalContext.current
|
||||
val request = remember(poster, context) { LibraryImages.request(context, poster) }
|
||||
AsyncImage(request, title.posterTitle, imageLoader = LibraryImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop,
|
||||
val request = remember(poster, context) { WolfskinImages.request(context, poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true) }
|
||||
AsyncImage(request, title.posterTitle, imageLoader = WolfskinImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop,
|
||||
onSuccess = { ready = true }, onError = { ready = false })
|
||||
} else MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
|
||||
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
|
||||
|
|
@ -63,7 +56,6 @@ fun LibraryPoster(
|
|||
if (held) C.onFocus else color, Modifier.size(L.badgeIcon))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,9 +39,9 @@ fun LibraryScreen(
|
|||
cachedRatings: Set<String> = emptySet(),
|
||||
ratingsReachable: Boolean = true,
|
||||
offlineArt: ((String) -> ImageBitmap?)? = null,
|
||||
view: RememberedView = shell.view,
|
||||
registerFocus: Boolean = true,
|
||||
) {
|
||||
val view = shell.view
|
||||
val density = LocalDensity.current
|
||||
val context = LocalContext.current
|
||||
val active = !shell.navFocused && !shell.pickerVisible
|
||||
|
|
@ -64,7 +64,7 @@ fun LibraryScreen(
|
|||
if (offlineArt == null) {
|
||||
val start = (grid.index / grid.columns + 1) * grid.columns
|
||||
for (index in start until (start + grid.columns).coerceAtMost(items.size)) {
|
||||
items[index].poster?.let { LibraryImages.loader(context).enqueue(LibraryImages.request(context, it)) }
|
||||
items[index].poster?.let { WolfskinImages.loader(context).enqueue(WolfskinImages.request(context, it, (K.posterW.value * 2).toInt(), (K.posterH.value * 2).toInt(), true)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,20 +8,23 @@ 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.TextStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.tv.material3.Text
|
||||
|
||||
/** Shelves expose the full underlying list without displaying transport pagination. */
|
||||
@Composable
|
||||
fun LibrarySeeAllCard(count: Int, focused: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier,
|
||||
width: Dp = WolfskinTokens.Card.posterW, height: Dp = WolfskinTokens.Card.posterH) {
|
||||
width: Dp = WolfskinTokens.Card.posterW, height: Dp = WolfskinTokens.Card.posterH,
|
||||
titleStyle: TextStyle = WolfskinTokens.Type.listTitle, countStyle: TextStyle = WolfskinTokens.Type.label) {
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Column(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card)
|
||||
.background(WolfskinTokens.Color.surface, shape).border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape)
|
||||
.clickable(onClick = onClick), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge))
|
||||
Text("See all", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.listTitle)
|
||||
Text(count.toString(), color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.label)
|
||||
TitleCard(focused, modifier.clickable(onClick = onClick), width, height) {
|
||||
Column(Modifier.fillMaxSize().background(WolfskinTokens.Color.surface, shape)
|
||||
.border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape),
|
||||
verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge))
|
||||
Text("See all", modifier = Modifier.exactLineBox(titleStyle), color = WolfskinTokens.Color.fg, style = titleStyle)
|
||||
Text(count.toString(), modifier = Modifier.exactLineBox(countStyle), color = WolfskinTokens.Color.muted, style = countStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import androidx.compose.ui.platform.LocalDensity
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V
|
||||
|
|
@ -100,13 +101,13 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier,
|
|||
}
|
||||
}
|
||||
}
|
||||
if (savedAt != null) {
|
||||
(savedAt ?: state.view.savedAt)?.let { time ->
|
||||
Row(Modifier.align(Alignment.TopEnd)
|
||||
.offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2)
|
||||
.height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button))
|
||||
.padding(horizontal = V.side + V.border), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph("wifi", C.warn, Modifier.size(V.icon))
|
||||
Text("Saved $savedAt", color = C.warn, style = WolfskinTokens.Type.caption)
|
||||
Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption)
|
||||
}
|
||||
}
|
||||
val avatar = boxes.last()
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ class RememberedView {
|
|||
val listState = LazyListState()
|
||||
var row by mutableStateOf(0)
|
||||
val cards = mutableStateMapOf<String, Int>()
|
||||
var savedAt by mutableStateOf<String?>(null)
|
||||
val home by lazy { HomeViewMemory() }
|
||||
var library: LibraryGridState? = null
|
||||
}
|
||||
|
||||
|
|
@ -67,9 +69,11 @@ class ShellState(
|
|||
private val views = mutableStateMapOf<Pair<String, String>, RememberedView>()
|
||||
private var armedCard: String? = null
|
||||
var contentFocus: ShellContentFocus? = null
|
||||
var inputRevision = 0L
|
||||
private set
|
||||
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
|
||||
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
|
||||
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
|
||||
val view: RememberedView get() = rememberedView(route)
|
||||
|
||||
/** Screen options share the same per-person memory as their root's focus and scroller. */
|
||||
fun rememberedView(destination: String): RememberedView =
|
||||
|
|
@ -87,18 +91,18 @@ class ShellState(
|
|||
}
|
||||
|
||||
/** Pointer navigation uses the same transitions as D-pad selection. */
|
||||
fun focusNav(index: Int) { navIndex = index; navFocused = true }
|
||||
fun focusPerson(index: Int) { pickerIndex = index; gearFocused = false }
|
||||
fun focusNav(index: Int) { inputRevision++; navIndex = index; navFocused = true }
|
||||
fun focusPerson(index: Int) { inputRevision++; pickerIndex = index; gearFocused = false }
|
||||
|
||||
/** Repeats are consumed, and a row activation is cancelled when focus moves. */
|
||||
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = if (!pickerVisible && !navFocused && !requestsVisible && !contentOverlayVisible) contentFocus?.cardKey else null): Boolean {
|
||||
if (down) inputRevision++
|
||||
if (key == ShellKey.Ok && repeat > 0) return true
|
||||
if (!pickerVisible && !requestsVisible && contentOverlayVisible) {
|
||||
if (down) contentFocus?.key(key)
|
||||
return true
|
||||
}
|
||||
if (key == ShellKey.Ok) {
|
||||
if (repeat > 0) return true
|
||||
if (card != null) {
|
||||
if (down) armedCard = card else {
|
||||
if (armedCard == card) activate()
|
||||
|
|
|
|||
|
|
@ -57,8 +57,8 @@ fun TitleRatings(ratings: List<LibraryRating>, modifier: Modifier = Modifier, st
|
|||
val desaturated = if (unknownCritic) remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) } else null
|
||||
Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") P.wordmarkRatio else 1f, markHeight),
|
||||
alpha = if (unknownCritic) P.unknownCriticOpacity else 1f, colorFilter = desaturated)
|
||||
if (rating.episode) Text("Ep", color = C.muted, style = textStyle.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), modifier = Modifier.cssLineBox(textStyle))
|
||||
Text(rating.formatted(), color = C.fg, style = numberStyle, modifier = Modifier.cssLineBox(numberStyle), maxLines = 1)
|
||||
if (rating.episode) Text("Ep", color = C.muted, style = textStyle.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), modifier = Modifier.exactLineBox(textStyle))
|
||||
Text(rating.formatted(), color = C.fg, style = numberStyle, modifier = Modifier.exactLineBox(numberStyle), maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -102,6 +102,6 @@ fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifi
|
|||
val fontSize = TextUnit(fit.second, TextUnitType.Sp)
|
||||
val style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize,
|
||||
lineHeight = WolfskinTokens.Type.ratingPlate.lineHeight * (fit.second / WolfskinTokens.Type.ratingPlate.fontSize.value))
|
||||
TitleRatings(ratings.take(fit.first), modifier = Modifier.cssLineBox(style, top = P.plateTop, bottom = P.plateBottom + P.plateInlineDescent), style = style, plate = true)
|
||||
TitleRatings(ratings.take(fit.first), modifier = Modifier.exactLineBox(style, top = P.plateTop, bottom = P.plateBottom + P.plateInlineDescent), style = style, plate = true)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Explicit CSS leading and margins share one fractional layout engine across Wolfskin screens. */
|
||||
fun Modifier.exactLineBox(style: TextStyle, top: Dp = 0.dp, bottom: Dp = 0.dp): Modifier =
|
||||
if (style.lineHeight == TextUnit.Unspecified) this else then(CssLineBox(style.lineHeight, top, bottom))
|
||||
|
|
@ -45,16 +45,16 @@ 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) {
|
||||
fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) {
|
||||
val bitmap = LocalTitleBitmaps.current[path]
|
||||
if (bitmap != null) Image(bitmap, null, modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
|
||||
else if (path != null) {
|
||||
val context = LocalContext.current
|
||||
val request = remember(context, path, width, height, poster) {
|
||||
LibraryImages.request(context, path, width, height, poster)
|
||||
WolfskinImages.request(context, path, width, height, poster)
|
||||
}
|
||||
AsyncImage(model = request, imageLoader = LibraryImages.loader(context),
|
||||
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
|
||||
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context),
|
||||
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, onError = { onError() })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/** Shelf, grid and continuation cards share one fixed slot and draw-only focus surface. */
|
||||
@Composable
|
||||
fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = WolfskinTokens.Card.posterW,
|
||||
height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) {
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card)) {
|
||||
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
val item = state.data.item
|
||||
val type = if (item.kind == "movie") "Movie" else "Series"
|
||||
CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
|
||||
Row(Modifier.cssLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon))
|
||||
Text(buildString {
|
||||
append("$type · ${if (item.held) "In your library" else "Not in your library"}")
|
||||
|
|
@ -47,29 +47,29 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
if (state.data.hidden) append(" · Hidden from your picks")
|
||||
}, color = C.accentText, style = TitleTextStyles.dLine)
|
||||
}
|
||||
Text(item.name, color = C.fg, style = TitleTextStyles.displayDetail, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.cssLineBox(TitleTextStyles.displayDetail, bottom = D.titleBottom).testTag("title-heading"))
|
||||
Text(item.name, color = C.fg, style = TitleTextStyles.displayDetail, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.displayDetail, bottom = D.titleBottom).testTag("title-heading"))
|
||||
Row(Modifier.padding(bottom = D.factsBottom).height(D.factsHeight).testTag("title-facts"), horizontalArrangement = Arrangement.spacedBy(D.factsGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body)) }
|
||||
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body))
|
||||
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body))
|
||||
item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) }
|
||||
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings)
|
||||
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body))
|
||||
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||
}
|
||||
state.data.requestedStage?.let { stage ->
|
||||
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
|
||||
Row(Modifier.cssLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon))
|
||||
Text(stage, color = color, style = TitleTextStyles.label)
|
||||
Text("for you · by ${state.data.requestActor.orEmpty()}", color = C.muted, style = TitleTextStyles.dLine)
|
||||
}
|
||||
}
|
||||
Text(item.overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.cssLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
|
||||
Text(item.overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
|
||||
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
|
||||
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
||||
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
||||
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
||||
}
|
||||
state.data.playTarget?.let { target ->
|
||||
Row(Modifier.cssLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (target.resumeSeconds > 0) {
|
||||
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
|
||||
val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f
|
||||
|
|
|
|||
|
|
@ -70,24 +70,24 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
|
|||
|
||||
@Composable
|
||||
private fun VersionChoices(state: TitleState) {
|
||||
Text("Version", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle))
|
||||
Text("Version", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle))
|
||||
SheetSubtitle("${state.target?.name ?: state.data.item.name} · ${state.data.versions.size} files")
|
||||
state.data.versions.forEachIndexed { index, version ->
|
||||
PlaybackChoice(version.name, listOf(version.video, version.bitrate, version.size).filter { it.isNotBlank() }.joinToString(" · "), version == state.version, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
|
||||
}
|
||||
Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = TitleTextStyles.caption, modifier = Modifier.cssLineBox(TitleTextStyles.caption))
|
||||
Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackChoices(state: TitleState) {
|
||||
val version = state.version ?: return
|
||||
Text("Audio and subtitles", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle))
|
||||
Text("Audio and subtitles", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle))
|
||||
SheetSubtitle("${state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name} · ${version.name}")
|
||||
Text("Audio", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.cssLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
|
||||
Text("Audio", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.exactLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
|
||||
version.audio.forEachIndexed { index, track ->
|
||||
PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
|
||||
}
|
||||
Text("Subtitles", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.cssLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
|
||||
Text("Subtitles", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.exactLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
|
||||
val start = version.audio.size
|
||||
PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) }
|
||||
version.subtitles.forEachIndexed { index, track ->
|
||||
|
|
@ -107,8 +107,8 @@ private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focus
|
|||
.clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
CssColumn(Modifier.weight(1f)) {
|
||||
Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.cssLineBox(TitleTextStyles.listTitle))
|
||||
if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption, modifier = Modifier.cssLineBox(TitleTextStyles.caption))
|
||||
Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.exactLineBox(TitleTextStyles.listTitle))
|
||||
if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption))
|
||||
}
|
||||
TitleRadio(selected, focused)
|
||||
}
|
||||
|
|
@ -117,7 +117,7 @@ private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focus
|
|||
@Composable
|
||||
private fun MediaInformation(state: TitleState) {
|
||||
val version = state.version ?: return
|
||||
Text("Media info", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle))
|
||||
Text("Media info", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle))
|
||||
SheetSubtitle(state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name)
|
||||
MediaInfoSection("General", listOf("Container" to version.container, "Size" to version.size.ifEmpty { "not known" }, "Bitrate" to version.bitrate, "Runtime" to (state.target?.runtime ?: "not known")))
|
||||
MediaInfoSection("Video", listOf("Stream" to version.video))
|
||||
|
|
@ -128,9 +128,9 @@ private fun MediaInformation(state: TitleState) {
|
|||
|
||||
@Composable
|
||||
private fun MediaInfoSection(label: String, values: List<Pair<String, String>>) {
|
||||
Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.cssLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
|
||||
Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.exactLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
|
||||
CssColumn(gap = D.infoRowGap) { values.forEach { (name, value) ->
|
||||
Row(Modifier.fillMaxWidth().cssLineBox(TitleTextStyles.caption), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) {
|
||||
Row(Modifier.fillMaxWidth().exactLineBox(TitleTextStyles.caption), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) {
|
||||
Text(name, color = C.dim, style = TitleTextStyles.infoKey, modifier = Modifier.width(D.infoLabelWidth))
|
||||
Text(value, color = C.fg, style = TitleTextStyles.caption)
|
||||
}
|
||||
|
|
@ -140,7 +140,7 @@ private fun MediaInfoSection(label: String, values: List<Pair<String, String>>)
|
|||
@Composable
|
||||
private fun WatchedConfirmation(state: TitleState) {
|
||||
val verb = if (state.watched) "unwatched" else "watched"
|
||||
Text("Mark the whole series as $verb?", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle))
|
||||
Text("Mark the whole series as $verb?", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle))
|
||||
SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.")
|
||||
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, Modifier.sheetFocused(state.sheetIndex == 0), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
|
||||
|
|
@ -150,7 +150,7 @@ private fun WatchedConfirmation(state: TitleState) {
|
|||
|
||||
@Composable
|
||||
private fun SheetSubtitle(text: String) {
|
||||
Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body, top = D.sheetSubLift, bottom = D.sheetSubBottom))
|
||||
Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, top = D.sheetSubLift, bottom = D.sheetSubBottom))
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ internal fun TitleRowPrefetch(data: TitlePageData, row: String?) {
|
|||
val context = LocalContext.current
|
||||
if (row == null || data.status != TitleReadStatus.Ready) return
|
||||
if (LocalTitleBitmaps.current.isNotEmpty()) return
|
||||
val loader = LibraryImages.loader(context)
|
||||
val loader = WolfskinImages.loader(context)
|
||||
DisposableEffect(loader, context, row, data.cast, data.episodes, data.related) {
|
||||
val requests = ArrayList<Disposable>()
|
||||
fun queue(path: String?, width: Int, height: Int, poster: Boolean = false) {
|
||||
if (path != null) requests.add(loader.enqueue(LibraryImages.request(context, path, width, height, poster)))
|
||||
if (path != null) requests.add(loader.enqueue(WolfskinImages.request(context, path, width, height, poster)))
|
||||
}
|
||||
when (row) {
|
||||
"cast" -> for (index in 0 until minOf(data.cast.size, WolfskinTokens.Card.personSlots))
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
|||
.titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) {
|
||||
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).clip(CircleShape))
|
||||
Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.cssLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
|
||||
Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.cssLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
|
||||
Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.exactLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
|
||||
Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
|
||||
/** Slot dimensions adapt to the shared exact-pixel artwork request, never another image loader. */
|
||||
@Composable
|
||||
private fun SlotImage(source: String, width: Dp, height: Dp, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) {
|
||||
TitleArtwork(source, (width.value * 2).toInt(), (height.value * 2).toInt(), modifier, poster, colorFilter, onError)
|
||||
}
|
||||
|
||||
/** Poster-only landscapes retain the upright one-sheet above the same dimmed image wash. */
|
||||
@Composable
|
||||
fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height: Dp, modifier: Modifier = Modifier,
|
||||
backdrop: String? = null, posterOnly: Boolean = false, landscape: Boolean = false, hero: Boolean = false) {
|
||||
val source = if (landscape) backdrop ?: poster else poster
|
||||
val failed = remember(source) { androidx.compose.runtime.mutableStateOf(false) }
|
||||
val missing = source == null || failed.value
|
||||
Box(modifier.background(C.surface2)) {
|
||||
if (!missing) {
|
||||
if (landscape && (posterOnly || backdrop == null) && poster != null) {
|
||||
val wash = remember(hero) {
|
||||
val matrix = ColorMatrix().apply { setToSaturation(if (hero) 1.2f else 1f) }
|
||||
val brightness = if (hero) .55f else .5f
|
||||
for (index in 0 until 15) matrix.values[index] *= brightness
|
||||
ColorFilter.colorMatrix(matrix)
|
||||
}
|
||||
val inset = if (hero) H.heroWashInset else H.cardWashInset
|
||||
val washModifier = Modifier.wrapContentSize(Alignment.Center, unbounded = true)
|
||||
.requiredSize(width + inset * 2, height + inset * 2)
|
||||
.blur(if (hero) H.heroBlur else H.cardBlur)
|
||||
.graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } }
|
||||
SlotImage(poster, width, height, washModifier, colorFilter = wash, onError = { failed.value = true })
|
||||
val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset
|
||||
val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f)
|
||||
val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop)
|
||||
SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true })
|
||||
} else SlotImage(source, if (hero) WolfskinTokens.Card.posterW * 5 else width, if (hero) WolfskinTokens.Card.posterW * 5 * (9f / 16f) else height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true })
|
||||
} else if (!hero) MediaArtworkFallback(title, type == "movie", showTitle = !landscape)
|
||||
else HomeAmbient(Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
|
@ -102,12 +102,17 @@ private fun RecoveryPicker(state: ShellState) {
|
|||
|
||||
/** Recovery actions remain focusable while profiles cannot be read. */
|
||||
@Composable
|
||||
fun PickerButton(label: String, focused: Boolean, primary: Boolean, onClick: () -> Unit) {
|
||||
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null,
|
||||
textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) {
|
||||
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
||||
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
|
||||
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
|
||||
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
|
||||
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
|
||||
Text(label, color = if (primary) C.onFocus else C.fg, style = WolfskinTokens.Type.button)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) {
|
||||
if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
|
||||
Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.content.Context
|
||||
import coil3.ImageLoader
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.disk.directory
|
||||
import coil3.imageLoader
|
||||
import coil3.request.crossfade
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.allowRgb565
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
|
||||
/** Wolfskin screens share one bounded loader; hidden-switch screens retain the upstream loader. */
|
||||
object WolfskinImages {
|
||||
private var application: Context? = null
|
||||
private var loader: ImageLoader? = null
|
||||
private const val MEBIBYTE = 1024L * 1024L
|
||||
private const val IMAGE_READS = 4
|
||||
private val readPermits = Semaphore(IMAGE_READS)
|
||||
|
||||
@Synchronized
|
||||
fun loader(context: Context): ImageLoader {
|
||||
val owner = context.applicationContext
|
||||
if (application !== owner) {
|
||||
loader?.shutdown()
|
||||
application = owner
|
||||
val reads = Dispatchers.IO.limitedParallelism(IMAGE_READS)
|
||||
loader = owner.imageLoader.newBuilder()
|
||||
.memoryCache { MemoryCache.Builder().maxSizeBytes((WolfskinTokens.Image.memoryCacheMB * MEBIBYTE).toLong()).build() }
|
||||
.diskCache { DiskCache.Builder().directory(owner.cacheDir.resolve("wolfskin-images")).maxSizeBytes((WolfskinTokens.Image.diskCacheMB * MEBIBYTE).toLong()).build() }
|
||||
.components { add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } }) }
|
||||
.fetcherCoroutineContext(reads).decoderCoroutineContext(reads).crossfade(false).build()
|
||||
}
|
||||
return loader!!
|
||||
}
|
||||
|
||||
fun request(context: Context, source: Any, width: Int, height: Int, poster: Boolean): ImageRequest =
|
||||
ImageRequest.Builder(context).data(source).size(width, height).allowRgb565(poster).build()
|
||||
|
||||
fun prefetch(context: Context, row: HomeRow) {
|
||||
val count = when (row.cardKind) { HomeCardKind.Poster -> 7; HomeCardKind.Tile -> 5; HomeCardKind.Wide -> 4 }
|
||||
val width = (row.cardWidth.value * 2).toInt()
|
||||
val height = (row.cardHeight.value * 2).toInt()
|
||||
row.cards.take(count).forEach { title ->
|
||||
val poster = row.cardKind == HomeCardKind.Poster
|
||||
val source = if (poster) title.poster else title.backdrop ?: title.poster
|
||||
if (source != null) loader(context).enqueue(request(context, source, width, height, poster))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -542,6 +542,66 @@ object WolfskinTokens {
|
|||
val border = 1.0.dp
|
||||
const val borderOpacity = 0.5f
|
||||
}
|
||||
object Home {
|
||||
val kickerGap = 6.0.dp
|
||||
val kickerBottom = 5.0.dp
|
||||
val titleBottom = 8.0.dp
|
||||
val factsHeight = 19.0.dp
|
||||
val factsBottom = 7.0.dp
|
||||
val factsGap = 11.0.dp
|
||||
val overviewBottom = 15.0.dp
|
||||
val cardPadding = 11.0.dp
|
||||
val cardOverlayTop = 27.0.dp
|
||||
val cardOverlayBottom = 9.0.dp
|
||||
val sectionTop = 20.0.dp
|
||||
val sectionAvatar = 28.0.dp
|
||||
val sectionGap = 11.0.dp
|
||||
val stateTextBottom = 12.0.dp
|
||||
val stateTop = 10.0.dp
|
||||
val statePaddingX = 20.0.dp
|
||||
val statePaddingY = 17.0.dp
|
||||
val stateGap = 4.0.dp
|
||||
val stateIcon = 18.0.dp
|
||||
val captionWidth = 320.0.dp
|
||||
val plateTop = 7.0.dp
|
||||
val plateSide = 8.0.dp
|
||||
val plateFade = 23.0.dp
|
||||
val ratingGap = 10.0.dp
|
||||
val ratingMarkGap = 4.0.dp
|
||||
val heroSkeletonWidth = 320.0.dp
|
||||
val heroSkeletonHeight = 42.0.dp
|
||||
val heroSkeletonFactsWidth = 230.0.dp
|
||||
val heroSkeletonFactsHeight = 17.0.dp
|
||||
val heroSkeletonButtonWidth = 130.0.dp
|
||||
val heroSkeletonGap = 11.0.dp
|
||||
val heroSkeletonButtonGap = 20.0.dp
|
||||
val artGlyph = 22.0.dp
|
||||
val cardSheetLeft = 11.0.dp
|
||||
val cardSheetTop = 9.0.dp
|
||||
val cardSheetInset = 18.0.dp
|
||||
val heroSheetRight = 90.0.dp
|
||||
val heroSheetTop = 75.0.dp
|
||||
val cardBlur = 13.0.dp
|
||||
val heroBlur = 30.0.dp
|
||||
val progressInset = 9.0.dp
|
||||
val progressBottom = 5.0.dp
|
||||
val endTop = 20.0.dp
|
||||
val frameBottom = 40.0.dp
|
||||
val toastPadding = 14.0.dp
|
||||
val toastTop = 75.0.dp
|
||||
val toastWidth = 380.0.dp
|
||||
val cardWashInset = 15.0.dp
|
||||
val heroWashInset = 40.0.dp
|
||||
val noteTop = 7.0.dp
|
||||
val noteGap = 8.0.dp
|
||||
val hairline = 1.0.dp
|
||||
val savedHeight = 26.0.dp
|
||||
val savedPadding = 10.0.dp
|
||||
val savedGap = 5.0.dp
|
||||
val savedIcon = 14.0.dp
|
||||
val savedRadius = 14.0.dp
|
||||
val posterProgressBottom = 6.0.dp
|
||||
}
|
||||
object Discover {
|
||||
val sheetPaddingX = 32.0.dp
|
||||
val sheetPaddingY = 40.0.dp
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M5.5,4 H18.5 A2.5,2.5 0,0 1,21 6.5 V17.5 A2.5,2.5 0,0 1,18.5 20 H5.5 A2.5,2.5 0,0 1,3 17.5 V6.5 A2.5,2.5 0,0 1,5.5 4 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" />
|
||||
<path android:pathData="M7,4 V20 M17,4 V20 M3,9 H7 M3,15 H7 M17,9 H21 M17,15 H21" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.6" />
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" android:pathData="M5.5,4H18.5Q21,4 21,6.5V17.5Q21,20 18.5,20H5.5Q3,20 3,17.5V6.5Q3,4 5.5,4Z" />
|
||||
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="1.6" android:pathData="M7,4V20M17,4V20M3,9H7M3,15H7M17,9H21M17,15H21" />
|
||||
</vector>
|
||||
|
|
|
|||
4
wolfskin-design/src/main/res/drawable/wolfskin_wifi.xml
Normal file
4
wolfskin-design/src/main/res/drawable/wolfskin_wifi.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round" android:pathData="M2.5,9a14,14 0,0 1,19 0M5.5,12.5a9.5,9.5 0,0 1,13 0M8.5,16a5,5 0,0 1,7 0" />
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M13.3,19.5a1.3,1.3 0,1 1,-2.6 0a1.3,1.3 0,1 1,2.6 0" />
|
||||
</vector>
|
||||
Loading…
Add table
Add a link
Reference in a new issue