Preserve request outcomes and align Discover shelf windows

This commit is contained in:
justinzeus 2026-11-05 17:37:39 +01:00
parent 90e956b157
commit 03ff22ea86
15 changed files with 226 additions and 92 deletions

View file

@ -28,8 +28,10 @@ class ConductarrDiscoverOperations(
require(title.type != "tv" || !title.seasons.isNullOrEmpty()) { "Whole-series season metadata is required" }
val body = RequestIntentBody(
mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV,
recommendationProfile = intent.profile, requester = intent.requester,
seasons = title.seasons?.map(Int::toLong), source = RequestIntentBody.Source.RECOMMENDATION, tmdbId = title.tmdb,
recommendationProfile = intent.profile.takeIf { intent.fromRecommendation }, requester = intent.requester,
seasons = title.seasons?.map(Int::toLong),
source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE,
tmdbId = title.tmdb,
)
return when (val result = client.mutate(TvRoutes.request(body))) {
is MutationResult.Success -> result.value.outcome()

View file

@ -304,6 +304,35 @@
"px": 34,
"dp": 17,
"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"
}
}
}

View file

@ -76,8 +76,8 @@ fun DiscoverCollection(state: DiscoverState, collection: DiscoverCollectionState
val item = collection.items.getOrNull(collection.index)
Caption(
item?.title ?: if (collection.lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
if (item != null) captionFacts(item, state, false) else listOf(CaptionFact("The last read only repeated titles")),
true, Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(D.captionWidth),
if (item != null) buildDiscoverCaptionFacts(item, state, false) else listOf(CaptionFact("The last read only repeated titles")),
true, Modifier.offset(L.safeSide + K.posterPitch * collection.column, K.posterH + L.captionOffset).width(D.captionWidth),
)
}
}

View file

@ -35,7 +35,7 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = {
val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") }
DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items)
}
return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready)
return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true)
}
fun service(service: DiscoverService, type: String): List<DiscoverLane> {

View file

@ -0,0 +1,41 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import kotlin.math.ceil
/** Dropped seed groups must not suppress titles from the later catalogue lanes. */
internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<DiscoverTitle>, filter: String, hidden: Map<String, DiscoverTitle>): List<DiscoverLane> {
fun matches(item: DiscoverTitle) = filter == "all" || item.type == filter
val seen = heroes.mapTo(mutableSetOf()) { it.key }
val result = mutableListOf<DiscoverLane>()
fun take(items: List<DiscoverTitle>, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) }
if (snapshot.status == DiscoverStatus.Ready) {
val groups = snapshot.seeds.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }.filter { it.items.isNotEmpty() }.take(3)
for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) {
val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden }
val count = all.count { it.key !in seen }
val items = take(all, minOf(12, ceil(count.toDouble() / (groups.size + 1)).toInt()))
if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all)
}
val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt())
groups.forEachIndexed { index, seed ->
val items = take(seed.items, size)
if (items.size >= 3) result += DiscoverLane("pk-w$index", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items)
}
}
val displayed = heroes.mapTo(mutableSetOf()) { it.key }
result.forEach { lane -> lane.titles.forEach { displayed.add(it.key) } }
if (snapshot.services.isNotEmpty()) result += DiscoverLane("svc", "Services", emptyList())
snapshot.lanes.forEach { lane ->
val source = lane.fullTitles.filter { matches(it) && it.key !in displayed && it.key !in hidden }
val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt())
if (items.isNotEmpty() || lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) {
result += lane.copy(titles = items, fullTitles = source, total = if (lane.id.startsWith("new-")) items.size else source.size,
tail = if (lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) lane.tail else if (!lane.id.startsWith("new-") && source.size > items.size) DiscoverTail.SeeAll else DiscoverTail.None)
if (!snapshot.reusesFixtureLanes) items.forEach { displayed.add(it.key) }
}
}
val hiddenItems = (snapshot.hidden + snapshot.picks).distinctBy { it.key }.filter { it.key in hidden && matches(it) }
if (hiddenItems.isNotEmpty()) result += DiscoverLane("hidden", "Hidden picks", hiddenItems, hidden = true)
return result
}

View file

@ -44,6 +44,7 @@ data class DiscoverSnapshot(
val hidden: List<DiscoverTitle> = emptyList(),
val status: DiscoverStatus = DiscoverStatus.NeedsSignIn,
val savedAt: String? = null,
val reusesFixtureLanes: Boolean = false,
)
/** Lane identity survives lazy recycling and provider refreshes. */
@ -59,7 +60,7 @@ data class DiscoverLane(
)
/** Caller identity is deliberately absent: the authenticated client supplies the actor. */
data class DiscoverRequest(val title: DiscoverTitle, val requester: String, val profile: String)
data class DiscoverRequest(val title: DiscoverTitle, val requester: String, val profile: String, val fromRecommendation: Boolean = true)
/** Request outcomes distinguish uncertainty from an acknowledged receipt. */
data class DiscoverRequestResult(

View file

@ -2,6 +2,8 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@ -33,11 +35,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
@ -49,12 +50,30 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
val services = lane.id == "svc"
val height = if (services) K.entryH else K.posterH
val cards = rememberLazyListState()
val column = state.shell.view.cards[lane.id] ?: 0
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0))
val active = state.contentFocused && state.focusedRow == row
LaunchedEffect(column) { cards.animateScrollToItem((column - 5).coerceAtLeast(0)) }
val slots = ((L.contentWidth + K.posterGap) / K.posterPitch).toInt()
val first = state.shell.view.cards["first:${lane.id}"] ?: 0
val density = LocalDensity.current
var initialized by remember { mutableStateOf(false) }
LaunchedEffect(column, count) {
var target = first
if (column > target + slots - 2) target = column - (slots - 2)
if (column < target + 1) target = (column - 1).coerceAtLeast(0)
target = target.coerceIn(0, (count - slots).coerceAtLeast(0))
state.shell.view.cards["first:${lane.id}"] = target
val offset = cards.layoutInfo.visibleItemsInfo.firstOrNull { it.index == target }?.offset
if (initialized && offset != null) {
if (offset != 0) cards.animateScrollBy(offset.toFloat(), tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
} else cards.scrollToItem(target)
initialized = true
}
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = WolfskinTokens.Type.rowHeading, maxLines = 1)
LazyRow(state = cards, contentPadding = PaddingValues(horizontal = L.safeSide, vertical = K.posterGap), horizontalArrangement = Arrangement.spacedBy(K.posterGap),
LazyRow(state = cards, userScrollEnabled = false,
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
@ -80,8 +99,8 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
val item = lane.titles.getOrNull(column)
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) captionFacts(item, state, lane.hidden) else listOf(CaptionFact(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, L.rowHeadingBand + K.posterH + L.captionOffset).width(D.captionWidth),
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else listOf(CaptionFact(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),
)
}
}
@ -102,12 +121,14 @@ fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean,
DiscoverRatings(title.ratings, Modifier.fillMaxWidth(), plate = true)
}
}
val result = state.requests[title.key]
if (state.shell.person.key in title.heldBy || result != null) {
val receipts = state.requests[title.key].orEmpty()
val held = state.shell.person.key in title.heldBy
val failed = receipts.isNotEmpty() && receipts.all { it.stage == "Failed" }
if (held || receipts.isNotEmpty()) {
Box(Modifier.align(Alignment.BottomEnd).offset(-L.badgeInset, -L.badgeInset).size(L.badge)
.background(if (result == null) C.ok else C.badgeRest, CircleShape)
.border(if (result == null) WolfskinTokens.Shell.zero else D.badgeRing, if (result?.stage == "Failed") C.fail else C.accent, CircleShape), contentAlignment = Alignment.Center) {
Glyph(if (result == null) "check" else if (result.stage == "Failed") "alert" else "clock", if (result == null) C.onFocus else if (result.stage == "Failed") C.fail else C.accent, Modifier.size(D.badgeIcon))
.background(if (held) C.ok else C.badgeRest, CircleShape)
.border(if (held) WolfskinTokens.Shell.zero else D.badgeRing, if (failed) C.fail else C.accent, CircleShape), contentAlignment = Alignment.Center) {
Glyph(if (held) "check" else if (failed) "alert" else "clock", if (held) C.onFocus else if (failed) C.fail else C.accent, Modifier.size(D.badgeIcon))
}
}
}
@ -119,21 +140,26 @@ internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: ()
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(L.buttonGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph(if (lane.tail == DiscoverTail.SeeAll) "interface" else "alert", if (lane.tail == DiscoverTail.Retry) C.warn else C.accent, Modifier.size(L.badge))
Text(when (lane.tail) { DiscoverTail.SeeAll -> "See all"; DiscoverTail.Retry -> "Try again"; else -> "Show more" }, color = C.fg, style = WolfskinTokens.Type.listTitle)
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 = WolfskinTokens.Type.caption)
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 captionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): List<CaptionFact> = buildList {
internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): List<CaptionFact> = buildList {
if (hidden) add(CaptionFact("Hidden from your picks", true))
title.year?.let { add(CaptionFact(it.toString())) }
add(CaptionFact(if (title.type == "tv") title.seasonCount?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series" else title.runtimeSeconds?.let { "${it / 3600}h ${(it / 60) % 60}m" } ?: "Movie"))
val receipts = state.requests[title.key].orEmpty()
if (state.shell.person.key in title.heldBy) add(CaptionFact("In your library", true))
else if (state.requests.containsKey(title.key)) {
val request = state.requests.getValue(title.key)
val recipient = if (request.requester == state.shell.person.key) "you" else state.shell.people.firstOrNull { it.key == request.requester }?.name ?: request.requester.orEmpty()
add(CaptionFact("Requested for $recipient · ${request.stage}", true))
} else title.heldBy.firstOrNull()?.let { key -> add(CaptionFact("In ${state.shell.people.firstOrNull { it.key == key }?.name ?: key}’s library", true)) }
else if (receipts.isNotEmpty()) {
val recipients = receipts.joinToString(", ") { request -> if (request.requester == state.shell.person.key) "you" else state.shell.people.firstOrNull { it.key == request.requester }?.name ?: request.requester.orEmpty() }
add(CaptionFact("Requested for $recipients · ${receipts.first().stage}", true))
} else if (title.heldBy.isNotEmpty()) {
val names = title.heldBy.joinToString(" and ") { key -> state.shell.people.firstOrNull { it.key == key }?.name ?: key }
add(CaptionFact("In $names’s library", true))
}
}

View file

@ -9,7 +9,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.math.ceil
/** One person owns a Discover view; the shell retains its root list and card memory. */
class DiscoverState(
@ -23,8 +22,16 @@ class DiscoverState(
) {
var snapshot by mutableStateOf(initial)
private set
var filter by mutableStateOf("all")
var slide by mutableStateOf(0)
private var serviceFilter by mutableStateOf("movie")
var filter: String
get() = if (service != null) serviceFilter else DISCOVER_FILTERS[shell.rememberedView("discover").cards["filter"] ?: 0]
set(value) {
if (service != null) serviceFilter = value
else shell.rememberedView("discover").cards["filter"] = DISCOVER_FILTERS.indexOf(value)
}
var slide: Int
get() = (shell.rememberedView("discover").cards["slide"] ?: 0).coerceIn(0, (heroes.size - 1).coerceAtLeast(0))
set(value) { shell.rememberedView("discover").cards["slide"] = value }
var title by mutableStateOf<DiscoverTitle?>(null)
var service by mutableStateOf<DiscoverService?>(null)
var collection by mutableStateOf<DiscoverCollectionState?>(null)
@ -36,7 +43,8 @@ class DiscoverState(
var toast by mutableStateOf<String?>(null)
val observedRatings = mutableSetOf<String>()
var plateKey by mutableStateOf<String?>(null)
val requests = mutableStateMapOf<String, DiscoverRequestResult>()
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 var armedCard: String? = null
private var revision by mutableStateOf(0)
@ -48,11 +56,15 @@ class DiscoverState(
private var cachedHeroesFilter = ""
private var cachedHeroes = emptyList<DiscoverTitle>()
private val dismissalMutex = Mutex()
private var dismissalVersion = 0
private val dismissalVersions = mutableMapOf<String, Int>()
private var rootFilter = "all"
val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn
val hasHero: Boolean get() = heroes.isNotEmpty() && snapshot.status !in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable, DiscoverStatus.Loading)
val canRequest: Boolean get() = when (snapshot.status) {
DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true
else -> false
}
val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && canRequest
val heroes: List<DiscoverTitle> get() {
if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) {
val source = if (snapshot.status == DiscoverStatus.Ready) snapshot.picks else snapshot.lanes.firstOrNull { it.id == "trending" }?.titles.orEmpty()
@ -63,7 +75,7 @@ class DiscoverState(
}
return cachedHeroes
}
val hero: DiscoverTitle? get() = heroes.getOrNull(slide.coerceAtMost((heroes.size - 1).coerceAtLeast(0)))
val hero: DiscoverTitle? get() = heroes.getOrNull(slide)
val lanes: List<DiscoverLane> get() {
if (cachedRevision != revision || cachedFilter != filter || cachedService != service) {
cachedLanes = buildLanes(); cachedRevision = revision; cachedFilter = filter; cachedService = service
@ -74,14 +86,25 @@ class DiscoverState(
val rowCount: Int get() = firstShelf + lanes.size
val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0))
val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf)
val column: Int get() = shell.view.cards[rowKey].orZero()
val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn())
val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() }
val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
fun focus(row: Int, column: Int = 0) {
shell.navFocused = false; shell.view.row = row
shell.view.cards[rowKey] = column
shell.view.cards[rowKey] = column.coerceIn(0, maxColumn())
}
private fun moveRow(direction: Int) {
val target = focusedRow + direction
if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return }
if (target >= rowCount) return
val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id }
val nearest = if (focusedRow >= firstShelf && target >= firstShelf)
column - shell.view.cards["first:$rowKey"].orZero() + shell.view.cards["first:$key"].orZero()
else 0
focus(target, shell.view.cards[key] ?: nearest.coerceAtLeast(0))
}
fun openTitle(item: DiscoverTitle) {
@ -113,6 +136,7 @@ class DiscoverState(
return true
}
if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false
if (snapshot.status == DiscoverStatus.Loading) { if (down) shell.focusNav(shell.currentTab); return true }
if (title == null) collection?.let { return it.key(key, down) }
if (isCard && key == ShellKey.Ok) {
val identity = "$rowKey:$column"
@ -132,8 +156,8 @@ class DiscoverState(
}
if (title != null) { titleKey(key); return true }
when (key) {
ShellKey.Up -> if (focusedRow <= if (hasHero) 0 else 1) shell.focusNav(shell.currentTab) else focus(focusedRow - 1, shell.view.cards[if (focusedRow == 2) "dchips" else lanes.getOrNull(focusedRow - 3)?.id].orZero())
ShellKey.Down -> focus((focusedRow + 1).coerceAtMost(rowCount - 1))
ShellKey.Up -> moveRow(-1)
ShellKey.Down -> moveRow(1)
ShellKey.Left, ShellKey.Right -> {
val direction = if (key == ShellKey.Left) -1 else 1
if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0))
@ -142,7 +166,7 @@ class DiscoverState(
ShellKey.Ok -> when {
!isSignedIn || snapshot.status == DiscoverStatus.Unavailable -> refresh()
focusedRow == 0 -> hero?.let(::openTitle)
focusedRow == 1 -> { filter = (if (service == null) listOf("all", "movie", "tv") else listOf("movie", "tv"))[column]; slide = 0; revision++ }
focusedRow == 1 -> { filter = (if (service == null) DISCOVER_FILTERS else listOf("movie", "tv"))[column]; if (service == null) slide = 0; revision++ }
}
else -> Unit
}
@ -167,12 +191,16 @@ class DiscoverState(
}
private fun titleKey(key: ShellKey) {
if (!canRequest) {
if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh()
return
}
when (key) {
ShellKey.Up -> shell.focusNav(shell.currentTab)
ShellKey.Left -> titleButton = (titleButton - 1).coerceAtLeast(0)
ShellKey.Right -> titleButton = (titleButton + 1).coerceAtMost(if (title?.let(::isPick) == true) 2 else 1)
ShellKey.Ok -> when (titleButton) {
0 -> if (requests[requireNotNull(title).key]?.requester == shell.person.key) shell.requestsVisible = true else openRequest(false)
0 -> if (requests[requireNotNull(title).key]?.any { it.requester == shell.person.key } == true) shell.requestsVisible = true else openRequest(false)
1 -> openRequest(true)
2 -> title?.let { if (it.key in hidden) restore(it) else hide(it) }
}
@ -181,8 +209,12 @@ class DiscoverState(
}
fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) {
if (!isSignedIn || snapshot.status == DiscoverStatus.Unavailable) { refresh(); return }
overlay = DiscoverOverlayState.Request(RequestSheetState(item, shell.person, shell.people, other, phase))
if (!canRequest) { refresh(); return }
val choice = RequestSheetState(item, shell.person, shell.people, other, phase)
val previous = requestSheets[item.key to choice.selectedPerson.key]
overlay = DiscoverOverlayState.Request(
if (phase == RequestPhase.Choose && previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) requireNotNull(previous) else choice,
)
}
private fun overlayKey(key: ShellKey) {
@ -211,65 +243,54 @@ class DiscoverState(
}
private fun send(sheet: RequestSheetState, check: Boolean) {
if (!isSignedIn || snapshot.status == DiscoverStatus.Unavailable || (!check && !sheet.canSubmit)) return
if (!canRequest || (!check && !sheet.canSubmit)) 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
scope.launch {
val intent = DiscoverRequest(sheet.title, sheet.selectedPerson.key, shell.person.key)
val result = (if (check) operations.check(intent) else operations.request(intent)).copy(requester = intent.requester)
sheet.result = result; sheet.phase = result.phase; sheet.focus = sheet.buttonIndex
if (result.phase == RequestPhase.Receipt) requests[sheet.title.key] = result
if (result.phase == RequestPhase.Receipt) {
requests[sheet.title.key] = buildList {
requests[sheet.title.key].orEmpty().forEach { if (it.requester != result.requester) add(it) }
add(result)
}
}
}
}
fun hide(item: DiscoverTitle) {
if (!canRequest) { refresh(); return }
hidden[item.key] = item; revision++; overlay = DiscoverOverlayState.Hidden(item)
val version = ++dismissalVersion
val profile = shell.person.key
val version = (dismissalVersions[item.key] ?: 0) + 1
dismissalVersions[item.key] = version
scope.launch {
val success = dismissalMutex.withLock { operations.hide(shell.person.key, item) }
if (!success && dismissalVersion == version) { hidden.remove(item.key); revision++; overlay = null; toast = "Couldn’t hide this pick. Try again." }
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." }
}
}
fun restore(item: DiscoverTitle) {
if (!canRequest) { refresh(); return }
hidden.remove(item.key); revision++; overlay = null; toast = "Back in your picks."
val version = ++dismissalVersion
val profile = shell.person.key
val version = (dismissalVersions[item.key] ?: 0) + 1
dismissalVersions[item.key] = version
scope.launch {
val success = dismissalMutex.withLock { operations.restore(shell.person.key, item) }
if (!success && dismissalVersion == version) { hidden[item.key] = item; revision++; toast = "Couldn’t restore this pick. Try again." }
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." }
}
}
fun refresh() { scope.launch { snapshot = operations.refresh(); revision++; toast = if (snapshot.status == DiscoverStatus.Unavailable) "Still can’t reach it. The last saved view stays on screen." else null } }
private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter
private fun buildLanes(): List<DiscoverLane> {
service?.let { return serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() } }
val seen = heroes.mapTo(mutableSetOf()) { it.key }
val result = mutableListOf<DiscoverLane>()
fun take(items: List<DiscoverTitle>, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) }
if (snapshot.status == DiscoverStatus.Ready) {
val groups = snapshot.seeds.take(3)
for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) {
val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden }
val count = all.count { it.key !in seen }
val items = take(all, minOf(12, ceil(count.toDouble() / (groups.size + 1)).toInt()))
if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all)
}
val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt())
groups.forEachIndexed { index, seed -> val items = take(seed.items, size); if (items.size >= 3) result += DiscoverLane("pk-w$index", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items) }
}
if (snapshot.services.isNotEmpty()) result += DiscoverLane("svc", "Services", emptyList())
snapshot.lanes.forEach { lane ->
val source = lane.fullTitles.filter { matches(it) && it.key !in seen && it.key !in hidden }
val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt())
if (items.isNotEmpty() || lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore))
result += lane.copy(titles = items, fullTitles = source, total = if (lane.id.startsWith("new-")) items.size else source.size,
tail = if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) lane.tail else if (!lane.id.startsWith("new-") && source.size > items.size) DiscoverTail.SeeAll else DiscoverTail.None)
}
val hiddenItems = (snapshot.hidden + snapshot.picks).distinctBy { it.key }.filter { it.key in hidden && matches(it) }
if (hiddenItems.isNotEmpty()) result += DiscoverLane("hidden", "Hidden picks", hiddenItems, hidden = true)
return result
}
private fun buildLanes(): List<DiscoverLane> = service?.let {
serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() }
} ?: projectDiscoverLanes(snapshot, heroes, filter, hidden)
}
private fun Int?.orZero(): Int = this ?: 0
private val DISCOVER_FILTERS = listOf("all", "movie", "tv")

View file

@ -35,7 +35,8 @@ fun DiscoverTitleDestination(state: DiscoverState, modifier: Modifier = Modifier
Text(title.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3)
Spacer(Modifier.height(D.heroOverviewGap))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
val receipt = state.requests[title.key]?.takeIf { it.requester == state.shell.person.key }
if (!state.canRequest) DiscoverButton("Try again", focused) { state.refresh() } else {
val receipt = state.requests[title.key]?.firstOrNull { it.requester == state.shell.person.key }
DiscoverButton(if (receipt == null) "Request" else "Requested · ${receipt.stage}", focused && state.titleButton == 0, glyph = if (receipt == null) "plus" else "clock") {
state.titleButton = 0; state.key(ShellKey.Ok, true)
}
@ -45,3 +46,4 @@ fun DiscoverTitleDestination(state: DiscoverState, modifier: Modifier = Modifier
}
}
}
}

View file

@ -60,6 +60,10 @@ class ShellState(
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
/** Screen options share the same per-person memory as their root's focus and scroller. */
fun rememberedView(destination: String): RememberedView =
views.getOrPut(person.key to destination) { RememberedView() }
/** External intents and upstream pops remain authoritative over the screen. */
fun enterDestination(destination: String) {
route = destination

View file

@ -336,5 +336,9 @@ object WolfskinTokens {
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)
}
}

View file

@ -92,7 +92,7 @@ class DiscoverCaptureTest {
@Test fun st032_restore() = capture("st-032") { state -> hidden(state); state.overlay = DiscoverOverlayState.Restore(state.snapshot.picks[7]) }
@Test fun st033_failedBadge() = capture("st-033") { state ->
val lane = state.lanes.first { it.id == "pk-movie" }
state.requests[lane.titles[1].key] = DiscoverRequestResult(RequestPhase.Receipt, "Justin", "Failed")
state.requests[lane.titles[1].key] = listOf(DiscoverRequestResult(RequestPhase.Receipt, "Justin", "Failed", requester = "justin"))
state.focus(state.lanes.indexOf(lane) + 2, 1)
}
@Test fun st034_retryTail() = capture("st-034", snapshotTransform = { snapshot ->
@ -136,7 +136,7 @@ class DiscoverCaptureTest {
if (phase == RequestPhase.Receipt) {
val sheet = (state.overlay as DiscoverOverlayState.Request).state
val receipt = DiscoverRequestResult(phase, state.shell.person.name, requester = sheet.selectedPerson.key)
sheet.result = receipt; state.requests[title.key] = receipt
sheet.result = receipt; state.requests[title.key] = listOf(receipt)
}
}
}

View file

@ -69,7 +69,7 @@ class DiscoverKeysTest {
assertEquals(RequestPhase.Receipt, sheet.phase)
assertEquals("justin", operations.sent.single().requester)
assertEquals("Justin", sheet.result?.actor)
assertEquals("Waiting", state.requests.getValue(requireNotNull(state.title).key).stage)
assertEquals("Waiting", state.requests.getValue(requireNotNull(state.title).key).single().stage)
}
@Test fun requestForMindelInNinePresses() {

View file

@ -225,4 +225,8 @@
--discover-sheetRadioRing: 3.0px;
--discover-skeletonNameWidth: 260.0px;
--discover-skeletonNameHeight: 34.0px;
--discover-tailGap: 10.0px;
--discover-tailIcon: 44.0px;
--discover-seeAllGap: 8.0px;
--discover-tailDescription: 24.0px;
}

View file

@ -1,6 +1,6 @@
b88b3ae2be6865a53076cbdbca8362a94f2cc40561f20ef9864394eb10c097dc discover-tokens.json
dd16d79ec484b38c2ac17e8fef9d61e522a9c72d934a104d0492bf46c652abae discover-tokens.json
fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py
408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json
3240addb082c7b681b675e9008618a7bfe4c2fde5e7970d702c2ac759eac9ffe src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
a639780189f0426d456f23dc0ba18c45581702b8d12a3f59430d71b912e4315d tokens.css
5c2d8555644b5b399f8d2be83ae6e1d0f4481c2fb2cb48e549412119e2aa62ca src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
403dc4dc8a151c6b456efa9f7f1a7776984a0b4315af62b130168728bc3f3682 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json