Integrate reviewed Requests and Discover surfaces
Some checks failed
review/design-claude Changes requested: 13 blocking design/behaviour findings (see PR comment)
|
|
@ -1,12 +1,12 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
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.style.TextOverflow
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
// 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) { LibraryImages.request(context, item.poster, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt()) }
|
||||
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 = LibraryImages.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")
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
// 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 coil3.request.ImageRequest
|
||||
import coil3.request.allowRgb565
|
||||
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) {
|
||||
ImageRequest.Builder(context).data(path).size(if (poster) 256 else 1280, if (poster) 384 else 720).allowRgb565(poster).build()
|
||||
}
|
||||
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
|
||||
else AsyncImage(
|
||||
model = request,
|
||||
imageLoader = LibraryImages.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)))
|
||||
}
|
||||
|
|
@ -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"
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,10 @@ import androidx.compose.animation.core.animateFloatAsState
|
|||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
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.geometry.CornerRadius
|
||||
|
|
@ -25,16 +29,31 @@ 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 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
|
||||
}
|
||||
}
|
||||
return shadow.drawBehind {
|
||||
if (ringAlpha > 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(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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,13 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
|||
"sparkle", "seerr" -> R.drawable.wolfskin_sparkle
|
||||
"advanced" -> R.drawable.wolfskin_sort
|
||||
"alert" -> R.drawable.wolfskin_alert
|
||||
"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
|
||||
"down" -> R.drawable.wolfskin_down
|
||||
|
|
|
|||
|
|
@ -72,6 +72,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)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ object LibraryImages {
|
|||
.components { add(Interceptor { chain -> reads.withPermit { chain.proceed() } }) }
|
||||
.crossfade(false).build().also { loader = it }
|
||||
|
||||
fun request(context: Context, path: String, width: Int = WolfskinTokens.Image.posterRequestWidth,
|
||||
fun request(context: Context, data: Any?, width: Int = WolfskinTokens.Image.posterRequestWidth,
|
||||
height: Int = WolfskinTokens.Image.posterRequestHeight, poster: Boolean = true): ImageRequest =
|
||||
ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build()
|
||||
ImageRequest.Builder(context).data(data).size(width, height).allowRgb565(poster).crossfade(false).build()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ fun LibraryPoster(
|
|||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Box(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)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
|
||||
.testTag("poster-${title.id}")) {
|
||||
Box(Modifier.fillMaxSize().clip(shape).background(C.surface2)) {
|
||||
val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } }
|
||||
|
|
@ -53,12 +53,15 @@ fun LibraryPoster(
|
|||
Box(Modifier.fillMaxHeight().fillMaxWidth(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)
|
||||
.then(if (title.badge != "held") Modifier.border(L.badgeBorder, if (title.badge == "failed") C.fail else C.accent, CircleShape) else Modifier),
|
||||
contentAlignment = Alignment.Center) {
|
||||
Glyph(if (title.badge == "held") "check" else if (title.badge == "failed") "alert" else "clock",
|
||||
if (title.badge == "held") C.onFocus else if (title.badge == "failed") C.fail else C.accent, Modifier.size(L.badgeIcon))
|
||||
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)
|
||||
.then(if (held) Modifier else Modifier.border(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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -40,6 +39,7 @@ fun LibraryScreen(
|
|||
cachedRatings: Set<String> = emptySet(),
|
||||
ratingsReachable: Boolean = true,
|
||||
offlineArt: ((String) -> ImageBitmap?)? = null,
|
||||
registerFocus: Boolean = true,
|
||||
) {
|
||||
val view = shell.view
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -47,10 +47,11 @@ fun LibraryScreen(
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ fun LibrarySeeAllCard(count: Int, focused: Boolean, onClick: () -> Unit, modifie
|
|||
width: Dp = WolfskinTokens.Card.posterW, height: Dp = WolfskinTokens.Card.posterH) {
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Column(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card)
|
||||
.background(WolfskinTokens.Color.surface, shape).border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape)
|
||||
.clickable(onClick = onClick), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
.clickable(onClick = onClick), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge))
|
||||
Text("See all", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.listTitle)
|
||||
Text(count.toString(), color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.label)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
@ -55,8 +57,11 @@ 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)
|
||||
var contentOverlayVisible by mutableStateOf(false)
|
||||
private var rootRoute by mutableStateOf("home")
|
||||
private var pickerReturnRoute = "home"
|
||||
private val views = mutableStateMapOf<Pair<String, String>, RememberedView>()
|
||||
|
|
@ -66,11 +71,19 @@ class ShellState(
|
|||
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
|
||||
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
|
||||
|
||||
/** Screen options share the same per-person memory as their root's focus and scroller. */
|
||||
fun rememberedView(destination: String): RememberedView =
|
||||
views.getOrPut(person.key to destination) { RememberedView() }
|
||||
|
||||
/** External intents and upstream pops remain authoritative over the screen. */
|
||||
fun enterDestination(destination: String) {
|
||||
route = destination
|
||||
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. */
|
||||
|
|
@ -78,7 +91,12 @@ class ShellState(
|
|||
fun focusPerson(index: Int) { 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 (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) {
|
||||
|
|
@ -94,7 +112,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)
|
||||
|
|
@ -143,6 +166,8 @@ class ShellState(
|
|||
navFocused = false
|
||||
navIndex = 0
|
||||
rootRoute = "home"
|
||||
requestReturnRoute = null
|
||||
requestsVisible = false
|
||||
views[person.key to route] = RememberedView()
|
||||
onAction(ShellAction.Person(selected))
|
||||
}
|
||||
|
|
@ -181,6 +206,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
|
||||
|
|
@ -189,7 +215,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))
|
||||
}
|
||||
|
|
@ -197,8 +223,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
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -59,6 +57,7 @@ fun ShellSurface(
|
|||
handlesContentKeys: Boolean = true,
|
||||
settingsOpenRequests: Int? = null,
|
||||
savedAt: String? = null,
|
||||
overlay: @Composable () -> Unit = {},
|
||||
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
||||
) {
|
||||
val focus = remember { FocusRequester() }
|
||||
|
|
@ -86,20 +85,10 @@ 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, savedAt = savedAt)
|
||||
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)
|
||||
overlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ private fun RecoveryPicker(state: ShellState) {
|
|||
fun PickerButton(label: String, focused: Boolean, primary: Boolean, 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)
|
||||
.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)
|
||||
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
|
||||
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
|
||||
Text(label, color = if (primary) C.onFocus else C.fg, style = WolfskinTokens.Type.button)
|
||||
|
|
|
|||
|
|
@ -246,6 +246,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
|
||||
|
|
@ -312,7 +356,6 @@ object WolfskinTokens {
|
|||
const val captionSeparatorOpacity = 0.4f
|
||||
val captionFactsGap = 9.0.dp
|
||||
val badgeIcon = 14.0.dp
|
||||
val badgeBorder = 1.5.dp
|
||||
val plateTop = 7.0.dp
|
||||
val plateSide = 8.0.dp
|
||||
val plateBottom = 23.0.dp
|
||||
|
|
@ -341,6 +384,10 @@ object WolfskinTokens {
|
|||
val tailIcon = 22.0.dp
|
||||
val tailPadding = 10.0.dp
|
||||
val tailGap = 5.0.dp
|
||||
val badgeRing = 1.5.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
|
||||
|
|
@ -352,6 +399,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 Title {
|
||||
val lineGap = 11.0.dp
|
||||
val lineLift = -2.0.dp
|
||||
|
|
@ -475,4 +536,65 @@ object WolfskinTokens {
|
|||
val border = 1.0.dp
|
||||
const val borderOpacity = 0.5f
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 804 B |
|
After Width: | Height: | Size: 813 B |
|
After Width: | Height: | Size: 964 B |
|
After Width: | Height: | Size: 1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1,023 B |
4
wolfskin-design/src/main/res/drawable/wolfskin_check.xml
Normal 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="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>
|
||||
5
wolfskin-design/src/main/res/drawable/wolfskin_clock.xml
Normal 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="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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_hide.xml
Normal 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>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_left.xml
Normal 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>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_plus.xml
Normal 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>
|
||||
5
wolfskin-design/src/main/res/drawable/wolfskin_undo.xml
Normal 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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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("title/${action.request.mediaType}:${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("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("title/${action.request.mediaType}:${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 })
|
||||
}
|
||||
}
|
||||
92
wolfskin-design/src/test/resources/discover-keys.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
21
wolfskin-design/src/test/resources/requests-press-paths.json
Normal 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"
|
||||
}
|
||||
]
|
||||
217
wolfskin-design/src/test/resources/wolfskin-requests.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |