Integrate Discover with canonical shared UI

This commit is contained in:
justinzeus 2026-11-05 20:57:57 +01:00
commit 575103ce98
690 changed files with 2899 additions and 174 deletions

View file

@ -9,6 +9,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.TextStyle
@ -17,28 +18,34 @@ import androidx.tv.material3.Text
/** Captions fade without moving the reserved band under the focused poster. */
@Composable
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false,
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false,
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
titleOverflow: TextOverflow = TextOverflow.Clip) {
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
val alpha = remember(title, facts) { Animatable(0f) }
LaunchedEffect(alpha, visible) {
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
}
val styledFacts = remember(facts, accentLastFact) {
val styledFacts = remember(facts, accentLastFact, accentFirstFact) {
buildAnnotatedString {
val separator = " · "
append(facts)
if (accentFirstFact) {
val end = facts.indexOf(separator).takeIf { it >= 0 } ?: facts.length
addStyle(SpanStyle(color = WolfskinTokens.Color.accentText), 0, end)
}
if (accentLastFact) {
val suffix = facts.lastIndexOf(separator).takeIf { it >= 0 }?.plus(separator.length) ?: facts.length
if (suffix < facts.length) addStyle(SpanStyle(color = WolfskinTokens.Color.accentText), suffix, facts.length)
}
val muted = WolfskinTokens.Color.muted
val separatorStyle = SpanStyle(color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity))
var start = facts.indexOf(separator)
while (start >= 0) {
val dot = start + separator.indexOf('·')
addStyle(separatorStyle, dot, dot + 1)
start = facts.indexOf(separator, start + separator.length)
if (start >= 0) {
val muted = WolfskinTokens.Color.muted
val separatorStyle = SpanStyle(color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity))
val dotOffset = separator.indexOf('·')
while (start >= 0) {
addStyle(separatorStyle, start + dotOffset, start + dotOffset + 1)
start = facts.indexOf(separator, start + separator.length)
}
}
}
}

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, FocusLift.Card)
.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

@ -0,0 +1,93 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil3.compose.AsyncImage
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Native captures provide decoded local art; neither posters nor heroes perform image HTTP reads. */
val LocalDiscoverArt = compositionLocalOf<((String) -> ImageBitmap?)?> { null }
/** Decode requests use the actual art slot, with RGB565 only for upright posters. */
@Composable
fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, dim: Boolean = false) {
if (path == null) return
val bitmap = LocalDiscoverArt.current?.invoke(path)
val painter = remember(bitmap) { bitmap?.let(::BitmapPainter) }
val filter = remember(dim) { if (dim) ColorFilter.colorMatrix(ColorMatrix().apply {
setToSaturation(D.ambientSaturation)
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
}) else null }
val context = LocalContext.current
val request = remember(context, path, poster) {
WolfskinImages.request(context, path, if (poster) 256 else 1280, if (poster) 384 else 720, poster)
}
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
else AsyncImage(
model = request,
imageLoader = WolfskinImages.loader(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter,
)
}
/** Poster-only titles retain the one-sheet over a blurred wash rather than stretching the poster. */
@Composable
fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Modifier = Modifier) {
Box(modifier.size(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).clipToBounds().background(C.surface3)) {
if (title.posterOnly) {
DiscoverArtwork(title.poster, Modifier.offset(-D.ambientInset, -D.ambientInset)
.wrapContentSize(Alignment.TopStart, unbounded = true)
.size(WolfskinTokens.Shell.stageWidth + D.ambientInset * 2, WolfskinTokens.Shell.stageHeight + D.ambientInset * 2)
.graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }.blur(D.ambientBlur), dim = true)
DiscoverArtwork(title.poster, Modifier.offset(
WolfskinTokens.Shell.stageWidth - D.heroSheetRight - if (detail) L.heroSheetWidth else D.heroSheetWidth,
if (detail) L.navBand else D.heroSheetTop,
).size(if (detail) L.heroSheetWidth else D.heroSheetWidth, if (detail) L.heroSheetHeight else D.heroSheetHeight)
.dropShadow(RoundedCornerShape(WolfskinTokens.Radius.heroSheet), Shadow(
radius = WolfskinTokens.Lift.heroBlur, color = Color.Black.copy(alpha = WolfskinTokens.Lift.heroOpacity),
offset = DpOffset(WolfskinTokens.Shell.zero, WolfskinTokens.Lift.heroY)))
.clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true)
} else DiscoverArtwork(title.backdrop ?: title.poster, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().drawWithCache {
val scrim = Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f), endX = size.width)
onDrawBehind { drawRect(scrim) }
})
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
}
}
/** Missing art is deterministic and quiet; it does not imply a provider read succeeded. */
fun discoverFallback(title: String): Brush {
val hue = title.fold(0) { hash, char -> (hash * 31 + char.code) % 360 }.toFloat()
return Brush.linearGradient(listOf(Color.hsl(hue, .22f, .22f), Color.hsl(hue, .26f, .10f)))
}

View file

@ -0,0 +1,29 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
/** Discover metadata projects into the same poster and grid contract as Libraries. */
internal fun DiscoverTitle.libraryCard(state: DiscoverState): LibraryTitle {
val receipts = state.requests[key].orEmpty()
val held = state.shell.person.key in heldBy
val availability = when {
held -> "In your library"
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()
}
"Requested for $recipients · ${receipts.first().stage}"
}
heldBy.isNotEmpty() -> "In ${heldBy.joinToString(" and ") { key -> state.shell.people.firstOrNull { it.key == key }?.name ?: key }}’s library"
else -> ""
}
return LibraryTitle(id = key, title = title, type = type, year = year,
runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasonCount, poster = poster,
ratings = ratings.map { LibraryRating(it.source, it.score, it.label) }, availability = availability,
badge = when {
held -> "held"
receipts.isEmpty() -> null
receipts.all { it.stage == "Failed" } -> "failed"
else -> "requested"
})
}

View file

@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import org.json.JSONArray
import org.json.JSONObject
/** Projects supplied prototype data; this class never loads a bundled fixture or contacts a server. */
class DiscoverFixture(json: String, private val artPath: (String) -> String = { it }) {
private val profiles = JSONObject(json).getJSONArray("profiles").objects()
private val holdings = profiles.associate { profile ->
profile.getString("key") to listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() }
.map { identity(it) }.toSet()
}
private val pool = profiles.flatMap { it.getJSONArray("discover").objects() }.distinctBy(::identity).map(::title)
val services = listOf("Netflix", "Prime Video", "Disney+", "HBO Max", "Apple TV+", "Videoland", "Hulu", "NPO Start", "Crunchyroll")
.mapIndexed { index, name -> DiscoverService(index.toLong(), name, listOf(355f, 200f, 230f, 265f, 0f, 20f, 140f, 25f, 30f)[index]) }
fun snapshot(person: String): DiscoverSnapshot {
val profile = profiles.first { it.getString("key") == person }
val picks = profile.getJSONArray("discover").objects().map(::title)
val played = listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() }
.filter { it.optBoolean("played") }
val groups = linkedMapOf<String, Pair<String, MutableList<DiscoverTitle>>>()
picks.sortedBy { if (it.type == "movie") 0 else 1 }.forEach { pick ->
played.map { it to normalizedGenres(it.getJSONArray("genres").strings()).intersect(normalizedGenres(pick.genres)).size }
.filter { it.second > 0 }.sortedByDescending { it.second }.take(2).forEach { (seed, _) ->
groups.getOrPut(identity(seed)) { seed.getString("title") to mutableListOf() }.second.add(pick)
}
}
val lanes = services.mapIndexed { index, service ->
val source = rotate(pool, index * 7).sortedByDescending { it.year }
DiscoverLane("new-$index", "New on ${service.name}", source.take(12), fullTitles = source)
} + listOf(DiscoverLane("trending", "Trending", pool.take(20), pool.size, fullTitles = pool)) +
listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").map { genre ->
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, reusesFixtureLanes = true)
}
fun service(service: DiscoverService, type: String): List<DiscoverLane> {
val base = rotate(pool.filter { it.type == type }, service.id.toInt() * 5)
val noun = if (type == "movie") "movies" else "series"
val genres = base.flatMap { it.genres }.groupingBy { it }.eachCount().entries.sortedByDescending { it.value }.take(3)
return listOf(
DiscoverLane("sv-pop", "Popular $noun", base.drop(1).take(20)),
DiscoverLane("sv-new", "New $noun", base.sortedByDescending { it.year }.take(20)),
DiscoverLane("sv-top", "Top rated $noun", rotate(base, 11).take(20)),
) + genres.map { genre -> DiscoverLane("sv-g-${genre.key}", "${genre.key} $noun", base.filter { genre.key in it.genres }.take(20)) }
}
fun find(name: String): DiscoverTitle = (pool + profiles.flatMap { p -> listOf("movies", "shows", "anime").flatMap { p.getJSONArray(it).objects() }.map(::title) }).first { it.title == name }
private fun title(item: JSONObject): DiscoverTitle = DiscoverTitle(
tmdb = item.optLong("tmdbId", item.getString("id").hashCode().toLong().let { if (it < 0) -it else it }),
type = item.getString("type"), title = item.getString("title"), year = item.nullableInt("year"),
genres = item.getJSONArray("genres").strings(), overview = item.optString("overview"),
poster = item.nullableString("poster")?.let(artPath), backdrop = item.nullableString("backdrop")?.let(artPath),
posterOnly = item.optJSONObject("artwork")?.optString("backdropKind") == "poster_crop",
runtimeSeconds = item.nullableInt("runtimeSeconds"), seasonCount = item.nullableInt("seasonCount"),
ratings = ratings(item.optJSONObject("ratings")), heldBy = holdings.filterValues { identity(item) in it }.keys,
)
private fun ratings(json: JSONObject?): List<DiscoverRating> = buildList {
if (json == null) return@buildList
for ((field, source, label) in listOf(Triple("rtCritics", "rtCritics", "rtCriticsLabel"), Triple("rtAudience", "rtAudience", "rtAudienceLabel"), Triple("imdb", "imdb", ""))) {
if (!json.isNull(field)) add(DiscoverRating(source, json.getDouble(field), json.nullableString(label)))
}
if (isEmpty() && !json.isNull("tmdb") && json.optInt("tmdbVotes") > 0) add(DiscoverRating("tmdb", json.getDouble("tmdb")))
}
private fun identity(json: JSONObject): String = "${json.getString("type")}:${json.optLong("tmdbId", json.getString("id").hashCode().toLong().let { if (it < 0) -it else it })}"
private fun <T> rotate(items: List<T>, offset: Int): List<T> = if (items.isEmpty()) items else items.drop(offset % items.size) + items.take(offset % items.size)
private fun normalizedGenres(genres: List<String>): Set<String> = genres.flatMap {
when (it) { "Sci-Fi & Fantasy" -> listOf("Science Fiction", "Fantasy"); "Action & Adventure" -> listOf("Action", "Adventure"); "Sci-Fi" -> listOf("Science Fiction"); else -> listOf(it) }
}.toSet()
}
private fun JSONArray.objects(): List<JSONObject> = List(length()) { getJSONObject(it) }
private fun JSONArray.strings(): List<String> = List(length()) { getString(it) }
private fun JSONObject.nullableString(key: String): String? = if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
private fun JSONObject.nullableInt(key: String): Int? = if (isNull(key)) null else getInt(key)

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

@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
/** Catalogue identity is stable across lanes, filters, requests and dismissals. */
data class DiscoverTitle(
val tmdb: Long,
val type: String,
val title: String,
val year: Int? = null,
val genres: List<String> = emptyList(),
val overview: String = "",
val poster: String? = null,
val backdrop: String? = null,
val posterOnly: Boolean = false,
val runtimeSeconds: Int? = null,
val seasonCount: Int? = null,
val seasons: List<Int>? = null,
val ratings: List<DiscoverRating> = emptyList(),
val heldBy: Set<String> = emptySet(),
val score: Double = 0.0,
) {
val key: String get() = "$type:$tmdb"
val typeWord: String get() = if (type == "movie") "Movie" else "Series"
}
/** Observed source marks keep labels separate from score values. */
data class DiscoverRating(val source: String, val score: Double, val label: String? = null)
/** A contribution group preserves feed order rather than assigning a visible rank. */
data class DiscoverSeed(val title: String, val items: List<DiscoverTitle>)
/** Services carry their catalogue id and a text wordmark, never third-party logos. */
data class DiscoverService(val id: Long, val name: String, val hue: Float)
enum class DiscoverStatus { Ready, NeedsSignIn, Unavailable, Loading, NoSeeds, PicksUnavailable }
enum class DiscoverTail { None, SeeAll, Retry, ShowMore }
/** Provider data is immutable; screen focus never mutates source ordering. */
data class DiscoverSnapshot(
val picks: List<DiscoverTitle> = emptyList(),
val seeds: List<DiscoverSeed> = emptyList(),
val services: List<DiscoverService> = emptyList(),
val lanes: List<DiscoverLane> = emptyList(),
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. */
data class DiscoverLane(
val id: String,
val title: String,
val titles: List<DiscoverTitle>,
val total: Int = titles.size,
val tail: DiscoverTail = if (total > titles.size) DiscoverTail.SeeAll else DiscoverTail.None,
val pick: Boolean = false,
val hidden: Boolean = false,
val fullTitles: List<DiscoverTitle> = titles,
)
/** Caller identity is deliberately absent: the authenticated client supplies the actor. */
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(
val phase: RequestPhase,
val actor: String? = null,
val stage: String = "Waiting",
val requester: String? = null,
)
enum class RequestPhase { Choose, LoadingRecipients, RecipientsUnavailable, Sending, Receipt, NotConfirmed, Pending, Refused }
/** Production adapters use the generated Conductarr client; fixture adapters cannot send HTTP. */
interface DiscoverOperations {
suspend fun request(intent: DiscoverRequest): DiscoverRequestResult
suspend fun check(intent: DiscoverRequest): DiscoverRequestResult
suspend fun hide(profile: String, title: DiscoverTitle): Boolean
suspend fun restore(profile: String, title: DiscoverTitle): Boolean
suspend fun refresh(): DiscoverSnapshot
}

View file

@ -0,0 +1,178 @@
// 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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.layout.layout
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Sheets draw after the shell nav: their scrim consumes pointers and focus stays inside. */
@Composable
fun DiscoverOverlay(state: DiscoverState) {
val overlay = state.overlay
if (overlay != null && !state.shell.pickerVisible) {
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable(onClick = {}).testTag("discover-overlay")) {
Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize()
.dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur,
color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity),
offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero)))
.background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
when (overlay) {
is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state)
is DiscoverOverlayState.Hidden -> {
ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.")
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title) }
DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) }
}
}
is DiscoverOverlayState.Restore -> {
Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle)
Text("Hidden from ${state.shell.person.name}’s picks.",
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body),
color = C.muted, style = DiscoverType.body)
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title) }
DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null }
}
}
}
}
}
}
state.toast?.let { text ->
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 = DiscoverType.body)
}
}
}
@Composable
private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
Text(if (sheet.held) "Add for someone" else "Request", color = C.fg, style = DiscoverType.sheetTitle)
Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}",
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop), color = C.muted, style = DiscoverType.body)
if (state.isSignedIn) DiscoverRatings(sheet.title.ratings, Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height + (D.sheetRatingTop + D.sheetRatingBottom).roundToPx()) {
placeable.placeRelative(0, D.sheetRatingTop.roundToPx())
}
})
when (sheet.phase) {
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet)
RequestPhase.LoadingRecipients -> {
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
repeat(3) {
Row(Modifier.fillMaxWidth().height(L.sheetRow).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)).padding(horizontal = D.sheetRowPadding),
horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(D.sheetAvatar).background(C.surface, CircleShape))
Box(Modifier.width(D.skeletonNameWidth).height(D.skeletonNameHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius)))
}
}
Box(Modifier.padding(top = D.sheetButtonTop)) {
DiscoverButton("Close", true, primary = false) { state.overlay = null }
}
}
else -> RequestOutcome(state, sheet)
}
}
@Composable
private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
sheet.people.forEachIndexed { index, person ->
val focused = sheet.focus == index
val disabled = person.key in sheet.title.heldBy
Row(Modifier.fillMaxWidth().height(L.sheetRow).alpha(if (disabled) .5f else 1f)
.focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
.clickable(enabled = !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex }
.padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow)
Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = DiscoverType.listTitle)
if (disabled) Text("In library", color = (if (focused) C.onFocus else C.fg).copy(alpha = D.reasonOpacity), style = DiscoverType.reason)
else Box(Modifier.size(D.sheetRadio).background(if (sheet.selected == index) C.accent else C.listRowRest.copy(alpha = 0f), CircleShape)
.border(if (sheet.selected != index || focused) D.sheetRadioRing else WolfskinTokens.Shell.zero, if (focused) C.onFocus.copy(alpha = .5f) else C.fg.copy(alpha = .35f), CircleShape), contentAlignment = Alignment.Center) {
if (sheet.selected == index) Box(Modifier.size(D.sheetRadioDot).background(C.onFocus, CircleShape))
}
}
}
if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = DiscoverType.caption)
else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = DiscoverType.caption)
Box(Modifier.padding(top = D.sheetButtonTop)) {
DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, glyph = if (sheet.blocked) null else "plus", primary = !sheet.blocked) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
}
}
@Composable
private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) {
val phase = sheet.phase
val name = sheet.selectedPerson.name
val actor = sheet.result?.actor ?: state.shell.person.name
val text = when (phase) {
RequestPhase.Receipt -> "for ${if (sheet.selectedPerson.key == state.shell.person.key) "you" else name} · by $actor"
RequestPhase.NotConfirmed -> "The request service didn’t answer. Nothing is assumed filed."
RequestPhase.Pending -> "Seerr hasn’t confirmed it yet. Checking again looks first, so nothing is filed twice."
RequestPhase.Refused -> "$name can’t receive requests from this TV."
else -> "Conductarr didn’t say who this TV may request for. Nothing was sent."
}
val heading = when (phase) {
RequestPhase.Receipt -> if (sheet.held) "Adding to $name’s library" else "Requested"
RequestPhase.NotConfirmed -> "Not confirmed"
RequestPhase.Pending -> "Request unresolved"
RequestPhase.Refused -> "Refused"
else -> "Can’t read who can receive requests"
}
ReceiptPanel(heading, text, if (phase == RequestPhase.Receipt) C.fg else if (phase == RequestPhase.Refused) C.fail else C.warn,
if (phase == RequestPhase.Receipt) sheet.result?.stage ?: "Waiting" else null)
if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = DiscoverType.caption)
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) {
DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false) { state.overlay = null }
} else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true) { state.overlay = null }
}
}
@Composable
private fun ReceiptPanel(title: String, text: String, color: androidx.compose.ui.graphics.Color = C.fg, stage: String? = null) {
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(WolfskinTokens.Shell.panelPaddingX, WolfskinTokens.Shell.panelPaddingY), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.panelGap)) {
Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
Glyph(if (title == "Requested" || title == "Hidden from your picks" || title.startsWith("Adding")) "check" else if (title == "Request unresolved") "clock" else "alert", color, Modifier.size(WolfskinTokens.Shell.panelIcon))
Text(title, color = color, style = DiscoverType.stateTitle)
}
Text(text, color = C.muted, style = DiscoverType.body)
if (stage != null) Row(horizontalArrangement = Arrangement.spacedBy(D.stageGap), verticalAlignment = Alignment.CenterVertically) {
val stageColor = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost" -> C.accent; else -> C.muted }
Glyph("clock", stageColor, Modifier.size(L.buttonIcon))
Text(stage, color = stageColor, style = DiscoverType.label)
}
}
}

View file

@ -0,0 +1,52 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
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.tv.material3.Text
import java.util.Locale
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
/** Localized score formatting never infers a tomato label from its percentage. */
fun formatDiscoverRating(rating: DiscoverRating, locale: Locale): String =
if (rating.source.startsWith("rt")) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score)
/** Heroes, title destinations and sheets show every observed score without a dwell or truncation. */
@Composable
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 style = DiscoverType.ratings
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

@ -0,0 +1,194 @@
// 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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.DisposableEffect
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
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Discover uses the shell's real scroller, Back ladder and per-person focus memory. */
@Composable
fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
val shell = state.shell
val focus = remember(state) { object : ShellContentFocus by state {} }
DisposableEffect(focus) {
shell.contentFocus = focus
onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } }
}
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
LaunchedEffect(focusedTitle?.key, state.contentFocused) {
state.plateKey = null
if (state.contentFocused && focusedTitle != null) {
if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong())
state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key
}
}
if (state.title != null) { DiscoverTitleDestination(state, modifier); return }
state.collection?.let {
LibraryScreen(shell, it, modifier, ratingsSignedIn = state.isSignedIn, offlineArt = LocalDiscoverArt.current, registerFocus = false)
return
}
if (state.snapshot.status == DiscoverStatus.Loading) {
val loading = remember(state) { LibraryGridState("Discover", emptyList(), catalogue = true).apply { status = LibraryStatus.Loading } }
LibraryScreen(shell, loading, modifier, offlineArt = LocalDiscoverArt.current, registerFocus = false)
return
}
val density = LocalDensity.current
val list = shell.view.listState
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 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.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 LibraryHeader(state.shell.person, state.service?.name ?: "Discover", if (state.service != null) "General interest" else "")
}
item(key = "discover-chips", contentType = "chips") {
if (state.snapshot.status !in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable, DiscoverStatus.Loading)) DiscoverChips(state)
}
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") {
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 = DiscoverType.body)
}
}
}
itemsIndexed(state.lanes, key = { _, lane -> lane.id }, contentType = { _, _ -> "shelf" }) { index, lane -> DiscoverShelf(state, lane, index + state.firstShelf) }
}
}
}
}
@Composable
private fun DiscoverHero(state: DiscoverState) {
val title = state.hero ?: return
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon))
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label)
}
Spacer(Modifier.height(D.heroKickerGap))
Text(title.title, Modifier.width(L.heroCopyWidth).testTag("discover-hero-title-box").discoverLineBox(DiscoverType.hero), color = C.fg, style = DiscoverType.hero, maxLines = 2, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(D.heroTitleGap))
Row(Modifier.height(D.heroFactsHeight), horizontalArrangement = Arrangement.spacedBy(D.heroFactsGap), verticalAlignment = Alignment.CenterVertically) {
Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = DiscoverType.body)
if (title.genres.isNotEmpty()) Text(title.genres.take(2).joinToString(", "), color = C.muted, style = DiscoverType.body)
if (state.isSignedIn) DiscoverRatings(title.ratings)
if (state.shell.person.key in title.heldBy) Text("In your library", color = C.accentText, style = DiscoverType.body)
}
Spacer(Modifier.height(D.heroMetaGap))
Text(title.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(D.heroOverviewGap))
Row(verticalAlignment = Alignment.CenterVertically) {
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
state.heroes.forEachIndexed { index, _ -> Box(Modifier.size(if (index == state.slide) D.dotActive else D.dotSize, D.dotSize).background(if (index == state.slide) C.fg else C.fg.copy(alpha = .3f), CircleShape)) }
Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
}
}
}
}
@Composable
private fun DiscoverChips(state: DiscoverState) {
Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = D.chipTop), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
(if (state.service == null) listOf("all" to "All", "movie" to "Movies", "tv" to "Series") else listOf("movie" to "Movies", "tv" to "Series")).forEachIndexed { index, (value, label) ->
val focused = state.contentFocused && state.focusedRow == 1 && state.column == index
Box(Modifier.height(D.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.background(if (focused) C.fg else if (state.filter == value) C.accentSoft else C.chipRest, RoundedCornerShape(WolfskinTokens.Radius.chip))
.border(if (!focused && state.filter == value) WolfskinTokens.Focus.ringCardRing / 2 else WolfskinTokens.Shell.zero, if (!focused && state.filter == value) C.accent else C.chipRest.copy(alpha = 0f), RoundedCornerShape(WolfskinTokens.Radius.chip))
.clickable { state.focus(1, index); state.key(ShellKey.Ok) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
Text(label, color = if (focused) C.onFocus else if (state.filter == value) C.accentText else C.fg, style = DiscoverType.label)
}
}
}
}
@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).wrapContentHeight(Alignment.Top, unbounded = true).width(L.stateWidth)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
.border(WolfskinTokens.Library.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(WolfskinTokens.Library.borderWidth).padding(D.statePaddingX, D.statePaddingY)) {
Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
Glyph(if (signIn) "profile" else "alert", if (signIn) C.accentText else C.warn, Modifier.size(L.buttonIcon))
Text(if (signIn) "Discover needs sign-in" else "Discover is resting", color = C.fg, style = DiscoverType.stateTitle)
}
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 = DiscoverType.body)
DiscoverButton("Try again", state.contentFocused, primary = signIn) { state.refresh() }
}
}
}
/** Hero and sheet buttons reuse the shell's scale and outside-ring primitives. */
@Composable
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) {
Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
.background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button))
.clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
Text(label, color = if (primary) C.onFocus else C.fg, style = DiscoverType.button, maxLines = 1)
}
}

View file

@ -0,0 +1,122 @@
// SPDX-License-Identifier: GPL-2.0-only
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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
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
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Every shelf is lazy and keyed; the focused caption is outside the scaled art slot. */
@Composable
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 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
val slots = ((L.contentWidth + K.posterGap) / K.posterPitch).toInt()
val first = state.shell.view.cards["first:${lane.id}"] ?: 0
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 = DiscoverType.rowHeading, maxLines = 1)
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)
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
.background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card))
.border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card))
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {
Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = DiscoverType.entry, color = C.fg)
}
} else {
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
DiscoverPoster(state, item, active && index == column, Modifier.clickable {
state.focus(row, index)
if (lane.hidden) state.overlay = DiscoverOverlayState.Restore(item) else state.openTitle(item)
})
}
if (lane.tail != DiscoverTail.None) item(key = "tail:${lane.id}", contentType = if (lane.tail == DiscoverTail.SeeAll) "seeAll" else "tail") {
DiscoverTailCard(lane, active && column == lane.titles.size) { state.focus(row, lane.titles.size); state.key(ShellKey.Ok) }
}
}
}
if (!services && active) {
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) 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(WolfskinTokens.Library.captionWidth),
accentFirstFact = lane.hidden,
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
)
}
}
}
/** Only the focused poster reads the ratings plate; cached observations paint immediately. */
@Composable
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,
modifier.zIndex(if (focused) 1f else 0f), offlineArt = LocalDiscoverArt.current)
}
@Composable
internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () -> Unit) {
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 {
val card = title.libraryCard(state)
return (if (hidden) "Hidden from your picks · " else "") + card.facts(true)
}

View file

@ -0,0 +1,339 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** One person owns a Discover view; the shell retains its root list and card memory. */
class DiscoverState(
val shell: ShellState,
initial: DiscoverSnapshot,
private val operations: DiscoverOperations,
private val scope: CoroutineScope,
private val onPush: (String) -> Unit = {},
private val onPop: () -> Unit = {},
private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() },
) : ShellContentFocus {
var snapshot by mutableStateOf(initial)
private set
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<LibraryGridState?>(null)
var titleButton by mutableStateOf(0)
private var activeOverlay by mutableStateOf<DiscoverOverlayState?>(null)
var overlay: DiscoverOverlayState?
get() = activeOverlay
set(value) { activeOverlay = value; shell.contentOverlayVisible = value != null }
var toast by mutableStateOf<String?>(null)
val observedRatings = mutableSetOf<String>()
var plateKey by mutableStateOf<String?>(null)
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 = ""
private var cachedService: DiscoverService? = null
private var cachedLanes = emptyList<DiscoverLane>()
private var cachedHeroesRevision = -1
private var cachedHeroesFilter = ""
private var cachedHeroes = emptyList<DiscoverTitle>()
private val dismissalMutex = Mutex()
private val dismissalVersions = mutableMapOf<String, Int>()
private var rootFilter = "all"
val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn
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()
cachedHeroes = source.asSequence().filter { it.key !in hidden && matches(it) }
.sortedWith(compareByDescending<DiscoverTitle> { it.score }.thenBy { if (it.type == "movie") 0 else 1 })
.take(if (snapshot.status == DiscoverStatus.Ready) 5 else 1).toList()
cachedHeroesRevision = revision; cachedHeroesFilter = filter
}
return cachedHeroes
}
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
}
return cachedLanes
}
val firstShelf: Int get() = 2
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().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
override val cardKey: String? get() = when {
overlay != null || title != null -> null
collection != null -> collection?.cardKey
isCard -> "$rowKey:$column"
else -> null
}
fun focus(row: Int, column: Int = 0) {
shell.navFocused = false; shell.view.row = row
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) {
title = item; titleButton = 0
if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key)
onPush("discover-title/${item.key}")
}
fun openCollection(lane: DiscoverLane) {
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)
fun openService(item: DiscoverService) {
rootFilter = filter; service = item; filter = "movie"; revision++
onPush("discover-service/${item.id}")
focus(firstShelf)
}
fun enterRoute(route: String) {
if (route == "discover") { title = null; collection = null; if (service != null) filter = rootFilter; service = null }
else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null
}
/** Card hold/release handling belongs to the shell, including the shared catalogue grid. */
override fun key(key: ShellKey): Boolean {
if (overlay != null) { overlayKey(key); return true }
if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false
if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true }
if (title == null) collection?.let {
if (key == ShellKey.Back) { collection = null; onPop(); return true }
return it.key(key)
}
if (key == ShellKey.Back) {
if (title != null) { title = null; onPop() }
else if (service != null) { service = null; filter = rootFilter; revision++; onPop() }
else shell.focusNav(shell.currentTab)
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)
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))
else shell.view.cards[rowKey] = (column + direction).coerceIn(0, maxColumn())
}
ShellKey.Ok -> when {
isCard -> activateCard()
focusedRow == 0 -> hero?.let(::openTitle)
focusedRow == 1 -> { filter = (if (service == null) DISCOVER_FILTERS else listOf("movie", "tv"))[column]; if (service == null) slide = 0; revision++ }
}
}
return true
}
private fun maxColumn(): Int = when (focusedRow) {
0 -> heroes.lastIndex
1 -> if (service == null) 2 else 1
else -> if (focusedLane?.id == "svc") snapshot.services.lastIndex else focusedLane?.let { it.titles.size - 1 + if (it.tail != DiscoverTail.None) 1 else 0 } ?: 0
}.coerceAtLeast(0)
private fun activateCard() {
val lane = focusedLane ?: return
if (lane.id == "svc") { snapshot.services.getOrNull(column)?.let(::openService); return }
val item = lane.titles.getOrNull(column)
if (item == null) {
if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) refresh()
else openCollection(lane)
} else if (lane.hidden) overlay = DiscoverOverlayState.Restore(item)
else openTitle(item)
}
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]?.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) }
}
else -> Unit
}
}
fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) {
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) {
when (val sheet = overlay) {
is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }) {
scope.launch {
snapshot = operations.refresh(); revision++
sheet.state.phase = if (snapshot.status == DiscoverStatus.Ready) RequestPhase.Choose else RequestPhase.RecipientsUnavailable
sheet.state.focus = sheet.state.buttonIndex
}
}
is DiscoverOverlayState.Hidden -> when (key) {
ShellKey.Left, ShellKey.Up -> sheet.focus = 0
ShellKey.Right, ShellKey.Down -> sheet.focus = 1
ShellKey.Back -> overlay = null
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else { overlay = null; title = null; onPop() }
}
is DiscoverOverlayState.Restore -> when (key) {
ShellKey.Left, ShellKey.Up -> sheet.focus = 0
ShellKey.Right, ShellKey.Down -> sheet.focus = 1
ShellKey.Back -> overlay = null
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else overlay = null
}
null -> Unit
}
}
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
scope.launch {
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] = buildList {
requests[sheet.title.key].orEmpty().forEach { if (it.requester != result.requester) add(it) }
add(result)
}
updateCollectionCards(sheet.title)
}
}
}
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++; 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++; updateCollectionCards(); 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 {
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

@ -0,0 +1,49 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Explicit destination stub exposes only the Discover-owned request and pick actions. */
@Composable
fun DiscoverTitleDestination(state: DiscoverState, modifier: Modifier = Modifier) {
val title = state.title ?: return
val focused = state.contentFocused
Box(modifier.fillMaxSize().testTag("discover-title-destination-stub")) {
DiscoverBackdrop(title, detail = true)
Column(Modifier.offset(L.safeSide, L.detailCopyTop).width(L.detailCopyWidth)) {
Text(title.title, color = C.fg, style = WolfskinTokens.Type.displayDetail, maxLines = 2)
Spacer(Modifier.height(D.heroTitleGap))
Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()} · ${title.genres.take(2).joinToString(", ")}", color = C.muted, style = WolfskinTokens.Type.body)
Spacer(Modifier.height(D.heroMetaGap))
if (state.isSignedIn) DiscoverRatings(title.ratings)
Spacer(Modifier.height(D.heroMetaGap))
Text(title.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3)
Spacer(Modifier.height(D.heroOverviewGap))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
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)
}
DiscoverButton(if (state.shell.person.key in title.heldBy) "Add for someone" else "For someone else", focused && state.titleButton == 1, glyph = "profile", primary = false) { state.openRequest(true) }
if (state.isPick(title)) DiscoverButton(if (state.isHidden(title)) "Show again" else "Not interested", focused && state.titleButton == 2, glyph = if (state.isHidden(title)) "undo" else "hide", primary = false) { state.titleButton = 2; state.key(ShellKey.Ok) }
}
}
}
}
}

View file

@ -0,0 +1,41 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.TextStyle
import kotlin.math.roundToInt
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineHeightStyle
/** CSS line boxes survive native first/last-line trimming only within Discover's text stacks. */
internal object DiscoverType {
private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
private val platform = PlatformTextStyle(includeFontPadding = false)
val hero = WolfskinTokens.Type.displayHero.copy(lineHeightStyle = lineBox, platformStyle = platform)
val body = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBox, platformStyle = platform)
val label = WolfskinTokens.Type.label.copy(lineHeightStyle = lineBox, platformStyle = platform)
val button = WolfskinTokens.Type.button.copy(lineHeightStyle = lineBox, platformStyle = platform)
val sheetTitle = WolfskinTokens.Type.sheetTitle.copy(lineHeightStyle = lineBox, platformStyle = platform)
val stateTitle = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold, lineHeightStyle = lineBox, platformStyle = platform)
val listTitle = WolfskinTokens.Type.listTitle.copy(lineHeightStyle = lineBox, platformStyle = platform)
val caption = WolfskinTokens.Type.caption.copy(lineHeightStyle = lineBox, platformStyle = platform)
val reason = WolfskinTokens.Type.caption.copy(fontWeight = FontWeight.Medium, lineHeightStyle = lineBox, platformStyle = platform)
val rowHeading = WolfskinTokens.Type.rowHeading.copy(lineHeightStyle = lineBox, platformStyle = platform)
val entry = WolfskinTokens.Discover.entryText.copy(lineHeightStyle = lineBox, platformStyle = platform)
val ratings = WolfskinTokens.Type.ratingPlate.copy(fontFeatureSettings = "tnum", lineHeightStyle = lineBox, platformStyle = platform)
}
/** Paragraph overflow must not expand the CSS block height. */
internal fun Modifier.discoverLineBox(style: TextStyle): Modifier = layout { measurable, constraints ->
val paragraph = measurable.measure(constraints)
val lineHeight = style.lineHeight.toPx()
val lines = ((paragraph[LastBaseline] - paragraph[FirstBaseline]) / lineHeight).roundToInt() + 1
val height = (lines * lineHeight).roundToInt().coerceIn(constraints.minHeight, constraints.maxHeight)
layout(paragraph.width, height) {
paragraph.placeRelative(0, (height - paragraph.height) / 2)
}
}

View file

@ -11,17 +11,16 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.Dp
/** Only transforms animate: focus never changes measured geometry. */
@ -35,37 +34,32 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
return graphicsLayer { scaleX = animated; scaleY = animated }
}
/** Content lifts are distinct from the white person/gear rings. */
enum class FocusLift { None, Card, Button }
/** Draw the ring outside the slot; neither the gap nor the ring obscures content. */
@Composable
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color): Modifier {
val alpha by animateFloatAsState(if (focused) 1f else 0f,
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None): Modifier {
val ringAlpha by animateFloatAsState(if (focused) 1f else 0f,
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
return drawBehind {
if (alpha > 0f) {
val outer = (gap + width).toPx()
val inner = gap.toPx()
drawRoundRect(color.copy(alpha = color.alpha * alpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer))
drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner))
val shadow = if (lift == FocusLift.None) this else {
val shape = remember(radius) { RoundedCornerShape(radius) }
val density = LocalDensity.current
val blur = with(density) { (if (lift == FocusLift.Card) WolfskinTokens.Lift.cardBlur else WolfskinTokens.Lift.buttonBlur).toPx() }
val shift = with(density) { (if (lift == FocusLift.Card) WolfskinTokens.Lift.cardY else WolfskinTokens.Lift.buttonY).toPx() }
dropShadow(shape) {
this.radius = blur
offset = Offset(0f, shift)
this.color = Color.Black
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
}
}
}
/** Native lift draws outside the slot without allocating an offscreen layer per card. */
@Composable
fun Modifier.focusLift(focused: Boolean, radius: Dp, offset: Dp, blur: Dp, opacity: Float): Modifier {
val alpha by animateFloatAsState(if (focused) 1f else 0f,
tween(WolfskinTokens.Motion.fast), label = "focus-lift")
return drawWithCache {
val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
color = WolfskinTokens.Color.bg.toArgb()
setShadowLayer(blur.toPx(), 0f, offset.toPx(), WolfskinTokens.Color.sheetScrim.copy(alpha = opacity).toArgb())
}
val corner = radius.toPx()
onDrawBehind {
if (alpha > 0f) {
paint.alpha = (alpha * 255).toInt()
drawIntoCanvas { it.nativeCanvas.drawRoundRect(0f, 0f, size.width, size.height, corner, corner, paint) }
}
return shadow.drawBehind {
if (ringAlpha > 0f) {
val outer = (gap + width).toPx()
val inner = gap.toPx()
drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer))
drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner))
}
}
}

View file

@ -0,0 +1,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

@ -22,7 +22,12 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
"alert" -> R.drawable.wolfskin_alert
"wifi" -> R.drawable.wolfskin_wifi
"clock" -> R.drawable.wolfskin_clock
"plus" -> R.drawable.wolfskin_plus
"check" -> R.drawable.wolfskin_check
"clock" -> R.drawable.wolfskin_clock
"hide" -> R.drawable.wolfskin_hide
"undo" -> R.drawable.wolfskin_undo
"left" -> R.drawable.wolfskin_left
"download" -> R.drawable.wolfskin_download
"film" -> R.drawable.wolfskin_film
"eye" -> R.drawable.wolfskin_eye

View file

@ -8,7 +8,7 @@ class HomeFocus(
private val rows: List<HomeRow>,
private val onAction: (HomeAction) -> Unit,
) : ShellContentFocus {
private val view = shell.viewFor("home")
private val view = shell.rememberedView("home")
override val cardKey: String? get() = row?.let { if (it.kind == HomeRowKind.Shelf) it.cards.getOrNull(column)?.id ?: it.id else null }
val row: HomeRow? get() = rows.getOrNull(view.row)
val column: Int get() = row?.let { view.cards[it.id] ?: 0 } ?: 0

View file

@ -44,8 +44,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
Spacer(Modifier.height(H.overviewBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, lift = true, textStyle = T.button) { onButton(0) }
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, lift = true, textStyle = T.button) { onButton(1) }
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
}
}
} else {
@ -90,7 +90,7 @@ fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> Unit, onHover: ()
Spacer(Modifier.height(H.stateGap))
Text(row.text, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body)
Spacer(Modifier.height(H.stateTextBottom))
PickerButton("Try again", focused, row.calm, onFocus = onHover, lift = true, textStyle = T.button, onClick = retry)
PickerButton("Try again", focused, row.calm, onFocus = onHover, textStyle = T.button, onClick = retry)
}
}
}

View file

@ -27,7 +27,7 @@ fun HomeScreen(
modifier: Modifier = Modifier,
onAction: (HomeAction) -> Unit,
) {
val view = shell.viewFor("home")
val view = shell.rememberedView("home")
LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt }
val action by rememberUpdatedState(onAction)
val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } }
@ -112,7 +112,7 @@ fun HomeScreen(
Text(item.text, color = if (item.calm) C.muted else C.warn, style = HomeType.body)
}
HomeRowKind.Explore -> Box(Modifier.fillMaxWidth().height(L.row_endcta).padding(start = L.safeSide, top = H.endTop)) {
PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }, lift = true, textStyle = HomeType.button) { activate(0) }
PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }, textStyle = HomeType.button) { activate(0) }
}
}
}

View file

@ -65,8 +65,8 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index")
.then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier)
if (title == null && !row.skeleton) {
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label, lift = true, gap = H.seeAllGap)
} else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height, lift = true) {
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label)
} else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height) {
if (title == null) Box(Modifier.fillMaxSize().background(C.surface)) else {
TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster)
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)

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

@ -4,6 +4,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.*
@ -52,9 +53,15 @@ fun LibraryPoster(
Box(Modifier.fillMaxHeight().fillMaxWidth(title.progress).background(C.accent, CircleShape))
}
}
if (catalogue && title.badge != null) Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset)
.size(WolfskinTokens.Layout.badge).background(if (title.badge == "held") C.ok else C.badgeRest, CircleShape), contentAlignment = Alignment.Center) {
Text(if (title.badge == "held") "✓" else if (title.badge == "failed") "!" else "◷", color = if (title.badge == "held") C.onFocus else C.accent, style = WolfskinTokens.Type.button)
if (catalogue && title.badge != null) {
val held = title.badge == "held"
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 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(L.badgeIcon))
}
}
}
}

View file

@ -5,7 +5,6 @@ import androidx.compose.foundation.border
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@ -41,16 +40,18 @@ fun LibraryScreen(
ratingsReachable: Boolean = true,
offlineArt: ((String) -> ImageBitmap?)? = null,
view: RememberedView = shell.view,
registerFocus: Boolean = true,
) {
val density = LocalDensity.current
val context = LocalContext.current
val active = !shell.navFocused && !shell.pickerVisible
val items = grid.items
val focused = grid.focusedTitle
val count = if (grid.catalogue) items.size else grid.source.size
var plateKey by remember { mutableStateOf<String?>(null) }
DisposableEffect(shell, grid) {
shell.contentFocus = grid
onDispose { if (shell.contentFocus === grid) shell.contentFocus = null }
DisposableEffect(shell, grid, registerFocus) {
if (registerFocus) shell.contentFocus = grid
onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null }
}
LaunchedEffect(grid) {
var revision = grid.pageRevision
@ -69,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
}
}
@ -102,7 +104,11 @@ fun LibraryScreen(
}
}
}, contentPadding = PaddingValues(bottom = WolfskinTokens.Shell.stageHeight - L.pinnedLine - rowHeight)) {
item(key = "header", contentType = "header") { LibraryHeader(grid, shell.person) }
item(key = "header", contentType = "header") {
LibraryHeader(shell.person, grid.title,
if (grid.status != LibraryStatus.Ready) "" else "$count ${if (grid.catalogue) "titles" else if (grid.title == "Movies") if (count == 1) "film" else "films" else "series"}",
grid.savedAt)
}
if (headerCount == 2) item(key = "toolbar", contentType = "chips") { LibraryToolbar(grid, active) }
when (grid.status) {
LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) {
@ -155,15 +161,15 @@ fun LibraryScreen(
}
}
/** Library, catalogue and service headers share one baseline and person treatment. */
@Composable
private fun LibraryHeader(grid: LibraryGridState, person: ShellPerson) {
val count = if (grid.catalogue) grid.items.size else grid.source.size
fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", savedAt: String? = null) {
Box(Modifier.fillMaxWidth().height(L.row_lhead)) {
Row(Modifier.offset(L.safeSide, L.navBand), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsHeaderGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
Text(grid.title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle)
Text(if (grid.status != LibraryStatus.Ready) "" else "$count ${if (grid.catalogue) "titles" else if (grid.title == "Movies") if (count == 1) "film" else "films" else "series"}", modifier = Modifier.alignByBaseline(), color = C.muted, style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal))
grid.savedAt?.let { Text("Saved $it", color = C.warn, style = WolfskinTokens.Type.caption) }
Text(title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle)
if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle)
savedAt?.let { Text("Saved $it", color = C.warn, style = WolfskinTokens.Type.caption) }
}
}
}
@ -222,15 +228,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))
}
@ -260,15 +258,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

@ -16,13 +16,12 @@ import androidx.tv.material3.Text
@Composable
fun LibrarySeeAllCard(count: Int, focused: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier,
width: Dp = WolfskinTokens.Card.posterW, height: Dp = WolfskinTokens.Card.posterH,
titleStyle: TextStyle = WolfskinTokens.Type.listTitle, countStyle: TextStyle = WolfskinTokens.Type.label,
lift: Boolean = false, gap: Dp = WolfskinTokens.Library.tailGap) {
titleStyle: TextStyle = WolfskinTokens.Type.listTitle, countStyle: TextStyle = WolfskinTokens.Type.label) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
TitleCard(focused, modifier.clickable(onClick = onClick), width, height, lift) {
TitleCard(focused, modifier.clickable(onClick = onClick), width, height) {
Column(Modifier.fillMaxSize().background(WolfskinTokens.Color.surface, shape)
.border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape),
verticalArrangement = Arrangement.spacedBy(gap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge))
Text("See all", modifier = Modifier.exactLineBox(titleStyle), color = WolfskinTokens.Color.fg, style = titleStyle)
Text(count.toString(), modifier = Modifier.exactLineBox(countStyle), color = WolfskinTokens.Color.muted, style = countStyle)

View file

@ -0,0 +1,66 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/** Overlay focus is independent of the screen below and never reaches the navigation bar. */
sealed interface DiscoverOverlayState {
class Request(val state: RequestSheetState) : DiscoverOverlayState
class Hidden(val title: DiscoverTitle) : DiscoverOverlayState { var focus by mutableStateOf(0) }
class Restore(val title: DiscoverTitle) : DiscoverOverlayState { var focus by mutableStateOf(0) }
}
/** Request selection cannot supply an actor and cannot submit an unknown-season series. */
class RequestSheetState(
val title: DiscoverTitle,
val viewer: ShellPerson,
people: List<ShellPerson>,
other: Boolean,
initialPhase: RequestPhase = RequestPhase.Choose,
) {
val people = listOf(viewer) + people.filter { it.key != viewer.key }
val held = viewer.key in title.heldBy
val blocked = title.type == "tv" && !held && title.seasons.isNullOrEmpty()
val buttonIndex = this.people.size
var selected by mutableStateOf(if (other || held) this.people.indexOfFirst { it.key != viewer.key && it.key !in title.heldBy }.coerceAtLeast(0) else 0)
var phase by mutableStateOf(initialPhase)
var focus by mutableStateOf(if (initialPhase != RequestPhase.Choose || blocked || !other) buttonIndex else selected)
var result by mutableStateOf<DiscoverRequestResult?>(null)
val selectedPerson: ShellPerson get() = people[selected]
val canSubmit: Boolean get() = phase == RequestPhase.Choose && !blocked && selectedPerson.key !in title.heldBy
val buttonLabel: String get() = when {
held -> "Add to ${selectedPerson.name}’s library"
selectedPerson.key == viewer.key -> "Request"
else -> "Request for ${selectedPerson.name}"
}
fun key(key: ShellKey, send: (RequestSheetState, Boolean) -> Unit, close: () -> Unit, retryRecipients: () -> Unit) {
if (key == ShellKey.Back) { close(); return }
when (phase) {
RequestPhase.Choose -> {
val order = people.indices.filter { people[it].key !in title.heldBy } + buttonIndex
val index = order.indexOf(focus)
when (key) {
ShellKey.Up -> focus = order[(index - 1).coerceAtLeast(0)]
ShellKey.Down -> focus = order[(index + 1).coerceAtMost(order.lastIndex)]
ShellKey.Ok -> if (focus == buttonIndex) { if (blocked) close() else if (canSubmit) send(this, false) }
else { selected = focus; focus = buttonIndex }
else -> Unit
}
}
RequestPhase.Sending -> Unit
RequestPhase.LoadingRecipients -> if (key == ShellKey.Ok) close()
RequestPhase.Receipt, RequestPhase.Refused -> if (key == ShellKey.Ok) close()
RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable -> {
when (key) {
ShellKey.Left, ShellKey.Up -> focus = buttonIndex
ShellKey.Right, ShellKey.Down -> focus = buttonIndex + 1
ShellKey.Ok -> if (focus != buttonIndex) close() else if (phase == RequestPhase.RecipientsUnavailable) retryRecipients() else send(this, true)
else -> Unit
}
}
}
}
}

View file

@ -63,6 +63,7 @@ class ShellState(
private var requestReturnRoute: String? = null
var handOff by mutableStateOf<String?>(null)
var settingsRow by mutableStateOf(0)
var contentOverlayVisible by mutableStateOf(false)
private var rootRoute by mutableStateOf("home")
private var pickerReturnRoute = "home"
private val views = mutableStateMapOf<Pair<String, String>, RememberedView>()
@ -72,10 +73,11 @@ class ShellState(
private set
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
val view: RememberedView get() = viewFor(route)
val view: RememberedView get() = rememberedView(route)
/** A composing destination must not borrow the previous route's scroller before its enter effect. */
fun viewFor(destination: String): RememberedView = views.getOrPut(person.key to destination) { 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) {
@ -93,10 +95,14 @@ class ShellState(
fun focusPerson(index: Int) { inputRevision++; pickerIndex = index; gearFocused = false }
/** Repeats are consumed, and a row activation is cancelled when focus moves. */
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = if (!pickerVisible && !navFocused && !requestsVisible) contentFocus?.cardKey else null): Boolean {
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = if (!pickerVisible && !navFocused && !requestsVisible && !contentOverlayVisible) contentFocus?.cardKey else null): Boolean {
if (down) inputRevision++
if (key == ShellKey.Ok && repeat > 0) return true
if (!pickerVisible && !requestsVisible && contentOverlayVisible) {
if (down) contentFocus?.key(key)
return true
}
if (key == ShellKey.Ok) {
if (repeat > 0) return true
if (card != null) {
if (down) armedCard = card else {
if (armedCard == card) activate()

View file

@ -55,6 +55,7 @@ fun ShellSurface(
modifier: Modifier = Modifier,
handlesContentKeys: Boolean = true,
settingsOpenRequests: Int? = null,
overlay: @Composable () -> Unit = {},
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
) {
val focus = remember { FocusRequester() }
@ -85,6 +86,7 @@ fun ShellSurface(
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
if (state.requestsVisible) ComingInSheet(state)
overlay()
}
}
}

View file

@ -14,11 +14,10 @@ import androidx.compose.ui.unit.Dp
/** Shelf, grid and continuation cards share one fixed slot and draw-only focus surface. */
@Composable
fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = WolfskinTokens.Card.posterW,
height: Dp = WolfskinTokens.Card.posterH, lift: Boolean = false, content: @Composable BoxScope.() -> Unit) {
height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.then(if (lift) Modifier.focusLift(focused, WolfskinTokens.Radius.card, WolfskinTokens.Home.cardLiftOffset, WolfskinTokens.Home.cardLiftBlur, WolfskinTokens.Home.cardLiftOpacity) else Modifier)
.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, FocusLift.Card)) {
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
}
}

View file

@ -102,12 +102,11 @@ private fun RecoveryPicker(state: ShellState) {
/** Recovery actions remain focusable while profiles cannot be read. */
@Composable
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, lift: Boolean = false,
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null,
textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) {
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.then(if (lift) Modifier.focusLift(focused, WolfskinTokens.Radius.button, WolfskinTokens.Home.buttonLiftOffset, WolfskinTokens.Home.buttonLiftBlur, WolfskinTokens.Home.buttonLiftOpacity) else Modifier)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {

View file

@ -365,6 +365,11 @@ 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)
val headerSubtitle = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(400), lineHeight = 19.5.sp, letterSpacing = 0.0.em)
const val imageReads = 4.0f
const val unknownCriticOpacity = 0.55f
val fallbackPadding = 11.0.dp
@ -376,6 +381,20 @@ object WolfskinTokens {
const val fallbackLightnessBottom = 0.1f
const val fallbackIconOpacity = 0.45f
}
object Lift {
val cardBlur = 25.0.dp
val cardY = 9.0.dp
const val cardOpacity = 0.65f
val buttonBlur = 20.0.dp
val buttonY = 6.0.dp
const val buttonOpacity = 0.5f
val heroBlur = 40.0.dp
val heroY = 15.0.dp
const val heroOpacity = 0.6f
val sheetBlur = 40.0.dp
val sheetX = -15.0.dp
const val sheetOpacity = 0.5f
}
object Home {
val kickerGap = 6.0.dp
val kickerBottom = 5.0.dp
@ -421,8 +440,6 @@ object WolfskinTokens {
val progressBottom = 5.0.dp
val endTop = 20.0.dp
val frameBottom = 40.0.dp
val seeAllGap = 4.0.dp
val seeAllIcon = 24.0.dp
val toastPadding = 14.0.dp
val toastTop = 75.0.dp
val toastWidth = 380.0.dp
@ -436,12 +453,67 @@ object WolfskinTokens {
val savedGap = 5.0.dp
val savedIcon = 14.0.dp
val savedRadius = 14.0.dp
val cardLiftOffset = 9.0.dp
val cardLiftBlur = 25.0.dp
const val cardLiftOpacity = 0.65f
val buttonLiftOffset = 6.0.dp
val buttonLiftBlur = 20.0.dp
const val buttonLiftOpacity = 0.5f
val posterProgressBottom = 6.0.dp
}
object Discover {
val sheetPaddingX = 32.0.dp
val sheetPaddingY = 40.0.dp
val sheetGap = 9.0.dp
val sheetSubGap = 7.0.dp
val sheetRowPadding = 13.0.dp
val sheetAvatar = 30.0.dp
val sheetRadio = 20.0.dp
val sheetRadioDot = 8.0.dp
val sheetPersonGap = 12.0.dp
val sheetButtonTop = 6.0.dp
val chipTop = 8.0.dp
val chipHeight = 34.0.dp
val chipPadding = 14.0.dp
val chipGap = 8.0.dp
val heroTitleGap = 8.0.dp
val heroKickerGap = 5.0.dp
val heroMetaGap = 7.0.dp
val heroOverviewGap = 15.0.dp
val heroFactsGap = 11.0.dp
val heroFactsHeight = 19.0.dp
val heroSheetRight = 90.0.dp
val heroSheetTop = 70.0.dp
val heroSheetWidth = 180.0.dp
val heroSheetHeight = 270.0.dp
val dotSize = 7.0.dp
val dotActive = 22.0.dp
val dotGap = 7.0.dp
val dotMargin = 9.0.dp
val dotChevron = 20.0.dp
val stateTop = 10.0.dp
val statePaddingX = 20.0.dp
val statePaddingY = 17.0.dp
val stateTextGap = 12.0.dp
val noteTop = 7.0.dp
val headerTop = 75.0.dp
val headerGap = 12.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 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
const val reasonOpacity = 0.8f
val stageGap = 5.0.dp
const val entryBorderOpacity = 0.08f
val ambientInset = 40.0.dp
val ambientBlur = 30.0.dp
const val ambientScale = 1.1f
const val ambientSaturation = 1.2f
const val ambientBrightness = 0.55f
}
}

View file

@ -1,3 +1,4 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5,12.5l4.5,4.5L19,7.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round"/>
<path android:pathData="M5 12.5l4.5 4.5L19 7.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -1,3 +1,5 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M20.5,12a8.5,8.5 0,1 1,-17,0a8.5,8.5 0,1 1,17,0M12,7.5V12l3,2" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round"/>
<path android:pathData="M 3.5,12.0 a 8.5,8.5 0 1,0 17.0,0 a 8.5,8.5 0 1,0 -17.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" />
<path android:pathData="M12 7.5V12l3 2" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round" />
</vector>

View file

@ -1 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:pathData="M6,9 L12,15 L18,9" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round"/></vector>
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="m6 9 6 6 6-6" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,4 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M3 3l18 18M10.6 5.1A10.6 10.6 0 0 1 12 5c6.4 0 10 7 10 7a17 17 0 0 1-3.2 4M6.6 6.6C3.8 8.4 2 12 2 12s3.6 7 10 7c1.6 0 3-.4 4.3-1" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,4 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="m15 6-6 6 6 6" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,4 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12 5v14M5 12h14" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,5 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M9 7 4.5 11.5 9 16" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M5 11.5h9a5.5 5.5 0 0 1 0 11h-2" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,184 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
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.SemanticsMatcher
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import org.json.JSONArray
import org.json.JSONObject
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.assertTrue
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Captures execute the shipped Compose surface with offline fixture artwork and actual shell focus. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val fixture = discoverTestFixture()
@Before fun fullscreen() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
private fun capture(name: String, person: String = "justin", snapshotTransform: (DiscoverSnapshot) -> DiscoverSnapshot = { it }, configure: (DiscoverState) -> Unit = {}) {
val titleState = name.substringAfter("st-").toInt() in setOf(58, 59, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 87, 110)
val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination(if (titleState) "home" else "discover") }
val snapshot = snapshotTransform(fixture.snapshot(person))
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, person), CoroutineScope(Dispatchers.Main.immediate),
onPush = shell::enterDestination, onPop = { shell.enterDestination("discover") }, serviceLanes = fixture::service)
configure(state)
val painters = mutableMapOf<String, ImageBitmap>()
rule.setContent {
WolfskinTheme {
CompositionLocalProvider(LocalDiscoverArt provides { path -> painters.getOrPut(path) { BitmapFactory.decodeFile(path).asImageBitmap() } }) {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) }
}
}
}
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
recordTypography(directory, name)
}
private fun recordTypography(directory: File, name: String) {
val rows = JSONArray()
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))
}
@Test fun st029_discover() = capture("st-029")
@Test fun st030_picksCollection() = capture("st-030") { state -> state.openCollection(state.lanes.first { it.id == "pk-movie" }) }
@Test fun st031_hidden() = capture("st-031") { hidden(it) }
@Test fun st032_restore() {
capture("st-032") { state -> hidden(state); state.overlay = DiscoverOverlayState.Restore(state.snapshot.picks[7]) }
val title = rule.onAllNodesWithText(fixture.snapshot("justin").picks[7].title, useUnmergedTree = true)
.fetchSemanticsNodes().maxBy { it.boundsInRoot.left }.boundsInRoot
val sub = rule.onNodeWithText("Hidden from Justin’s picks.", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val gap = with(rule.density) { (WolfskinTokens.Discover.sheetGap + WolfskinTokens.Discover.sheetSubTop).toPx() }
assertTrue("Restore subtitle must retain the negative top margin", kotlin.math.abs(sub.top - title.bottom - gap) <= 1f)
}
@Test fun st033_failedBadge() = capture("st-033") { state ->
val lane = state.lanes.first { it.id == "pk-movie" }
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 ->
snapshot.copy(lanes = snapshot.lanes.map { if (it.id == "trending") it.copy(tail = DiscoverTail.Retry) else it })
}) { state -> val lane = state.lanes.first { it.id == "trending" }; state.focus(state.lanes.indexOf(lane) + 2, lane.titles.size) }
@Test fun st035_duplicateTail() = capture("st-035") { state ->
val titles = state.snapshot.lanes.first { it.id == "trending" }.fullTitles.take(36)
state.openCollection(DiscoverLane("trending", "Trending", titles, tail = DiscoverTail.ShowMore))
state.collection!!.index = state.collection!!.tailIndex
}
@Test fun st036_needsSignIn() {
capture("st-036", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn) })
val button = rule.onNodeWithText("Try again").fetchSemanticsNode().boundsInRoot
val minimum = with(rule.density) { WolfskinTokens.Layout.buttonHeight.toPx() }
assertTrue("The sign-in button must not shrink to fit the scroll row", button.height >= minimum)
}
@Test fun st037_series() {
capture("st-037", person = "mindel") { it.filter = "tv" }
val title = rule.onNodeWithText("56 Days", useUnmergedTree = true).fetchSemanticsNode()
val layouts = mutableListOf<TextLayoutResult>()
title.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
val layout = layouts.single()
val cssHeight = with(rule.density) { DiscoverType.hero.lineHeight.toPx() } * layout.lineCount
assertTrue("Natural font height must not inflate the hero CSS line box", kotlin.math.abs(title.boundsInRoot.height - cssHeight) <= 1f)
}
@Test fun st038_noSeeds() = capture("st-038", snapshotTransform = { it.copy(status = DiscoverStatus.NoSeeds) })
@Test fun st039_trendingCollection() = capture("st-039") { state -> state.openCollection(state.lanes.first { it.id == "trending" }) }
@Test fun st040_service() = capture("st-040") { state -> state.openService(state.snapshot.services.first()) }
@Test fun st041_unreachable() = capture("st-041", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.Unavailable) })
@Test fun st058_hiddenReceipt() = capture("st-058") { state -> state.openTitle(state.snapshot.picks.first()); state.titleButton = 2; state.hide(state.title!!) }
@Test fun st059_hiddenTitle() = capture("st-059") { state -> state.hide(state.snapshot.picks.first()); state.overlay = null; state.openTitle(state.snapshot.picks.first()); state.titleButton = 2 }
@Test fun st071_requestSelf() = capture("st-071") { request(it) }
@Test fun st072_requestOther() = capture("st-072") { request(it, other = true) }
@Test fun st073_addHeld() = capture("st-073") { state -> request(state, fixture.find("The Girl Who Leapt Through Time"), other = true) }
@Test fun st074_receipt() = capture("st-074") { request(it, other = true, phase = RequestPhase.Receipt) }
@Test fun st075_notConfirmed() = capture("st-075") { request(it, phase = RequestPhase.NotConfirmed) }
@Test fun st076_pending() = capture("st-076") { request(it, phase = RequestPhase.Pending) }
@Test fun st077_refused() = capture("st-077", person = "mindel") { request(it, other = true, phase = RequestPhase.Refused) }
@Test fun st078_recipientsLoading() = capture("st-078") { request(it, other = true, phase = RequestPhase.LoadingRecipients) }
@Test fun st079_recipientsUnavailable() = capture("st-079") { request(it, other = true, phase = RequestPhase.RecipientsUnavailable) }
@Test fun st080_seasonsUnknown() = capture("st-080") { state -> request(state, state.snapshot.picks.first { it.type == "tv" }) }
@Test fun st087_sharedReceipt() = capture("st-087", person = "shared") { request(it, phase = RequestPhase.Receipt) }
@Test fun st105_pickPlate() = capture("st-105") { state -> val lane = state.lanes.first { it.id == "pk-movie" }; state.focus(state.lanes.indexOf(lane) + 2, 1) }
@Test fun st110_heldRatings() = capture("st-110") { state -> request(state, fixture.find("The Girl Who Leapt Through Time"), other = true) }
private fun hidden(state: DiscoverState) {
state.hide(state.snapshot.picks[7]); state.hide(state.snapshot.picks[20]); state.overlay = null
val lane = state.lanes.first { it.hidden }
state.focus(state.lanes.indexOf(lane) + 2, lane.titles.indexOfFirst { it.key == state.snapshot.picks[7].key })
}
private fun request(state: DiscoverState, title: DiscoverTitle = state.snapshot.picks.first(), other: Boolean = false, phase: RequestPhase = RequestPhase.Choose) {
state.openTitle(title); state.openRequest(other, phase = phase)
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] = listOf(receipt)
}
}
}
internal fun discoverTestFixture(): DiscoverFixture {
val json = DiscoverCaptureTest::class.java.getResourceAsStream("/wolfskin-discover.json")!!.bufferedReader().use { it.readText() }
val assets = File("../app/src/debug/assets/wolfskin-discover").canonicalFile
return DiscoverFixture(json) { File(assets, it).absolutePath }
}
/** In-process effects are inspectable without exposing any live client or endpoint. */
internal class RecordingDiscoverOperations(private val snapshot: DiscoverSnapshot, private val actor: String) : DiscoverOperations {
val sent = mutableListOf<DiscoverRequest>()
var checked = 0
var next = RequestPhase.Receipt
var mutationSucceeds = true
override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { sent += intent; return DiscoverRequestResult(next, actor.replaceFirstChar { it.uppercase() }) }
override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult { checked++; return DiscoverRequestResult(next, actor.replaceFirstChar { it.uppercase() }) }
override suspend fun hide(profile: String, title: DiscoverTitle) = mutationSucceeds
override suspend fun restore(profile: String, title: DiscoverTitle) = mutationSucceeds
override suspend fun refresh() = snapshot
}

View file

@ -0,0 +1,258 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Acceptance data drives real Android events through the shell's production focus adapter. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class DiscoverKeysTest {
@get:Rule val rule = createComposeRule()
private val fixture = discoverTestFixture()
private val shell = ShellState(fixturePeople(), "justin")
private val snapshot = fixture.snapshot("justin")
private val operations = RecordingDiscoverOperations(snapshot, "justin")
private val state = DiscoverState(shell, snapshot, operations, CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination,
{ shell.enterDestination("discover") }, fixture::service)
private fun launch() {
rule.setContent {
WolfskinTheme {
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
if (shell.route == "discover" || shell.route.startsWith("discover-")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
}
}
}
}
private fun press(first: Key, second: Key? = null) = listOfNotNull(first, second).forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
private fun replay(goal: String) {
val paths = JSONObject(javaClass.getResourceAsStream("/discover-keys.json")!!.bufferedReader().use { it.readText() }).getJSONArray("pressPaths")
val path = (0 until paths.length()).map { paths.getJSONObject(it) }.single { it.getString("goal") == goal }
val keys = path.getJSONArray("keys")
assertEquals(path.getInt("presses"), keys.length())
launch()
repeat(keys.length()) { index -> press(when (keys.getString(index)) {
"OK" -> Key.DirectionCenter; "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown; "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight; else -> Key.Back
}) }
rule.waitForIdle()
}
@Test fun openDiscoverInFourPresses() {
replay("Open Discover")
assertEquals("discover", shell.route)
assertFalse(shell.navFocused)
assertEquals("The Amazing Spider-Man", state.hero?.title)
}
@Test fun requestForYourselfInSevenPresses() {
replay("Request a pick for yourself")
val sheet = (state.overlay as DiscoverOverlayState.Request).state
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).single().stage)
}
@Test fun requestForMindelInNinePresses() {
replay("Request a pick for Mindel")
val sheet = (state.overlay as DiscoverOverlayState.Request).state
assertEquals(RequestPhase.Receipt, sheet.phase)
assertEquals("mindel", operations.sent.single().requester)
assertEquals("Justin", sheet.result?.actor)
}
@Test fun notInterestedInEightPressesAndUndoRestoresEveryIndex() {
val original = state.lanes.map { lane -> lane.id to lane.titles.map { it.key } }
replay("Round 3 · Not interested on a pick (Discover hero)")
val hidden = state.overlay as DiscoverOverlayState.Hidden
assertTrue(state.isHidden(hidden.title))
assertTrue(state.heroes.none { it.key == hidden.title.key })
assertTrue(state.lanes.filterNot { it.hidden }.none { lane -> lane.titles.any { it.key == hidden.title.key } })
press(Key.DirectionCenter)
assertNull(state.overlay)
assertEquals("The Amazing Spider-Man", state.hero?.title)
assertEquals(original, state.lanes.map { lane -> lane.id to lane.titles.map { it.key } })
}
@Test fun heldOkOpensSheetButNeverSubmits() {
replay("Open Discover")
press(Key.DirectionCenter)
rule.runOnIdle {
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
repeat(8) { index -> shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, index + 1))) }
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
}
assertEquals(RequestPhase.Choose, (state.overlay as DiscoverOverlayState.Request).state.phase)
assertTrue(operations.sent.isEmpty())
}
@Test fun uncertainOutcomeChecksWithoutResending() {
operations.next = RequestPhase.NotConfirmed
replay("Request a pick for yourself")
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 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) }
press(Key.DirectionCenter)
assertTrue(operations.sent.isEmpty())
assertNull(state.overlay)
assertNotNull(state.title)
assertFalse(shell.navFocused)
}
@Test fun cardOkWaitsForReleaseAndFocusMoveCancelsActivation() {
replay("Open Discover")
press(Key.DirectionDown, Key.DirectionDown)
rule.runOnIdle { shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) }
assertNull(state.title)
press(Key.DirectionRight)
rule.runOnIdle { shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) }
assertNull(state.title)
press(Key.DirectionCenter)
assertNotNull(state.title)
}
@Test fun downClampsToOccupiedCardsAndThenTheTail() {
shell.pickerVisible = false
val items = snapshot.picks.take(8)
state.openCollection(DiscoverLane("short", "Picks", items, tail = DiscoverTail.ShowMore))
launch()
repeat(5) { press(Key.DirectionRight) }
assertEquals(5, state.collection!!.index % state.collection!!.columns)
press(Key.DirectionDown)
assertEquals(1, state.collection!!.index / state.collection!!.columns)
assertEquals(1, state.collection!!.index % state.collection!!.columns)
assertEquals(items.last().key, state.collection!!.items[state.collection!!.index].id)
press(Key.DirectionDown, Key.DirectionRight)
assertEquals(2, state.collection!!.index / state.collection!!.columns)
assertEquals(0, state.collection!!.index % state.collection!!.columns)
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)
val origin = shell.view
state.openCollection(state.lanes.first { it.id == "pk-movie" })
launch()
press(Key.DirectionDown, Key.Back)
assertNull(state.collection)
assertSame(origin, shell.view)
assertEquals(3, shell.view.row)
assertEquals(2, shell.view.cards[state.rowKey])
}
@Test
@Config(qualifiers = "nl-rNL-w960dp-h540dp-xhdpi")
fun dutchPlateUsesLocaleAndStaysOnOneLine() {
shell.pickerVisible = false
val title = snapshot.picks.first().copy(ratings = listOf(DiscoverRating("tmdb", 7.8)))
state.openCollection(DiscoverLane("locale", "Picks", listOf(title)))
launch()
rule.mainClock.advanceTimeBy(1000)
rule.onNodeWithText("7,8").assertExists()
assertEquals("7.8", formatDiscoverRating(title.ratings.single(), java.util.Locale.ENGLISH))
assertEquals("91%", formatDiscoverRating(DiscoverRating("rtCritics", 91.7), java.util.Locale.forLanguageTag("nl-NL")))
}
}

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

@ -0,0 +1,92 @@
{
"pressPaths": [
{
"goal": "Open Discover",
"keys": [
"OK",
"UP",
"RIGHT",
"OK"
],
"presses": 4,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Request a pick for yourself",
"keys": [
"OK",
"UP",
"RIGHT",
"OK",
"OK",
"OK",
"OK"
],
"presses": 7,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Request a pick for Mindel",
"keys": [
"OK",
"UP",
"RIGHT",
"OK",
"OK",
"RIGHT",
"OK",
"OK",
"OK"
],
"presses": 9,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Round 3 · Not interested on a pick (Discover hero)",
"keys": [
"OK",
"UP",
"RIGHT",
"OK",
"OK",
"RIGHT",
"RIGHT",
"OK"
],
"presses": 8,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Round 4 · Discover pick nobody holds → its Trailer (sample hand-off)",
"keys": [
"OK",
"UP",
"RIGHT",
"OK",
"OK",
"RIGHT",
"RIGHT",
"RIGHT",
"OK"
],
"presses": 9,
"note": "4 on the title page (3 Rights to Trailer, OK); The Amazing Spider-Man",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05",
"requires": "Conductarr trailer field (spec 3.2, fifth addition); the press path runs once it lands"
}
]
}

File diff suppressed because one or more lines are too long