Compare commits
6 commits
c534ea75c2
...
b54bcc148c
| Author | SHA1 | Date | |
|---|---|---|---|
| b54bcc148c | |||
| 2b65ee7b53 | |||
| 5a0497c9ec | |||
| 3b7249888f | |||
| 7a878b2d81 | |||
| 74033ee1da |
19 changed files with 244 additions and 32 deletions
|
|
@ -18,11 +18,13 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.toPixelMap
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.assertIsNotFocused
|
||||
import androidx.compose.ui.test.captureToImage
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
|
|
@ -131,6 +133,7 @@ class UpstreamContentFocusTest {
|
|||
rule.runOnIdle { WolfskinScreenPreference.enabled = true }
|
||||
rule.mainClock.advanceTimeBy(1500)
|
||||
rule.waitForIdle()
|
||||
rule.onNodeWithText("Sign in as Justin to open their Jellyfin library").assertIsDisplayed()
|
||||
val on = rule.onRoot().captureToImage().toPixelMap()[1700, 900]
|
||||
val background = org.justinvisser.wolfskin.design.WolfskinTokens.Color.bg
|
||||
org.junit.Assert.assertEquals("Owned background red", background.red, on.red, 1f / 255f)
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ fun WolfskinApp(
|
|||
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
||||
ShellSurface(state, handlesContentKeys = upstream == null) { contentModifier ->
|
||||
when {
|
||||
upstream != null && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||
(upstream != null || destination is Destination.Home) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
|
||||
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
||||
|
|
|
|||
|
|
@ -78,6 +78,11 @@ resolves consistently in native captures; the approved JSON weights stay unchang
|
|||
Nav, picker names, labels and pill weights use that same mapping. Nav label colour
|
||||
fades for 200 ms ease-out; the blob retains its critically damped spring.
|
||||
|
||||
Settings pills have a separate 1 dp stroke (2 px on the stage), not a focus ring.
|
||||
The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box
|
||||
padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width
|
||||
ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%.
|
||||
|
||||
The small gear under the final person opens Settings with Requests focused.
|
||||
Settings groups hand off to existing upstream pages; the Requests entry explicitly
|
||||
has no request data yet. **Advanced settings → Wolfskin screens** is the persistent
|
||||
|
|
@ -125,12 +130,15 @@ The final action opens Discover at its top. Discover (`discover`) and See all
|
|||
later screens. Title navigation remains the shell's later-slice target.
|
||||
|
||||
All Home components and immutable screen models live in `:wolfskin-design`.
|
||||
The shell's remembered view owns vertical position, each row's card and the
|
||||
bounded six-hour ratings session cache. Back preserves the viewport; returning
|
||||
The shell's remembered view owns vertical position, each row's card and first
|
||||
visible card, and the bounded six-hour ratings session cache. Back preserves the viewport; returning
|
||||
to Home does not re-run focus-follow over a manually scrolled saved view. The
|
||||
next arrow re-enters visible content when scrolling moved focus off-screen.
|
||||
Pointing changes focus without activation, cards activate on key-up, and held
|
||||
OK events cannot activate twice.
|
||||
Between shelves, an unvisited row selects the nearest visible card center even
|
||||
when card widths differ. A visited row restores its column. Horizontal movement
|
||||
keeps the track still while focus remains in its visible middle.
|
||||
|
||||
`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release
|
||||
variants read the selected user's Resume, Next up, newest titles and destination
|
||||
|
|
@ -139,6 +147,8 @@ disk cache. A missing saved library becomes a recovery panel; a saved library
|
|||
stays visible when Jellyfin is unreachable. Playback resolves an actual film or
|
||||
episode and checks Jellyfin before opening upstream's player. No season,
|
||||
recommendation stub or different person's session is promoted to playback.
|
||||
An unresolved person switch shows the same sign-in guard for Home as for native
|
||||
library content, before mounting Home's source reads.
|
||||
|
||||
Debug variants additionally package the locked fixture and local artwork under
|
||||
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
|
||||
|
|
@ -153,6 +163,11 @@ the next shelf. Rating plates use the TV locale, start at 14 sp, shrink to 12 sp
|
|||
and drop trailing entries rather than wrap. Their uncached read gate is 350 ms;
|
||||
cached focus returns without that delay.
|
||||
|
||||
Focus rings and native lift draw outside fixed slots. The Saved chip uses its
|
||||
own measured geometry rather than the smaller Settings pill; caption separators
|
||||
use the approved 40% opacity token. Vertical moves clamp to the short target row,
|
||||
including a final See all card, rather than preserving an invalid column.
|
||||
|
||||
`make test` includes `HomeCaptureTest` (23 native acceptance states) and the
|
||||
data-driven paths in `wolfskin-home-presses.json`: play next 2, resume 3, Discover
|
||||
4, deep-row Back/Down 9, Trending 13 and Trending's See all card 33 presses.
|
||||
|
|
|
|||
|
|
@ -317,6 +317,19 @@
|
|||
"px": 16,
|
||||
"dp": 8.0,
|
||||
"source": "locked index.html:191"
|
||||
}
|
||||
},
|
||||
"hairline": { "$type": "dimension", "px": 2, "dp": 1.0, "source": "locked index.html:87,146,194" },
|
||||
"savedHeight": { "$type": "dimension", "px": 52, "dp": 26.0, "source": "locked index.html:87" },
|
||||
"savedPadding": { "$type": "dimension", "px": 20, "dp": 10.0, "source": "locked index.html:87" },
|
||||
"savedGap": { "$type": "dimension", "px": 10, "dp": 5.0, "source": "locked index.html:87" },
|
||||
"savedIcon": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:88" },
|
||||
"savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" },
|
||||
"cardLiftOffset": { "$type": "dimension", "px": 18, "dp": 9.0, "source": "locked index.html:134" },
|
||||
"cardLiftBlur": { "$type": "dimension", "px": 50, "dp": 25.0, "source": "locked index.html:134" },
|
||||
"cardLiftOpacity": { "$type": "number", "$value": 0.65, "source": "locked index.html:134" },
|
||||
"buttonLiftOffset": { "$type": "dimension", "px": 12, "dp": 6.0, "source": "locked index.html:110" },
|
||||
"buttonLiftBlur": { "$type": "dimension", "px": 40, "dp": 20.0, "source": "locked index.html:110" },
|
||||
"buttonLiftOpacity": { "$type": "number", "$value": 0.5, "source": "locked index.html:110" },
|
||||
"factSeparatorOpacity": { "$type": "number", "$value": 0.4, "source": "approved spec 4.4" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -215,6 +215,32 @@
|
|||
"dp": 20.0,
|
||||
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
|
||||
},
|
||||
"settingsPillStroke": {
|
||||
"$type": "dimension",
|
||||
"px": 2,
|
||||
"dp": 1.0,
|
||||
"source": "index.html:122; pill border 2 px inside its 40 px border-box"
|
||||
},
|
||||
"settingsPillDashRatio": {
|
||||
"$type": "number",
|
||||
"$value": 3,
|
||||
"source": "st-091 frozen browser top border: 6 px dashes at 2 px stroke width"
|
||||
},
|
||||
"settingsPillDashGapRatio": {
|
||||
"$type": "number",
|
||||
"$value": 2,
|
||||
"source": "st-091 frozen browser top border: 4 px gaps at 2 px stroke width"
|
||||
},
|
||||
"settingsPillBorder": {
|
||||
"$type": "color",
|
||||
"$value": "#ffffff38",
|
||||
"source": "index.html:122; white at 22 percent opacity, rounded to 8-bit alpha"
|
||||
},
|
||||
"settingsPillFocusedBorder": {
|
||||
"$type": "color",
|
||||
"$value": "#0b0b1066",
|
||||
"source": "index.html:328; focused pill ink at 40 percent opacity"
|
||||
},
|
||||
"settingsPillPadding": {
|
||||
"$type": "dimension",
|
||||
"px": 16,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
|
|||
Row(Modifier.clipToBounds(), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Layout.captionOffset)) {
|
||||
val entries = facts.split(" · ") + listOfNotNull(availability)
|
||||
entries.forEachIndexed { index, fact ->
|
||||
if (index > 0) Text("·", color = WolfskinTokens.Color.fg.copy(alpha = .4f), style = WolfskinTokens.Type.caption)
|
||||
if (index > 0) Text("·", color = WolfskinTokens.Color.fg.copy(alpha = WolfskinTokens.Home.factSeparatorOpacity), style = WolfskinTokens.Type.caption)
|
||||
Text(fact, color = if (availability != null && index == entries.lastIndex) WolfskinTokens.Color.accentText else WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,15 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
|||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
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.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/** Only transforms animate: focus never changes measured geometry. */
|
||||
|
|
@ -46,6 +50,26 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color:
|
|||
}
|
||||
}
|
||||
|
||||
/** Native lift draws outside the slot without allocating an offscreen layer per card. */
|
||||
@Composable
|
||||
fun Modifier.focusLift(focused: Boolean, radius: Dp, offset: Dp, blur: Dp, opacity: Float): Modifier {
|
||||
val alpha by animateFloatAsState(if (focused) 1f else 0f,
|
||||
tween(WolfskinTokens.Motion.fast), label = "focus-lift")
|
||||
return drawWithCache {
|
||||
val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = WolfskinTokens.Color.bg.toArgb()
|
||||
setShadowLayer(blur.toPx(), 0f, offset.toPx(), WolfskinTokens.Color.sheetScrim.copy(alpha = opacity).toArgb())
|
||||
}
|
||||
val corner = radius.toPx()
|
||||
onDrawBehind {
|
||||
if (alpha > 0f) {
|
||||
paint.alpha = (alpha * 255).toInt()
|
||||
drawIntoCanvas { it.nativeCanvas.drawRoundRect(0f, 0f, size.width, size.height, corner, corner, paint) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Pointing changes the same logical focus as the D-pad without activating an action. */
|
||||
@Composable
|
||||
fun Modifier.focusOnPointerEnter(onEnter: () -> Unit): Modifier {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Home navigation mutates the shell's per-person view, including each row's remembered column. */
|
||||
class HomeFocus(
|
||||
private val shell: ShellState,
|
||||
|
|
@ -15,7 +17,10 @@ class HomeFocus(
|
|||
shell.navFocused = false
|
||||
shell.view.row = index
|
||||
shell.view.home.focusedKey = rows[index].id
|
||||
shell.view.cards[rows[index].id] = column.coerceIn(0, (rows[index].count - 1).coerceAtLeast(0))
|
||||
val target = rows[index]
|
||||
val bounded = column.coerceIn(0, (target.count - 1).coerceAtLeast(0))
|
||||
shell.view.cards[target.id] = bounded
|
||||
if (target.kind == HomeRowKind.Shelf) shell.view.home.firstCards[target.id] = target.adjustedFirst(bounded, shell.view.home.firstCards[target.id] ?: 0)
|
||||
}
|
||||
|
||||
fun key(key: ShellKey, down: Boolean, repeat: Int): Boolean {
|
||||
|
|
@ -43,7 +48,7 @@ class HomeFocus(
|
|||
while (next in rows.indices && !rows[next].focusable) next += direction
|
||||
if (next in rows.indices) {
|
||||
val target = rows[next]
|
||||
val nearest = (column * pitch(current) / pitch(target)).toInt()
|
||||
val nearest = nearest(current, target)
|
||||
focus(next, shell.view.cards[target.id] ?: nearest)
|
||||
} else if (direction < 0) {
|
||||
shell.view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
|
||||
|
|
@ -84,9 +89,38 @@ class HomeFocus(
|
|||
}
|
||||
}
|
||||
|
||||
private fun nearest(from: HomeRow, to: HomeRow): Int {
|
||||
if (to.kind != HomeRowKind.Shelf) return (column * pitch(from) / pitch(to)).toInt()
|
||||
val fromFirst = if (from.kind == HomeRowKind.Shelf) shell.view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0
|
||||
val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2
|
||||
val first = (shell.view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0))
|
||||
val last = minOf(to.count - 1, first + to.focusSlots - 1)
|
||||
var best = first
|
||||
var distance = Float.POSITIVE_INFINITY
|
||||
for (index in first..last) {
|
||||
val candidate = abs((index - first) * pitch(to) + to.cardWidth.value / 2 - x)
|
||||
if (candidate < distance) { best = index; distance = candidate }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
private fun pitch(row: HomeRow): Float = when (row.cardKind) {
|
||||
HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value
|
||||
HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value
|
||||
HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value
|
||||
}
|
||||
}
|
||||
|
||||
internal val HomeRow.focusSlots: Int get() = when (cardKind) {
|
||||
HomeCardKind.Poster -> 6
|
||||
HomeCardKind.Tile -> 4
|
||||
HomeCardKind.Wide -> 3
|
||||
}
|
||||
|
||||
/** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */
|
||||
internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int {
|
||||
var first = previous
|
||||
if (column > first + focusSlots - 2) first = column - (focusSlots - 2)
|
||||
if (column < first + 1) first = (column - 1).coerceAtLeast(0)
|
||||
return first.coerceIn(0, (count - focusSlots).coerceAtLeast(0))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
|||
Spacer(Modifier.height(H.overviewBottom))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
||||
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }) { onButton(0) }
|
||||
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }) { onButton(1) }
|
||||
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, lift = true) { onButton(0) }
|
||||
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, lift = true) { onButton(1) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -78,7 +78,7 @@ fun HomeSection(person: ShellPerson, savedAt: String?) {
|
|||
@Composable
|
||||
fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> Unit, onHover: () -> Unit) {
|
||||
Box(Modifier.fillMaxWidth().height(L.row_state)) {
|
||||
Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(WolfskinTokens.Focus.ringCardRing / 2, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX, vertical = H.statePaddingY)) {
|
||||
Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX, vertical = H.statePaddingY)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.plateTop)) {
|
||||
Glyph(if (row.calm) "profile" else "alert", if (row.calm) C.accentText else C.warn, Modifier.size(H.stateIcon))
|
||||
Text(row.title, color = C.fg, style = T.stateTitle.copy(fontWeight = FontWeight.Bold))
|
||||
|
|
@ -86,7 +86,7 @@ fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> Unit, onHover: ()
|
|||
Spacer(Modifier.height(H.stateGap))
|
||||
Text(row.text, color = C.muted, style = T.body)
|
||||
Spacer(Modifier.height(H.stateTextBottom))
|
||||
PickerButton("Try again", focused, row.calm, onFocus = onHover, onClick = retry)
|
||||
PickerButton("Try again", focused, row.calm, onFocus = onHover, lift = true, onClick = retry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ fun HomeScreen(
|
|||
onDispose { shell.contentInput = null }
|
||||
}
|
||||
LaunchedEffect(snapshot.rows, view) {
|
||||
snapshot.rows.forEach { item ->
|
||||
view.cards[item.id]?.let { view.cards[item.id] = it.coerceIn(0, (item.count - 1).coerceAtLeast(0)) }
|
||||
}
|
||||
val remembered = snapshot.rows.indexOfFirst { it.id == view.home.focusedKey && it.focusable }
|
||||
if (remembered >= 0) view.row = remembered
|
||||
if (snapshot.rows.none { it.focusable }) { shell.navFocused = true; shell.navIndex = shell.currentTab }
|
||||
|
|
@ -86,7 +89,7 @@ fun HomeScreen(
|
|||
fun activate(column: Int) { focus.focus(index, column); focus.key(ShellKey.Ok, true, 0); focus.key(ShellKey.Ok, false, 0) }
|
||||
when (item.kind) {
|
||||
HomeRowKind.Hero -> HomeHero(item, selected, focus.column, if (showRatings) heroRatings else emptyList(), ::activate) { focus.focus(index, it) }
|
||||
HomeRowKind.Shelf -> HomeShelf(item, selected, view.cards[item.id] ?: 0, if (showRatings && selected) focusedRatings else emptyList(), ::activate) { focus.focus(index, it) }
|
||||
HomeRowKind.Shelf -> HomeShelf(item, view.home, selected, view.cards[item.id] ?: 0, if (showRatings && selected) focusedRatings else emptyList(), ::activate) { focus.focus(index, it) }
|
||||
HomeRowKind.Section -> HomeSection(shell.person, snapshot.savedAt)
|
||||
HomeRowKind.State -> HomeRecovery(item, selected, { activate(0) }) { focus.focus(index, 0) }
|
||||
HomeRowKind.Note -> Row(Modifier.fillMaxWidth().height(L.row_note).padding(start = L.safeSide, top = H.noteTop), horizontalArrangement = Arrangement.spacedBy(H.noteGap)) {
|
||||
|
|
@ -94,7 +97,7 @@ fun HomeScreen(
|
|||
Text(item.text, color = if (item.calm) C.muted else C.warn, style = WolfskinTokens.Type.body)
|
||||
}
|
||||
HomeRowKind.Explore -> Box(Modifier.fillMaxWidth().height(L.row_endcta).padding(start = L.safeSide, top = H.endTop)) {
|
||||
PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }) { activate(0) }
|
||||
PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }, lift = true) { activate(0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import androidx.compose.foundation.shape.CircleShape
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
|
@ -31,12 +32,12 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Type as T
|
|||
|
||||
/** Every lazy row and card uses a stable source identity and an explicit content type. */
|
||||
@Composable
|
||||
fun HomeShelf(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) {
|
||||
fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) {
|
||||
val width = row.cardWidth
|
||||
val height = row.cardHeight
|
||||
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero
|
||||
val slots = if (row.cardKind == HomeCardKind.Poster) 6 else if (row.cardKind == HomeCardKind.Tile) 4 else 3
|
||||
val first = (column - (slots - 2)).coerceIn(0, (row.count - slots).coerceAtLeast(0))
|
||||
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
|
||||
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
|
||||
val track = rememberLazyListState(first)
|
||||
val density = LocalDensity.current
|
||||
LaunchedEffect(first) {
|
||||
|
|
@ -49,7 +50,7 @@ fun HomeShelf(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRat
|
|||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
|
||||
itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title ->
|
||||
val selected = focused && column == index
|
||||
Box(Modifier.size(width, height).zIndex(if (selected) 2f else 0f).focusScale(selected, WolfskinTokens.Focus.scaleCard).focusRing(selected, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).testTag("home-card:${row.id}:$index").then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) }.clickable { onCard(index) } else Modifier)) {
|
||||
Box(Modifier.size(width, height).zIndex(if (selected) 2f else 0f).focusScale(selected, WolfskinTokens.Focus.scaleCard).focusLift(selected, WolfskinTokens.Radius.card, H.cardLiftOffset, H.cardLiftBlur, H.cardLiftOpacity).focusRing(selected, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).testTag("home-card:${row.id}:$index").then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) }.clickable { onCard(index) } else Modifier)) {
|
||||
Box(Modifier.fillMaxSize().clip(RoundedCornerShape(WolfskinTokens.Radius.card))) {
|
||||
when {
|
||||
row.skeleton -> Box(Modifier.fillMaxSize().background(C.surface))
|
||||
|
|
@ -95,7 +96,7 @@ private fun TileOverlay(title: HomeTitle) {
|
|||
|
||||
@Composable
|
||||
private fun SeeAll(total: Int) {
|
||||
Column(Modifier.fillMaxSize().background(C.surface).border(WolfskinTokens.Focus.ringCardRing, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)), verticalArrangement = Arrangement.spacedBy(H.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Column(Modifier.fillMaxSize().background(C.surface).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)), verticalArrangement = Arrangement.spacedBy(H.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Glyph("interface", C.accent, Modifier.size(H.seeAllIcon))
|
||||
Text("See all", color = C.fg, style = T.listTitle)
|
||||
Text("$total", color = C.muted, style = T.label)
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ package org.justinvisser.wolfskin.design
|
|||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/** Focus-follow runs on a new row, never on re-entry to an already-positioned saved viewport. */
|
||||
class HomeViewMemory {
|
||||
val ratings = HomeRatingCache()
|
||||
val firstCards = mutableStateMapOf<String, Int>()
|
||||
var focusedKey: String? = null
|
||||
var positionedKey: String? = null
|
||||
var resetScroll by mutableIntStateOf(0)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import androidx.compose.ui.platform.LocalDensity
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||
|
||||
|
|
@ -100,10 +101,10 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
|||
}
|
||||
}
|
||||
state.view.savedAt?.let { time ->
|
||||
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide - L.navAvatar - L.buttonGap, S.navItemTop + (L.navItemHeight - S.settingsPillHeight) / 2)
|
||||
.height(S.settingsPillHeight).border(WolfskinTokens.Focus.ringCardRing / 2, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill))
|
||||
.padding(horizontal = S.settingsPillPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsPillGap)) {
|
||||
Glyph("wifi", C.warn, Modifier.size(S.settingsPillHeight - S.settingsPillGap))
|
||||
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide - L.navAvatar - L.navItemGap, S.navItemTop + (L.navItemHeight - H.savedHeight) / 2)
|
||||
.height(H.savedHeight).border(H.hairline, C.warn.copy(alpha = .5f), RoundedCornerShape(H.savedRadius))
|
||||
.padding(horizontal = H.savedPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.savedGap)) {
|
||||
Glyph("wifi", C.warn, Modifier.size(H.savedIcon))
|
||||
Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,10 @@ 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.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
|
@ -108,10 +110,19 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi
|
|||
@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) {
|
||||
val border = if (focused) S.settingsPillFocusedBorder else S.settingsPillBorder
|
||||
Box(Modifier.height(S.settingsPillHeight).drawWithCache {
|
||||
val stroke = S.settingsPillStroke.toPx()
|
||||
val inset = Offset(stroke / 2, stroke / 2)
|
||||
val bounds = Size(size.width - stroke, size.height - stroke)
|
||||
val outline = Stroke(stroke, pathEffect = PathEffect.dashPathEffect(
|
||||
floatArrayOf(stroke * S.settingsPillDashRatio, stroke * S.settingsPillDashGapRatio),
|
||||
))
|
||||
val radius = CornerRadius(WolfskinTokens.Radius.pill.toPx())
|
||||
onDrawBehind {
|
||||
drawRoundRect(border, topLeft = inset, size = bounds, cornerRadius = radius, style = outline)
|
||||
}
|
||||
}.padding(horizontal = S.settingsPillPadding + S.settingsPillStroke), contentAlignment = Alignment.Center) {
|
||||
Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,9 +102,10 @@ private fun RecoveryPicker(state: ShellState) {
|
|||
|
||||
/** Recovery actions remain focusable while profiles cannot be read. */
|
||||
@Composable
|
||||
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
|
||||
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, lift: Boolean = false, 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)
|
||||
.then(if (lift) Modifier.focusLift(focused, WolfskinTokens.Radius.button, WolfskinTokens.Home.buttonLiftOffset, WolfskinTokens.Home.buttonLiftBlur, WolfskinTokens.Home.buttonLiftOpacity) else Modifier)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
|
||||
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
|
||||
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
|
||||
|
|
|
|||
|
|
@ -266,6 +266,11 @@ object WolfskinTokens {
|
|||
val settingsRowGap = 12.0.dp
|
||||
val settingsIcon = 18.0.dp
|
||||
val settingsPillHeight = 20.0.dp
|
||||
val settingsPillStroke = 1.0.dp
|
||||
const val settingsPillDashRatio = 3.0f
|
||||
const val settingsPillDashGapRatio = 2.0f
|
||||
val settingsPillBorder = ComposeColor(0x38FFFFFF)
|
||||
val settingsPillFocusedBorder = ComposeColor(0x660B0B10)
|
||||
val settingsPillPadding = 8.0.dp
|
||||
val settingsPillGap = 6.0.dp
|
||||
val settingsChevron = 15.0.dp
|
||||
|
|
@ -334,5 +339,18 @@ object WolfskinTokens {
|
|||
val heroWashInset = 40.0.dp
|
||||
val noteTop = 7.0.dp
|
||||
val noteGap = 8.0.dp
|
||||
val hairline = 1.0.dp
|
||||
val savedHeight = 26.0.dp
|
||||
val savedPadding = 10.0.dp
|
||||
val savedGap = 5.0.dp
|
||||
val savedIcon = 14.0.dp
|
||||
val savedRadius = 14.0.dp
|
||||
val cardLiftOffset = 9.0.dp
|
||||
val cardLiftBlur = 25.0.dp
|
||||
const val cardLiftOpacity = 0.65f
|
||||
val buttonLiftOffset = 6.0.dp
|
||||
val buttonLiftBlur = 20.0.dp
|
||||
const val buttonLiftOpacity = 0.5f
|
||||
const val factSeparatorOpacity = 0.4f
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,53 @@ class HomeKeysTest {
|
|||
assertNull(action)
|
||||
}
|
||||
|
||||
@Test fun downClampsToShortRowsAndNeverMovesPastTheFinalSeeAll() {
|
||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false }
|
||||
val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards
|
||||
val long = HomeRow(id = "long", kind = HomeRowKind.Shelf, cards = cards)
|
||||
val short = long.copy(id = "short", cards = cards.take(2), total = 2)
|
||||
val tail = short.copy(id = "tail", total = 20)
|
||||
shell.view.cards[long.id] = cards.lastIndex
|
||||
var action: HomeAction? = null
|
||||
rule.setContent { WolfskinTheme {
|
||||
ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(long, short, tail)), modifier) { action = it } }
|
||||
} }
|
||||
press("DOWN")
|
||||
assertEquals(short.id, listOf(long, short, tail)[shell.view.row].id)
|
||||
assertEquals(1, shell.view.cards[short.id])
|
||||
rule.onNodeWithTag("home-card:short:1").assertIsDisplayed()
|
||||
press("RIGHT")
|
||||
assertEquals(1, shell.view.cards[short.id])
|
||||
press("DOWN")
|
||||
press("RIGHT")
|
||||
press("DOWN")
|
||||
assertEquals(2, shell.view.row)
|
||||
assertEquals(2, shell.view.cards[tail.id])
|
||||
rule.onNodeWithTag("home-card:tail:2").assertIsDisplayed()
|
||||
press("OK")
|
||||
assertEquals(tail.id, (action as HomeAction.SeeAll).row.id)
|
||||
}
|
||||
|
||||
@Test fun mixedWidthRowsUseNearestCenterAndKeepTheTrackWhenMovingBackInsideItsMiddle() {
|
||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false }
|
||||
val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards
|
||||
val posters = HomeRow(id = "posters", kind = HomeRowKind.Shelf, cards = cards)
|
||||
val tiles = posters.copy(id = "tiles", cardKind = HomeCardKind.Tile)
|
||||
shell.view.cards[posters.id] = 3
|
||||
rule.setContent { WolfskinTheme {
|
||||
ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(posters, tiles)), modifier) {} }
|
||||
} }
|
||||
press("DOWN")
|
||||
assertEquals("The third tile is nearest the fourth poster's horizontal center", 2, shell.view.cards[tiles.id])
|
||||
repeat(4) { press("RIGHT") }
|
||||
val left = rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left
|
||||
press("LEFT")
|
||||
assertEquals("Moving back within the visible middle must not shift the track", left,
|
||||
rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left, .1f)
|
||||
press("UP")
|
||||
assertEquals("A visited row retains its column", 3, shell.view.cards[posters.id])
|
||||
}
|
||||
|
||||
private fun press(name: String) {
|
||||
val key = when (name) {
|
||||
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown
|
||||
|
|
|
|||
|
|
@ -163,6 +163,9 @@
|
|||
--shell-settingsRowGap: 24.0px;
|
||||
--shell-settingsIcon: 36.0px;
|
||||
--shell-settingsPillHeight: 40.0px;
|
||||
--shell-settingsPillStroke: 2.0px;
|
||||
--shell-settingsPillBorder: #ffffff38;
|
||||
--shell-settingsPillFocusedBorder: #0b0b1066;
|
||||
--shell-settingsPillPadding: 16.0px;
|
||||
--shell-settingsPillGap: 12.0px;
|
||||
--shell-settingsChevron: 30.0px;
|
||||
|
|
@ -226,4 +229,14 @@
|
|||
--home-heroWashInset: 80.0px;
|
||||
--home-noteTop: 14.0px;
|
||||
--home-noteGap: 16.0px;
|
||||
--home-hairline: 2.0px;
|
||||
--home-savedHeight: 52.0px;
|
||||
--home-savedPadding: 20.0px;
|
||||
--home-savedGap: 10.0px;
|
||||
--home-savedIcon: 28.0px;
|
||||
--home-savedRadius: 28.0px;
|
||||
--home-cardLiftOffset: 18.0px;
|
||||
--home-cardLiftBlur: 50.0px;
|
||||
--home-buttonLiftOffset: 12.0px;
|
||||
--home-buttonLiftBlur: 40.0px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
28f6f7bb878b8388ce04293cd2819bc1cb684da34925c9ddcded78fcddf6d1dd generate_tokens.py
|
||||
77a489b428b038a874624c9d7d881e69b9f20ca478961743061b76ce31b7bd31 home-tokens.json
|
||||
6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
|
||||
b051a021d1fc9bb7d03287891f4a7eb62e4eaee4058d54e13bbff108924c6fa0 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
853f22d6f2572f55a2c8a0cac7e5ba11647d3d70e1b2c0be70d23cf06c7f2e58 tokens.css
|
||||
73398fc0d3d6b780170fa6d7ef9246f788c800c499981fde092fe790330c8ad1 home-tokens.json
|
||||
408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json
|
||||
3ff67d4867c2735ed2ad7ffc66bbfb04dad5f0000072daa2f5e98ed1b7059dde src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
7ded358afa758c8b0dd191d650d06dc0bcfc8dcc2baebba2c07a3e70c435ff5a tokens.css
|
||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue