Preserve title data and focus across updates
This commit is contained in:
parent
0c94e8f910
commit
6d7df0fb19
31 changed files with 747 additions and 147 deletions
|
|
@ -86,6 +86,8 @@ def render_tokens(tokens: dict) -> tuple[str, str]:
|
|||
elif isinstance(token.get("dp"), dict):
|
||||
for suffix, dimension in token["dp"].items():
|
||||
lines.append(f" val {name}{suffix[0].upper() + suffix[1:]} = {number(dimension)}.dp")
|
||||
if kind == "card":
|
||||
lines.append(f" const val {name}Slots = {token['fullSlotsPerRow']}")
|
||||
elif group_name == "image" and isinstance(token.get("px"), list):
|
||||
for suffix, dimension in zip(("Width", "Height"), token["px"]):
|
||||
lines.append(f" const val {name}{suffix} = {int(dimension)}")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import androidx.compose.animation.core.CubicBezierEasing
|
|||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -15,8 +20,14 @@ import androidx.tv.material3.Text
|
|||
@Composable
|
||||
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) {
|
||||
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "caption-fade")
|
||||
val parts = remember(facts) { facts.split(" · ") }
|
||||
Column(modifier.graphicsLayer { this.alpha = alpha }) {
|
||||
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(facts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Row(Modifier.fillMaxWidth().clipToBounds(), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Caption.factsGap)) {
|
||||
parts.forEachIndexed { index, part ->
|
||||
if (index > 0) Text("·", color = WolfskinTokens.Color.muted.copy(alpha = WolfskinTokens.Caption.separatorOpacity), style = WolfskinTokens.Type.caption)
|
||||
Text(part, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ import androidx.compose.animation.core.tween
|
|||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -30,6 +35,7 @@ import androidx.tv.material3.Text
|
|||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V
|
||||
|
||||
/** Blob bounds retain their measurement while their draw coordinates spring between targets. */
|
||||
data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp)
|
||||
|
|
@ -50,7 +56,7 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
|
|||
|
||||
/** There is one blob, including when focus wraps around the nav avatar. */
|
||||
@Composable
|
||||
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
|
||||
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier, savedAt: String? = null) {
|
||||
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
|
||||
val labelFade = remember { tween<Color>(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) }
|
||||
val index = if (state.navFocused) state.navIndex else state.currentTab
|
||||
|
|
@ -94,6 +100,15 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
|||
}
|
||||
}
|
||||
}
|
||||
if (savedAt != null) {
|
||||
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), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph("wifi", C.warn, Modifier.size(V.icon))
|
||||
Text("Saved $savedAt", color = C.warn, style = WolfskinTokens.Type.caption)
|
||||
}
|
||||
}
|
||||
val avatar = boxes.last()
|
||||
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
|
||||
Modifier.offset(avatar.left, avatar.top)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -19,6 +20,8 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -36,8 +39,16 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
|||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
||||
/** Filmography retains a real credit key and requests more credits before focus reaches the tail. */
|
||||
class PersonState(val person: TitlePerson, initialCredits: List<TitleItem>, private val onAction: (TitleAction) -> Unit, private val onReadMore: () -> Unit = {}) {
|
||||
class PersonState(initialPerson: TitlePerson, initialCredits: List<TitleItem>, private val onAction: (TitleAction) -> Unit, private val onReadMore: () -> Unit = {}) {
|
||||
var person by mutableStateOf(initialPerson)
|
||||
private set
|
||||
var credits by mutableStateOf(initialCredits)
|
||||
var savedAt by mutableStateOf<String?>(null)
|
||||
var toast by mutableStateOf<String?>(null)
|
||||
fun update(actor: TitlePerson, titles: List<TitleItem>, saved: String? = null) {
|
||||
person = actor; credits = titles; savedAt = saved
|
||||
index = index.coerceAtMost((credits.size - 1).coerceAtLeast(0))
|
||||
}
|
||||
var index by mutableStateOf(0)
|
||||
private var armed: String? = null
|
||||
|
||||
|
|
@ -46,7 +57,11 @@ class PersonState(val person: TitlePerson, initialCredits: List<TitleItem>, priv
|
|||
|
||||
/** The first grid row exits upward into the existing shell nav. */
|
||||
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
|
||||
if (credits.isEmpty()) { if (down && key == ShellKey.Back) onAction(TitleAction.Pop); return true }
|
||||
if (credits.isEmpty()) {
|
||||
if (down && key == ShellKey.Back) onAction(TitleAction.Pop)
|
||||
else if (down && key == ShellKey.Up) shell.focusNav(shell.currentTab)
|
||||
return true
|
||||
}
|
||||
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
|
||||
if (key == ShellKey.Ok) {
|
||||
if (repeat > 0) return true
|
||||
|
|
@ -72,8 +87,11 @@ class PersonState(val person: TitlePerson, initialCredits: List<TitleItem>, priv
|
|||
@Composable
|
||||
fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } }
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
|
||||
ShellSurface(shell, modifier, handlesContentKeys = false) { PersonPage(state, shell, it) }
|
||||
Box(modifier.fillMaxSize().background(C.bg)) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
|
||||
ShellSurface(shell, handlesContentKeys = false, savedAt = state.savedAt) { PersonPage(state, shell, it) }
|
||||
}
|
||||
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,13 +99,32 @@ fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Mo
|
|||
private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val density = LocalDensity.current
|
||||
val list = shell.view.listState
|
||||
var wheelPending by remember { mutableStateOf(false) }
|
||||
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
|
||||
LaunchedEffect(state.index / columns, shell.navFocused) {
|
||||
if (shell.navFocused || state.index < columns) list.animateScrollToItem(0)
|
||||
else list.animateScrollToItem(2 + state.index / columns, -with(density) { L.pinnedLine.roundToPx() })
|
||||
val target = if (shell.navFocused || state.index < columns) 0f
|
||||
else with(density) { (L.row_phead + L.row_gh + D.gridHeight * (state.index / columns) - L.pinnedLine).toPx() }
|
||||
val first = list.firstVisibleItemIndex
|
||||
val before = when (first) {
|
||||
0 -> WolfskinTokens.Shell.zero
|
||||
1 -> L.row_phead
|
||||
else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2)
|
||||
}
|
||||
val current = with(density) { before.toPx() } + list.firstVisibleItemScrollOffset
|
||||
if (target != current) list.animateScrollBy(target - current, titleFrameScrollSpec)
|
||||
}
|
||||
Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" }
|
||||
.onPreviewKeyEvent { event -> readShellKey(event)?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false }.focusable()) {
|
||||
.titleWheel { wheelPending = true; shell.navFocused = false }
|
||||
.onPreviewKeyEvent { event ->
|
||||
val key = readShellKey(event)
|
||||
if (key != null && key != ShellKey.Back && event.type == KeyEventType.KeyDown && wheelPending) {
|
||||
val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index >= 2 && it.key != "end-padding" && it.offset + it.size > with(density) { L.pinnedLine.roundToPx() } }
|
||||
state.index = ((visible?.index?.minus(2) ?: 0) * columns).coerceAtMost((state.credits.size - 1).coerceAtLeast(0))
|
||||
wheelPending = false
|
||||
if (key != ShellKey.Ok) return@onPreviewKeyEvent true
|
||||
}
|
||||
key?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false
|
||||
}.focusable()) {
|
||||
LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
|
||||
item(key = "person-header", contentType = "header") {
|
||||
Box(Modifier.height(L.row_phead)) {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ fun ShellSurface(
|
|||
modifier: Modifier = Modifier,
|
||||
handlesContentKeys: Boolean = true,
|
||||
settingsOpenRequests: Int? = null,
|
||||
savedAt: String? = null,
|
||||
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
||||
) {
|
||||
val focus = remember { FocusRequester() }
|
||||
|
|
@ -86,7 +87,7 @@ fun ShellSurface(
|
|||
WhosWatching(state)
|
||||
} else {
|
||||
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2, savedAt = savedAt)
|
||||
if (state.requestsVisible) {
|
||||
Box(Modifier.fillMaxSize().background(C.sheetScrim)) {
|
||||
Column(Modifier.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -19,7 +20,7 @@ import androidx.compose.ui.graphics.ColorMatrix
|
|||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -30,9 +31,6 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.tv.material3.Text
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.allowRgb565
|
||||
import coil3.request.crossfade
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
|
@ -48,7 +46,7 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo
|
|||
else if (path != null) {
|
||||
val context = LocalContext.current
|
||||
val request = remember(context, path, width, height, poster) {
|
||||
ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build()
|
||||
titleImageRequest(context, path, width, height, poster)
|
||||
}
|
||||
AsyncImage(model = request, imageLoader = LocalWolfskinImageLoader.current ?: SingletonImageLoader.get(context),
|
||||
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
|
||||
|
|
@ -58,11 +56,11 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo
|
|||
/** Initials follow the same title-hashed hue as the locked art fallback. */
|
||||
@Composable
|
||||
fun PersonPortrait(person: TitlePerson, modifier: Modifier = Modifier, large: Boolean = false) {
|
||||
val brush = remember(person.name) { initialsBrush(person.name) }
|
||||
Box(modifier.background(brush), contentAlignment = Alignment.Center) {
|
||||
if (person.portrait != null) TitleArtwork(person.portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight, Modifier.fillMaxSize())
|
||||
else Text(person.name.split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2).joinToString("") { it.take(1) }.uppercase(),
|
||||
color = C.fg, style = if (large) D.personInitialSize else D.initialSize, textAlign = TextAlign.Center)
|
||||
val initials = remember(person.name) { person.name.split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2).joinToString("") { it.take(1) }.uppercase() }
|
||||
val size = if (large) (L.personPortrait.value * 2).toInt() else WolfskinTokens.Image.castRequestWidth
|
||||
Box(modifier.titleFallbackBackground(person.name, D.personFallbackGradient), contentAlignment = Alignment.Center) {
|
||||
Text(initials, color = D.initialsColor, style = if (large) D.personInitialSize else D.initialSize, textAlign = TextAlign.Center)
|
||||
if (person.portrait != null) TitleArtwork(person.portrait, size, size, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,10 +103,43 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
|
|||
}
|
||||
}
|
||||
|
||||
private fun initialsBrush(name: String): Brush {
|
||||
/** Fallback art stays behind an image, so a failed decode never leaves an empty card. */
|
||||
@Composable
|
||||
fun TitleCardArtwork(item: TitleItem, tile: Boolean) {
|
||||
Box(Modifier.fillMaxSize().titleFallbackBackground(item.seriesName ?: item.name, D.cardFallbackGradient)) {
|
||||
TitleGlyph(if (item.kind == "movie") "film" else "tv", D.fallbackGlyphColor, Modifier.offset(D.fallbackInset, D.fallbackInset).size(D.fallbackGlyph))
|
||||
if (!tile) Text(item.name, color = C.fg, style = D.fallbackTitle, maxLines = 4,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(D.fallbackInset))
|
||||
if (tile && item.isPosterBackdrop && item.poster != null) {
|
||||
val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply {
|
||||
for (index in 0 until 15) values[index] *= D.tileAmbientBrightness
|
||||
}) }
|
||||
TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().blur(D.tileAmbientBlur), colorFilter = filter)
|
||||
val height = WolfskinTokens.Card.tileH - D.tileSheetInset * 2
|
||||
TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(),
|
||||
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).clip(RoundedCornerShape(D.tileSheetRadius)), poster = true)
|
||||
} else {
|
||||
TitleArtwork(if (tile) item.backdrop else item.poster,
|
||||
if (tile) WolfskinTokens.Image.tileRequestWidth else WolfskinTokens.Image.posterRequestWidth,
|
||||
if (tile) WolfskinTokens.Image.tileRequestHeight else WolfskinTokens.Image.posterRequestHeight,
|
||||
Modifier.fillMaxSize(), poster = !tile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.titleFallbackBackground(name: String, token: String): Modifier = drawWithCache {
|
||||
var hue = 0
|
||||
name.forEach { hue = (hue * 31 + it.code) % 360 }
|
||||
return Brush.linearGradient(listOf(Color.hsl(hue.toFloat(), .24f, .30f), Color.hsl(hue.toFloat(), .26f, .14f)))
|
||||
val values = Regex("([\\d.]+)%").findAll(token).map { it.groupValues[1].toFloat() / 100 }.toList()
|
||||
val angle = Math.toRadians(Regex("(\\d+)deg").find(token)!!.groupValues[1].toDouble())
|
||||
val dx = kotlin.math.sin(angle).toFloat()
|
||||
val dy = -kotlin.math.cos(angle).toFloat()
|
||||
val half = (kotlin.math.abs(size.width * dx) + kotlin.math.abs(size.height * dy)) / 2
|
||||
val vector = Offset(dx * half, dy * half)
|
||||
val middle = Offset(size.width / 2, size.height / 2)
|
||||
val brush = Brush.linearGradient(listOf(Color.hsl(hue.toFloat(), values[0], values[1]), Color.hsl(hue.toFloat(), values[2], values[3])),
|
||||
start = middle - vector, end = middle + vector)
|
||||
onDrawBehind { drawRect(brush) }
|
||||
}
|
||||
|
||||
private fun Modifier.titleAmbientBackground(): Modifier = drawWithCache {
|
||||
|
|
|
|||
|
|
@ -24,9 +24,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
|||
|
||||
/** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */
|
||||
@Composable
|
||||
fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
|
||||
fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, onClick: () -> Unit) {
|
||||
val focusOwner = LocalTitleFocusOwner.current
|
||||
val background = if (option.primary) C.fg else if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary
|
||||
val background = if (option.primary) C.fg else if (overArtwork) {
|
||||
if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary
|
||||
} else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
||||
val foreground = if (option.primary) C.onFocus else C.fg
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
|
||||
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
|
||||
|
|
@ -57,6 +59,8 @@ fun TitleGlyph(name: String, color: androidx.compose.ui.graphics.Color, modifier
|
|||
"clock" -> R.drawable.title_clock
|
||||
"users" -> R.drawable.title_users
|
||||
"tv" -> R.drawable.title_tv
|
||||
"film" -> R.drawable.title_film
|
||||
"alert" -> R.drawable.title_alert
|
||||
"wifi" -> R.drawable.title_wifi
|
||||
"check" -> R.drawable.title_check
|
||||
"sparkle" -> R.drawable.title_sparkle
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -77,7 +79,9 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index, onFocus = { state.focus("header", index) }) { state.click("header", index) } }
|
||||
state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index,
|
||||
Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) },
|
||||
onFocus = { state.focus("header", index) }) { state.click("header", index) } }
|
||||
}
|
||||
if (state.options.any { it.icon }) {
|
||||
val option = state.options.getOrNull(state.column)?.takeIf { active && it.icon }
|
||||
|
|
|
|||
|
|
@ -86,6 +86,8 @@ data class TitlePageData(
|
|||
val pick: Boolean = false,
|
||||
val savedAt: String? = null,
|
||||
val seasonNumbers: List<Int> = List(item.seasons) { it + 1 },
|
||||
val selectedSeason: Int? = null,
|
||||
val episodesStatus: TitleReadStatus = TitleReadStatus.Ready,
|
||||
)
|
||||
|
||||
/** Effects are handled by the app's authenticated facade and navigation layer. */
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ private fun WatchedConfirmation(state: TitleState) {
|
|||
SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
|
||||
TitleControl(TitleOption("cancel", "Cancel", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }) { state.clickSheet(1) }
|
||||
TitleControl(TitleOption("cancel", "Cancel", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -158,6 +158,6 @@ private fun HiddenReceipt(state: TitleState) {
|
|||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
TitleControl(TitleOption("undo", "Undo", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
|
||||
TitleControl(TitleOption("done", "Done", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }) { state.clickSheet(1) }
|
||||
TitleControl(TitleOption("done", "Done", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -24,6 +26,12 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
|
@ -44,22 +52,28 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
|||
/** The shared shell remains authoritative; title sheets overlay it and trap all input. */
|
||||
@Composable
|
||||
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
LaunchedEffect(state.toast) {
|
||||
if (state.toast != null) { delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null }
|
||||
}
|
||||
Box(modifier.fillMaxSize()) {
|
||||
val focusOwner = remember(shell) { { shell.navFocused = false } }
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
||||
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
||||
ShellSurface(shell, handlesContentKeys = false, savedAt = state.data.savedAt) { TitlePage(state, shell, it) }
|
||||
}
|
||||
if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell)
|
||||
if (!shell.pickerVisible) state.toast?.let { message ->
|
||||
Row(Modifier.align(Alignment.TopEnd).padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth)
|
||||
.background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph("wifi", C.warn, Modifier.size(L.buttonIcon))
|
||||
Text(message, color = C.fg, style = WolfskinTokens.Type.body)
|
||||
}
|
||||
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
||||
}
|
||||
}
|
||||
|
||||
/** Playback and person reads share one calm, timed notice above the persistent nav. */
|
||||
@Composable
|
||||
internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDismiss: () -> Unit) {
|
||||
LaunchedEffect(message) {
|
||||
if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() }
|
||||
}
|
||||
if (message != null) {
|
||||
Row(modifier.padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth)
|
||||
.background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph("alert", C.warn, Modifier.size(L.buttonIcon))
|
||||
Text(message, color = C.fg, style = WolfskinTokens.Type.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -69,6 +83,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
|
|||
fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val density = LocalDensity.current
|
||||
val list = shell.view.listState
|
||||
var wheelPending by remember { mutableStateOf(false) }
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
|
||||
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
||||
|
|
@ -78,31 +93,52 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
add("header")
|
||||
if (state.data.item.held && state.data.item.kind == "tv" && state.data.item.seasons > 0) {
|
||||
add("seasons")
|
||||
if (state.data.episodes.isNotEmpty()) add("episodes")
|
||||
if (state.data.episodesStatus == TitleReadStatus.Loading || state.data.episodes.isNotEmpty()) add("episodes")
|
||||
}
|
||||
if (state.data.castStatus == TitleReadStatus.Loading || state.data.cast.isNotEmpty()) add("cast")
|
||||
if (state.data.related.isNotEmpty()) add("related")
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.row, shell.navFocused, state.data.status) {
|
||||
val rowOffsets = remember(displayedRows, density) {
|
||||
FloatArray(displayedRows.size + 1).apply {
|
||||
displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row).toPx() } }
|
||||
}
|
||||
}
|
||||
val position by remember(list, rowOffsets) { derivedStateOf {
|
||||
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
|
||||
} }
|
||||
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows) {
|
||||
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
|
||||
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
|
||||
val y = displayedRows.take(index).fold(WolfskinTokens.Shell.zero) { total, row -> total + titleRowHeight(row) }
|
||||
val height = titleRowHeight(state.row)
|
||||
if (index == 0 || (y < L.windowAhead && y + height <= WolfskinTokens.Shell.stageHeight - L.safeTopPicker)) list.animateScrollToItem(0)
|
||||
else list.animateScrollToItem(index, -with(density) { L.pinnedLine.roundToPx() })
|
||||
val y = rowOffsets[index]
|
||||
val height = with(density) { titleRowHeight(state.row).toPx() }
|
||||
val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f
|
||||
else y - with(density) { L.pinnedLine.toPx() }
|
||||
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
|
||||
}
|
||||
val dim by remember { derivedStateOf {
|
||||
val before = displayedRows.take(list.firstVisibleItemIndex).sumOf { with(density) { titleRowHeight(it).toPx().toDouble() } }
|
||||
((before + list.firstVisibleItemScrollOffset) / D.dimScrollPx).toFloat().coerceAtMost(D.dimMaximum)
|
||||
} }
|
||||
val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } }
|
||||
val heroVisible by remember(rowOffsets, list, density) { derivedStateOf { position < with(density) { WolfskinTokens.Shell.stageHeight.toPx() } } }
|
||||
val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } }
|
||||
val nextArtRow = remember(displayedRows, lastVisible) { if (lastVisible < 0) null else displayedRows.drop(lastVisible + 1).firstOrNull { it == "cast" || it == "episodes" || it == "related" } }
|
||||
TitleRowPrefetch(state.data, nextArtRow)
|
||||
Box(modifier.focusRequester(focus).testTag("title-page").semantics { stateDescription = "${state.data.item.id}:${state.row}:${state.column}:${state.sheet}" }
|
||||
.titleWheel { if (state.data.status == TitleReadStatus.Ready) { wheelPending = true; shell.navFocused = false } }
|
||||
.onPreviewKeyEvent { event ->
|
||||
val key = readShellKey(event)
|
||||
if (key != null && key != ShellKey.Back && event.type == KeyEventType.KeyDown && wheelPending) {
|
||||
val visible = list.layoutInfo.visibleItemsInfo.firstOrNull {
|
||||
it.key in state.rows && it.offset + it.size > with(density) { L.pinnedLine.roundToPx() }
|
||||
}?.key as? String ?: "header"
|
||||
state.focus(visible, state.columns[visible] ?: 0)
|
||||
wheelPending = false
|
||||
if (key != ShellKey.Ok) return@onPreviewKeyEvent true
|
||||
}
|
||||
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||
}.focusable()) {
|
||||
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().background(C.bg.copy(alpha = dim)))
|
||||
if (heroVisible) {
|
||||
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg))
|
||||
} else Box(Modifier.fillMaxSize().background(C.bg))
|
||||
if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state)
|
||||
else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
|
||||
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
|
||||
|
|
@ -140,6 +176,7 @@ private fun TitleSeasons(state: TitleState, active: Boolean) {
|
|||
val selected = state.season == number
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.chip)
|
||||
Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) }
|
||||
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape)
|
||||
.then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier)
|
||||
.clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
|
||||
|
|
@ -151,9 +188,17 @@ private fun TitleSeasons(state: TitleState, active: Boolean) {
|
|||
|
||||
@Composable
|
||||
private fun TitleReadError(state: TitleState) {
|
||||
Column(Modifier.offset(L.safeSide, L.navBand).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(D.overviewGap), verticalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
Text("This title can’t be read", color = C.warn, style = WolfskinTokens.Type.stateTitle)
|
||||
Text("Conductarr didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }) { state.click("header", index) } } }
|
||||
Column(Modifier.offset(L.safeSide, D.errorTop).width(L.stateWidth)
|
||||
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.padding(horizontal = D.errorSide, vertical = D.errorVertical)) {
|
||||
Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph("alert", C.warn, Modifier.size(D.receiptIcon))
|
||||
Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = D.errorTitle)
|
||||
}
|
||||
Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.errorBodyBottom))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }, overArtwork = false) { state.click("header", index) } } }
|
||||
}
|
||||
}
|
||||
|
||||
internal val titleFrameScrollSpec = tween<Float>(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))
|
||||
|
|
|
|||
|
|
@ -21,7 +21,20 @@ fun Modifier.titlePointerFocus(onFocus: (() -> Unit)?): Modifier {
|
|||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
if (event.type == PointerEventType.Enter) { owner?.invoke(); latest?.invoke() }
|
||||
if (event.type == PointerEventType.Enter) { owner?.invoke(); latest() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Observe wheel input without consuming the frame's native scroll gesture. */
|
||||
@Composable
|
||||
fun Modifier.titleWheel(onScroll: () -> Unit): Modifier {
|
||||
val latest by rememberUpdatedState(onScroll)
|
||||
return pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
if (awaitPointerEvent().type == PointerEventType.Scroll) latest()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,13 +32,15 @@ fun formatTitleRating(rating: TitleRating, locale: Locale = Locale.getDefault())
|
|||
|
||||
/** A full facts-line set never shrinks or waits for focused-card dwell. */
|
||||
@Composable
|
||||
fun TitleRatings(ratings: List<TitleRating>, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.button) {
|
||||
fun TitleRatings(ratings: List<TitleRating>, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.button, plate: Boolean = false) {
|
||||
val density = LocalDensity.current
|
||||
val markHeight = with(density) { style.fontSize.toDp() }
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(D.ratingGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
val gap = if (plate) with(density) { (style.fontSize * D.ratingPlateEntryEm).toDp() } else D.ratingGap
|
||||
val markGap = if (plate) with(density) { (style.fontSize * D.ratingPlateMarkEm).toDp() } else D.ratingMarkGap
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
ratings.forEach { rating ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(D.ratingMarkGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (rating.episode) Text("Ep", color = C.muted, style = style)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (rating.episode) Text("Ep", color = C.muted, style = style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
|
||||
val resource = when (rating.mark) {
|
||||
"rotten" -> R.drawable.ic_rotten_tomatoes_rotten
|
||||
"upright" -> R.drawable.ic_rt_upright
|
||||
|
|
@ -47,8 +49,8 @@ fun TitleRatings(ratings: List<TitleRating>, modifier: Modifier = Modifier, styl
|
|||
"tmdb" -> R.drawable.ic_rating_tmdb
|
||||
else -> R.drawable.ic_rotten_tomatoes_fresh
|
||||
}
|
||||
Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") 1.8f else 1f, markHeight),
|
||||
alpha = if (rating.mark == "tomato") .55f else 1f,
|
||||
Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") D.ratingWordmarkAspect else 1f, markHeight),
|
||||
alpha = if (rating.mark == "tomato") D.mutedTomatoOpacity else 1f,
|
||||
colorFilter = if (rating.mark == "tomato") ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
|
||||
Text(formatTitleRating(rating), color = C.fg, style = style.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold), maxLines = 1)
|
||||
}
|
||||
|
|
@ -68,16 +70,27 @@ fun TitleRatingPlate(ratings: List<TitleRating>, modifier: Modifier = Modifier)
|
|||
val fit = remember(ratings, width, density, Locale.getDefault()) {
|
||||
var size = WolfskinTokens.Type.ratingPlate.fontSize.value
|
||||
var count = ratings.size
|
||||
val numbers = ratings.map { formatTitleRating(it) }
|
||||
fun measured(): Float {
|
||||
val text = ratings.take(count).joinToString("") { (if (it.episode) "Ep " else "") + formatTitleRating(it) }
|
||||
val textWidth = measurer.measure(text, WolfskinTokens.Type.button.copy(fontSize = TextUnit(size, TextUnitType.Sp))).size.width
|
||||
val marks = ratings.take(count).sumOf { if (it.source == "imdb" || it.source == "tmdb") 1.8 else 1.0 }.toFloat()
|
||||
return textWidth + with(density) { TextUnit(size * marks, TextUnitType.Sp).toPx() + D.ratingMarkGap.toPx() * count + D.ratingGap.toPx() * (count - 1) }
|
||||
val fontSize = TextUnit(size, TextUnitType.Sp)
|
||||
val style = WolfskinTokens.Type.button.copy(fontSize = fontSize, fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold)
|
||||
val markGap = with(density) { (fontSize * D.ratingPlateMarkEm).toPx() }
|
||||
val entryGap = with(density) { (fontSize * D.ratingPlateEntryEm).toPx() }
|
||||
var total = 0f
|
||||
for (index in 0 until count) {
|
||||
val rating = ratings[index]
|
||||
total += measurer.measure(numbers[index], style, maxLines = 1).size.width
|
||||
total += with(density) { fontSize.toPx() } * if (rating.source == "imdb" || rating.source == "tmdb") D.ratingWordmarkAspect else 1f
|
||||
total += markGap
|
||||
if (rating.episode) total += measurer.measure("Ep", style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), maxLines = 1).size.width + markGap
|
||||
if (index > 0) total += entryGap
|
||||
}
|
||||
return total
|
||||
}
|
||||
while (size > WolfskinTokens.Type.caption.fontSize.value && measured() > width) size = (size - .25f).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value)
|
||||
while (size > WolfskinTokens.Type.caption.fontSize.value && measured() > width) size = (size - D.ratingPlateStepSp).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value)
|
||||
while (count > 1 && measured() > width) count--
|
||||
count to size
|
||||
}
|
||||
TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.button.copy(fontSize = TextUnit(fit.second, TextUnitType.Sp)))
|
||||
TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.button.copy(fontSize = TextUnit(fit.second, TextUnitType.Sp)), plate = true)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -15,6 +23,9 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
|
|
@ -39,17 +50,19 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
|||
fun TitleCast(state: TitleState, active: Boolean) {
|
||||
val track = rememberLazyListState()
|
||||
val focusOwner = LocalTitleFocusOwner.current
|
||||
LaunchedEffect(state.column, active) { if (active) track.animateScrollToItem((state.column - 5).coerceAtLeast(0)) }
|
||||
val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() }
|
||||
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) }
|
||||
Column(Modifier.height(D.castHeight)) {
|
||||
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
||||
if (state.data.castStatus == TitleReadStatus.Loading) {
|
||||
items(7, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||
items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||
Box(Modifier.size(L.castCircle).background(C.surface, CircleShape))
|
||||
}
|
||||
} else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person ->
|
||||
val focused = active && state.column == index
|
||||
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f)
|
||||
.onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) }
|
||||
.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) {
|
||||
|
|
@ -70,13 +83,20 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
|||
val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
|
||||
val height = if (episodes) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
|
||||
val track = rememberLazyListState()
|
||||
val visible = if (episodes) 4 else 6
|
||||
LaunchedEffect(state.column, active) { if (active) track.animateScrollToItem((state.column - (visible - 2)).coerceAtLeast(0)) }
|
||||
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
|
||||
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() }
|
||||
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) }
|
||||
Column(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
|
||||
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||
itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
|
||||
TitlePoster(item, active && state.column == index, Modifier.width(width), episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
|
||||
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
|
||||
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||
Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||
}
|
||||
} else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
|
||||
TitlePoster(item, active && state.column == index, Modifier.width(width)
|
||||
.onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) },
|
||||
episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -92,24 +112,46 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
|
|||
Box(Modifier.size(width, height).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
|
||||
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
|
||||
TitleArtwork(if (tile) item.backdrop ?: item.poster else item.poster, (width.value * 2).toInt(), (height.value * 2).toInt(), Modifier.fillMaxSize(), !tile)
|
||||
if (item.poster == null && item.backdrop == null) Text(item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 4, modifier = Modifier.padding(D.lineGap))
|
||||
TitleCardArtwork(item, tile)
|
||||
if (tile) {
|
||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg))).padding(D.lineGap)) {
|
||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
|
||||
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
|
||||
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(if (item.kind == "episode") "${item.episodeLabel} · ${item.name}" else "${if (item.kind == "movie") "Movie" else "Series"} · ${item.year ?: ""}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (item.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) {
|
||||
val progress = if (item.runtimeSeconds > 0) item.resumeSeconds.toFloat() / item.runtimeSeconds else 0f
|
||||
if (progress > 0f && progress < 1f) {
|
||||
Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = if (tile) D.tileProgressBottom else D.cardProgressBottom)
|
||||
.fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
|
||||
Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent))
|
||||
}
|
||||
}
|
||||
if (item.held && !tile) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) {
|
||||
TitleGlyph("check", C.onFocus, Modifier.size(L.buttonIcon))
|
||||
}
|
||||
if (focused && ratingsVisible) TitleRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
||||
AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) {
|
||||
TitleRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
if (!tile) {
|
||||
Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) {
|
||||
Caption(item.name, listOfNotNull(item.character?.let { "as $it" }, item.year?.toString(), if (item.kind == "movie") item.runtime else if (item.seasons > 0) "${item.seasons} seasons" else "Series", if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }).joinToString(" · "), focused,
|
||||
Caption(item.name, listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(), if (item.kind == "movie") item.runtime.takeIf { item.runtimeSeconds > 0 } ?: "Movie" else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series", if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }).joinToString(" · "), focused,
|
||||
Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep a full lookahead card, but do not recenter a row when reversing direction. */
|
||||
private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float) {
|
||||
val first = firstVisibleItemIndex
|
||||
val next = when {
|
||||
column < first -> column
|
||||
column >= first + slots - 1 -> (column - slots + 2).coerceAtLeast(0)
|
||||
else -> first
|
||||
}
|
||||
if (next != first) animateScrollBy((next - first) * pitch - firstVisibleItemScrollOffset, titleTrackScrollSpec)
|
||||
}
|
||||
|
||||
private val titleTrackScrollSpec = tween<Float>(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))
|
||||
|
|
|
|||
|
|
@ -17,10 +17,14 @@ class TitleState(
|
|||
var row by mutableStateOf("header")
|
||||
var column by mutableStateOf(0)
|
||||
val columns = mutableStateMapOf<String, Int>()
|
||||
private val centers = HashMap<String, MutableMap<Int, Float>>()
|
||||
fun recordCenter(row: String, index: Int, x: Float) { centers.getOrPut(row) { HashMap() }[index] = x }
|
||||
fun showSeasonLoading() { if (data.episodesStatus != TitleReadStatus.Loading) data = data.copy(episodesStatus = TitleReadStatus.Loading) }
|
||||
fun restoreSeason() { season = data.selectedSeason ?: season; data = data.copy(episodesStatus = TitleReadStatus.Ready) }
|
||||
var sheet by mutableStateOf<TitleSheet?>(null)
|
||||
private set
|
||||
var sheetIndex by mutableStateOf(0)
|
||||
var season by mutableStateOf(initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
|
||||
var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
|
||||
var versionId by mutableStateOf(initial.selectedVersion)
|
||||
var audioIndex by mutableStateOf(initial.selectedAudio)
|
||||
var subtitleIndex by mutableStateOf(initial.selectedSubtitle)
|
||||
|
|
@ -37,7 +41,7 @@ class TitleState(
|
|||
if (data.status != TitleReadStatus.Ready) return@buildList
|
||||
if (data.item.held && data.item.kind == "tv" && data.item.seasons > 0) {
|
||||
add("seasons")
|
||||
if (data.episodes.isNotEmpty()) add("episodes")
|
||||
if (data.episodesStatus == TitleReadStatus.Ready && data.episodes.isNotEmpty()) add("episodes")
|
||||
}
|
||||
if (data.castStatus == TitleReadStatus.Ready && data.cast.isNotEmpty()) add("cast")
|
||||
if (data.related.isNotEmpty()) add("related")
|
||||
|
|
@ -47,12 +51,16 @@ class TitleState(
|
|||
/** Refresh keeps the selected row and file when they still exist. */
|
||||
fun update(value: TitlePageData) {
|
||||
val wasLoading = data.status == TitleReadStatus.Loading
|
||||
val pendingWatched = watched != data.watchTarget.watched
|
||||
val pendingVersion = versionId != data.selectedVersion
|
||||
val pendingAudio = audioIndex != data.selectedAudio
|
||||
val pendingSubtitle = subtitleIndex != data.selectedSubtitle
|
||||
data = value
|
||||
if (wasLoading) season = value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
|
||||
watched = value.watchTarget.watched
|
||||
versionId = value.selectedVersion
|
||||
audioIndex = value.selectedAudio
|
||||
subtitleIndex = value.selectedSubtitle
|
||||
if (wasLoading || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
|
||||
if (!pendingWatched) watched = value.watchTarget.watched
|
||||
if (!pendingVersion) versionId = value.selectedVersion
|
||||
if (!pendingAudio) audioIndex = value.selectedAudio
|
||||
if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle
|
||||
if (row !in rows) { row = "header"; column = 0 }
|
||||
column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0))
|
||||
}
|
||||
|
|
@ -71,8 +79,29 @@ class TitleState(
|
|||
}
|
||||
|
||||
|
||||
/** Film and episode toggles are optimistic; the facade restores this value on failure. */
|
||||
fun restoreWatched(value: Boolean) { watched = value }
|
||||
/** Keep the last confirmed selections separate from immediate, optimistic UI feedback. */
|
||||
fun confirmPlayback(action: TitleAction) {
|
||||
data = when (action) {
|
||||
is TitleAction.Version -> data.copy(selectedVersion = action.version.id, selectedAudio = null, selectedSubtitle = -1)
|
||||
is TitleAction.Track -> data.copy(selectedVersion = action.version.id, selectedAudio = action.audio, selectedSubtitle = action.subtitle)
|
||||
is TitleAction.Watched -> data.copy(watchTarget = data.watchTarget.copy(watched = action.watched))
|
||||
else -> data
|
||||
}
|
||||
}
|
||||
|
||||
/** A failed older write cannot undo a newer selection still waiting to save. */
|
||||
fun rejectPlayback(action: TitleAction) {
|
||||
when (action) {
|
||||
is TitleAction.Version -> if (versionId == action.version.id) {
|
||||
versionId = data.selectedVersion; audioIndex = data.selectedAudio; subtitleIndex = data.selectedSubtitle
|
||||
}
|
||||
is TitleAction.Track -> if (audioIndex == action.audio && subtitleIndex == action.subtitle) {
|
||||
audioIndex = data.selectedAudio; subtitleIndex = data.selectedSubtitle
|
||||
}
|
||||
is TitleAction.Watched -> if (watched == action.watched) watched = data.watchTarget.watched
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** Key-up can only activate the same card that armed the first key-down. */
|
||||
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
|
||||
|
|
@ -86,7 +115,7 @@ class TitleState(
|
|||
}
|
||||
if (repeat > 0 || okHeld) return true
|
||||
okHeld = true
|
||||
if (sheet == null && row in listOf("cast", "related", "episodes")) {
|
||||
if (sheet == null && (row == "cast" || row == "related" || row == "episodes")) {
|
||||
armedCard = "$row:$column"
|
||||
return true
|
||||
}
|
||||
|
|
@ -115,7 +144,9 @@ class TitleState(
|
|||
private fun moveRow(delta: Int) {
|
||||
val index = (rows.indexOf(row) + delta).coerceIn(0, rows.lastIndex)
|
||||
val next = rows[index]
|
||||
focus(next, (columns[next] ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0)))
|
||||
val x = centers[row]?.get(column)
|
||||
val nearest = if (x != null) centers[next]?.entries?.minByOrNull { kotlin.math.abs(it.value - x) }?.key else null
|
||||
focus(next, (columns[next] ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0)))
|
||||
}
|
||||
|
||||
private fun rowSize(id: String): Int = when (id) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,44 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.Disposable
|
||||
import coil3.request.allowRgb565
|
||||
import coil3.request.crossfade
|
||||
import coil3.ImageLoader
|
||||
|
||||
/** Owned screens share one bounded loader; standalone fixtures can use Coil's local asset loader. */
|
||||
val LocalWolfskinImageLoader = compositionLocalOf<ImageLoader?> { null }
|
||||
|
||||
/** Visible and prefetched images use exactly the same size and decode policy. */
|
||||
internal fun titleImageRequest(context: Context, path: String, width: Int, height: Int, poster: Boolean): ImageRequest =
|
||||
ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build()
|
||||
|
||||
/** Read the next hidden art row into the bounded loader, cancelling work when the window changes. */
|
||||
@Composable
|
||||
internal fun TitleRowPrefetch(data: TitlePageData, row: String?) {
|
||||
val loader = LocalWolfskinImageLoader.current ?: return
|
||||
val context = LocalContext.current
|
||||
if (row == null || data.status != TitleReadStatus.Ready) return
|
||||
DisposableEffect(loader, context, row, data.cast, data.episodes, data.related) {
|
||||
val requests = ArrayList<Disposable>()
|
||||
fun queue(path: String?, width: Int, height: Int, poster: Boolean = false) {
|
||||
if (path != null) requests.add(loader.enqueue(titleImageRequest(context, path, width, height, poster)))
|
||||
}
|
||||
when (row) {
|
||||
"cast" -> for (index in 0 until minOf(data.cast.size, WolfskinTokens.Card.personSlots))
|
||||
queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight)
|
||||
"episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) {
|
||||
val item = data.episodes[index]
|
||||
queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight)
|
||||
}
|
||||
"related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots))
|
||||
queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true)
|
||||
}
|
||||
onDispose { requests.forEach { it.dispose() } }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,26 +166,31 @@ object WolfskinTokens {
|
|||
val posterGap = 12.0.dp
|
||||
val posterPitch = 140.0.dp
|
||||
val posterCaptionBand = 38.0.dp
|
||||
const val posterSlots = 6
|
||||
val tileW = 208.0.dp
|
||||
val tileH = 117.0.dp
|
||||
val tileGap = 12.0.dp
|
||||
val tilePitch = 220.0.dp
|
||||
val tileCaptionBand = 0.0.dp
|
||||
const val tileSlots = 4
|
||||
val wideW = 280.0.dp
|
||||
val wideH = 157.5.dp
|
||||
val wideGap = 12.0.dp
|
||||
val widePitch = 292.0.dp
|
||||
val wideCaptionBand = 0.0.dp
|
||||
const val wideSlots = 3
|
||||
val entryW = 128.0.dp
|
||||
val entryH = 72.0.dp
|
||||
val entryGap = 12.0.dp
|
||||
val entryPitch = 140.0.dp
|
||||
val entryCaptionBand = 0.0.dp
|
||||
const val entrySlots = 6
|
||||
val personW = 100.0.dp
|
||||
val personH = 145.0.dp
|
||||
val personGap = 14.0.dp
|
||||
val personPitch = 114.0.dp
|
||||
val personCaptionBand = 0.0.dp
|
||||
const val personSlots = 7
|
||||
}
|
||||
object Focus {
|
||||
const val scaleCard = 1.08f
|
||||
|
|
@ -372,5 +377,44 @@ object WolfskinTokens {
|
|||
const val heroAmbientBackground = "radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg))"
|
||||
const val dimScrollPx = 1400.0f
|
||||
const val dimMaximum = 0.7f
|
||||
val errorTop = 85.0.dp
|
||||
val errorSide = 20.0.dp
|
||||
val errorVertical = 17.0.dp
|
||||
val errorHeadingBottom = 4.0.dp
|
||||
val errorBodyBottom = 12.0.dp
|
||||
val errorTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight.Bold, lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em)
|
||||
val fallbackGlyph = 22.0.dp
|
||||
val fallbackInset = 11.0.dp
|
||||
val tileSheetInset = 9.0.dp
|
||||
val tileSheetLeft = 11.0.dp
|
||||
val tileSheetRadius = 5.0.dp
|
||||
val tileAmbientBlur = 13.0.dp
|
||||
const val tileAmbientBrightness = 0.5f
|
||||
const val cardFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))"
|
||||
const val personFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))"
|
||||
val initialsColor = ComposeColor(0xD1FFFFFF)
|
||||
val fallbackGlyphColor = ComposeColor(0x73FFFFFF)
|
||||
val fallbackTitle = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.099999999999998.sp, letterSpacing = 0.0.em)
|
||||
val tileOverlayTop = 27.0.dp
|
||||
val tileOverlaySide = 11.0.dp
|
||||
val tileOverlayBottom = 9.0.dp
|
||||
const val cardOverlayOpacity = 0.92f
|
||||
val cardProgressInset = 9.0.dp
|
||||
val cardProgressBottom = 6.0.dp
|
||||
val tileProgressBottom = 5.0.dp
|
||||
const val ratingPlateEntryEm = 0.64f
|
||||
const val ratingPlateMarkEm = 0.29f
|
||||
}
|
||||
object SavedIndicator {
|
||||
val height = 26.0.dp
|
||||
val side = 10.0.dp
|
||||
val gap = 5.0.dp
|
||||
val icon = 14.0.dp
|
||||
val border = 1.0.dp
|
||||
const val borderOpacity = 0.5f
|
||||
}
|
||||
object Caption {
|
||||
const val separatorOpacity = 0.4f
|
||||
val factsGap = 9.0.dp
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4
wolfskin-design/src/main/res/drawable/title_alert.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_alert.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M12,3 L2,20 H22 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M12,10 V14.5 M12,17 V17.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/title_film.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_film.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M5.5,4 H18.5 A2.5,2.5 0,0 1,21 6.5 V17.5 A2.5,2.5 0,0 1,18.5 20 H5.5 A2.5,2.5 0,0 1,3 17.5 V6.5 A2.5,2.5 0,0 1,5.5 4 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" />
|
||||
<path android:pathData="M7,4 V20 M17,4 V20 M3,9 H7 M3,15 H7 M17,9 H21 M17,15 H21" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.6" />
|
||||
</vector>
|
||||
|
|
@ -78,6 +78,45 @@
|
|||
"toastTop": {"dp": 11, "px": 22, "source": "index.html:270"},
|
||||
"heroAmbientBackground": {"$type": "gradient", "$value": "radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg))", "source": "index.html:94"},
|
||||
"dimScrollPx": {"$value": 1400, "source": "index.html:1541"},
|
||||
"dimMaximum": {"$value": 0.7, "source": "index.html:1541"}
|
||||
"dimMaximum": {"$value": 0.7, "source": "index.html:1541"},
|
||||
"errorTop": {"dp": 85, "px": 170, "source": "index.html:1457,194"},
|
||||
"errorSide": {"dp": 20, "px": 40, "source": "index.html:194"},
|
||||
"errorVertical": {"dp": 17, "px": 34, "source": "index.html:194"},
|
||||
"errorHeadingBottom": {"dp": 4, "px": 8, "source": "index.html:195"},
|
||||
"errorBodyBottom": {"dp": 12, "px": 24, "source": "index.html:198"},
|
||||
"errorTitle": {"$type": "typography", "sp": 18, "px": 36, "weight": 700, "$value": {"fontWeight": 700}, "source": "index.html:195"},
|
||||
"fallbackGlyph": {"dp": 22, "px": 44, "source": "index.html:131"},
|
||||
"fallbackInset": {"dp": 11, "px": 22, "source": "index.html:130-131"},
|
||||
"tileSheetInset": {"dp": 9, "px": 18, "source": "index.html:128"},
|
||||
"tileSheetLeft": {"dp": 11, "px": 22, "source": "index.html:128"},
|
||||
"tileSheetRadius": {"dp": 5, "px": 10, "source": "index.html:128"},
|
||||
"tileAmbientBlur": {"dp": 13, "px": 26, "source": "index.html:129"},
|
||||
"tileAmbientBrightness": {"$value": 0.5, "source": "index.html:129"},
|
||||
"cardFallbackGradient": {"$type": "gradient", "$value": "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))", "source": "index.html:130"},
|
||||
"personFallbackGradient": {"$type": "gradient", "$value": "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))", "source": "index.html:288"},
|
||||
"initialsColor": {"$type": "color", "$value": "#ffffffd1", "source": "index.html:288"},
|
||||
"fallbackGlyphColor": {"$type": "color", "$value": "#ffffff73", "source": "index.html:131"},
|
||||
"fallbackTitle": {"$type": "typography", "sp": 14, "px": 28, "weight": 700, "lineHeight": 1.15, "$value": {"fontWeight": 700}, "source": "index.html:132"},
|
||||
"tileOverlayTop": {"dp": 27, "px": 54, "source": "index.html:135"},
|
||||
"tileOverlaySide": {"dp": 11, "px": 22, "source": "index.html:135"},
|
||||
"tileOverlayBottom": {"dp": 9, "px": 18, "source": "index.html:135"},
|
||||
"cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"},
|
||||
"cardProgressInset": {"dp": 9, "px": 18, "source": "index.html:138"},
|
||||
"cardProgressBottom": {"dp": 6, "px": 12, "source": "index.html:138"},
|
||||
"tileProgressBottom": {"dp": 5, "px": 10, "source": "index.html:140"},
|
||||
"ratingPlateEntryEm": {"$value": 0.64, "source": "index.html:164"},
|
||||
"ratingPlateMarkEm": {"$value": 0.29, "source": "index.html:165"}
|
||||
},
|
||||
"savedIndicator": {
|
||||
"height": {"dp": 26, "px": 52, "source": "index.html:87"},
|
||||
"side": {"dp": 10, "px": 20, "source": "index.html:87"},
|
||||
"gap": {"dp": 5, "px": 10, "source": "index.html:87"},
|
||||
"icon": {"dp": 14, "px": 28, "source": "index.html:88"},
|
||||
"border": {"dp": 1, "px": 2, "source": "index.html:87"},
|
||||
"borderOpacity": {"$value": 0.5, "source": "index.html:87"}
|
||||
},
|
||||
"caption": {
|
||||
"separatorOpacity": {"$value": 0.4, "source": "spec.md:4.4; index.html:1207"},
|
||||
"factsGap": {"dp": 9, "px": 18, "source": "index.html:154"}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -246,4 +246,33 @@
|
|||
--title-toastSide: 28.0px;
|
||||
--title-toastTop: 22.0px;
|
||||
--title-heroAmbientBackground: radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg));
|
||||
--title-errorTop: 170.0px;
|
||||
--title-errorSide: 40.0px;
|
||||
--title-errorVertical: 34.0px;
|
||||
--title-errorHeadingBottom: 8.0px;
|
||||
--title-errorBodyBottom: 24.0px;
|
||||
--title-errorTitle: 36.0px;
|
||||
--title-fallbackGlyph: 44.0px;
|
||||
--title-fallbackInset: 22.0px;
|
||||
--title-tileSheetInset: 18.0px;
|
||||
--title-tileSheetLeft: 22.0px;
|
||||
--title-tileSheetRadius: 10.0px;
|
||||
--title-tileAmbientBlur: 26.0px;
|
||||
--title-cardFallbackGradient: linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%));
|
||||
--title-personFallbackGradient: linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%));
|
||||
--title-initialsColor: #ffffffd1;
|
||||
--title-fallbackGlyphColor: #ffffff73;
|
||||
--title-fallbackTitle: 28.0px;
|
||||
--title-tileOverlayTop: 54.0px;
|
||||
--title-tileOverlaySide: 22.0px;
|
||||
--title-tileOverlayBottom: 18.0px;
|
||||
--title-cardProgressInset: 18.0px;
|
||||
--title-cardProgressBottom: 12.0px;
|
||||
--title-tileProgressBottom: 10.0px;
|
||||
--savedIndicator-height: 52.0px;
|
||||
--savedIndicator-side: 20.0px;
|
||||
--savedIndicator-gap: 10.0px;
|
||||
--savedIndicator-icon: 28.0px;
|
||||
--savedIndicator-border: 2.0px;
|
||||
--caption-factsGap: 18.0px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
fa375a78bf808451823caa3c23c8aeca5b370310706ae347f978bfe5f2f0eed3 generate_tokens.py
|
||||
811d6397d71d7771cad62e6745e8d85acd22a60878a4d2719792831de58d2fdb generate_tokens.py
|
||||
6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
|
||||
eea6348a80840947512e7a3aee599a69e91099b87ae7be2035843eb435142fb7 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
b7d9c553420775707607e906582b1d3050459cd5dc6c2c5488d5cc2b18719413 title-tokens.json
|
||||
074cf8514b56b862e93a6f8794e6f5064e08dda7675e2a145ab4e2a955187905 tokens.css
|
||||
1ac2b30fb03e204ab940f213714c1315f6b50224aff557b59b55230679bc6ffb src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
13526fae90cfa38063184c9d4374798387351d5e51fb8ac94f9942c898bc2163 title-tokens.json
|
||||
59377d433fe2c020e72ce166780238ee6c70d2d44c86b5f167106f7b8b491394 tokens.css
|
||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue