Keep catalogue outcomes and recovery consistent across views

This commit is contained in:
justinzeus 2026-11-05 18:32:09 +01:00
parent c1559e2513
commit b1b15b384b
21 changed files with 340 additions and 216 deletions

View file

@ -40,7 +40,7 @@ class ConductarrDiscoverOperations(
} }
override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult { override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult {
return when (val result = client.read(TvRoutes.requests(intent.title.tmdb, intent.title.mediaType())).last()) { return when (val result = client.read(TvRoutes.requests(intent.title.tmdb, intent.title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) {
is ReadResult.Value -> result.saved.value?.requests?.firstOrNull { it.requester == intent.requester }?.outcome() ?: DiscoverRequestResult(RequestPhase.NotConfirmed) is ReadResult.Value -> result.saved.value?.requests?.firstOrNull { it.requester == intent.requester }?.outcome() ?: DiscoverRequestResult(RequestPhase.NotConfirmed)
is ReadResult.Failure -> DiscoverRequestResult(RequestPhase.NotConfirmed) is ReadResult.Failure -> DiscoverRequestResult(RequestPhase.NotConfirmed)
} }

View file

@ -107,14 +107,24 @@ The debug-only fixture and local art live in
Conductarr sign-in state. The fixture host cannot construct a network client: Conductarr sign-in state. The fixture host cannot construct a network client:
debug request outcomes and dismissals stay in memory. The real mutation adapter debug request outcomes and dismissals stay in memory. The real mutation adapter
accepts an injected `ConductarrClient` and uses only its typed routes; it is not accepts an injected `ConductarrClient` and uses only its typed routes; it is not
wired to a live server. Unknown request outcomes use a title-requests lookup rather wired to a live server. Unknown request outcomes force a fresh title-requests read
than blindly submitting again. Whole-series requests require every season. instead of blindly submitting again, including after closing a sheet or choosing
the same uncertain recipient from another chooser. Whole-series requests require every season.
Discover's native capture suite covers its 28 named acceptance states and emits Discover's native capture suite covers its 28 named acceptance states and emits
text-layout metrics beside the PNGs. Data-driven Android key replays cover opening text-layout metrics beside the PNGs. Data-driven Android key replays cover opening
Discover in 4 presses, requesting for yourself in 7, requesting for Mindel in 9 and Discover in 4 presses, requesting for yourself in 7, requesting for Mindel in 9 and
Not interested in 8. Additional regressions exercise held-OK safety, uncertain Not interested in 8. Additional regressions exercise held-OK safety, uncertain
receipts, unknown seasons, Dutch decimal formatting and short-row/tail clamping. receipts, per-recipient outcomes, collection recovery and Undo, unknown seasons,
Dutch decimal formatting and short-row/tail clamping. Discover reuses the Libraries
grid, poster, ratings plate, See all and recovery cards, bounded image loader, caption
separator token and the shell's single hold guard. Both frames share the 180 ms scroll tween.
The TV keeps platform Roboto with the approved type sizes and weights. Browser
system-font wrapping is not a per-title contract: no bundled font, forced title
breaks or width compensation is applied. Native text-layout measurements accompany
each acceptance capture; font-driven wrapping and text-block positions must be
listed explicitly in its comparison report.
Native Roborazzi captures use Robolectric SDK 34 and Native Roborazzi captures use Robolectric SDK 34 and
`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and `w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and

View file

@ -198,30 +198,6 @@
"dp": 12, "dp": 12,
"source": "locked index.html:198" "source": "locked index.html:198"
}, },
"captionWidth": {
"$type": "dimension",
"px": 640,
"dp": 320,
"source": "locked index.html:1242"
},
"plateTop": {
"$type": "dimension",
"px": 14,
"dp": 7,
"source": "locked index.html:162"
},
"plateSide": {
"$type": "dimension",
"px": 16,
"dp": 8,
"source": "locked index.html:162"
},
"plateFade": {
"$type": "dimension",
"px": 46,
"dp": 23,
"source": "locked index.html:162"
},
"noteTop": { "noteTop": {
"$type": "dimension", "$type": "dimension",
"px": 14, "px": 14,
@ -240,29 +216,6 @@
"dp": 12, "dp": 12,
"source": "locked index.html:202" "source": "locked index.html:202"
}, },
"captionFactsGap": {
"$type": "dimension",
"px": 18,
"dp": 9,
"source": "locked index.html:154"
},
"captionSeparatorOpacity": {
"$type": "number",
"$value": 0.4,
"source": "locked index.html:1207"
},
"badgeRing": {
"$type": "dimension",
"px": 3,
"dp": 1.5,
"source": "locked index.html:144"
},
"badgeIcon": {
"$type": "dimension",
"px": 28,
"dp": 14,
"source": "locked index.html:142"
},
"entryPaddingX": { "entryPaddingX": {
"$type": "dimension", "$type": "dimension",
"px": 22, "px": 22,
@ -305,39 +258,15 @@
"dp": 17, "dp": 17,
"source": "locked index.html:1851" "source": "locked index.html:1851"
}, },
"tailGap": {
"$type": "dimension",
"px": 10,
"dp": 5,
"source": "locked index.html:299"
},
"tailIcon": {
"$type": "dimension",
"px": 44,
"dp": 22,
"source": "locked index.html:300"
},
"seeAllGap": {
"$type": "dimension",
"px": 8,
"dp": 4,
"source": "locked index.html:146"
},
"tailDescription": {
"$type": "typography",
"px": 24,
"sp": 12,
"weight": 500,
"$value": {
"fontSize": "12sp",
"fontWeight": 500
},
"source": "locked index.html:301"
},
"sheetSubTop": {"$type": "dimension", "px": -8, "dp": -4, "source": "locked index.html:229"}, "sheetSubTop": {"$type": "dimension", "px": -8, "dp": -4, "source": "locked index.html:229"},
"sheetRatingTop": {"$type": "dimension", "px": -10, "dp": -5, "source": "locked index.html:168"}, "sheetRatingTop": {"$type": "dimension", "px": -10, "dp": -5, "source": "locked index.html:168"},
"sheetRatingBottom": {"$type": "dimension", "px": 6, "dp": 3, "source": "locked index.html:168"}, "sheetRatingBottom": {"$type": "dimension", "px": 6, "dp": 3, "source": "locked index.html:168"},
"sheetLabelTop": {"$type": "dimension", "px": 8, "dp": 4, "source": "locked index.html:230"}, "sheetLabelTop": {"$type": "dimension", "px": 8, "dp": 4, "source": "locked index.html:230"},
"noteIcon": {"$type": "dimension", "px": 30, "dp": 15, "source": "locked index.html:192"},
"noteGap": {"$type": "dimension", "px": 16, "dp": 8, "source": "locked index.html:191"},
"frameBottomMin": {"$type": "dimension", "px": 80, "dp": 40, "source": "locked index.html:1042"},
"anchorFitTop": {"$type": "dimension", "px": 700, "dp": 350, "source": "locked index.html:1062"},
"anchorBottomClearance": {"$type": "dimension", "px": 40, "dp": 20, "source": "locked index.html:1062"},
"ambientInset": {"$type": "dimension", "px": 80, "dp": 40, "source": "locked index.html:96"}, "ambientInset": {"$type": "dimension", "px": 80, "dp": 40, "source": "locked index.html:96"},
"ambientBlur": {"$type": "dimension", "px": 60, "dp": 30, "source": "locked index.html:96"}, "ambientBlur": {"$type": "dimension", "px": 60, "dp": 30, "source": "locked index.html:96"},
"ambientScale": {"$type": "number", "$value": 1.1, "source": "locked index.html:96"}, "ambientScale": {"$type": "number", "$value": 1.1, "source": "locked index.html:96"},

View file

@ -353,6 +353,10 @@
"tailIcon": {"$type":"dimension","px":44,"dp":22,"source":"index.html:300"}, "tailIcon": {"$type":"dimension","px":44,"dp":22,"source":"index.html:300"},
"tailPadding": {"$type":"dimension","px":20,"dp":10,"source":"index.html:299"}, "tailPadding": {"$type":"dimension","px":20,"dp":10,"source":"index.html:299"},
"tailGap": {"$type":"dimension","px":10,"dp":5,"source":"index.html:299"}, "tailGap": {"$type":"dimension","px":10,"dp":5,"source":"index.html:299"},
"badgeRing": {"$type":"dimension","px":3,"dp":1.5,"source":"index.html:144"},
"badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"},
"seeAllGap": {"$type":"dimension","px":8,"dp":4,"source":"index.html:146"},
"tailDescription": {"$type":"typography","px":24,"sp":12,"weight":500,"$value":{"fontWeight":500},"source":"index.html:301"},
"imageReads": {"$type":"number","$value":4,"source":"spec 4.9, tokens.json image.concurrentReads"}, "imageReads": {"$type":"number","$value":4,"source":"spec 4.9, tokens.json image.concurrentReads"},
"unknownCriticOpacity": {"$type":"number","$value":0.55,"source":"spec 4.7, index.html:161"}, "unknownCriticOpacity": {"$type":"number","$value":0.55,"source":"spec 4.7, index.html:161"},
"fallbackPadding": {"$type":"dimension","px":22,"dp":11,"source":"index.html:130"}, "fallbackPadding": {"$type":"dimension","px":22,"dp":11,"source":"index.html:130"},

View file

@ -0,0 +1,34 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as G
/** Shelves and continuous grids share the same recoverable tail instead of a page selector. */
@Composable
fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding),
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))
Text(if (retry) "Try again" else "Show more", color = C.fg, style = WolfskinTokens.Type.button, textAlign = TextAlign.Center)
Text(if (retry) "More titles couldn’t load" else "Only titles already shown came back", color = C.muted,
style = G.tailDescription, textAlign = TextAlign.Center)
}
}

View file

@ -59,7 +59,7 @@ fun DiscoverOverlay(state: DiscoverState) {
} }
} }
state.toast?.let { text -> state.toast?.let { text ->
Box(Modifier.offset(WolfskinTokens.Shell.stageWidth - L.safeSide - D.captionWidth, L.navBand).width(D.captionWidth).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(L.buttonGap)) { Box(Modifier.offset(WolfskinTokens.Shell.stageWidth - L.safeSide - WolfskinTokens.Library.captionWidth, L.navBand).width(WolfskinTokens.Library.captionWidth).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(L.buttonGap)) {
Text(text, color = C.fg, style = WolfskinTokens.Type.body) Text(text, color = C.fg, style = WolfskinTokens.Type.body)
} }
} }

View file

@ -3,21 +3,18 @@ package org.justinvisser.wolfskin.design
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.tv.material3.Text import androidx.tv.material3.Text
import java.util.Locale import java.util.Locale
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
@ -26,47 +23,29 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
fun formatDiscoverRating(rating: DiscoverRating, locale: Locale): String = fun formatDiscoverRating(rating: DiscoverRating, locale: Locale): String =
if (rating.source.startsWith("rt")) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score) if (rating.source.startsWith("rt")) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score)
/** Full rows retain every observed score; plates shrink then remove only trailing entries. */ /** Heroes, title destinations and sheets show every observed score without a dwell or truncation. */
@Composable @Composable
fun DiscoverRatings(ratings: List<DiscoverRating>, modifier: Modifier = Modifier, plate: Boolean = false) { fun DiscoverRatings(ratings: List<DiscoverRating>, modifier: Modifier = Modifier) {
if (ratings.isEmpty()) return if (ratings.isEmpty()) return
val locale = LocalConfiguration.current.locales[0] val locale = LocalConfiguration.current.locales[0]
val labels = remember(ratings, locale) { ratings.map { formatDiscoverRating(it, locale) } } val labels = remember(ratings, locale) { ratings.map { formatDiscoverRating(it, locale) } }
val measurer = rememberTextMeasurer() val style = remember { WolfskinTokens.Type.ratingPlate.copy(fontFeatureSettings = "tnum") }
val density = LocalDensity.current val markSize = with(LocalDensity.current) { style.fontSize.toDp() }
BoxWithConstraints(modifier) { val desaturated = remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) }
val available = with(density) { maxWidth.toPx() } Row(modifier, horizontalArrangement = Arrangement.spacedBy(markSize * .7142857f), verticalAlignment = Alignment.CenterVertically) {
val base = WolfskinTokens.Type.ratingPlate ratings.forEachIndexed { index, rating ->
var font = base.fontSize.value val resource = when (rating.source) {
var count = ratings.size "rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh
fun width(): Float { "rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright
val style = base.copy(fontSize = TextUnit(font, TextUnitType.Sp)) "imdb" -> R.drawable.ic_rating_imdb
return (0 until count).sumOf { index -> else -> R.drawable.ic_rating_tmdb
val mark = if (ratings[index].source in setOf("imdb", "tmdb")) 1.8f else 1f }
(measurer.measure(labels[index], style).size.width + with(density) { style.fontSize.toPx() } * (mark + if (plate) .29f else .2857143f)).toDouble() val muted = rating.source == "rtCritics" && rating.label != "Rotten" && rating.label != "Fresh" && rating.label != "Certified Fresh"
}.toFloat() + (count - 1) * with(density) { style.fontSize.toPx() } * (if (plate) .64f else .7142857f) val wordmark = rating.source == "imdb" || rating.source == "tmdb"
} Row(horizontalArrangement = Arrangement.spacedBy(markSize * .2857143f), verticalAlignment = Alignment.CenterVertically) {
if (plate) { Image(painterResource(resource), null, Modifier.height(markSize).width(markSize * if (wordmark) 1.8f else 1f),
while (font > WolfskinTokens.Type.caption.fontSize.value && width() > available) font = (font - .25f).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value) alpha = if (muted) .55f else 1f, colorFilter = if (muted) desaturated else null)
while (count > 1 && width() > available) count-- Text(labels[index], color = C.fg, style = style, maxLines = 1)
}
val style = base.copy(fontSize = TextUnit(font, TextUnitType.Sp), fontFeatureSettings = "tnum")
val markSize = with(density) { style.fontSize.toDp() }
Row(horizontalArrangement = Arrangement.spacedBy(markSize * if (plate) .64f else .7142857f)) {
repeat(count) { index ->
val rating = ratings[index]
val resource = when (rating.source) {
"rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh
"rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright
"imdb" -> R.drawable.ic_rating_imdb
else -> R.drawable.ic_rating_tmdb
}
Row(horizontalArrangement = Arrangement.spacedBy(markSize * if (plate) .29f else .2857143f)) {
val muted = rating.source == "rtCritics" && rating.label !in setOf("Rotten", "Fresh", "Certified Fresh")
Image(painterResource(resource), null, Modifier.height(markSize).width(markSize * if (rating.source in setOf("imdb", "tmdb")) 1.8f else 1f), alpha = if (muted) .55f else 1f,
colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
Text(labels[index], color = C.fg, style = style, maxLines = 1)
}
} }
} }
} }

View file

@ -28,6 +28,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text import androidx.tv.material3.Text
@ -65,17 +66,33 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
} }
val density = LocalDensity.current val density = LocalDensity.current
val list = shell.view.listState val list = shell.view.listState
LaunchedEffect(state.focusedRow, shell.navFocused) { val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)
val heights = remember(state.hasHero, state.lanes, note, state.canRequest) {
buildList {
add(if (state.hasHero) L.row_phero else L.row_lhead)
add(if (state.canRequest) L.row_chips else WolfskinTokens.Shell.zero)
if (!state.canRequest) add(L.row_state) else {
if (note) add(L.row_note)
state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
}
}
}
LaunchedEffect(state.focusedRow, shell.navFocused, heights) {
if (!shell.navFocused) { if (!shell.navFocused) {
val row = state.focusedRow val row = state.focusedRow
val alreadyFits = state.service != null && row <= 2 val physical = row + if (row >= state.firstShelf && note) 1 else 0
if (row <= 1 || alreadyFits) list.animateScrollToItem(0) var top = WolfskinTokens.Shell.zero
else list.animateScrollToItem(row + if (state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) 1 else 0, -with(density) { L.pinnedLine.roundToPx() }) for (index in 0 until physical.coerceAtMost(heights.size)) top += heights[index]
val fits = top < D.anchorFitTop && top + heights[physical.coerceAtMost(heights.lastIndex)] <= WolfskinTokens.Shell.stageHeight - D.anchorBottomClearance
val target = if (row <= 1 || fits) 0f else with(density) { (top - L.pinnedLine).toPx() }
var current = WolfskinTokens.Shell.zero
for (index in 0 until list.firstVisibleItemIndex.coerceAtMost(heights.size)) current += heights[index]
animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset)
} }
} }
Box(modifier.fillMaxSize().testTag("discover-screen")) { Box(modifier.fillMaxSize().testTag("discover-screen")) {
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { DiscoverBackdrop(it, modifier = Modifier.offset(y = -with(density) { list.firstVisibleItemScrollOffset.toDp() })) } if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { DiscoverBackdrop(it, modifier = Modifier.graphicsLayer { translationY = -list.firstVisibleItemScrollOffset.toFloat() }) }
LazyColumn(state = list, contentPadding = PaddingValues(bottom = WolfskinTokens.Shell.stageHeight - L.pinnedLine), modifier = Modifier.fillMaxSize()) { LazyColumn(state = list, contentPadding = PaddingValues(bottom = (WolfskinTokens.Shell.stageHeight - L.pinnedLine - heights.last()).coerceAtLeast(D.frameBottomMin)), modifier = Modifier.fillMaxSize()) {
item(key = "discover-header", contentType = if (state.hasHero && state.service == null) "hero" else "header") { item(key = "discover-header", contentType = if (state.hasHero && state.service == null) "hero" else "header") {
if (state.hasHero && state.service == null) DiscoverHero(state) else DiscoverHeader(state.shell.person, state.service?.name ?: "Discover", if (state.service != null) "General interest" else "") if (state.hasHero && state.service == null) DiscoverHero(state) else DiscoverHeader(state.shell.person, state.service?.name ?: "Discover", if (state.service != null) "General interest" else "")
} }
@ -85,7 +102,13 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
if (state.snapshot.status in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable)) item(key = "discover-state", contentType = "state") { DiscoverStatePanel(state) } if (state.snapshot.status in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable)) item(key = "discover-state", contentType = "state") { DiscoverStatePanel(state) }
else { else {
if (state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) item(key = "discover-note", contentType = "note") { if (state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) item(key = "discover-note", contentType = "note") {
Text(if (state.snapshot.status == DiscoverStatus.NoSeeds) "Watch something to get picks for ${shell.person.name}." else "Picks unavailable right now.", Modifier.height(L.row_note).padding(start = L.safeSide, top = D.noteTop), color = C.muted, style = WolfskinTokens.Type.body) Box(Modifier.height(L.row_note).fillMaxWidth()) {
Row(Modifier.offset(L.safeSide, D.noteTop), horizontalArrangement = Arrangement.spacedBy(D.noteGap), verticalAlignment = Alignment.CenterVertically) {
val color = if (state.snapshot.status == DiscoverStatus.NoSeeds) C.muted else C.warn
Glyph(if (state.snapshot.status == DiscoverStatus.NoSeeds) "eye" else "alert", color, Modifier.size(D.noteIcon))
Text(if (state.snapshot.status == DiscoverStatus.NoSeeds) "Watch something to get picks for ${shell.person.name}." else "Picks unavailable right now.", color = color, style = WolfskinTokens.Type.body)
}
}
} }
itemsIndexed(state.lanes, key = { _, lane -> lane.id }, contentType = { _, _ -> "shelf" }) { index, lane -> DiscoverShelf(state, lane, index + state.firstShelf) } itemsIndexed(state.lanes, key = { _, lane -> lane.id }, contentType = { _, _ -> "shelf" }) { index, lane -> DiscoverShelf(state, lane, index + state.firstShelf) }
} }
@ -152,6 +175,7 @@ private fun DiscoverChips(state: DiscoverState) {
@Composable @Composable
private fun DiscoverStatePanel(state: DiscoverState) { private fun DiscoverStatePanel(state: DiscoverState) {
val signIn = state.snapshot.status == DiscoverStatus.NeedsSignIn val signIn = state.snapshot.status == DiscoverStatus.NeedsSignIn
Box(Modifier.fillMaxWidth().height(L.row_state)) {
Column(Modifier.padding(start = L.safeSide, top = D.stateTop).width(L.stateWidth) Column(Modifier.padding(start = L.safeSide, top = D.stateTop).width(L.stateWidth)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)) .background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
.border(WolfskinTokens.Library.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)) .border(WolfskinTokens.Library.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
@ -163,6 +187,7 @@ private fun DiscoverStatePanel(state: DiscoverState) {
Text(if (signIn) "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual." else "Conductarr can’t be reached. Your library still plays.", Modifier.padding(top = L.navBlobAvatarPad, bottom = D.stateTextGap), color = C.muted, style = WolfskinTokens.Type.body) Text(if (signIn) "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual." else "Conductarr can’t be reached. Your library still plays.", Modifier.padding(top = L.navBlobAvatarPad, bottom = D.stateTextGap), color = C.muted, style = WolfskinTokens.Type.body)
DiscoverButton("Try again", state.contentFocused) { state.refresh() } DiscoverButton("Try again", state.contentFocused) { state.refresh() }
} }
}
} }
/** Hero and sheet buttons reuse the shell's scale and outside-ring primitives. */ /** Hero and sheet buttons reuse the shell's scale and outside-ring primitives. */

View file

@ -84,7 +84,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
} }
} else { } else {
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item -> itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
DiscoverPoster(state, item, active && index == column, lane.hidden, Modifier.clickable { DiscoverPoster(state, item, active && index == column, Modifier.clickable {
state.focus(row, index) state.focus(row, index)
if (lane.hidden) state.overlay = DiscoverOverlayState.Restore(item) else state.openTitle(item) if (lane.hidden) state.overlay = DiscoverOverlayState.Restore(item) else state.openTitle(item)
}) })
@ -99,7 +99,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
Caption( Caption(
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles", if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), L.rowHeadingBand + K.posterH + L.captionOffset).width(D.captionWidth), true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), L.rowHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth),
accentFirstFact = lane.hidden, accentFirstFact = lane.hidden,
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()), accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
) )
@ -109,7 +109,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
/** Only the focused poster reads the ratings plate; cached observations paint immediately. */ /** Only the focused poster reads the ratings plate; cached observations paint immediately. */
@Composable @Composable
fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, hidden: Boolean, modifier: Modifier = Modifier) { fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, modifier: Modifier = Modifier) {
val receipts = state.requests[title.key] val receipts = state.requests[title.key]
val card = remember(title, receipts, state.shell.person.key) { title.libraryCard(state) } val card = remember(title, receipts, state.shell.person.key) { title.libraryCard(state) }
LibraryPoster(card, focused, focused && state.plateKey == title.key && state.isSignedIn, true, LibraryPoster(card, focused, focused && state.plateKey == title.key && state.isSignedIn, true,
@ -118,16 +118,8 @@ fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean,
@Composable @Composable
internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () -> Unit) { internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () -> Unit) {
Column(Modifier.size(K.posterW, K.posterH).focusScale(focused, WolfskinTokens.Focus.scaleCard) if (lane.tail == DiscoverTail.SeeAll) LibrarySeeAllCard(lane.total, focused, onClick)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent) else CatalogueRecoveryCard(lane.tail == DiscoverTail.Retry, focused, onClick)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)).border(WolfskinTokens.Focus.ringCardRing / 2, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)).clickable(onClick = onClick).padding(L.buttonGap),
verticalArrangement = Arrangement.spacedBy(if (lane.tail == DiscoverTail.SeeAll) D.seeAllGap else D.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph(when (lane.tail) { DiscoverTail.SeeAll -> "interface"; DiscoverTail.Retry -> "alert"; else -> "down" },
when (lane.tail) { DiscoverTail.Retry -> C.warn; DiscoverTail.SeeAll -> C.accent; else -> C.accentText },
Modifier.size(if (lane.tail == DiscoverTail.SeeAll) L.badge else D.tailIcon))
Text(when (lane.tail) { DiscoverTail.SeeAll -> "See all"; DiscoverTail.Retry -> "Try again"; else -> "Show more" }, color = C.fg, style = if (lane.tail == DiscoverTail.SeeAll) WolfskinTokens.Type.listTitle else WolfskinTokens.Type.button, textAlign = TextAlign.Center)
Text(when (lane.tail) { DiscoverTail.SeeAll -> lane.total.toString(); DiscoverTail.Retry -> "More titles couldn’t load"; else -> "Only titles already shown came back" }, color = C.muted, style = if (lane.tail == DiscoverTail.SeeAll) WolfskinTokens.Type.label else D.tailDescription, textAlign = TextAlign.Center)
}
} }
internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String { internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String {

View file

@ -46,6 +46,7 @@ class DiscoverState(
val requests = mutableStateMapOf<String, List<DiscoverRequestResult>>() val requests = mutableStateMapOf<String, List<DiscoverRequestResult>>()
private val requestSheets = mutableMapOf<Pair<String, String>, RequestSheetState>() private val requestSheets = mutableMapOf<Pair<String, String>, RequestSheetState>()
private val hidden = mutableStateMapOf<String, DiscoverTitle>().apply { initial.hidden.forEach { put(it.key, it) } } private val hidden = mutableStateMapOf<String, DiscoverTitle>().apply { initial.hidden.forEach { put(it.key, it) } }
private lateinit var collectionCards: LinkedHashMap<String, LibraryTitle>
private var revision by mutableStateOf(0) private var revision by mutableStateOf(0)
private var cachedRevision = -1 private var cachedRevision = -1
private var cachedFilter = "" private var cachedFilter = ""
@ -114,16 +115,46 @@ class DiscoverState(
fun openTitle(item: DiscoverTitle) { fun openTitle(item: DiscoverTitle) {
title = item; titleButton = 0 title = item; titleButton = 0
if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key)
onPush("discover-title/${item.key}") onPush("discover-title/${item.key}")
} }
fun openCollection(lane: DiscoverLane) { fun openCollection(lane: DiscoverLane) {
val titles = lane.fullTitles.associateBy { it.key } var titles = lane.fullTitles.associateBy { it.key }
collection = LibraryGridState(lane.title, lane.fullTitles.map { it.libraryCard(this) }, catalogue = true, collectionCards = LinkedHashMap<String, LibraryTitle>(titles.size).apply {
onTitle = { openTitle(titles.getValue(it.id)) }, onRecover = ::refresh).apply { titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) }
}
lateinit var grid: LibraryGridState
grid = LibraryGridState(lane.title, collectionCards.values.filter { it.id !in hidden }, catalogue = true, observedRatings = observedRatings,
onTitle = { openTitle(titles.getValue(it.id)) }, onDiscover = { collection = null; onPop() }, onRecover = {
scope.launch {
snapshot = operations.refresh(); revision++
if (collection !== grid) return@launch
val fresh = lanes.firstOrNull { it.id == lane.id }
if (canRequest && fresh != null) {
titles = fresh.fullTitles.associateBy { it.key }
collectionCards = LinkedHashMap<String, LibraryTitle>(titles.size).apply {
titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) }
}
grid.tail = when (fresh.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
updateCollectionCards()
} else grid.tail = GridTail.Retry
}
}).apply {
tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None } tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
} }
collection = grid
onPush("discover-see-all/${lane.id}") onPush("discover-see-all/${lane.id}")
} }
private fun updateCollectionCards(changed: DiscoverTitle? = null) {
val grid = collection ?: return
val focused = title?.key ?: grid.focusedTitle?.id
if (changed != null && changed.key in collectionCards) collectionCards[changed.key] = changed.libraryCard(this)
grid.source = collectionCards.values.filter { it.id !in hidden }
val restored = grid.items.indexOfFirst { it.id == focused }
grid.index = if (restored >= 0) restored else grid.index.coerceIn(0,
if (grid.tail == GridTail.None) grid.items.lastIndex.coerceAtLeast(0) else grid.tailIndex)
}
fun isHidden(item: DiscoverTitle): Boolean = item.key in hidden fun isHidden(item: DiscoverTitle): Boolean = item.key in hidden
fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item) fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item)
@ -154,6 +185,10 @@ class DiscoverState(
return true return true
} }
if (title != null) { titleKey(key); return true } if (title != null) { titleKey(key); return true }
if (!canRequest) {
if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh()
return true
}
when (key) { when (key) {
ShellKey.Up -> moveRow(-1) ShellKey.Up -> moveRow(-1)
ShellKey.Down -> moveRow(1) ShellKey.Down -> moveRow(1)
@ -164,11 +199,9 @@ class DiscoverState(
} }
ShellKey.Ok -> when { ShellKey.Ok -> when {
isCard -> activateCard() isCard -> activateCard()
!isSignedIn || snapshot.status == DiscoverStatus.Unavailable -> refresh()
focusedRow == 0 -> hero?.let(::openTitle) focusedRow == 0 -> hero?.let(::openTitle)
focusedRow == 1 -> { filter = (if (service == null) DISCOVER_FILTERS else listOf("movie", "tv"))[column]; if (service == null) slide = 0; revision++ } focusedRow == 1 -> { filter = (if (service == null) DISCOVER_FILTERS else listOf("movie", "tv"))[column]; if (service == null) slide = 0; revision++ }
} }
else -> Unit
} }
return true return true
} }
@ -244,6 +277,13 @@ class DiscoverState(
private fun send(sheet: RequestSheetState, check: Boolean) { private fun send(sheet: RequestSheetState, check: Boolean) {
if (!canRequest || (!check && !sheet.canSubmit)) return if (!canRequest || (!check && !sheet.canSubmit)) return
if (!check) {
val previous = requestSheets[sheet.title.key to sheet.selectedPerson.key]
if (previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) {
overlay = DiscoverOverlayState.Request(requireNotNull(previous))
return
}
}
val intent = DiscoverRequest(sheet.title, sheet.selectedPerson.key, sheet.viewer.key, isPick(sheet.title)) val intent = DiscoverRequest(sheet.title, sheet.selectedPerson.key, sheet.viewer.key, isPick(sheet.title))
requestSheets[sheet.title.key to sheet.selectedPerson.key] = sheet requestSheets[sheet.title.key to sheet.selectedPerson.key] = sheet
sheet.phase = RequestPhase.Sending sheet.phase = RequestPhase.Sending
@ -255,6 +295,7 @@ class DiscoverState(
requests[sheet.title.key].orEmpty().forEach { if (it.requester != result.requester) add(it) } requests[sheet.title.key].orEmpty().forEach { if (it.requester != result.requester) add(it) }
add(result) add(result)
} }
updateCollectionCards(sheet.title)
} }
} }
} }
@ -262,24 +303,26 @@ class DiscoverState(
fun hide(item: DiscoverTitle) { fun hide(item: DiscoverTitle) {
if (!canRequest) { refresh(); return } if (!canRequest) { refresh(); return }
hidden[item.key] = item; revision++; overlay = DiscoverOverlayState.Hidden(item) hidden[item.key] = item; revision++; overlay = DiscoverOverlayState.Hidden(item)
updateCollectionCards()
val profile = shell.person.key val profile = shell.person.key
val version = (dismissalVersions[item.key] ?: 0) + 1 val version = (dismissalVersions[item.key] ?: 0) + 1
dismissalVersions[item.key] = version dismissalVersions[item.key] = version
scope.launch { scope.launch {
val success = dismissalMutex.withLock { operations.hide(profile, item) } val success = dismissalMutex.withLock { operations.hide(profile, item) }
if (!success && dismissalVersions[item.key] == version) { hidden.remove(item.key); revision++; overlay = null; toast = "Couldn’t hide this pick. Try again." } if (!success && dismissalVersions[item.key] == version) { hidden.remove(item.key); revision++; updateCollectionCards(); overlay = null; toast = "Couldn’t hide this pick. Try again." }
} }
} }
fun restore(item: DiscoverTitle) { fun restore(item: DiscoverTitle) {
if (!canRequest) { refresh(); return } if (!canRequest) { refresh(); return }
hidden.remove(item.key); revision++; overlay = null; toast = "Back in your picks." hidden.remove(item.key); revision++; overlay = null; toast = "Back in your picks."
updateCollectionCards()
val profile = shell.person.key val profile = shell.person.key
val version = (dismissalVersions[item.key] ?: 0) + 1 val version = (dismissalVersions[item.key] ?: 0) + 1
dismissalVersions[item.key] = version dismissalVersions[item.key] = version
scope.launch { scope.launch {
val success = dismissalMutex.withLock { operations.restore(profile, item) } val success = dismissalMutex.withLock { operations.restore(profile, item) }
if (!success && dismissalVersions[item.key] == version) { hidden[item.key] = item; revision++; toast = "Couldn’t restore this pick. Try again." } if (!success && dismissalVersions[item.key] == version) { hidden[item.key] = item; revision++; updateCollectionCards(); toast = "Couldn’t restore this pick. Try again." }
} }
} }

View file

@ -0,0 +1,20 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.lazy.LazyListState
/** Every frame follows focus with the same bounded scroll tween. */
internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) {
if (distance == 0f) return
list.scroll {
var previous = 0f
animate(0f, distance, animationSpec = tween(WolfskinTokens.Motion.scroll,
easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) { value, _ ->
scrollBy(value - previous)
previous = value
}
}
}

View file

@ -56,6 +56,7 @@ class LibraryGridState(
val onRecover: () -> Unit = {}, val onRecover: () -> Unit = {},
val onDiscover: () -> Unit = {}, val onDiscover: () -> Unit = {},
val onReadAhead: (Int) -> Unit = {}, val onReadAhead: (Int) -> Unit = {},
val observedRatings: MutableSet<String> = mutableSetOf(),
) : ShellContentFocus { ) : ShellContentFocus {
var source by mutableStateOf(items) var source by mutableStateOf(items)
var status by mutableStateOf(LibraryStatus.Ready) var status by mutableStateOf(LibraryStatus.Ready)

View file

@ -65,9 +65,9 @@ fun LibraryPoster(
val color = if (title.badge == "failed") C.fail else C.accent val color = if (title.badge == "failed") C.fail else C.accent
Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset) Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset)
.size(WolfskinTokens.Layout.badge).background(if (held) C.ok else C.badgeRest, CircleShape) .size(WolfskinTokens.Layout.badge).background(if (held) C.ok else C.badgeRest, CircleShape)
.border(if (held) WolfskinTokens.Shell.zero else WolfskinTokens.Discover.badgeRing, color, CircleShape), contentAlignment = Alignment.Center) { .border(if (held) WolfskinTokens.Shell.zero else L.badgeRing, color, CircleShape), contentAlignment = Alignment.Center) {
Glyph(if (held) "check" else if (title.badge == "failed") "alert" else "clock", Glyph(if (held) "check" else if (title.badge == "failed") "alert" else "clock",
if (held) C.onFocus else color, Modifier.size(WolfskinTokens.Discover.badgeIcon)) if (held) C.onFocus else color, Modifier.size(L.badgeIcon))
} }
} }
} }

View file

@ -70,8 +70,9 @@ fun LibraryScreen(
} }
LaunchedEffect(focused?.id, active, ratingsSignedIn, ratingsReachable) { LaunchedEffect(focused?.id, active, ratingsSignedIn, ratingsReachable) {
plateKey = null plateKey = null
if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings)) { if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings || focused.id in grid.observedRatings)) {
if (focused.id !in cachedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong()) if (focused.id !in cachedRatings && focused.id !in grid.observedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong())
if (focused.ratings.isNotEmpty()) grid.observedRatings.add(focused.id)
plateKey = focused.id plateKey = focused.id
} }
} }
@ -223,15 +224,7 @@ private fun LibraryRecovery(grid: LibraryGridState, focused: Boolean) {
private fun LibraryTail(grid: LibraryGridState, focused: Boolean) { private fun LibraryTail(grid: LibraryGridState, focused: Boolean) {
val retry = grid.tail == GridTail.Retry val retry = grid.tail == GridTail.Retry
Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) { Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) {
Column(Modifier.offset(L.safeSide).size(K.posterW, K.posterH).focusScale(focused, WolfskinTokens.Focus.scaleCard) CatalogueRecoveryCard(retry, focused, { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }, Modifier.offset(L.safeSide))
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)).border(G.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.card))
.clickable { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }.padding(G.tailPadding),
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))
Text(if (retry) "Try again" else "Show more", color = C.fg, style = WolfskinTokens.Type.button, textAlign = TextAlign.Center)
Text(if (retry) "More titles couldn’t load" else "Only titles already shown came back", color = C.muted, style = WolfskinTokens.Type.caption, textAlign = TextAlign.Center)
}
Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused, Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused,
Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth)) Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth))
} }
@ -261,15 +254,5 @@ private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int,
if (first < headerCount) WolfskinTokens.Shell.zero else if (first < headerCount) WolfskinTokens.Shell.zero else
(if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx() (if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
} + view.listState.firstVisibleItemScrollOffset } + view.listState.firstVisibleItemScrollOffset
val distance = target - current animateFrameScroll(view.listState, target - current)
if (distance == 0f) return
view.listState.scroll {
var previous = 0f
androidx.compose.animation.core.animate(0f, distance,
animationSpec = androidx.compose.animation.core.tween(WolfskinTokens.Motion.scroll,
easing = androidx.compose.animation.core.CubicBezierEasing(.33f, 1f, .68f, 1f))) { value, _ ->
scrollBy(value - previous)
previous = value
}
}
} }

View file

@ -19,7 +19,7 @@ fun LibrarySeeAllCard(count: Int, focused: Boolean, onClick: () -> Unit, modifie
Column(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard) Column(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent)
.background(WolfskinTokens.Color.surface, shape).border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape) .background(WolfskinTokens.Color.surface, shape).border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape)
.clickable(onClick = onClick), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) { .clickable(onClick = onClick), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge)) Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge))
Text("See all", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.listTitle) Text("See all", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.listTitle)
Text(count.toString(), color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.label) Text(count.toString(), color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.label)

View file

@ -321,6 +321,10 @@ object WolfskinTokens {
val tailIcon = 22.0.dp val tailIcon = 22.0.dp
val tailPadding = 10.0.dp val tailPadding = 10.0.dp
val tailGap = 5.0.dp val tailGap = 5.0.dp
val badgeRing = 1.5.dp
val badgeIcon = 14.0.dp
val seeAllGap = 4.0.dp
val tailDescription = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(500), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
const val imageReads = 4.0f const val imageReads = 4.0f
const val unknownCriticOpacity = 0.55f const val unknownCriticOpacity = 0.55f
val fallbackPadding = 11.0.dp val fallbackPadding = 11.0.dp
@ -366,31 +370,24 @@ object WolfskinTokens {
val statePaddingX = 20.0.dp val statePaddingX = 20.0.dp
val statePaddingY = 17.0.dp val statePaddingY = 17.0.dp
val stateTextGap = 12.0.dp val stateTextGap = 12.0.dp
val captionWidth = 320.0.dp
val plateTop = 7.0.dp
val plateSide = 8.0.dp
val plateFade = 23.0.dp
val noteTop = 7.0.dp val noteTop = 7.0.dp
val headerTop = 75.0.dp val headerTop = 75.0.dp
val headerGap = 12.0.dp val headerGap = 12.0.dp
val captionFactsGap = 9.0.dp
const val captionSeparatorOpacity = 0.4f
val badgeRing = 1.5.dp
val badgeIcon = 14.0.dp
val entryPaddingX = 11.0.dp val entryPaddingX = 11.0.dp
val entryPaddingY = 10.0.dp val entryPaddingY = 10.0.dp
val entryText = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800), lineHeight = 19.5.sp, letterSpacing = -0.01.em) val entryText = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800), lineHeight = 19.5.sp, letterSpacing = -0.01.em)
val sheetRadioRing = 1.5.dp val sheetRadioRing = 1.5.dp
val skeletonNameWidth = 130.0.dp val skeletonNameWidth = 130.0.dp
val skeletonNameHeight = 17.0.dp val skeletonNameHeight = 17.0.dp
val tailGap = 5.0.dp
val tailIcon = 22.0.dp
val seeAllGap = 4.0.dp
val tailDescription = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(500), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
val sheetSubTop = -4.0.dp val sheetSubTop = -4.0.dp
val sheetRatingTop = -5.0.dp val sheetRatingTop = -5.0.dp
val sheetRatingBottom = 3.0.dp val sheetRatingBottom = 3.0.dp
val sheetLabelTop = 4.0.dp val sheetLabelTop = 4.0.dp
val noteIcon = 15.0.dp
val noteGap = 8.0.dp
val frameBottomMin = 40.0.dp
val anchorFitTop = 350.0.dp
val anchorBottomClearance = 20.0.dp
val ambientInset = 40.0.dp val ambientInset = 40.0.dp
val ambientBlur = 30.0.dp val ambientBlur = 30.0.dp
const val ambientScale = 1.1f const val ambientScale = 1.1f

View file

@ -14,7 +14,8 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextLayoutResult
import org.json.JSONArray import org.json.JSONArray
@ -64,23 +65,23 @@ class DiscoverCaptureTest {
rule.waitForIdle() rule.waitForIdle()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
recordTypography(directory, name, state) recordTypography(directory, name)
} }
private fun recordTypography(directory: File, name: String, state: DiscoverState) { private fun recordTypography(directory: File, name: String) {
val rows = JSONArray() val rows = JSONArray()
val labels = listOfNotNull(state.hero?.title, "Request", "For", "Justin (you)", "Hidden picks", "Netflix", "Popular movies") rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsProperties.Text), useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
labels.forEach { text -> val layouts = mutableListOf<TextLayoutResult>()
rule.onAllNodesWithText(text, useUnmergedTree = true).fetchSemanticsNodes().forEach { node -> if (SemanticsActions.GetTextLayoutResult in node.config) node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
val layouts = mutableListOf<TextLayoutResult>() layouts.forEach { layout ->
node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts) val text = layout.layoutInput.text.text
layouts.forEach { layout -> val lines = JSONArray()
rows.put(JSONObject().put("text", text).put("lines", layout.lineCount) repeat(layout.lineCount) { index -> lines.put(text.substring(layout.getLineStart(index), layout.getLineEnd(index, visibleEnd = true))) }
.put("fontSizeSp", layout.layoutInput.style.fontSize.value).put("fontWeight", layout.layoutInput.style.fontWeight?.weight) rows.put(JSONObject().put("text", text).put("lines", layout.lineCount).put("lineTexts", lines)
.put("lineHeightSp", layout.layoutInput.style.lineHeight.value).put("trim", layout.layoutInput.style.lineHeightStyle?.trim.toString()) .put("fontSizeSp", layout.layoutInput.style.fontSize.value).put("fontWeight", layout.layoutInput.style.fontWeight?.weight)
.put("density", layout.layoutInput.density.density).put("fontScale", layout.layoutInput.density.fontScale) .put("lineHeightSp", layout.layoutInput.style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL).put("trim", layout.layoutInput.style.lineHeightStyle?.trim.toString())
.put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height)) .put("density", layout.layoutInput.density.density).put("fontScale", layout.layoutInput.density.fontScale)
} .put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height))
} }
} }
File(directory, "$name-type-metrics.json").writeText(rows.toString(2)) File(directory, "$name-type-metrics.json").writeText(rows.toString(2))

View file

@ -117,6 +117,50 @@ class DiscoverKeysTest {
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase) assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
} }
@Test fun closingAnUncertainReceiptNeverTurnsReopeningIntoAnotherPost() {
operations.next = RequestPhase.NotConfirmed
replay("Request a pick for yourself")
press(Key.Back, Key.DirectionCenter)
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
operations.next = RequestPhase.Receipt
press(Key.DirectionCenter)
assertEquals(1, operations.sent.size)
assertEquals(1, operations.checked)
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
}
@Test fun requestingForAnotherPersonKeepsYourOwnRequestedAction() {
replay("Request a pick for yourself")
press(Key.DirectionCenter)
press(Key.DirectionRight, Key.DirectionCenter)
press(Key.DirectionCenter, Key.DirectionCenter)
assertEquals(setOf("justin", "mindel"), state.requests.getValue(state.title!!.key).map { it.requester }.toSet())
press(Key.DirectionCenter)
press(Key.DirectionLeft, Key.DirectionCenter)
assertTrue(shell.requestsVisible)
assertEquals(2, operations.sent.size)
}
@Test fun choosingAnUncertainRecipientAgainNeverResubmits() {
operations.next = RequestPhase.NotConfirmed
replay("Open Discover")
press(Key.DirectionCenter)
press(Key.DirectionRight, Key.DirectionCenter)
press(Key.DirectionDown, Key.DirectionCenter)
press(Key.DirectionCenter)
assertEquals("shared", operations.sent.single().requester)
press(Key.Back, Key.DirectionCenter)
press(Key.DirectionDown, Key.DirectionCenter)
press(Key.DirectionCenter)
assertEquals(1, operations.sent.size)
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
operations.next = RequestPhase.Receipt
press(Key.DirectionCenter)
assertEquals(1, operations.sent.size)
assertEquals(1, operations.checked)
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
}
@Test fun unknownSeriesSeasonsNeverSubmitAndBackRestoresOpener() { @Test fun unknownSeriesSeasonsNeverSubmitAndBackRestoresOpener() {
replay("Open Discover") replay("Open Discover")
rule.runOnIdle { state.openTitle(snapshot.picks.first { it.type == "tv" }); state.openRequest(false) } rule.runOnIdle { state.openTitle(snapshot.picks.first { it.type == "tv" }); state.openRequest(false) }
@ -155,6 +199,37 @@ class DiscoverKeysTest {
assertEquals(GridTail.ShowMore, state.collection!!.tail) assertEquals(GridTail.ShowMore, state.collection!!.tail)
} }
@Test fun collectionRetryReadsNewTitlesAndOpensTheFirstAppendedTitle() {
shell.pickerVisible = false
val lane = state.lanes.first { it.id == "trending" }
state.openCollection(lane.copy(titles = lane.fullTitles.take(6), fullTitles = lane.fullTitles.take(6), tail = DiscoverTail.Retry))
val grid = state.collection!!
grid.index = grid.tailIndex
launch()
press(Key.DirectionCenter)
assertEquals(GridTail.None, grid.tail)
assertEquals(lane.fullTitles.map { it.key }, grid.items.map { it.id })
assertEquals(lane.fullTitles[6].key, grid.focusedTitle?.id)
press(Key.DirectionCenter)
assertEquals(lane.fullTitles[6], state.title)
}
@Test fun collectionDismissalAndUndoKeepTheOriginalOrderAndFocus() {
shell.pickerVisible = false
state.openCollection(state.lanes.first { it.id == "pk-movie" })
val grid = state.collection!!
val original = grid.items.map { it.id }
launch()
press(Key.DirectionCenter)
val opener = state.title!!
press(Key.DirectionRight, Key.DirectionRight)
press(Key.DirectionCenter)
assertFalse(grid.items.any { it.id == opener.key })
press(Key.DirectionCenter)
assertEquals(original, grid.items.map { it.id })
assertEquals(opener.key, grid.focusedTitle?.id)
}
@Test fun collectionBackRestoresTheExactOrigin() { @Test fun collectionBackRestoresTheExactOrigin() {
shell.pickerVisible = false; shell.enterDestination("discover") shell.pickerVisible = false; shell.enterDestination("discover")
state.focus(3, 2) state.focus(3, 2)

View file

@ -0,0 +1,33 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import org.junit.Assert.assertEquals
import org.junit.Test
/** Feed projection must not lose titles when a contribution row is too short to display. */
class DiscoverLaneProjectionTest {
@Test fun discardedSeedGroupRemainsAvailableInCatalogue() {
val titles = (1L..6L).map { DiscoverTitle(it, "movie", "Film $it") }
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("Seed", titles.subList(3, 5))),
lanes = listOf(DiscoverLane("trending", "Trending", titles)), status = DiscoverStatus.Ready)
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
assertEquals(listOf("pk-movie", "trending"), rows.map { it.id })
assertEquals(titles.take(3), rows.first().titles)
assertEquals(titles.drop(3), rows.last().titles)
}
@Test fun catalogueDeduplicationKeepsMediaTypesDistinctAndHiddenPicksLast() {
val movie = DiscoverTitle(1, "movie", "Film")
val series = DiscoverTitle(1, "tv", "Series")
val hidden = DiscoverTitle(2, "movie", "Hidden")
val later = DiscoverTitle(3, "movie", "Later")
val snapshot = DiscoverSnapshot(hidden = listOf(hidden), status = DiscoverStatus.Ready, lanes = listOf(
DiscoverLane("first", "First", listOf(movie, series, hidden)),
DiscoverLane("second", "Second", listOf(movie, series, later))))
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", mapOf(hidden.key to hidden))
assertEquals(listOf(listOf(movie, series), listOf(later), listOf(hidden)), rows.map { it.titles })
assertEquals("hidden", rows.last().id)
val filtered = projectDiscoverLanes(snapshot, emptyList(), "tv", mapOf(hidden.key to hidden))
assertEquals(listOf(listOf(series)), filtered.map { it.titles })
}
}

View file

@ -202,6 +202,10 @@
--library-tailIcon: 44.0px; --library-tailIcon: 44.0px;
--library-tailPadding: 20.0px; --library-tailPadding: 20.0px;
--library-tailGap: 10.0px; --library-tailGap: 10.0px;
--library-badgeRing: 3.0px;
--library-badgeIcon: 28.0px;
--library-seeAllGap: 8.0px;
--library-tailDescription: 24.0px;
--library-fallbackPadding: 22.0px; --library-fallbackPadding: 22.0px;
--library-fallbackIcon: 44.0px; --library-fallbackIcon: 44.0px;
--discover-sheetPaddingX: 64.0px; --discover-sheetPaddingX: 64.0px;
@ -237,30 +241,24 @@
--discover-statePaddingX: 40.0px; --discover-statePaddingX: 40.0px;
--discover-statePaddingY: 34.0px; --discover-statePaddingY: 34.0px;
--discover-stateTextGap: 24.0px; --discover-stateTextGap: 24.0px;
--discover-captionWidth: 640.0px;
--discover-plateTop: 14.0px;
--discover-plateSide: 16.0px;
--discover-plateFade: 46.0px;
--discover-noteTop: 14.0px; --discover-noteTop: 14.0px;
--discover-headerTop: 150.0px; --discover-headerTop: 150.0px;
--discover-headerGap: 24.0px; --discover-headerGap: 24.0px;
--discover-captionFactsGap: 18.0px;
--discover-badgeRing: 3.0px;
--discover-badgeIcon: 28.0px;
--discover-entryPaddingX: 22.0px; --discover-entryPaddingX: 22.0px;
--discover-entryPaddingY: 20.0px; --discover-entryPaddingY: 20.0px;
--discover-entryText: 30.0px; --discover-entryText: 30.0px;
--discover-sheetRadioRing: 3.0px; --discover-sheetRadioRing: 3.0px;
--discover-skeletonNameWidth: 260.0px; --discover-skeletonNameWidth: 260.0px;
--discover-skeletonNameHeight: 34.0px; --discover-skeletonNameHeight: 34.0px;
--discover-tailGap: 10.0px;
--discover-tailIcon: 44.0px;
--discover-seeAllGap: 8.0px;
--discover-tailDescription: 24.0px;
--discover-sheetSubTop: -8.0px; --discover-sheetSubTop: -8.0px;
--discover-sheetRatingTop: -10.0px; --discover-sheetRatingTop: -10.0px;
--discover-sheetRatingBottom: 6.0px; --discover-sheetRatingBottom: 6.0px;
--discover-sheetLabelTop: 8.0px; --discover-sheetLabelTop: 8.0px;
--discover-noteIcon: 30.0px;
--discover-noteGap: 16.0px;
--discover-frameBottomMin: 80.0px;
--discover-anchorFitTop: 700.0px;
--discover-anchorBottomClearance: 40.0px;
--discover-ambientInset: 80.0px; --discover-ambientInset: 80.0px;
--discover-ambientBlur: 60.0px; --discover-ambientBlur: 60.0px;
} }

View file

@ -1,6 +1,6 @@
ab28d8fa83a954e827056c4437473529b279ad702cbf9c7faae737bdd25db63d discover-tokens.json ffc070ec958ece59f2e8eecd1144f43cdaf7fdb53fe51b178f1b17457ea4151a discover-tokens.json
fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py
1052c79be79e0fb85e64ba5b48a77e7e9dddd114c656e5572f39c0a85e09b9f5 shell-tokens.json 17c45415eb8e334a31a8b187ad2e2c960c35b58e565b19fbbcc9cb5e10717a3a shell-tokens.json
914f60ca5bcbc71459cfde3abc37497b46faab3726fc3816ab1227a555dcc647 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 9359aaa6f81650ccfa46c3f0a76d128ea229ff16d79573b342804cc4cb12330e src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
9e671a8103a1df343d5b39bcd82e9fc28078ac940a04efe5f687ec1a297b92a6 tokens.css b230411658c4b82e7f9e1677842c60bbf61939567958075fff277bc0561e00ec tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json