Integrate Requests with the shared image cache

This commit is contained in:
justinzeus 2026-11-05 20:20:06 +01:00
commit 4cd75cfa26
60 changed files with 1648 additions and 29 deletions

View file

@ -0,0 +1,23 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import org.json.JSONObject
/** Debug assets and native screenshots consume the same projection of the frozen prototype. */
fun readComingInFixture(json: String, poster: (String) -> Any?): List<ComingInRequest> {
val items = JSONObject(json).getJSONArray("items")
return List(items.length()) { index ->
val item = items.getJSONObject(index)
val scores = item.getJSONArray("ratings")
ComingInRequest(
id = item.getString("id"), mediaType = item.getString("mediaType"), tmdbId = item.getLong("tmdbId"),
title = item.getString("title"), requesterKey = item.getString("requesterKey"), requesterName = item.getString("requesterName"),
actorKey = item.getString("actorKey"), actorName = item.getString("actorName"), stage = ComingInStage.valueOf(item.getString("stage")),
poster = poster(item.getString("poster")),
ratings = List(scores.length()) { scoreIndex ->
val score = scores.getJSONObject(scoreIndex)
ComingInRating(score.getString("mark"), score.getDouble("score"), score.getBoolean("percentage"))
},
)
}
}

View file

@ -0,0 +1,16 @@
// 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.layout
import androidx.compose.ui.unit.Dp
/** A CSS line box has its own height and baseline, independent of native leading rounding. */
internal fun Modifier.comingInLineBox(height: Dp, baseline: Dp): Modifier = layout { measurable, constraints ->
val text = measurable.measure(constraints.copy(minHeight = 0))
val baselineOffset = baseline.roundToPx() - text[FirstBaseline]
layout(text.width, height.roundToPx()) {
text.placeRelative(0, baselineOffset)
}
}

View file

@ -0,0 +1,51 @@
// 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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.tv.material3.Text
import java.util.Locale
import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
/** Percentages remain whole; ten-point scores use the television's locale. */
fun formatComingInRating(rating: ComingInRating, locale: Locale = Locale.getDefault()): String =
if (rating.percentage) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score)
/** This row always displays the full supplier set rather than the card plate's shortened prefix. */
@Composable
fun ComingInRatings(ratings: List<ComingInRating>, color: Color) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsGap)) {
ratings.forEach { rating ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsEntryGap)) {
val mark = when (rating.mark) {
"fresh" -> R.drawable.coming_in_rating_fresh
"rotten" -> R.drawable.coming_in_rating_rotten
"upright" -> R.drawable.coming_in_rating_upright
"spilled" -> R.drawable.coming_in_rating_spilled
"imdb" -> R.drawable.coming_in_rating_imdb
else -> R.drawable.coming_in_rating_tmdb
}
val wide = rating.mark == "imdb" || rating.mark == "tmdb"
Image(painterResource(mark), contentDescription = rating.mark,
modifier = Modifier.height(T.readinessGlyph).width(T.readinessGlyph * if (wide) T.ratingsWordmarkRatio else 1f)
.clip(ratingMarkShape).background(WolfskinTokens.Color.surface3),
contentScale = ContentScale.Fit)
Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color)
}
}
}
}
private val ratingMarkShape = RoundedCornerShape(WolfskinTokens.Radius.thumb)

View file

@ -0,0 +1,172 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import androidx.compose.runtime.mutableStateOf
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
/** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */
@Composable
fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
val state = shell.requests
val data = state.snapshot
LaunchedEffect(state.focusedId, data.signedIn, data.status) {
state.awaitFocusedRatings()
}
Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim"))
Column(modifier.fillMaxHeight().width(L.sheetWidth)
.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
.drawWithCache {
val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
color = C.surface.toArgb()
setShadowLayer(T.shadowBlur.toPx(), T.shadowOffset.toPx(), 0f, T.shadowColor.toArgb())
}
onDrawBehind {
drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) }
}
}.pointerInput(Unit) {
awaitPointerEventScope {
while (true) awaitPointerEvent(PointerEventPass.Final).changes.forEach { it.consume() }
}
}
.padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY)
.testTag("coming-in"), verticalArrangement = Arrangement.spacedBy(T.sheetGap)) {
Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg)
when {
data.status == ComingInStatus.Loading && data.items.isEmpty() -> {
LazyColumn(verticalArrangement = Arrangement.spacedBy(T.listGap)) {
items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() }
}
SheetButton("Close") { shell.closeRequests() }
}
data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> {
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(horizontal = T.receiptPaddingX, vertical = T.receiptPaddingY),
verticalArrangement = Arrangement.spacedBy(T.receiptGap)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowPaddingY)) {
Glyph("alert", C.warn, Modifier.size(T.receiptGlyph))
Text("Requests unavailable", style = ComingInTypography.stateTitle, color = C.warn)
}
Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted)
}
SheetButton("Try again", primary = true) { shell.retryRequests() }
}
data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted)
else -> {
if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn)
val start = state.windowStart
val visibleRows = remember(data, start, state.windowSize) {
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
}
LazyColumn(Modifier.clipToBounds(), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
itemsIndexed(visibleRows,
key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item ->
ComingInRow(item, shell.person, item.id == state.focusedId, state.expanded && item.id == state.focusedId,
state.ratingsVisible && data.signedIn && item.id == state.focusedId,
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
}
}
}
}
}
}
@Composable
private fun SheetButton(label: String, primary: Boolean = false, onClick: () -> Unit) {
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, true, primary, onClick) }
}
@Composable
private fun ComingInSkeleton() {
Row(Modifier.fillMaxWidth().padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
Box(Modifier.size(L.thumbWidth, L.thumbHeight).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.thumb)))
Column(verticalArrangement = Arrangement.spacedBy(T.skeletonGap)) {
Box(Modifier.size(T.skeletonTitleWidth, T.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius)))
Box(Modifier.size(T.skeletonSubtitleWidth, T.skeletonSubtitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
}
}
}
/** Thumbnail slots do not resize when the text or the readiness block grows. */
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class)
@Composable
fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean,
showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
val foreground = if (focused) C.onFocus else C.fg
val secondary = if (focused) C.onFocus else C.muted
val context = LocalContext.current
val request = remember(item.poster, context) {
item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) }
}
val posterReady = remember(request) { mutableStateOf(false) }
Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel))
.pointerInput(item.id) {
awaitPointerEventScope {
while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus()
}
}.clickable(onClick = onClick)
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
.background(if (posterReady.value) Color.Transparent else C.surface3),
onState = { posterReady.value = it is AsyncImagePainter.State.Success },
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
Column(Modifier.weight(1f)) {
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("for ${item.recipient(viewer)} · by ${item.actor(viewer)}", Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline),
style = ComingInTypography.caption, color = secondary)
FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
Glyph(item.stage.glyph, secondary, Modifier.size(T.stageGlyph))
Text(item.stage.label, Modifier.comingInLineBox(T.stageLabelLineHeight, T.stageBaseline), style = ComingInTypography.label, color = secondary)
}
if (showRatings) ComingInRatings(item.ratings, foreground)
}
if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin),
horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) {
item.stage.readiness.forEachIndexed { index, word ->
Row {
val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" }
if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight })
Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary)
}
}
}
}
if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron))
}
}
private val READINESS_NAMES = listOf("Request", "Download", "Library", "Playback")

View file

@ -0,0 +1,110 @@
// 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
import kotlinx.coroutines.delay
/** Household acquisition stages, ordered independently from their display wording. */
enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List<String>) {
Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")),
Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")),
AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")),
Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")),
Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")),
Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")),
Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")),
}
/** Ratings have already passed the supplier's observed-score and vote checks. */
data class ComingInRating(val mark: String, val score: Double, val percentage: Boolean = false)
/** Requests carry authenticated actor evidence, never the current viewer as a substitute. */
data class ComingInRequest(
val id: String,
val mediaType: String,
val tmdbId: Long,
val title: String,
val requesterKey: String,
val requesterName: String,
val actorKey: String?,
val actorName: String?,
val stage: ComingInStage,
val poster: Any? = null,
val ratings: List<ComingInRating> = emptyList(),
) {
val ratingKey = "$mediaType:$tmdbId"
fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName
fun actor(viewer: ShellPerson): String = when {
actorKey == "shared" -> "Shared"
actorKey != null && actorKey == viewer.key -> "you"
else -> actorName ?: "Unknown"
}
}
enum class ComingInStatus { Ready, Loading, Unavailable }
/** The provider retains saved rows when refresh fails; an unavailable empty result is not empty success. */
data class ComingInSnapshot(
val items: List<ComingInRequest> = emptyList(),
val status: ComingInStatus = ComingInStatus.Unavailable,
val partial: Boolean = false,
val signedIn: Boolean = false,
) {
val ordered = items.sortedBy { it.stage.rank }
val openCount: Int get() = items.count { it.stage != ComingInStage.Ready }
}
/** A bounded window and stable request id preserve focus across refreshes and title round trips. */
class ComingInState {
var snapshot by mutableStateOf(ComingInSnapshot())
private set
var focusedId by mutableStateOf<String?>(null)
private set
var expanded by mutableStateOf(false)
private set
var ratingsVisible by mutableStateOf(false)
private set
private val cachedRatings = mutableSetOf<String>()
val index: Int get() = snapshot.ordered.indexOfFirst { it.id == focusedId }.coerceAtLeast(0)
val focused: ComingInRequest? get() = snapshot.ordered.getOrNull(index)
val windowSize: Int get() = if (expanded) 4 else 5
val windowStart: Int get() = (index - 2).coerceIn(0, (snapshot.ordered.size - windowSize).coerceAtLeast(0))
fun update(value: ComingInSnapshot) {
snapshot = value
if (!value.signedIn) cachedRatings.clear()
if (value.ordered.none { it.id == focusedId }) focus(value.ordered.firstOrNull()?.id)
ratingsVisible = value.signedIn && focused?.ratingKey in cachedRatings
}
fun focus(id: String?) {
focusedId = id
expanded = false
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
}
/** Coroutine cancellation and the captured request id prevent stale focus reads from painting. */
suspend fun awaitFocusedRatings() {
val item = focused ?: return
if (!snapshot.signedIn || snapshot.status != ComingInStatus.Ready || item.ratings.isEmpty() || ratingsVisible) return
delay(WolfskinTokens.Motion.ratingDwell.toLong())
if (focusedId != item.id || !snapshot.signedIn || snapshot.status != ComingInStatus.Ready) return
cachedRatings.add(item.ratingKey)
ratingsVisible = true
}
/** Return true only when a row requests a title; every direction remains trapped in the sheet. */
fun key(key: ShellKey): Boolean {
when (key) {
ShellKey.Up -> focus(snapshot.ordered.getOrNull((index - 1).coerceAtLeast(0))?.id)
ShellKey.Down -> focus(snapshot.ordered.getOrNull((index + 1).coerceAtMost(snapshot.ordered.lastIndex))?.id)
ShellKey.Right -> if (focused != null) expanded = true
ShellKey.Left -> expanded = false
ShellKey.Ok -> return focused != null
else -> Unit
}
return false
}
}

View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.font.FontWeight
/** CSS line boxes retain their leading even on single lines; trimming changes sheet geometry. */
internal object ComingInTypography {
private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
val title = WolfskinTokens.Type.sheetTitle.copy(lineHeightStyle = lineBox)
val listTitle = WolfskinTokens.Type.listTitle.copy(lineHeightStyle = lineBox)
val body = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBox)
val label = WolfskinTokens.Type.label.copy(lineHeightStyle = lineBox)
val caption = WolfskinTokens.Type.caption.copy(lineHeightStyle = lineBox)
val stateTitle = WolfskinTokens.Type.stateTitle.copy(lineHeightStyle = lineBox, fontWeight = FontWeight.Bold)
val readiness = caption.copy(fontWeight = FontWeight.Bold)
val rating = WolfskinTokens.Type.label.copy(fontFeatureSettings = "tnum")
}

View file

@ -21,6 +21,9 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
"advanced" -> R.drawable.wolfskin_sort
"alert" -> R.drawable.wolfskin_alert
"wifi" -> R.drawable.wolfskin_wifi
"clock" -> R.drawable.wolfskin_clock
"check" -> R.drawable.wolfskin_check
"download" -> R.drawable.wolfskin_download
"film" -> R.drawable.wolfskin_film
"eye" -> R.drawable.wolfskin_eye
"down" -> R.drawable.wolfskin_down

View file

@ -52,7 +52,7 @@ val SETTINGS_GROUPS = listOf(
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
)
/** Requests is intentionally a no-data Wolfskin entry until the Requests slice. */
/** The household request count shares the sheet's snapshot and excludes Ready. */
@Composable
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) {
val list = state.view.listState
@ -70,14 +70,14 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests:
item(key = "settings-header") {
Row(Modifier.height(L.row_lhead).padding(start = L.safeSide, top = S.settingsHeaderTop),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader)
Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
Text("Settings", Modifier.alignByBaseline(), style = WolfskinTokens.Type.screenTitle, color = C.fg)
Text("Requests and Wholphin settings on this TV · ${state.person.name}", Modifier.alignByBaseline(), style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal), color = C.muted)
}
}
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
Box(Modifier.height(L.row_srow)) {
SettingsRow(group, !state.navFocused && index == state.settingsRow,
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
if (group.key == "requests") openRequests else null,
Modifier.offset(L.safeSide, S.settingsRowTop)) {
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)

View file

@ -16,6 +16,8 @@ sealed interface ShellAction {
data class HandOff(val group: String) : ShellAction
data object Pop : ShellAction
data object RetryPeople : ShellAction
data object RetryRequests : ShellAction
data class RequestTitle(val request: ComingInRequest) : ShellAction
}
/** D-pad inputs are shared by Android events and deterministic JVM replays. */
@ -57,6 +59,8 @@ class ShellState(
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
var recoveryIndex by mutableStateOf(0)
var requestsVisible by mutableStateOf(false)
val requests = ComingInState()
private var requestReturnRoute: String? = null
var handOff by mutableStateOf<String?>(null)
var settingsRow by mutableStateOf(0)
private var rootRoute by mutableStateOf("home")
@ -78,6 +82,10 @@ class ShellState(
route = destination
if (destination in tabs) rootRoute = destination
handOff = null
if (requestReturnRoute == destination) {
requestsVisible = true
requestReturnRoute = null
}
}
/** Pointer navigation uses the same transitions as D-pad selection. */
@ -102,7 +110,12 @@ class ShellState(
armedCard = null
}
if (requestsVisible) {
if (key == ShellKey.Back || key == ShellKey.Ok) requestsVisible = false
when {
key == ShellKey.Back -> closeRequests()
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && requests.snapshot.items.isEmpty() -> retryRequests()
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests()
requests.key(key) -> activateRequest()
}
return true
}
if (pickerVisible) pickerKey(key) else if (key == ShellKey.Back) back() else if (navFocused) navKey(key) else contentKey(key)
@ -151,6 +164,8 @@ class ShellState(
navFocused = false
navIndex = 0
rootRoute = "home"
requestReturnRoute = null
requestsVisible = false
views[person.key to route] = RememberedView()
onAction(ShellAction.Person(selected))
}
@ -189,6 +204,7 @@ class ShellState(
gearFocused = false
} else {
val target = tabs[navIndex]
requestReturnRoute = null
if (target == route) views[person.key to target] = RememberedView()
route = target
rootRoute = target
@ -197,7 +213,7 @@ class ShellState(
}
} else if (route == "settings") {
val group = SETTINGS_GROUPS[settingsRow].key
if (group == "requests") requestsVisible = true else {
if (group == "requests") openRequests() else {
handOff = group
onAction(ShellAction.HandOff(group))
}
@ -205,8 +221,34 @@ class ShellState(
else contentFocus?.key(ShellKey.Ok)
}
/** Requested buttons may name an existing request without changing its authenticated actor. */
fun openRequests(requestId: String? = null) {
requests.focus(requestId ?: requests.snapshot.ordered.firstOrNull()?.id)
requestsVisible = true
navFocused = false
}
fun closeRequests() { requestsVisible = false }
fun retryRequests() { onAction(ShellAction.RetryRequests) }
fun activateRequest() {
val request = requests.focused ?: return
requestReturnRoute = route
requestsVisible = false
onAction(ShellAction.RequestTitle(request))
}
/** Back never discards a root view, including the card below a focused nav. */
fun back() {
if (requestsVisible) { closeRequests(); return }
val returnRoute = requestReturnRoute
if (returnRoute != null && route != returnRoute) {
route = returnRoute
requestsVisible = true
requestReturnRoute = null
onAction(ShellAction.Pop)
return
}
if (!pickerVisible && !navFocused && contentFocus?.key(ShellKey.Back) == true) return
if (route == "settings") {
route = pickerReturnRoute

View file

@ -82,20 +82,9 @@ fun ShellSurface(
if (state.pickerVisible) {
WhosWatching(state)
} else {
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
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) {
Box(Modifier.fillMaxSize().background(C.sheetScrim)) {
Column(Modifier.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
.width(L.sheetWidth).fillMaxSize().background(C.surface).padding(L.safeSide),
verticalArrangement = Arrangement.spacedBy(L.buttonGap)) {
Text("Requests", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text("Wolfskin screen", color = C.accentText, style = WolfskinTokens.Type.label)
Text("No request data yet. Conductarr sign-in is not available on this TV.", color = C.muted, style = WolfskinTokens.Type.body)
PickerButton("Close", true, true) { state.requestsVisible = false }
}
}
}
if (state.requestsVisible) ComingInSheet(state)
}
}
}

View file

@ -3,6 +3,7 @@ package org.justinvisser.wolfskin.design
import android.content.Context
import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.intercept.Interceptor
import coil3.disk.directory
import coil3.imageLoader
import coil3.request.crossfade
@ -10,6 +11,8 @@ import coil3.memory.MemoryCache
import coil3.request.ImageRequest
import coil3.request.allowRgb565
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
/** Wolfskin screens share one bounded loader; hidden-switch screens retain the upstream loader. */
object WolfskinImages {
@ -17,6 +20,7 @@ object WolfskinImages {
private var loader: ImageLoader? = null
private const val MEBIBYTE = 1024L * 1024L
private const val IMAGE_READS = 4
private val readPermits = Semaphore(IMAGE_READS)
@Synchronized
fun loader(context: Context): ImageLoader {
@ -28,12 +32,13 @@ object WolfskinImages {
loader = owner.imageLoader.newBuilder()
.memoryCache { MemoryCache.Builder().maxSizeBytes((WolfskinTokens.Image.memoryCacheMB * MEBIBYTE).toLong()).build() }
.diskCache { DiskCache.Builder().directory(owner.cacheDir.resolve("wolfskin-images")).maxSizeBytes((WolfskinTokens.Image.diskCacheMB * MEBIBYTE).toLong()).build() }
.components { add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } }) }
.fetcherCoroutineContext(reads).decoderCoroutineContext(reads).crossfade(false).build()
}
return loader!!
}
fun request(context: Context, source: String, width: Int, height: Int, poster: Boolean): ImageRequest =
fun request(context: Context, source: Any, width: Int, height: Int, poster: Boolean): ImageRequest =
ImageRequest.Builder(context).data(source).size(width, height).allowRgb565(poster).build()
fun prefetch(context: Context, row: HomeRow) {

View file

@ -229,6 +229,50 @@ object WolfskinTokens {
const val requestsPollSeconds = 30.0f
const val comingInVisibleRows = 5.0f
}
object Requests {
val sheetPaddingY = 40.0.dp
val sheetPaddingX = 32.0.dp
val sheetGap = 9.0.dp
val sheetTitleLineHeight = 28.0.dp
val sheetTitleBaseline = 22.0.dp
val titleLineHeight = 19.5.dp
val titleBaseline = 15.5.dp
val factsLineHeight = 15.6.dp
val factsBaseline = 12.0.dp
val stageLineHeight = 19.8.dp
val stageLabelLineHeight = 16.9.dp
val stageBaseline = 12.0.dp
val emptyMargin = 10.0.dp
val rowPaddingY = 7.0.dp
val rowPaddingX = 9.0.dp
val rowGap = 12.0.dp
val listGap = 3.0.dp
val stageMargin = 2.0.dp
val stageGlyph = 14.0.dp
val stageGap = 5.0.dp
val readinessGapX = 11.0.dp
val readinessGapY = 5.0.dp
val readinessMargin = 4.0.dp
val readinessGlyph = 13.0.dp
val chevron = 15.0.dp
const val chevronAlpha = 0.6f
val receiptPaddingY = 14.0.dp
val receiptPaddingX = 15.0.dp
val receiptGap = 5.0.dp
val receiptGlyph = 18.0.dp
val buttonMargin = 6.0.dp
val skeletonTitleWidth = 180.0.dp
val skeletonTitleHeight = 17.0.dp
val skeletonSubtitleWidth = 120.0.dp
val skeletonSubtitleHeight = 13.0.dp
val skeletonGap = 6.0.dp
val ratingsGap = 10.0.dp
val ratingsEntryGap = 4.0.dp
val shadowBlur = 40.0.dp
val shadowOffset = -15.0.dp
val shadowColor = ComposeColor(0x80000000)
const val ratingsWordmarkRatio = 1.8f
}
object Shell {
const val navLabelFade = 200
val zero = 0.0.dp

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,023 B

View file

@ -0,0 +1,3 @@
<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"/>
</vector>

View file

@ -0,0 +1,3 @@
<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"/>
</vector>

View file

@ -0,0 +1,3 @@
<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,4v11m-5,-5l5,5l5,-5M5,20h14" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round"/>
</vector>

View file

@ -0,0 +1,84 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import java.util.Locale
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Test
/** The fake coroutine clock proves the production dwell/cache decision without a Compose scheduler. */
@OptIn(ExperimentalCoroutinesApi::class)
class ComingInRatingsGateTest {
private val first = ComingInRequest("first", "movie", 1, "First", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting,
ratings = listOf(ComingInRating("imdb", 6.0)))
private val second = first.copy(id = "second", tmdbId = 2, title = "Second", ratings = listOf(ComingInRating("tmdb", 7.2)))
@Test fun uncachedDwellCancelsOnLeaveAndReentryStartsAFreshRead() = runTest {
val state = ComingInState()
state.update(ComingInSnapshot(listOf(first, second), ComingInStatus.Ready, signedIn = true))
val abandoned = launch { state.awaitFocusedRatings() }
runCurrent()
advanceTimeBy(100)
assertFalse(state.ratingsVisible)
abandoned.cancel()
state.focus(second.id)
val next = launch { state.awaitFocusedRatings() }
runCurrent()
advanceTimeBy(349)
runCurrent()
assertFalse(state.ratingsVisible)
advanceTimeBy(1)
runCurrent()
assertTrue(state.ratingsVisible)
next.join()
state.focus(first.id)
assertFalse(state.ratingsVisible)
launch { state.awaitFocusedRatings() }
runCurrent()
advanceTimeBy(350)
runCurrent()
assertTrue("Returning to the abandoned uncached title must complete a new dwell", state.ratingsVisible)
state.focus(second.id)
assertTrue("A previously read title must be visible on the focus transition itself", state.ratingsVisible)
}
@Test fun signOutClearsCachedVisibilityAndSignInUsesFreshRatingAndLocale() = runTest {
val state = ComingInState()
val snapshot = ComingInSnapshot(listOf(first), ComingInStatus.Ready, signedIn = true)
state.update(snapshot)
launch { state.awaitFocusedRatings() }
runCurrent(); advanceTimeBy(350); runCurrent()
assertTrue(state.ratingsVisible)
state.update(snapshot.copy(signedIn = false))
assertFalse(state.ratingsVisible)
val refreshed = first.copy(ratings = listOf(ComingInRating("imdb", 7.6)))
state.update(snapshot.copy(items = listOf(refreshed)))
assertFalse("Sign-out invalidates the previous session's rating gate", state.ratingsVisible)
launch { state.awaitFocusedRatings() }
runCurrent(); advanceTimeBy(350); runCurrent()
assertTrue(state.ratingsVisible)
val rating = state.focused!!.ratings.single()
assertEquals("7,6", formatComingInRating(rating, Locale.forLanguageTag("nl-NL")))
assertEquals("7.6", formatComingInRating(rating, Locale.ENGLISH))
assertEquals("87%", formatComingInRating(ComingInRating("fresh", 87.9, true), Locale.forLanguageTag("nl-NL")))
}
@Test fun savedOfflineRowsOnlyShowPreviouslyReadRatingsAcrossHouseholdRequests() = runTest {
val state = ComingInState()
val sharedCopy = first.copy(id = "other-request", requesterKey = "shared", requesterName = "Shared")
val online = ComingInSnapshot(listOf(first, second, sharedCopy), ComingInStatus.Ready, signedIn = true)
state.update(online)
launch { state.awaitFocusedRatings() }
runCurrent(); advanceTimeBy(350); runCurrent()
state.update(online.copy(status = ComingInStatus.Unavailable))
state.focus(second.id)
state.awaitFocusedRatings()
assertFalse("An unread title cannot acquire ratings while offline", state.ratingsVisible)
state.focus(sharedCopy.id)
assertTrue("The title cache is shared across request rows for the same media type and TMDB id", state.ratingsVisible)
}
}

View file

@ -0,0 +1,70 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Every approved Requests state uses the actual shell overlay at native television density. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class RequestsCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@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, snapshot: ComingInSnapshot, person: String = "justin", expanded: Boolean = false) {
val state = ShellState(fixturePeople(), person)
state.pickerVisible = false
state.enterDestination("settings")
state.requests.update(snapshot)
state.openRequests()
if (expanded) { state.requests.key(ShellKey.Down); state.requests.key(ShellKey.Right) }
rule.setContent {
WolfskinTheme { ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) }
}
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory"))
directory.mkdirs()
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
}
@Test fun st081_empty() = capture("st-081", ComingInSnapshot(status = ComingInStatus.Ready, signedIn = true))
@Test fun st082_stages() = capture("st-082", requestsFixture())
@Test fun st083_unavailable() = capture("st-083", ComingInSnapshot())
@Test fun st084_loading() = capture("st-084", ComingInSnapshot(status = ComingInStatus.Loading))
@Test fun st085_partial() = capture("st-085", requestsFixture().copy(items = requestsFixture().items.take(3), partial = true))
@Test fun st086_readiness() = capture("st-086", requestsFixture(), expanded = true)
@Test fun st088_sharedActor() = capture("st-088", requestsFixture(shared = true), person = "shared")
@Test fun st111_focusedRatings() = capture("st-111", requestsFixture(ratings = true))
}
/** Shared actor and stage fixtures are projections, never network calls or synthetic UI redraws. */
fun requestsFixture(shared: Boolean = false, ratings: Boolean = false): ComingInSnapshot {
val source = RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests.json")!!.bufferedReader().use { it.readText() }
val items = readComingInFixture(source) { name ->
RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests/$name")!!.use { it.readBytes() }
}
val selected = when { shared -> items.filter { it.id == "x7" }; ratings -> items.filter { it.id == "x8" }; else -> items.take(6) }
return ComingInSnapshot(selected, ComingInStatus.Ready, signedIn = true)
}

View file

@ -0,0 +1,150 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import java.util.Locale
import org.json.JSONArray
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
/** Key replays exercise real shell handling, including trapped overlays and title-return memory. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class RequestsKeysTest {
@get:Rule val rule = createComposeRule()
private fun mount(state: ShellState) {
rule.setContent { WolfskinTheme { ShellSurface(state) } }
rule.mainClock.advanceTimeByFrame()
}
private fun send(first: Key, second: Key? = null, third: Key? = null, fourth: Key? = null) {
listOfNotNull(first, second, third, fourth).forEach { key ->
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
rule.mainClock.advanceTimeByFrame()
}
}
private fun sheet(snapshot: ComingInSnapshot = requestsFixture(), actions: (ShellAction) -> Unit = {}): ShellState =
ShellState(fixturePeople(), "justin", actions).apply {
pickerVisible = false
enterDestination("settings")
requests.update(snapshot)
openRequests()
}
@Test fun approvedNinePressPathUsesRealShellFocus() {
val paths = RequestsKeysTest::class.java.getResourceAsStream("/requests-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) }
val keys = mapOf("OK" to Key.DirectionCenter, "UP" to Key.DirectionUp, "LEFT" to Key.DirectionLeft,
"RIGHT" to Key.DirectionRight, "DOWN" to Key.DirectionDown)
val state = ShellState(fixturePeople(), "justin")
state.requests.update(requestsFixture())
mount(state)
val path = paths.getJSONObject(0)
val presses = path.getJSONArray("keys")
assertEquals(9, path.getInt("presses"))
assertEquals(path.getInt("presses"), presses.length())
for (index in 0 until presses.length()) send(keys.getValue(presses.getString(index)))
rule.onNodeWithTag("coming-in").assertExists()
rule.onNodeWithText("Requests · 5 open").assertExists()
assertEquals("settings", state.route)
assertEquals("x5", state.requests.focusedId)
assertFalse(state.navFocused)
}
@Test fun readinessWindowAndTitleRoundTripPreserveRequest() {
lateinit var state: ShellState
state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("request-title/${action.request.tmdbId}") }
mount(state)
send(Key.DirectionDown, Key.DirectionRight)
rule.onNodeWithText("Download · Downloading").assertExists()
assertEquals(4, state.requests.windowSize)
assertEquals("x1", state.requests.focusedId)
send(Key.DirectionLeft)
rule.onNodeWithText("Download · Downloading").assertDoesNotExist()
send(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionDown)
assertEquals("x6", state.requests.focusedId)
assertEquals(1, state.requests.windowStart)
send(Key.DirectionDown)
assertEquals("x6", state.requests.focusedId)
send(Key.DirectionCenter)
assertFalse(state.requestsVisible)
assertTrue(state.route.startsWith("request-title/"))
send(Key.Back)
rule.onNodeWithTag("coming-row-x6").assertExists()
assertEquals("x6", state.requests.focusedId)
assertTrue(state.requestsVisible)
send(Key.Back)
assertFalse(state.requestsVisible)
assertEquals("settings", state.route)
assertEquals(0, state.settingsRow)
}
@Test fun emptyAndLoadingTrapDirectionsWhileUnavailableRetries() {
var retries = 0
val state = sheet(ComingInSnapshot(status = ComingInStatus.Loading)) { if (it == ShellAction.RetryRequests) retries++ }
mount(state)
send(Key.DirectionUp, Key.DirectionLeft)
assertFalse(state.navFocused)
assertTrue(state.requestsVisible)
send(Key.DirectionCenter)
assertFalse(state.requestsVisible)
rule.runOnIdle { state.requests.update(ComingInSnapshot()); state.openRequests() }
send(Key.DirectionCenter)
assertEquals(1, retries)
assertTrue(state.requestsVisible)
rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) }
send(Key.DirectionDown, Key.DirectionCenter)
rule.onNodeWithText("Nothing coming in.").assertExists()
assertTrue(state.requestsVisible)
}
@Test fun cachedRatingsRenderInTheTelevisionLocale() {
val original = Locale.getDefault()
try {
Locale.setDefault(Locale.forLanguageTag("nl-NL"))
val state = sheet(requestsFixture(ratings = true))
kotlinx.coroutines.test.runTest { state.requests.awaitFocusedRatings() }
mount(state)
rule.onNodeWithText("7,6").assertExists()
rule.onNodeWithText("87%").assertExists()
rule.runOnIdle { state.requests.update(state.requests.snapshot.copy(signedIn = false)) }
rule.onNodeWithText("7,6").assertDoesNotExist()
rule.onNodeWithText("87%").assertDoesNotExist()
} finally { Locale.setDefault(original) }
}
@Test fun selectingARootAfterARequestTitleDiscardsTheSheetReturnLink() {
lateinit var state: ShellState
state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("request-title/${action.request.tmdbId}") }
mount(state)
send(Key.DirectionCenter, Key.DirectionUp, Key.DirectionCenter)
assertEquals("home", state.route)
send(Key.Back)
assertFalse("Root Back must focus navigation, not reopen the abandoned sheet", state.requestsVisible)
assertTrue(state.navFocused)
}
@Test fun refreshKeepsStableFocusAndSharedActorNeverBecomesYou() {
val snapshot = requestsFixture()
val state = sheet(snapshot)
mount(state)
send(Key.DirectionDown)
rule.runOnIdle { state.requests.update(snapshot.copy(items = snapshot.items.reversed(), partial = true)) }
assertEquals("x1", state.requests.focusedId)
rule.onNodeWithText("Some requests may be missing: Conductarr answered only in part.").assertExists()
val shared = requestsFixture(shared = true).items.single()
val viewer = fixturePeople().single { it.key == "shared" }
assertEquals("Shared", shared.actor(viewer))
assertEquals("Justin", shared.recipient(viewer))
assertEquals(5, snapshot.openCount)
assertEquals(listOf(0, 1, 1, 2, 3, 4), snapshot.ordered.map { it.stage.rank })
}
}

View file

@ -0,0 +1,21 @@
[
{
"goal": "Round 4 \u00b7 Home hero \u2192 Requests (Coming in) in Settings",
"keys": [
"OK",
"UP",
"LEFT",
"OK",
"RIGHT",
"RIGHT",
"DOWN",
"OK",
"OK"
],
"presses": 9,
"note": "8 from the hero",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
}
]

View file

@ -0,0 +1,217 @@
{
"source": "Locked Opus fixture/fixture.json; index.html EXAMPLES() pool order",
"items": [
{
"id": "x1",
"mediaType": "tv",
"tmdbId": 11821,
"title": "The Abbott and Costello Show",
"requesterKey": "justin",
"requesterName": "Justin",
"actorKey": "justin",
"actorName": "Justin",
"stage": "Downloading",
"poster": "tv-the-abbott-and-costello-show-1952-poster.jpg",
"ratings": [
{
"mark": "tmdb",
"score": 7.2,
"percentage": false
}
]
},
{
"id": "x2",
"mediaType": "movie",
"tmdbId": 13483,
"title": "Awake",
"requesterKey": "mindel",
"requesterName": "Mindel",
"actorKey": "justin",
"actorName": "Justin",
"stage": "AlmostReady",
"poster": "movie-awake-2007-poster.jpg",
"ratings": [
{
"mark": "rotten",
"score": 23,
"percentage": true
},
{
"mark": "spilled",
"score": 55,
"percentage": true
},
{
"mark": "imdb",
"score": 6.5,
"percentage": false
}
]
},
{
"id": "x3",
"mediaType": "tv",
"tmdbId": 70785,
"title": "Anne with an E",
"requesterKey": "shared",
"requesterName": "Shared",
"actorKey": "mindel",
"actorName": "Mindel",
"stage": "Searching",
"poster": "tv-anne-with-an-e-2017-poster.jpg",
"ratings": [
{
"mark": "fresh",
"score": 63,
"percentage": true
}
]
},
{
"id": "x4",
"mediaType": "movie",
"tmdbId": 99861,
"title": "Avengers: Age of Ultron",
"requesterKey": "justin",
"requesterName": "Justin",
"actorKey": "justin",
"actorName": "Justin",
"stage": "Waiting",
"poster": "movie-avengers-age-of-ultron-2015-poster.jpg",
"ratings": [
{
"mark": "fresh",
"score": 75,
"percentage": true
},
{
"mark": "upright",
"score": 82,
"percentage": true
},
{
"mark": "imdb",
"score": 7.3,
"percentage": false
}
]
},
{
"id": "x5",
"mediaType": "movie",
"tmdbId": 1033148,
"title": "Die My Love",
"requesterKey": "mindel",
"requesterName": "Mindel",
"actorKey": "mindel",
"actorName": "Mindel",
"stage": "Failed",
"poster": "movie-die-my-love-2025-poster.jpg",
"ratings": [
{
"mark": "fresh",
"score": 74,
"percentage": true
},
{
"mark": "spilled",
"score": 45,
"percentage": true
},
{
"mark": "imdb",
"score": 6,
"percentage": false
}
]
},
{
"id": "x6",
"mediaType": "movie",
"tmdbId": 416153,
"title": "Arctic Dogs",
"requesterKey": "shared",
"requesterName": "Shared",
"actorKey": "justin",
"actorName": "Justin",
"stage": "Ready",
"poster": "movie-arctic-dogs-2019-poster.jpg",
"ratings": [
{
"mark": "rotten",
"score": 13,
"percentage": true
},
{
"mark": "upright",
"score": 60,
"percentage": true
},
{
"mark": "imdb",
"score": 4.9,
"percentage": false
}
]
},
{
"id": "x7",
"mediaType": "movie",
"tmdbId": 76203,
"title": "12 Years a Slave",
"requesterKey": "justin",
"requesterName": "Justin",
"actorKey": "shared",
"actorName": "Shared",
"stage": "Waiting",
"poster": "movie-12-years-a-slave-2013-poster.jpg",
"ratings": [
{
"mark": "fresh",
"score": 95,
"percentage": true
},
{
"mark": "upright",
"score": 90,
"percentage": true
},
{
"mark": "imdb",
"score": 8.1,
"percentage": false
}
]
},
{
"id": "x8",
"mediaType": "movie",
"tmdbId": 14069,
"title": "The Girl Who Leapt Through Time",
"requesterKey": "mindel",
"requesterName": "Mindel",
"actorKey": "justin",
"actorName": "Justin",
"stage": "Waiting",
"poster": "movie-the-girl-who-leapt-through-time-2006-poster.jpg",
"ratings": [
{
"mark": "fresh",
"score": 87,
"percentage": true
},
{
"mark": "upright",
"score": 90,
"percentage": true
},
{
"mark": "imdb",
"score": 7.6,
"percentage": false
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB