Build the Wolfskin TV shell and navigation

This commit is contained in:
justinzeus 2026-11-05 14:38:38 +01:00
parent de452448ba
commit f60a22e435
97 changed files with 4363 additions and 91 deletions

View file

@ -0,0 +1,21 @@
package org.justinvisser.wolfskin.design
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.tv.material3.Text
/** Captions fade without moving the reserved band under the focused poster. */
@Composable
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) {
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "caption-fade")
Column(modifier.graphicsLayer { this.alpha = alpha }) {
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1)
Text(facts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1)
}
}

View file

@ -0,0 +1,41 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
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.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp
/** Only transforms animate: focus never changes measured geometry. */
@Composable
fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
val animated by animateFloatAsState(
if (focused) scale else 1f,
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)),
label = "focus-scale",
)
return graphicsLayer { scaleX = animated; scaleY = animated }
}
/** Draw the ring outside the slot; neither the gap nor the ring obscures content. */
@Composable
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color): Modifier {
val alpha by animateFloatAsState(if (focused) 1f else 0f,
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
return drawBehind {
if (alpha > 0f) {
val outer = (gap + width).toPx()
val inner = gap.toPx()
drawRoundRect(color.copy(alpha = color.alpha * alpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer))
drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner))
}
}
}

View file

@ -0,0 +1,26 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.tv.material3.Icon
/** Shell glyph geometry is copied from the locked prototype, not a platform icon font. */
@Composable
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
val resource = when (name) {
"gear" -> R.drawable.wolfskin_gear
"requests" -> R.drawable.wolfskin_tray
"interface" -> R.drawable.wolfskin_grid
"playback" -> R.drawable.wolfskin_play
"nextup" -> R.drawable.wolfskin_tv
"profile" -> R.drawable.wolfskin_users
"about" -> R.drawable.wolfskin_info
"seerr" -> R.drawable.wolfskin_sparkle
"advanced" -> R.drawable.wolfskin_sort
"alert" -> R.drawable.wolfskin_alert
else -> R.drawable.wolfskin_right
}
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)
}

View file

@ -0,0 +1,39 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.tv.material3.Text
/** Fixed household colours never depend on preference or avatar presets. */
fun personColor(key: String): Color = when (key.lowercase()) {
"justin" -> WolfskinTokens.PersonColor.justin
"mindel" -> WolfskinTokens.PersonColor.mindel
"shared" -> WolfskinTokens.PersonColor.shared
else -> WolfskinTokens.Color.accent
}
/** The same mark anchors the picker and the persistent navigation. */
@Composable
fun Wordmark(modifier: Modifier = Modifier) {
Row(modifier) {
Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark)
Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark)
}
}
/** Initials are drawn over opaque person colour at the exact slot size. */
@Composable
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier) {
Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) {
Text(person.initial, color = WolfskinTokens.Color.onFocus, style = textStyle)
}
}

View file

@ -0,0 +1,91 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
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.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
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.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
/** 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)
fun navBoxes(tabs: List<String>): List<NavBox> {
var left = L.safeSide + S.wordmarkWidth + L.wordmarkGap + L.navItemGap
return tabs.map { tab ->
val width = when (tab) {
"home" -> S.navHomeWidth
"discover" -> S.navDiscoverWidth
"movies" -> S.navMoviesWidth
"shows" -> S.navShowsWidth
else -> S.navAnimeWidth
}
NavBox(left, S.navItemTop, width, L.navItemHeight).also { left += width + L.navItemGap }
} + NavBox(S.stageWidth - L.safeSide - L.navAvatar, S.navItemTop, L.navAvatar, L.navAvatar)
}
/** There is one blob, including when focus wraps around the nav avatar. */
@Composable
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
val index = if (state.navFocused) state.navIndex else state.currentTab
val box = boxes[index]
val avatarFocused = state.navFocused && index == state.tabs.size
val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero
val damping = WolfskinTokens.Motion.navBlobDampingRatio
val stiffness = WolfskinTokens.Motion.navBlobStiffness
val x by animateFloatAsState((box.left - pad).value, spring(damping, stiffness), label = "blob-x")
val y by animateFloatAsState((box.top - pad).value, spring(damping, stiffness), label = "blob-y")
val width by animateFloatAsState((box.width + pad * 2).value, spring(damping, stiffness), label = "blob-width")
val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height")
val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color")
val density = LocalDensity.current.density
val band = remember(scrolled) {
if (scrolled) Brush.verticalGradient(0f to C.bg, S.navSolidStop to C.bg, 1f to C.bg.copy(alpha = 0f))
else Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f)))
}
Box(modifier.fillMaxWidth().height(L.navBand)) {
Canvas(Modifier.matchParentSize()) {
drawRect(band)
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
}
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))
state.tabs.forEachIndexed { itemIndex, tab ->
val item = boxes[itemIndex]
val focused = state.navFocused && state.navIndex == itemIndex
Box(Modifier.offset(item.left, item.top).size(item.width, item.height).clickable {
state.focusNav(itemIndex); state.key(ShellKey.Ok)
}, contentAlignment = Alignment.Center) {
Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted)
if (itemIndex == state.currentTab && !focused) {
Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent))
}
}
}
val avatar = boxes.last()
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
Modifier.offset(avatar.left, avatar.top)
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
}
}

View file

@ -0,0 +1,117 @@
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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
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.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
/** Settings routes identify real upstream destinations rather than stand-in panels. */
data class SettingsGroup(val key: String, val title: String, val description: String, val wolfskin: Boolean = false)
val SETTINGS_GROUPS = listOf(
SettingsGroup("requests", "Requests", "Coming in: what the household asked for, and how far each is"),
SettingsGroup("interface", "Interface", "Sign in automatically · Remember selected tabs · Theme music · Subtitle style · Application theme · Screensaver"),
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Skip back when resuming"),
SettingsGroup("nextup", "Next up", "Show next up timing · Auto play next up · Delay · Passout protection"),
SettingsGroup("profile", "User profile settings", "Profile protection · Customize home page (Wolfskin presets: Default, Compact, Series thumbs, Episode thumbnails) · Navigation drawer items · Language", true),
SettingsGroup("about", "About", "Installed version · Update (Wolfskin releases on Forgejo) · Wolfskin about", true),
SettingsGroup("seerr", "Seerr integration", "Add, change or remove the Seerr server this TV asks for Discover and ratings", true),
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
)
/** Requests is intentionally a no-data Wolfskin entry until the Requests slice. */
@Composable
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) {
val list = state.view.listState
val density = LocalDensity.current
LaunchedEffect(state.settingsRow, state.navFocused) {
if (state.navFocused) return@LaunchedEffect
val y = L.row_lhead + L.row_srow * state.settingsRow
val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero
val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1)
val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset
list.animateScrollBy(distance, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
}
LazyColumn(modifier.fillMaxSize(), state = list,
contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) {
item(key = "settings-header") {
Row(Modifier.height(L.row_lhead).padding(start = L.safeSide, top = S.settingsHeaderTop),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader)
Text("Settings", Modifier.alignByBaseline(), style = WolfskinTokens.Type.screenTitle, color = C.fg)
Text("Requests and Wholphin settings on this TV · ${state.person.name}", Modifier.alignByBaseline(), style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal), color = C.muted)
}
}
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
Box(Modifier.height(L.row_srow)) {
SettingsRow(group, !state.navFocused && index == state.settingsRow,
if (group.key == "requests") openRequests else null,
Modifier.offset(L.safeSide, S.settingsRowTop)) {
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
}
}
}
}
}
/** The inverse focus fill has no focus ring and never changes row layout. */
@Composable
fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) {
val foreground = if (focused) C.onFocus else C.fg
Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
.clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
Column(Modifier.weight(1f)) {
val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else ""
Text(group.title + count, color = foreground, style = WolfskinTokens.Type.listTitle.copy(fontWeight = FontWeight.Bold), maxLines = 1)
Text(group.description, color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (group.wolfskin) SettingsPill("Wolfskin", focused)
SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused)
Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron))
}
}
@Composable
private fun SettingsPill(label: String, focused: Boolean) {
val foreground = if (focused) C.onFocus else C.dim
Box(Modifier.height(S.settingsPillHeight).drawBehind {
val stroke = WolfskinTokens.Focus.ringButtonRing.toPx()
drawRoundRect(foreground.copy(alpha = .4f), cornerRadius = CornerRadius(WolfskinTokens.Radius.pill.toPx()), style = Stroke(stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(stroke * 2, stroke * 2))))
}.padding(horizontal = S.settingsPillPadding), contentAlignment = Alignment.Center) {
Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = FontWeight.SemiBold))
}
}

View file

@ -0,0 +1,216 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/** Household identity data contains no credentials or server objects. */
data class ShellPerson(val key: String, val name: String, val initial: String, val hasAnime: Boolean)
/** The shell owns keys; the integration owns navigation and authenticated user changes. */
sealed interface ShellAction {
data class Destination(val route: String) : ShellAction
data class Person(val person: ShellPerson) : ShellAction
data class HandOff(val group: String) : ShellAction
data object Pop : ShellAction
data object RetryPeople : ShellAction
}
/** D-pad inputs are shared by Android events and deterministic JVM replays. */
enum class ShellKey { Up, Down, Left, Right, Ok, Back }
enum class PeopleStatus { Ready, Loading, Unavailable }
/** A root destination retains focus, horizontal tracks and its actual Compose scroller. */
class RememberedView {
val listState = LazyListState()
var row by mutableStateOf(0)
val cards = mutableStateMapOf<String, Int>()
}
/** Focus and Back transitions are independent of drawing and server availability. */
class ShellState(
val people: List<ShellPerson>,
initialPerson: String,
private val onAction: (ShellAction) -> Unit = {},
) {
var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first())
private set
var route by mutableStateOf("home")
private set
var pickerVisible by mutableStateOf(true)
var pickerLaunch by mutableStateOf(true)
var pickerIndex by mutableStateOf(people.indexOf(person))
var gearFocused by mutableStateOf(false)
var navFocused by mutableStateOf(false)
var navIndex by mutableStateOf(0)
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
var recoveryIndex by mutableStateOf(0)
var requestsVisible by mutableStateOf(false)
var handOff by mutableStateOf<String?>(null)
var settingsRow by mutableStateOf(0)
private var rootRoute by mutableStateOf("home")
private var pickerReturnRoute = "home"
private val views = mutableStateMapOf<Pair<String, String>, RememberedView>()
private var armedCard: String? = null
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
/** External intents and upstream pops remain authoritative over the screen. */
fun enterDestination(destination: String) {
route = destination
if (destination in tabs) rootRoute = destination
handOff = null
}
/** 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 }
/** Repeats are consumed, and a row activation is cancelled when focus moves. */
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = null): Boolean {
if (key == ShellKey.Ok) {
if (repeat > 0) return true
if (card != null) {
if (down) armedCard = card else {
if (armedCard == card) activate()
armedCard = null
}
return true
}
if (!down) return true
} else {
if (!down) return true
armedCard = null
}
if (requestsVisible) {
if (key == ShellKey.Back || key == ShellKey.Ok) requestsVisible = false
return true
}
if (pickerVisible) pickerKey(key) else if (key == ShellKey.Back) back() else if (navFocused) navKey(key) else contentKey(key)
return true
}
private fun pickerKey(key: ShellKey) {
if (peopleStatus != PeopleStatus.Ready) {
when (key) {
ShellKey.Left -> recoveryIndex = 0
ShellKey.Right -> if (peopleStatus == PeopleStatus.Unavailable) recoveryIndex = 1
ShellKey.Back -> closePicker()
ShellKey.Ok -> if (peopleStatus == PeopleStatus.Unavailable && recoveryIndex == 0) onAction(ShellAction.RetryPeople) else choosePerson(person)
else -> Unit
}
} else if (gearFocused) {
when (key) {
ShellKey.Up -> gearFocused = false
ShellKey.Ok -> {
pickerVisible = false
navFocused = false
settingsRow = 0
route = "settings"
onAction(ShellAction.Destination(route))
}
ShellKey.Back -> closePicker()
else -> Unit
}
} else {
when (key) {
ShellKey.Left -> pickerIndex = (pickerIndex - 1).coerceAtLeast(0)
ShellKey.Right -> pickerIndex = (pickerIndex + 1).coerceAtMost(people.lastIndex)
ShellKey.Down -> if (pickerIndex == people.lastIndex) gearFocused = true
ShellKey.Ok -> choosePerson(people[pickerIndex])
ShellKey.Back -> closePicker()
else -> Unit
}
}
}
private fun choosePerson(selected: ShellPerson) {
person = selected
pickerVisible = false
pickerLaunch = false
route = "home"
navFocused = false
navIndex = 0
rootRoute = "home"
views[person.key to route] = RememberedView()
onAction(ShellAction.Person(selected))
}
private fun closePicker() {
if (!pickerLaunch) { pickerVisible = false; navFocused = true; navIndex = tabs.size }
}
private fun navKey(key: ShellKey) {
when (key) {
ShellKey.Left -> navIndex = (navIndex + tabs.size) % (tabs.size + 1)
ShellKey.Right -> navIndex = (navIndex + 1) % (tabs.size + 1)
ShellKey.Down -> navFocused = false
ShellKey.Ok -> activate()
else -> Unit
}
}
private fun contentKey(key: ShellKey) {
when (key) {
ShellKey.Up -> if (route == "settings" && settingsRow > 0) settingsRow-- else { navFocused = true; navIndex = currentTab }
ShellKey.Down -> if (route == "settings") settingsRow = (settingsRow + 1).coerceAtMost(SETTINGS_GROUPS.lastIndex)
ShellKey.Ok -> activate()
else -> Unit
}
}
private fun activate() {
if (navFocused) {
if (navIndex == tabs.size) {
pickerReturnRoute = route
pickerVisible = true
pickerLaunch = false
pickerIndex = people.indexOf(person)
gearFocused = false
} else {
val target = tabs[navIndex]
if (target == route) views[person.key to target] = RememberedView()
route = target
rootRoute = target
navFocused = false
onAction(ShellAction.Destination(target))
}
} else if (route == "settings") {
val group = SETTINGS_GROUPS[settingsRow].key
if (group == "requests") requestsVisible = true else {
handOff = group
onAction(ShellAction.HandOff(group))
}
}
}
/** Back never discards a root view, including the card below a focused nav. */
fun back() {
if (route == "settings") {
route = pickerReturnRoute
onAction(ShellAction.Pop)
pickerVisible = true
pickerLaunch = false
gearFocused = true
pickerIndex = people.lastIndex
} else if (route !in tabs) {
onAction(ShellAction.Pop)
} else if (!navFocused) {
navFocused = true
navIndex = currentTab
} else if (route != "home") {
route = "home"
rootRoute = "home"
navIndex = 0
onAction(ShellAction.Destination(route))
}
}
companion object {
private val BASE_TABS = listOf("home", "discover", "movies", "shows")
private val ANIME_TABS = BASE_TABS + "anime"
}
}

View file

@ -0,0 +1,103 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.focusGroup
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Map Android key events once, preserving repeat count and key-up semantics. */
fun ShellState.handleKey(event: KeyEvent): Boolean {
val key = when (event.key) {
Key.DirectionUp -> ShellKey.Up
Key.DirectionDown -> ShellKey.Down
Key.DirectionLeft -> ShellKey.Left
Key.DirectionRight -> ShellKey.Right
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok
Key.Back, Key.Escape -> ShellKey.Back
else -> return false
}
return this.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount)
}
/** Content and nav share a single focus policy; upstream full-screen pages bypass this surface. */
@Composable
fun ShellSurface(
state: ShellState,
modifier: Modifier = Modifier,
handlesContentKeys: Boolean = true,
settingsOpenRequests: Int? = null,
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
) {
val focus = remember { FocusRequester() }
val contentFocus = remember { FocusRequester() }
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
else contentFocus.requestFocus()
}
Box(modifier.fillMaxSize().background(C.bg).testTag("wolfskin-shell")
.semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" }
.focusRequester(focus)
.onPreviewKeyEvent {
if (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys) state.handleKey(it) else false
}
.onKeyEvent {
if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) {
state.navFocused = true; state.navIndex = state.currentTab; true
} else false
}.focusable()) {
if (state.pickerVisible) {
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)
if (state.requestsVisible) {
Box(Modifier.fillMaxSize().background(C.sheetScrim)) {
Column(Modifier.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
.width(L.sheetWidth).fillMaxSize().background(C.surface).padding(L.safeSide),
verticalArrangement = Arrangement.spacedBy(L.buttonGap)) {
Text("Requests", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text("Wolfskin screen", color = C.accentText, style = WolfskinTokens.Type.label)
Text("No request data yet. Conductarr sign-in is not available on this TV.", color = C.muted, style = WolfskinTokens.Type.body)
PickerButton("Close", true, true) { state.requestsVisible = false }
}
}
}
}
}
}
/** Future screens are explicitly out of scope, never simulated library or title data. */
@Composable
fun ShellPlaceholder(route: String, modifier: Modifier = Modifier) {
Column(modifier.offset(L.safeSide, L.detailCopyTop), verticalArrangement = Arrangement.spacedBy(L.buttonGap)) {
Text(route.replaceFirstChar { it.uppercase() }, color = C.fg, style = WolfskinTokens.Type.screenTitle)
Text("Wolfskin screen — not built in this slice", color = C.accentText, style = WolfskinTokens.Type.body)
Text("Home and Library remain available through the Wolfskin screens switch in Advanced settings.", color = C.muted, style = WolfskinTokens.Type.body)
}
}

View file

@ -0,0 +1,113 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.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
/** The picker covers the nav and exposes Settings only below the final person. */
@Composable
fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) {
Box(modifier.fillMaxSize().background(C.bg)) {
Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker))
if (state.peopleStatus == PeopleStatus.Ready) PeoplePicker(state) else RecoveryPicker(state)
}
}
@Composable
private fun PeoplePicker(state: ShellState) {
Box(Modifier.fillMaxWidth().offset(y = S.pickerTitleTop), contentAlignment = Alignment.TopCenter) {
Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle)
}
Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
state.people.forEachIndexed { index, person ->
val focused = !state.gearFocused && state.pickerIndex == index
Column(Modifier.width(L.pickerAvatar).focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable {
state.focusPerson(index); state.key(ShellKey.Ok)
}, horizontalAlignment = Alignment.CenterHorizontally) {
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerGap, WolfskinTokens.Focus.ringPickerRing, C.fg))
Spacer(Modifier.height(S.pickerNameGap))
Text(person.name, color = if (focused) C.fg else C.muted, style = WolfskinTokens.Type.pickerName)
}
}
}
val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1)
val left = (S.stageWidth - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - L.gear) / 2
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
if (state.gearFocused) {
Box(Modifier.align(Alignment.TopCenter).offset(y = S.pickerGearCaptionTop).requiredWidth(L.pickerAvatar), contentAlignment = Alignment.TopCenter) {
Text("Settings", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false)
}
}
}
}
@Composable
private fun RecoveryPicker(state: ShellState) {
val loading = state.peopleStatus == PeopleStatus.Loading
val titleTop = if (loading) S.pickerLoadingTitleTop else S.pickerErrorTitleTop
Box(Modifier.fillMaxWidth().offset(y = titleTop), contentAlignment = Alignment.TopCenter) {
Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle)
}
if (loading) {
Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
repeat(state.people.size) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.size(L.pickerAvatar).background(C.surface2, CircleShape))
Spacer(Modifier.height(S.pickerNameGap))
Box(Modifier.size(S.pickerSkeletonNameWidth, S.pickerSkeletonNameHeight).background(C.surface, RoundedCornerShape(S.skeletonRadius)))
}
}
}
} else {
Column(Modifier.offset((S.stageWidth - S.pickerErrorPanelWidth) / 2, S.pickerErrorPanelTop)
.size(S.pickerErrorPanelWidth, S.pickerErrorPanelHeight).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(horizontal = S.panelPaddingX, vertical = S.panelPaddingY), verticalArrangement = Arrangement.spacedBy(S.panelGap)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.panelHeadingGap)) {
Glyph("alert", C.warn, Modifier.size(S.panelIcon))
Text("Can’t read who’s here", color = C.warn, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold))
}
Text("Conductarr didn’t answer. You can continue as the last person on\nthis TV.", color = C.muted, style = WolfskinTokens.Type.body)
}
}
Row(Modifier.fillMaxWidth().offset(y = if (loading) S.pickerLoadingButtonTop else S.pickerErrorButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap, Alignment.CenterHorizontally)) {
if (!loading) PickerButton("Try again", state.recoveryIndex == 0, true) { state.recoveryIndex = 0; state.key(ShellKey.Ok) }
PickerButton("Continue as ${state.person.name}", loading || state.recoveryIndex == 1, false) { state.recoveryIndex = 1; state.key(ShellKey.Ok) }
}
}
/** Recovery actions remain focusable while profiles cannot be read. */
@Composable
fun PickerButton(label: String, focused: Boolean, primary: Boolean, 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)
.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)
}
}

View file

@ -0,0 +1,41 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.Composable
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme
/** Both Compose Material families use the same opaque TV palette for hand-offs. */
object WolfskinColors {
private val c = WolfskinTokens.Color
val darkScheme = darkColorScheme(
primary = c.accent, onPrimary = c.onFocus,
primaryContainer = c.accentSoft, onPrimaryContainer = c.accentText,
secondary = c.accent, onSecondary = c.onFocus,
secondaryContainer = c.accentSoft, onSecondaryContainer = c.accentText,
tertiary = c.accent, onTertiary = c.onFocus,
tertiaryContainer = c.accentSoft, onTertiaryContainer = c.accentText,
background = c.bg, onBackground = c.fg,
surface = c.bg, onSurface = c.fg,
surfaceVariant = c.surface2, onSurfaceVariant = c.fg,
border = c.accent,
)
val darkSchemeMaterial = androidx.compose.material3.darkColorScheme(
primary = c.accent, onPrimary = c.onFocus,
primaryContainer = c.accentSoft, onPrimaryContainer = c.accentText,
secondary = c.accent, onSecondary = c.onFocus,
secondaryContainer = c.accentSoft, onSecondaryContainer = c.accentText,
tertiary = c.accent, onTertiary = c.onFocus,
tertiaryContainer = c.accentSoft, onTertiaryContainer = c.accentText,
background = c.bg, onBackground = c.fg,
surface = c.bg, onSurface = c.fg,
surfaceVariant = c.surface2, onSurfaceVariant = c.fg,
)
}
/** Upstream content inherits tokens without a fork-specific theme enum. */
@Composable
fun WolfskinTheme(content: @Composable () -> Unit) {
androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) {
MaterialTheme(colorScheme = WolfskinColors.darkScheme, content = content)
}
}

View file

@ -0,0 +1,282 @@
// Generated by generate_tokens.py; edit the JSON sources instead.
package org.justinvisser.wolfskin.design
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
/** The locked TV design, converted at the source density. */
object WolfskinTokens {
object Color {
val bg = ComposeColor(0xFF07070A)
val surface = ComposeColor(0xFF13131A)
val surface2 = ComposeColor(0xFF1B1B25)
val surface3 = ComposeColor(0xFF252532)
val fg = ComposeColor(0xFFF4F4F6)
val muted = ComposeColor(0xBDF4F4F6)
val dim = ComposeColor(0x8FF4F4F6)
val border = ComposeColor(0x1AFFFFFF)
val accent = ComposeColor(0xFFCC9FFF)
val accentSoft = ComposeColor(0xFF2C1A3F)
val accentText = ComposeColor(0xFFE4CBFF)
val ok = ComposeColor(0xFF89DA9B)
val warn = ComposeColor(0xFFF4C26A)
val fail = ComposeColor(0xFFF97770)
val onFocus = ComposeColor(0xFF0B0B10)
val buttonSecondary = ComposeColor(0x24FFFFFF)
val buttonSecondaryFocused = ComposeColor(0x42FFFFFF)
val titleButtonSecondary = ComposeColor(0xC72C2C38)
val titleButtonSecondaryFocused = ComposeColor(0xE6464654)
val chipRest = ComposeColor(0x1AFFFFFF)
val navBlobRest = ComposeColor(0x1AFFFFFF)
val listRowRest = ComposeColor(0x0DFFFFFF)
val sheetScrim = ComposeColor(0x9E000000)
val badgeRest = ComposeColor(0xBF07070A)
val badgeWatched = ComposeColor(0xD9F4F4F6)
val progressTrack = ComposeColor(0x38FFFFFF)
val railEmpty = ComposeColor(0x38F4F4F6)
}
object PersonColor {
val justin = ComposeColor(0xFF62C4FF)
val mindel = ComposeColor(0xFFFF9D72)
val shared = ComposeColor(0xFF9BE38F)
}
object Gradient {
const val heroScrim = "linear-gradient(90deg,rgb(7 7 10 / .92) 0%,rgb(7 7 10 / .55) 42%,rgb(7 7 10 / 0) 70%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .6) 26%,rgb(7 7 10 / 0) 48%)"
const val detailScrim = "linear-gradient(90deg,rgb(7 7 10 / .94) 0%,rgb(7 7 10 / .6) 46%,rgb(7 7 10 / .05) 75%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .35) 34%,rgb(7 7 10 / 0) 55%)"
const val navBand = "linear-gradient(180deg,rgb(7 7 10 / .72),rgb(7 7 10 / 0))"
const val navBandSolid = "linear-gradient(180deg,var(--bg) 0%,var(--bg) 84%,rgb(7 7 10 / 0) 100%)"
const val ratingPlate = "linear-gradient(180deg,rgb(7 7 10 / .9) 0%,rgb(7 7 10 / .72) 52%,rgb(7 7 10 / 0) 100%)"
const val tileOverlay = "linear-gradient(0deg,rgb(7 7 10 / .92),rgb(7 7 10 / 0))"
const val fallbackArt = "linear-gradient(160deg,hsl(var(--h,260) 22% 22%),hsl(var(--h,260) 26% 10%))"
const val entryTile = "linear-gradient(150deg,hsl(var(--h) 30% 26%),hsl(var(--h) 30% 12%))"
const val initialsCircle = "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))"
const val heroAmbientFilter = "blur(60px) saturate(1.2) brightness(.55)"
const val cardAmbientFilter = "blur(26px) brightness(.5)"
}
object Type {
val displayDetail = TextStyle(fontSize = 44.0.sp, fontWeight = FontWeight(800), lineHeight = 44.88.sp, letterSpacing = -0.02.em)
val displayHero = TextStyle(fontSize = 42.0.sp, fontWeight = FontWeight(800), lineHeight = 42.84.sp, letterSpacing = -0.02.em)
val screenTitle = TextStyle(fontSize = 32.0.sp, fontWeight = FontWeight(800), lineHeight = 41.6.sp, letterSpacing = -0.02.em)
val sectionTitle = TextStyle(fontSize = 28.0.sp, fontWeight = FontWeight(800), lineHeight = 36.4.sp, letterSpacing = -0.02.em)
val sheetTitle = TextStyle(fontSize = 22.0.sp, fontWeight = FontWeight(800), lineHeight = 28.6.sp, letterSpacing = -0.01.em)
val wordmark = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 23.400000000000002.sp, letterSpacing = -0.02.em)
val stateTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(400), lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em)
val rowHeading = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight(700), lineHeight = 22.1.sp, letterSpacing = -0.01.em)
val pickerName = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight(650), lineHeight = 22.1.sp, letterSpacing = 0.0.em)
val nav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(600), lineHeight = 19.5.sp, letterSpacing = 0.0.em)
val listTitle = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(650), lineHeight = 19.5.sp, letterSpacing = 0.0.em)
val body = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(400), lineHeight = 18.2.sp, letterSpacing = 0.0.em)
val button = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(700), lineHeight = 18.2.sp, letterSpacing = 0.0.em)
val label = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(650), lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
val caption = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
val ratingPlate = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(700), lineHeight = 16.8.sp, letterSpacing = 0.0.em)
val avatarLetterPicker = TextStyle(fontSize = 50.0.sp, fontWeight = FontWeight(800))
val avatarLetterNav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800))
val avatarLetterSheetRow = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800))
val avatarLetterShelfHead = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(800))
val avatarLetterChip = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(800))
val avatarLetterLibHeader = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(800))
val avatarLetterSectionHeader = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800))
const val stageLineHeight = 1.3f
}
object Radius {
val card = 8.0.dp
val button = 19.0.dp
val chip = 17.0.dp
val navItem = 16.0.dp
val listRow = 12.0.dp
val panel = 11.0.dp
val menu = 13.0.dp
val heroSheet = 9.0.dp
val cardSheet = 5.0.dp
val thumb = 6.0.dp
val pill = 11.0.dp
val progress = 2.0.dp
}
object Layout {
val safeSide = 48.0.dp
val contentWidth = 864.0.dp
val safeTopPicker = 27.0.dp
val navBand = 75.0.dp
val navPaddingTop = 22.0.dp
val navItemHeight = 32.0.dp
val navItemPaddingX = 13.0.dp
val navItemGap = 6.0.dp
val wordmarkGap = 20.0.dp
val navUnderline = 2.0.dp
val navBlobAvatarPad = 4.0.dp
val navSolidAfter = 20.0.dp
val pinnedLine = 84.0.dp
val windowAhead = 350.0.dp
val rowHeadingBand = 30.0.dp
val rowGapAfter = 22.0.dp
val gridRowGap = 14.0.dp
val row_hero = 410.0.dp
val row_phero = 370.0.dp
val row_section = 75.0.dp
val row_chips = 52.0.dp
val row_note = 42.0.dp
val row_state = 150.0.dp
val row_lhead = 118.0.dp
val row_dhead = 450.0.dp
val row_phead = 235.0.dp
val row_gh = 42.0.dp
val row_endcta = 100.0.dp
val row_srow = 62.0.dp
val heroCopyLeft = 48.0.dp
val heroCopyBottom = 55.0.dp
val heroCopyWidth = 500.0.dp
val heroOverviewMax = 480.0.dp
val detailCopyTop = 100.0.dp
val detailCopyWidth = 530.0.dp
val heroSheetWidth = 210.0.dp
val heroSheetHeight = 315.0.dp
val buttonHeight = 38.0.dp
val buttonPaddingX = 19.0.dp
val buttonGap = 10.0.dp
val buttonIcon = 15.0.dp
val iconButton = 38.0.dp
val iconCaptionLine = 19.0.dp
val sheetWidth = 400.0.dp
val sheetRow = 50.0.dp
val settingsRowWidth = 650.0.dp
val settingsRowHeight = 52.0.dp
val stateWidth = 550.0.dp
val captionOffset = 9.0.dp
val badge = 24.0.dp
val badgeInset = 7.0.dp
val progressBar = 4.0.dp
val pickerAvatar = 110.0.dp
val pickerGap = 45.0.dp
val pickerStackGap = 35.0.dp
val gear = 28.0.dp
val navAvatar = 32.0.dp
val castCircle = 100.0.dp
val personPortrait = 120.0.dp
val thumbWidth = 48.0.dp
val thumbHeight = 72.0.dp
}
object Card {
val posterW = 128.0.dp
val posterH = 192.0.dp
val posterGap = 12.0.dp
val posterPitch = 140.0.dp
val posterCaptionBand = 38.0.dp
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
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
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
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
}
object Focus {
const val scaleCard = 1.08f
const val scaleControl = 1.05f
const val scaleRow = 1.02f
val ringCardGap = 2.0.dp
val ringCardRing = 2.0.dp
val ringButtonGap = 2.0.dp
val ringButtonRing = 2.0.dp
val ringAvatarGap = 2.0.dp
val ringAvatarRing = 2.0.dp
val ringPickerGap = 3.0.dp
val ringPickerRing = 3.0.dp
const val fillInverse = "background color.fg, text color.onFocus"
}
object Motion {
const val fast = 140
const val base = 160
const val scroll = 180
const val navBlobDampingRatio = 1.0f
const val navBlobStiffness = 650.0f
const val ratingDwell = 350
const val toast = 3200
const val reducedMotion = "all transitions instant"
}
object Image {
const val memoryCacheMB = 64.0f
const val diskCacheMB = 128.0f
}
object Behaviour {
const val rowLimit = 20.0f
const val letterRailMin = 200.0f
const val gridColumns = 6.0f
const val libraryChunk = 200.0f
const val laneLoadRows = 2.0f
const val nextPageCards = 10.0f
const val heroSlides = 5.0f
const val cacheRatingsHours = 6.0f
const val laneCacheMinutes = 30.0f
const val libraryRefreshMinutes = 10.0f
const val requestsPollSeconds = 30.0f
const val comingInVisibleRows = 5.0f
}
object Shell {
val zero = 0.0.dp
val stageWidth = 960.0.dp
val stageHeight = 540.0.dp
val wordmarkWidth = 84.5.dp
val wordmarkTop = 36.8.dp
val navHomeWidth = 74.5.dp
val navDiscoverWidth = 99.0.dp
val navMoviesWidth = 85.5.dp
val navShowsWidth = 80.5.dp
val navAnimeWidth = 79.0.dp
val navItemTop = 32.5.dp
val pickerTitleTop = 136.15.dp
val pickerPeopleTop = 212.75.dp
val pickerNameGap = 13.0.dp
val pickerGearTop = 375.85.dp
val pickerGearCaptionTop = 36.0.dp
val pickerLetter = 50.0.dp
val pickerSkeletonNameWidth = 70.0.dp
val pickerSkeletonNameHeight = 20.0.dp
val pickerLoadingTitleTop = 123.7.dp
val pickerLoadingPeopleTop = 200.3.dp
val pickerLoadingButtonTop = 378.3.dp
val pickerErrorTitleTop = 148.8.dp
val pickerErrorPanelTop = 225.4.dp
val pickerErrorPanelWidth = 490.0.dp
val pickerErrorPanelHeight = 92.8.dp
val pickerErrorButtonTop = 353.2.dp
val settingsHeaderTop = 75.0.dp
val settingsHeaderAvatar = 26.0.dp
val settingsHeaderGap = 12.0.dp
val settingsRowTop = 5.0.dp
val settingsRowPadding = 15.0.dp
val settingsRowGap = 12.0.dp
val settingsIcon = 18.0.dp
val settingsPillHeight = 20.0.dp
val settingsPillPadding = 8.0.dp
val settingsPillGap = 6.0.dp
val settingsChevron = 15.0.dp
val settingsEndPadding = 310.0.dp
val panelPaddingX = 15.0.dp
val panelPaddingY = 14.0.dp
val panelGap = 5.0.dp
val panelHeadingGap = 7.0.dp
val panelIcon = 18.0.dp
val skeletonRadius = 5.0.dp
const val gearOpacity = 0.7f
const val navBandOpacity = 0.72f
const val navSolidStop = 0.84f
}
}

View file

@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12 3 2 20h20z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
<path android:pathData="M12 10v4.5M12 17v.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M 8.8,12.0 a 3.2,3.2 0 1,0 6.4,0 a 3.2,3.2 0 1,0 -6.4,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" />
<path android:pathData="M12 2.8v2.6M12 18.6v2.6M2.8 12h2.6M18.6 12h2.6M5.5 5.5l1.9 1.9M16.6 16.6l1.9 1.9M5.5 18.5l1.9-1.9M16.6 7.4l1.9-1.9" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round" />
<path android:pathData="M 5.4,12.0 a 6.6,6.6 0 1,0 13.2,0 a 6.6,6.6 0 1,0 -13.2,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" />
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M 3.0,12.0 a 9.0,9.0 0 1,0 18.0,0 a 9.0,9.0 0 1,0 -18.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" />
<path android:pathData="M12 11v6M12 7.5v.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M8 5v14l11-7z" android:fillColor="#FFFFFFFF" />
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="m9 6 6 6-6 6" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M7 5v14m0 0-3-3m3 3 3-3M17 19V5m0 0-3 3m3-3 3 3" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12 3c.6 4.2 2.8 6.4 7 7-4.2.6-6.4 2.8-7 7-.6-4.2-2.8-6.4-7-7 4.2-.6 6.4-2.8 7-7z" android:fillColor="#FFFFFFFF" />
</vector>

View file

@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M3 13.5 5.5 5h13l2.5 8.5V19H3z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
<path android:pathData="M3 13.5h5.5a3.5 3.5 0 0 0 7 0H21" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" />
</vector>

View file

@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M 5.5,5.0 h 13.0 a 2.5,2.5 0 0,1 2.5,2.5 v 7.0 a 2.5,2.5 0 0,1 -2.5,2.5 h -13.0 a 2.5,2.5 0 0,1 -2.5,-2.5 v -7.0 a 2.5,2.5 0 0,1 2.5,-2.5 z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" />
<path android:pathData="M8 21h8M12 17v4" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M 5.5,8.5 a 3.5,3.5 0 1,0 7.0,0 a 3.5,3.5 0 1,0 -7.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" />
<path android:pathData="M2.5 19.5c.8-3.4 3.4-5 6.5-5s5.7 1.6 6.5 5M15.5 5.2a3.4 3.4 0 0 1 0 6.6M18 14.8c1.8.7 3 2.2 3.5 4.7" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,38 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.TargetBasedAnimation
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.spring
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** Sample the shipped Compose spring, not a separately approximated motion function. */
class NavSpringTest {
@Test
fun criticallyDampedBlobMatchesLockedCurveWithoutOvershoot() {
val animation = TargetBasedAnimation(
animationSpec = spring<Float>(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness),
typeConverter = Float.VectorConverter,
initialValue = 478f,
targetValue = 688f,
)
val times = listOf(50, 99, 150, 200)
val expected = listOf(554.5f, 628.7f, 665.9f, 680.2f)
val values = times.map { animation.getValueFromNanos(it * 1_000_000L) }
values.zip(expected).forEach { (actual, target) -> assertEquals(target, actual, .2f) }
assertTrue((values[1] - 478) / 210 in .55f.. .8f)
assertEquals(688f, animation.getValueFromNanos(300_000_000), 1f)
var previous = 478f
for (millisecond in 0..1000) {
val value = animation.getValueFromNanos(millisecond * 1_000_000L)
assertTrue("Spring reversed at $millisecond ms", value >= previous)
assertTrue("Spring overshot at $millisecond ms", value <= 688f)
previous = value
}
val directory = File(System.getProperty("wolfskin.captureDirectory"))
directory.mkdirs()
File(directory, "nav-spring.json").writeText(times.zip(values).joinToString(prefix = "[", postfix = "]") { (time, value) -> "{\"ms\":$time,\"x\":$value}" })
}
}

View file

@ -0,0 +1,81 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.json.JSONObject
import org.junit.Before
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Native 2.0-density captures are compared against the frozen browser states outside the build tree. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ShellCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Before
fun fullscreen() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
private fun capture(name: String, openRequests: Int? = null, configure: (ShellState) -> Unit = {}) {
val state = ShellState(fixturePeople(), "justin")
configure(state)
rule.setContent {
WolfskinTheme {
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsOpenRequests = openRequests)
}
}
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory"))
directory.mkdirs()
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
}
@Test fun st001_launch() = capture("st-001")
@Test fun st002_gear() {
capture("st-002") { it.pickerIndex = 2; it.gearFocused = true }
val layouts = mutableListOf<TextLayoutResult>()
rule.onNodeWithText("Settings").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) }
assertEquals("The caption must not wrap under the small gear", 1, layouts.single().lineCount)
}
@Test fun st003_loading() = capture("st-003") { it.peopleStatus = PeopleStatus.Loading }
@Test fun st004_unavailable() = capture("st-004") { it.peopleStatus = PeopleStatus.Unavailable }
@Test fun st010_homeNav() = capture("st-010") { it.pickerVisible = false; it.navFocused = true }
@Test fun st011_rememberedNav() = capture("st-011") { it.pickerVisible = false; it.navFocused = true; it.view.row = 4; it.view.cards["row-4"] = 2 }
@Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 }
@Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") }
@Test fun st092_requestsCountFixture() = capture("st-092", openRequests = 5) { it.pickerVisible = false; it.enterDestination("settings") }
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
}
/** Tests and debug builds share the same shell-only projection of the locked fixture. */
fun fixturePeople(): List<ShellPerson> {
val json = ShellCaptureTest::class.java.getResourceAsStream("/wolfskin-shell.json")!!.bufferedReader().use { JSONObject(it.readText()) }
val people = json.getJSONArray("profiles")
return List(people.length()) { index ->
val person = people.getJSONObject(index)
ShellPerson(person.getString("key"), person.getString("name"), person.getString("initial"), person.getJSONObject("destinations").has("Anime"))
}
}

View file

@ -0,0 +1,114 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Press counts exercise the actual Android-to-shell event path, not a second focus implementation. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class ShellKeysTest {
@get:Rule val rule = createComposeRule()
private val state = ShellState(fixturePeople(), "justin")
private val settings = listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter, Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionCenter)
private fun replay(keys: List<Key>, presses: Int) {
assertEquals("Acceptance press count", presses, keys.size)
rule.setContent { WolfskinTheme { ShellSurface(state) } }
keys.forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
}
@Test fun switchPersonInSixPresses() {
replay(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter, Key.DirectionRight, Key.DirectionCenter), 6)
assertEquals("mindel", state.person.key)
assertEquals("home", state.route)
assertFalse(state.pickerVisible)
assertFalse(state.navFocused)
assertFalse(state.tabs.contains("anime"))
}
@Test fun openSettingsInEightPresses() {
replay(settings, 8)
assertEquals("settings", state.route)
assertEquals(0, state.settingsRow)
assertFalse(state.pickerVisible)
assertFalse("Requests content, not the nav, must own focus", state.navFocused)
}
@Test fun settingsBackBackInTenPresses() {
replay(settings + listOf(Key.Back, Key.Back), 10)
assertEquals("home", state.route)
assertFalse(state.pickerVisible)
assertTrue(state.navFocused)
assertEquals(state.tabs.size, state.navIndex)
}
@Test fun playbackHandOffInElevenPresses() {
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11)
assertEquals("playback", state.handOff)
assertEquals(2, state.settingsRow)
}
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() {
replay(listOf(Key.DirectionDown, Key.DirectionRight, Key.DirectionDown), 3)
assertFalse(state.gearFocused)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionDown) }
assertTrue(state.gearFocused)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp) }
assertFalse(state.gearFocused)
assertEquals(2, state.pickerIndex)
}
@Test fun rootBackAndNavDownPreserveViewAndWrap() {
replay(listOf(Key.DirectionCenter), 1)
val view = state.view
rule.runOnIdle { view.row = 4; view.cards["row-4"] = 2 }
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back); pressKey(Key.DirectionDown) }
assertSame(view, state.view)
assertEquals(4, state.view.row)
assertEquals(2, state.view.cards["row-4"])
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp); pressKey(Key.DirectionLeft) }
assertEquals(state.tabs.size, state.navIndex)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight) }
assertEquals(0, state.navIndex)
}
@Test fun destinationViewsAreIsolatedAndReselectResets() {
replay(listOf(Key.DirectionCenter), 1)
rule.runOnIdle {
state.enterDestination("movies")
val movies = state.view
movies.row = 3; movies.cards["movies"] = 2
state.enterDestination("shows")
state.view.row = 1
state.enterDestination("movies")
assertSame(movies, state.view)
state.focusNav(2); state.key(ShellKey.Ok)
assertNotSame(movies, state.view)
assertEquals(0, state.view.row)
assertTrue(state.view.cards.isEmpty())
}
}
@Test fun heldOkCannotActivateNewOverlay() {
replay(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft), 3)
rule.runOnIdle {
state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
repeat(5) { repeat -> state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, repeat + 1))) }
state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
}
assertTrue(state.pickerVisible)
assertEquals("justin", state.person.key)
}
}

View file

@ -0,0 +1,78 @@
{
"source": "Locked Opus fixture/fixture.json; shell-only projection; debug/test only",
"profiles": [
{
"key": "justin",
"name": "Justin",
"initial": "J",
"color": "#62c4ff",
"destinations": {
"Home": {
"kind": "home"
},
"Movies": {
"kind": "library",
"collection": "movies"
},
"Shows": {
"kind": "library",
"collection": "shows"
},
"Anime": {
"kind": "library",
"collection": "anime"
},
"Discover": {
"kind": "discover",
"collection": "discover"
}
}
},
{
"key": "mindel",
"name": "Mindel",
"initial": "M",
"color": "#ff9d72",
"destinations": {
"Home": {
"kind": "home"
},
"Movies": {
"kind": "library",
"collection": "movies"
},
"Shows": {
"kind": "library",
"collection": "shows"
},
"Discover": {
"kind": "discover",
"collection": "discover"
}
}
},
{
"key": "shared",
"name": "Shared",
"initial": "S",
"color": "#9be38f",
"destinations": {
"Home": {
"kind": "home"
},
"Movies": {
"kind": "library",
"collection": "movies"
},
"Shows": {
"kind": "library",
"collection": "shows"
},
"Discover": {
"kind": "discover",
"collection": "discover"
}
}
}
]
}