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

@ -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 ->
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)
}
}

View file

@ -3,21 +3,18 @@ package org.justinvisser.wolfskin.design
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.tv.material3.Text
import java.util.Locale
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 =
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
fun DiscoverRatings(ratings: List<DiscoverRating>, modifier: Modifier = Modifier, plate: Boolean = false) {
fun DiscoverRatings(ratings: List<DiscoverRating>, modifier: Modifier = Modifier) {
if (ratings.isEmpty()) return
val locale = LocalConfiguration.current.locales[0]
val labels = remember(ratings, locale) { ratings.map { formatDiscoverRating(it, locale) } }
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
BoxWithConstraints(modifier) {
val available = with(density) { maxWidth.toPx() }
val base = WolfskinTokens.Type.ratingPlate
var font = base.fontSize.value
var count = ratings.size
fun width(): Float {
val style = base.copy(fontSize = TextUnit(font, TextUnitType.Sp))
return (0 until count).sumOf { index ->
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()
}.toFloat() + (count - 1) * with(density) { style.fontSize.toPx() } * (if (plate) .64f else .7142857f)
}
if (plate) {
while (font > WolfskinTokens.Type.caption.fontSize.value && width() > available) font = (font - .25f).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value)
while (count > 1 && width() > available) count--
}
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)
}
val style = remember { WolfskinTokens.Type.ratingPlate.copy(fontFeatureSettings = "tnum") }
val markSize = with(LocalDensity.current) { style.fontSize.toDp() }
val desaturated = remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) }
Row(modifier, horizontalArrangement = Arrangement.spacedBy(markSize * .7142857f), verticalAlignment = Alignment.CenterVertically) {
ratings.forEachIndexed { index, rating ->
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
}
val muted = rating.source == "rtCritics" && rating.label != "Rotten" && rating.label != "Fresh" && rating.label != "Certified Fresh"
val wordmark = rating.source == "imdb" || rating.source == "tmdb"
Row(horizontalArrangement = Arrangement.spacedBy(markSize * .2857143f), verticalAlignment = Alignment.CenterVertically) {
Image(painterResource(resource), null, Modifier.height(markSize).width(markSize * if (wordmark) 1.8f else 1f),
alpha = if (muted) .55f else 1f, colorFilter = if (muted) desaturated 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.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
@ -65,17 +66,33 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
}
val density = LocalDensity.current
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) {
val row = state.focusedRow
val alreadyFits = state.service != null && row <= 2
if (row <= 1 || alreadyFits) list.animateScrollToItem(0)
else list.animateScrollToItem(row + if (state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) 1 else 0, -with(density) { L.pinnedLine.roundToPx() })
val physical = row + if (row >= state.firstShelf && note) 1 else 0
var top = WolfskinTokens.Shell.zero
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")) {
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { DiscoverBackdrop(it, modifier = Modifier.offset(y = -with(density) { list.firstVisibleItemScrollOffset.toDp() })) }
LazyColumn(state = list, contentPadding = PaddingValues(bottom = WolfskinTokens.Shell.stageHeight - L.pinnedLine), modifier = Modifier.fillMaxSize()) {
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 - heights.last()).coerceAtLeast(D.frameBottomMin)), modifier = Modifier.fillMaxSize()) {
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 "")
}
@ -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) }
else {
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) }
}
@ -152,6 +175,7 @@ private fun DiscoverChips(state: DiscoverState) {
@Composable
private fun DiscoverStatePanel(state: DiscoverState) {
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)
.background(C.surface, 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)
DiscoverButton("Try again", state.contentFocused) { state.refresh() }
}
}
}
/** 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 {
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)
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(
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",
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,
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. */
@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 card = remember(title, receipts, state.shell.person.key) { title.libraryCard(state) }
LibraryPoster(card, focused, focused && state.plateKey == title.key && state.isSignedIn, true,
@ -118,16 +118,8 @@ fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean,
@Composable
internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () -> Unit) {
Column(Modifier.size(K.posterW, K.posterH).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.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)
}
if (lane.tail == DiscoverTail.SeeAll) LibrarySeeAllCard(lane.total, focused, onClick)
else CatalogueRecoveryCard(lane.tail == DiscoverTail.Retry, focused, onClick)
}
internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String {

View file

@ -46,6 +46,7 @@ class DiscoverState(
val requests = mutableStateMapOf<String, List<DiscoverRequestResult>>()
private val requestSheets = mutableMapOf<Pair<String, String>, RequestSheetState>()
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 cachedRevision = -1
private var cachedFilter = ""
@ -114,16 +115,46 @@ class DiscoverState(
fun openTitle(item: DiscoverTitle) {
title = item; titleButton = 0
if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key)
onPush("discover-title/${item.key}")
}
fun openCollection(lane: DiscoverLane) {
val titles = lane.fullTitles.associateBy { it.key }
collection = LibraryGridState(lane.title, lane.fullTitles.map { it.libraryCard(this) }, catalogue = true,
onTitle = { openTitle(titles.getValue(it.id)) }, onRecover = ::refresh).apply {
var titles = lane.fullTitles.associateBy { it.key }
collectionCards = LinkedHashMap<String, LibraryTitle>(titles.size).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 }
}
collection = grid
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 isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item)
@ -154,6 +185,10 @@ class DiscoverState(
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) {
ShellKey.Up -> moveRow(-1)
ShellKey.Down -> moveRow(1)
@ -164,11 +199,9 @@ class DiscoverState(
}
ShellKey.Ok -> when {
isCard -> activateCard()
!isSignedIn || snapshot.status == DiscoverStatus.Unavailable -> refresh()
focusedRow == 0 -> hero?.let(::openTitle)
focusedRow == 1 -> { filter = (if (service == null) DISCOVER_FILTERS else listOf("movie", "tv"))[column]; if (service == null) slide = 0; revision++ }
}
else -> Unit
}
return true
}
@ -244,6 +277,13 @@ class DiscoverState(
private fun send(sheet: RequestSheetState, check: Boolean) {
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))
requestSheets[sheet.title.key to sheet.selectedPerson.key] = sheet
sheet.phase = RequestPhase.Sending
@ -255,6 +295,7 @@ class DiscoverState(
requests[sheet.title.key].orEmpty().forEach { if (it.requester != result.requester) add(it) }
add(result)
}
updateCollectionCards(sheet.title)
}
}
}
@ -262,24 +303,26 @@ class DiscoverState(
fun hide(item: DiscoverTitle) {
if (!canRequest) { refresh(); return }
hidden[item.key] = item; revision++; overlay = DiscoverOverlayState.Hidden(item)
updateCollectionCards()
val profile = shell.person.key
val version = (dismissalVersions[item.key] ?: 0) + 1
dismissalVersions[item.key] = version
scope.launch {
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) {
if (!canRequest) { refresh(); return }
hidden.remove(item.key); revision++; overlay = null; toast = "Back in your picks."
updateCollectionCards()
val profile = shell.person.key
val version = (dismissalVersions[item.key] ?: 0) + 1
dismissalVersions[item.key] = version
scope.launch {
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 onDiscover: () -> Unit = {},
val onReadAhead: (Int) -> Unit = {},
val observedRatings: MutableSet<String> = mutableSetOf(),
) : ShellContentFocus {
var source by mutableStateOf(items)
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
Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset)
.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",
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) {
plateKey = null
if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings)) {
if (focused.id !in cachedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong())
if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings || focused.id in grid.observedRatings)) {
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
}
}
@ -223,15 +224,7 @@ private fun LibraryRecovery(grid: LibraryGridState, focused: Boolean) {
private fun LibraryTail(grid: LibraryGridState, focused: Boolean) {
val retry = grid.tail == GridTail.Retry
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)
.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)
}
CatalogueRecoveryCard(retry, focused, { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }, Modifier.offset(L.safeSide))
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))
}
@ -261,15 +254,5 @@ private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int,
if (first < headerCount) WolfskinTokens.Shell.zero else
(if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
} + view.listState.firstVisibleItemScrollOffset
val distance = 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
}
}
animateFrameScroll(view.listState, target - current)
}

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)
.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)
.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))
Text("See all", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.listTitle)
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 tailPadding = 10.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 unknownCriticOpacity = 0.55f
val fallbackPadding = 11.0.dp
@ -366,31 +370,24 @@ object WolfskinTokens {
val statePaddingX = 20.0.dp
val statePaddingY = 17.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 headerTop = 75.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 entryPaddingY = 10.0.dp
val entryText = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800), lineHeight = 19.5.sp, letterSpacing = -0.01.em)
val sheetRadioRing = 1.5.dp
val skeletonNameWidth = 130.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 sheetRatingTop = -5.0.dp
val sheetRatingBottom = 3.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 ambientBlur = 30.0.dp
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.test.junit4.createAndroidComposeRule
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.text.TextLayoutResult
import org.json.JSONArray
@ -64,23 +65,23 @@ class DiscoverCaptureTest {
rule.waitForIdle()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
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 labels = listOfNotNull(state.hero?.title, "Request", "For", "Justin (you)", "Hidden picks", "Netflix", "Popular movies")
labels.forEach { text ->
rule.onAllNodesWithText(text, useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
val layouts = mutableListOf<TextLayoutResult>()
node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
layouts.forEach { layout ->
rows.put(JSONObject().put("text", text).put("lines", layout.lineCount)
.put("fontSizeSp", layout.layoutInput.style.fontSize.value).put("fontWeight", layout.layoutInput.style.fontWeight?.weight)
.put("lineHeightSp", layout.layoutInput.style.lineHeight.value).put("trim", layout.layoutInput.style.lineHeightStyle?.trim.toString())
.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))
}
rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsProperties.Text), useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
val layouts = mutableListOf<TextLayoutResult>()
if (SemanticsActions.GetTextLayoutResult in node.config) node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
layouts.forEach { layout ->
val text = layout.layoutInput.text.text
val lines = JSONArray()
repeat(layout.lineCount) { index -> lines.put(text.substring(layout.getLineStart(index), layout.getLineEnd(index, visibleEnd = true))) }
rows.put(JSONObject().put("text", text).put("lines", layout.lineCount).put("lineTexts", lines)
.put("fontSizeSp", layout.layoutInput.style.fontSize.value).put("fontWeight", layout.layoutInput.style.fontWeight?.weight)
.put("lineHeightSp", layout.layoutInput.style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL).put("trim", layout.layoutInput.style.lineHeightStyle?.trim.toString())
.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))

View file

@ -117,6 +117,50 @@ class DiscoverKeysTest {
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() {
replay("Open Discover")
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)
}
@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() {
shell.pickerVisible = false; shell.enterDestination("discover")
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 })
}
}