Merge shared library surfaces and connect title navigation
|
|
@ -1,33 +1,48 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
|
||||
/** Captions fade without moving the reserved band under the focused poster. */
|
||||
@Composable
|
||||
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) {
|
||||
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "caption-fade")
|
||||
val parts = remember(facts) { facts.split(" · ") }
|
||||
Column(modifier.graphicsLayer { this.alpha = alpha }) {
|
||||
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false) {
|
||||
val alpha = remember(title, facts) { Animatable(0f) }
|
||||
LaunchedEffect(title, facts, visible) {
|
||||
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
||||
}
|
||||
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
|
||||
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Row(Modifier.fillMaxWidth().clipToBounds(), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Caption.factsGap)) {
|
||||
parts.forEachIndexed { index, part ->
|
||||
if (index > 0) Text("·", color = WolfskinTokens.Color.muted.copy(alpha = WolfskinTokens.Caption.separatorOpacity), style = WolfskinTokens.Type.caption)
|
||||
Text(part, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
val styledFacts = remember(facts, accentLastFact) {
|
||||
buildAnnotatedString {
|
||||
val separator = " · "
|
||||
append(facts)
|
||||
if (accentLastFact) {
|
||||
val suffix = facts.lastIndexOf(separator).takeIf { it >= 0 }?.plus(separator.length) ?: facts.length
|
||||
if (suffix < facts.length) addStyle(SpanStyle(color = WolfskinTokens.Color.accentText), suffix, facts.length)
|
||||
}
|
||||
var start = facts.indexOf(separator)
|
||||
if (start >= 0) {
|
||||
val muted = WolfskinTokens.Color.muted
|
||||
val separatorStyle = SpanStyle(color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity))
|
||||
val dotOffset = separator.indexOf('·')
|
||||
while (start >= 0) {
|
||||
addStyle(separatorStyle, start + dotOffset, start + dotOffset + 1)
|
||||
start = facts.indexOf(separator, start + separator.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(styledFacts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
|||
"seerr" -> R.drawable.wolfskin_sparkle
|
||||
"advanced" -> R.drawable.wolfskin_sort
|
||||
"alert" -> R.drawable.wolfskin_alert
|
||||
"film" -> R.drawable.wolfskin_film
|
||||
"eye" -> R.drawable.wolfskin_eye
|
||||
"down" -> R.drawable.wolfskin_down
|
||||
else -> R.drawable.wolfskin_right
|
||||
}
|
||||
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ fun Wordmark(modifier: Modifier = Modifier) {
|
|||
|
||||
/** Initials are drawn over opaque person colour at the exact slot size. */
|
||||
@Composable
|
||||
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier) {
|
||||
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus) {
|
||||
Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) {
|
||||
Text(person.initial, color = WolfskinTokens.Color.onFocus, style = textStyle)
|
||||
Text(person.initial, color = letterColor, style = textStyle)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** The locked fixture is supplied by debug assets or JVM resources, never a release fallback. */
|
||||
class LibraryFixture(json: String, private val posterPrefix: String) {
|
||||
private val document = JSONObject(json)
|
||||
private val profiles = document.getJSONArray("profiles")
|
||||
private val people = (0 until profiles.length()).associate { profiles.getJSONObject(it).let { person -> person.getString("key") to person } }
|
||||
private val holders = buildMap<String, MutableList<String>> {
|
||||
people.forEach { (key, person) ->
|
||||
for (destination in listOf("movies", "shows", "anime")) {
|
||||
val library = person.optJSONArray(destination) ?: continue
|
||||
for (index in 0 until library.length()) {
|
||||
val item = library.getJSONObject(index)
|
||||
getOrPut("${item.optString("type")}:${item.optString("tmdbId")}") { mutableListOf() }.add(key.replaceFirstChar { it.uppercase() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun library(person: String, destination: String): List<LibraryTitle> = readTitles(people.getValue(person).optJSONArray(destination) ?: JSONArray())
|
||||
fun picks(person: String, type: String): List<LibraryTitle> = readTitles(people.getValue(person).getJSONArray("discover")).filter { it.type == type }
|
||||
fun trending(): List<LibraryTitle> = readTitles(document.getJSONObject("seeAll").getJSONArray("trending"))
|
||||
fun duplicatePage(): List<LibraryTitle> = readTitles(document.getJSONObject("seeAll").getJSONArray("duplicate"))
|
||||
|
||||
private fun readTitles(array: JSONArray): List<LibraryTitle> = List(array.length()) { index ->
|
||||
val item = array.getJSONObject(index)
|
||||
val type = item.getString("type")
|
||||
val ratings = item.optJSONObject("ratings")
|
||||
val owners = holders["$type:${item.optString("tmdbId")}"].orEmpty()
|
||||
val mine = item.optString("availability") == "in_library"
|
||||
LibraryTitle(
|
||||
id = item.getString("id"), title = item.getString("title"), type = type,
|
||||
year = item.optInt("year").takeIf { it > 0 }, runtimeSeconds = item.optLong("runtimeSeconds").takeIf { it > 0 },
|
||||
seasons = item.optInt("seasonCount").takeIf { it > 0 },
|
||||
poster = item.optString("poster").takeIf { it.isNotBlank() && it != "null" }?.let { posterPrefix + it },
|
||||
played = item.optBoolean("played"), progress = item.optDouble("progress", 0.0).toFloat(),
|
||||
addedAt = item.optString("addedAt"), ratings = buildList {
|
||||
fun append(source: String, field: String, label: String? = null) {
|
||||
val score = ratings?.optDouble(field, Double.NaN) ?: Double.NaN
|
||||
val maximum = if (source.startsWith("rt")) 100 else 10
|
||||
if (score.isFinite() && score in 0.0..maximum.toDouble()) add(LibraryRating(source, score, label))
|
||||
}
|
||||
append("rtCritics", "rtCritics", ratings?.optString("rtCriticsLabel"))
|
||||
append("rtAudience", "rtAudience", ratings?.optString("rtAudienceLabel"))
|
||||
if (type == "movie" && (ratings?.optInt("imdbVotes", 0) ?: 0) > 0) append("imdb", "imdb")
|
||||
if (isEmpty() && (ratings?.optInt("tmdbVotes", 0) ?: 0) > 0) append("tmdb", "tmdb")
|
||||
},
|
||||
availability = if (mine) "In your library" else if (owners.isNotEmpty()) "In ${owners.joinToString(" and ")}’s library" else "",
|
||||
badge = if (mine) "held" else null,
|
||||
tmdbId = item.optString("tmdbId"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import java.util.Locale
|
||||
|
||||
private val leadingArticle = Regex("^(the|a|an)\\s+", RegexOption.IGNORE_CASE)
|
||||
|
||||
/** Immutable title facts cross the provider boundary without SDK objects. */
|
||||
data class LibraryTitle(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val type: String,
|
||||
val year: Int? = null,
|
||||
val runtimeSeconds: Long? = null,
|
||||
val seasons: Int? = null,
|
||||
val poster: String? = null,
|
||||
val played: Boolean = false,
|
||||
val progress: Float = 0f,
|
||||
val addedAt: String = "",
|
||||
val ratings: List<LibraryRating> = emptyList(),
|
||||
val availability: String = "",
|
||||
val badge: String? = null,
|
||||
val tmdbId: String? = null,
|
||||
) {
|
||||
val sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT)
|
||||
val catalogueKey: String = "$type:${tmdbId ?: id}"
|
||||
val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#'
|
||||
val leadScore: Double get() = ratings.firstOrNull()?.let { if (it.source.startsWith("rt")) it.score else it.score * 10 } ?: -1.0
|
||||
|
||||
fun facts(catalogue: Boolean): String = buildList {
|
||||
year?.let { add(it.toString()) }
|
||||
add(if (type == "movie") runtimeSeconds?.let { val minutes = (it + 30) / 60; if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" } ?: "Movie" else seasons?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series")
|
||||
if (catalogue) { if (availability.isNotEmpty()) add(availability) }
|
||||
else if (played) add("Watched")
|
||||
else if (progress > 0f && runtimeSeconds != null) add("${((runtimeSeconds * (1 - progress) + 30) / 60).toInt().coerceAtLeast(1)} min left")
|
||||
}.joinToString(" · ")
|
||||
}
|
||||
|
||||
/** Ordered rating entries are formatted after the active TV locale is known. */
|
||||
data class LibraryRating(val source: String, val score: Double, val label: String? = null) {
|
||||
fun formatted(locale: Locale = Locale.getDefault()): String = if (source.startsWith("rt")) "${score.toInt()}%" else String.format(locale, "%.1f", score)
|
||||
}
|
||||
|
||||
enum class LibraryStatus { Ready, Loading, Unavailable }
|
||||
enum class GridTail { None, Retry, ShowMore }
|
||||
|
||||
/** A grid retains its exact card, toolbar choices and rail return point with the shell view. */
|
||||
class LibraryGridState(
|
||||
val title: String,
|
||||
items: List<LibraryTitle>,
|
||||
val catalogue: Boolean = false,
|
||||
val onTitle: (LibraryTitle) -> Unit = {},
|
||||
val onRecover: () -> Unit = {},
|
||||
val onDiscover: () -> Unit = {},
|
||||
val onReadAhead: (Int) -> Unit = {},
|
||||
) : ShellContentFocus {
|
||||
var source by mutableStateOf(items)
|
||||
var status by mutableStateOf(LibraryStatus.Ready)
|
||||
var savedAt by mutableStateOf<String?>(null)
|
||||
var sort by mutableStateOf(0)
|
||||
var unwatched by mutableStateOf(false)
|
||||
var mediaType by mutableStateOf("all")
|
||||
var tail by mutableStateOf(GridTail.None)
|
||||
var pagination: LibraryPagination? = null
|
||||
var pageRevision by mutableStateOf(0)
|
||||
var refreshRevision by mutableStateOf(0)
|
||||
var pointerReentry: (() -> Int?)? = null
|
||||
var index by mutableStateOf(0)
|
||||
var toolbar by mutableStateOf(false)
|
||||
var tool by mutableStateOf(0)
|
||||
var inRail by mutableStateOf(false)
|
||||
var railLetter by mutableStateOf('#')
|
||||
private var railReturn = 0
|
||||
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
|
||||
val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" }
|
||||
val hasToolbar: Boolean get() = !catalogue || mixed
|
||||
val items: List<LibraryTitle> by derivedStateOf {
|
||||
val filtered = source.filter { (!unwatched || !it.played) && (mediaType == "all" || it.type == mediaType) }
|
||||
if (catalogue) filtered else when (sort) {
|
||||
1 -> filtered.sortedWith(compareByDescending<LibraryTitle> { it.addedAt }.thenBy { it.sortName })
|
||||
2 -> filtered.sortedWith(compareByDescending<LibraryTitle> { it.leadScore }.thenBy { it.sortName })
|
||||
else -> filtered.sortedBy { it.sortName }
|
||||
}
|
||||
}
|
||||
val railVisible: Boolean get() = !catalogue && sort == 0 && items.size >= WolfskinTokens.Behaviour.letterRailMin && status == LibraryStatus.Ready
|
||||
val tailIndex: Int get() = ((items.size + columns - 1) / columns) * columns
|
||||
override val cardKey: String? get() = if (toolbar || inRail || status != LibraryStatus.Ready || items.isEmpty()) null else items.getOrNull(index)?.id ?: "grid-tail"
|
||||
val focusedTitle: LibraryTitle? get() = if (toolbar || inRail) null else items.getOrNull(index)
|
||||
|
||||
override fun key(key: ShellKey): Boolean {
|
||||
if (inRail) return railKey(key)
|
||||
if (key == ShellKey.Back) return false
|
||||
if (key == ShellKey.Up || key == ShellKey.Down || key == ShellKey.Left || key == ShellKey.Right) {
|
||||
val reentry = pointerReentry
|
||||
pointerReentry = null
|
||||
reentry?.invoke()?.let { index = it; toolbar = false; return true }
|
||||
}
|
||||
if (status == LibraryStatus.Loading) return false
|
||||
if (status == LibraryStatus.Unavailable || (items.isEmpty() && !toolbar)) {
|
||||
when (key) {
|
||||
ShellKey.Up -> if (status == LibraryStatus.Ready && hasToolbar) toolbar = true else return false
|
||||
ShellKey.Ok -> if (status == LibraryStatus.Unavailable) onRecover() else if (unwatched) { unwatched = false; index = 0 } else onDiscover()
|
||||
else -> Unit
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (toolbar) return toolbarKey(key)
|
||||
val currentItems = items
|
||||
when (key) {
|
||||
ShellKey.Up -> if (index >= columns) index -= columns else if (hasToolbar) toolbar = true else return false
|
||||
ShellKey.Down -> when {
|
||||
index / columns < currentItems.lastIndex / columns -> index = (index + columns).coerceAtMost(currentItems.lastIndex)
|
||||
tail != GridTail.None && index < tailIndex -> index = tailIndex
|
||||
}
|
||||
ShellKey.Left -> if (index % columns > 0) index--
|
||||
ShellKey.Right -> if (index % columns < columns - 1 && index < currentItems.lastIndex) index++ else if (railVisible) { inRail = true; railReturn = index; railLetter = currentItems[index].letter }
|
||||
ShellKey.Ok -> currentItems.getOrNull(index)?.let(onTitle) ?: if (pagination != null) pageRevision++ else onRecover()
|
||||
}
|
||||
onReadAhead(index)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun toolbarKey(key: ShellKey): Boolean {
|
||||
val count = if (catalogue) 3 else 2
|
||||
when (key) {
|
||||
ShellKey.Up -> return false
|
||||
ShellKey.Down -> { toolbar = false; index = index.coerceIn(0, items.lastIndex.coerceAtLeast(0)) }
|
||||
ShellKey.Left -> tool = (tool - 1).coerceAtLeast(0)
|
||||
ShellKey.Right -> tool = (tool + 1).coerceAtMost(count - 1)
|
||||
ShellKey.Ok -> {
|
||||
if (catalogue) mediaType = listOf("all", "movie", "tv")[tool]
|
||||
else if (tool == 0) sort = (sort + 1) % 3 else unwatched = !unwatched
|
||||
index = 0
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun railKey(key: ShellKey): Boolean {
|
||||
val letters = items.map { it.letter }.distinct()
|
||||
when (key) {
|
||||
ShellKey.Up, ShellKey.Down -> {
|
||||
val next = (letters.indexOf(railLetter) + if (key == ShellKey.Up) -1 else 1).coerceIn(0, letters.lastIndex)
|
||||
railLetter = letters[next]
|
||||
}
|
||||
ShellKey.Left, ShellKey.Ok -> { index = items.indexOfFirst { it.letter == railLetter }; inRail = false }
|
||||
ShellKey.Back -> { index = railReturn; inRail = false }
|
||||
else -> Unit
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.content.Context
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.allowRgb565
|
||||
import coil3.request.crossfade
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import coil3.disk.directory
|
||||
|
||||
/** Every grid shares one bounded cache and four concurrent image reads. */
|
||||
object LibraryImages {
|
||||
private var loader: ImageLoader? = null
|
||||
private val reads = Semaphore(WolfskinTokens.Library.imageReads.toInt())
|
||||
|
||||
@Synchronized
|
||||
fun loader(context: Context): ImageLoader = loader ?: SingletonImageLoader.get(context).newBuilder()
|
||||
.memoryCache(MemoryCache.Builder().maxSizeBytes((WolfskinTokens.Image.memoryCacheMB * 1024 * 1024).toLong()).build())
|
||||
.diskCache(DiskCache.Builder().directory(context.cacheDir.resolve("wolfskin-library-art"))
|
||||
.maxSizeBytes((WolfskinTokens.Image.diskCacheMB * 1024 * 1024).toLong()).build())
|
||||
.components { add(Interceptor { chain -> reads.withPermit { chain.proceed() } }) }
|
||||
.crossfade(false).build().also { loader = it }
|
||||
|
||||
fun request(context: Context, poster: String): ImageRequest = ImageRequest.Builder(context).data(poster)
|
||||
.size((WolfskinTokens.Card.posterW.value * 2).toInt(), (WolfskinTokens.Card.posterH.value * 2).toInt())
|
||||
.allowRgb565(true).crossfade(false).build()
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/** Provider pages are merged by title identity; transport pagination is never displayed. */
|
||||
data class LibraryPage(val items: List<LibraryTitle>, val hasMore: Boolean)
|
||||
|
||||
/** Duplicate-only reads pause until Show more; failures pause until Try again. */
|
||||
class LibraryPagination(private val grid: LibraryGridState, private val load: suspend (Int) -> LibraryPage) {
|
||||
private var nextPage = 2
|
||||
private var hasMore = true
|
||||
private var reading = false
|
||||
|
||||
suspend fun readAhead(force: Boolean = false) {
|
||||
if (reading || !hasMore || !force && (grid.tail != GridTail.None || grid.index < grid.items.size - WolfskinTokens.Behaviour.nextPageCards)) return
|
||||
reading = true
|
||||
try {
|
||||
val page = load(nextPage)
|
||||
val known = grid.source.mapTo(mutableSetOf()) { it.catalogueKey }
|
||||
val additional = page.items.filter { known.add(it.catalogueKey) }
|
||||
nextPage++
|
||||
hasMore = page.hasMore
|
||||
if (additional.isEmpty() && page.items.isNotEmpty() && hasMore) grid.tail = GridTail.ShowMore
|
||||
else {
|
||||
grid.source = grid.source + additional
|
||||
grid.tail = GridTail.None
|
||||
grid.index = grid.index.coerceAtMost(grid.items.lastIndex.coerceAtLeast(0))
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
grid.tail = GridTail.Retry
|
||||
} finally {
|
||||
reading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L
|
||||
|
||||
/** Poster slots use exact requests and share the shell's draw-only focus treatment. */
|
||||
@Composable
|
||||
fun LibraryPoster(
|
||||
title: LibraryTitle,
|
||||
focused: Boolean,
|
||||
ratingsVisible: Boolean,
|
||||
catalogue: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
offlineArt: ((String) -> ImageBitmap?)? = null,
|
||||
) {
|
||||
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)
|
||||
.testTag("poster-${title.id}")) {
|
||||
Box(Modifier.fillMaxSize().clip(shape).background(C.surface2)) {
|
||||
val bitmap = remember(title.poster) { title.poster?.let { offlineArt?.invoke(it) } }
|
||||
if (bitmap != null) Image(bitmap, title.title, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
else if (title.poster != null && offlineArt == null) {
|
||||
val context = LocalContext.current
|
||||
val request = remember(title.poster, context) { LibraryImages.request(context, title.poster) }
|
||||
AsyncImage(request, title.title, imageLoader = LibraryImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
} else MissingLibraryArt(title)
|
||||
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
|
||||
if (title.progress > 0f && title.progress < 1f) {
|
||||
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap)
|
||||
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).background(C.progressTrack, CircleShape)) {
|
||||
Box(Modifier.fillMaxHeight().fillMaxWidth(title.progress).background(C.accent, CircleShape))
|
||||
}
|
||||
}
|
||||
if (catalogue && title.badge != null) Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset)
|
||||
.size(WolfskinTokens.Layout.badge).background(if (title.badge == "held") C.ok else C.badgeRest, CircleShape), contentAlignment = Alignment.Center) {
|
||||
Text(if (title.badge == "held") "✓" else if (title.badge == "failed") "!" else "◷", color = if (title.badge == "held") C.onFocus else C.accent, style = WolfskinTokens.Type.button)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MissingLibraryArt(title: LibraryTitle) {
|
||||
val hue = remember(title.title) { title.title.fold(0) { hash, character -> (hash * L.fallbackHueMultiplier.toInt() + character.code) % 360 }.toFloat() }
|
||||
Column(Modifier.fillMaxSize().background(Brush.linearGradient(listOf(
|
||||
Color.hsl(hue, L.fallbackSaturationTop, L.fallbackLightnessTop),
|
||||
Color.hsl(hue, L.fallbackSaturationBottom, L.fallbackLightnessBottom))))
|
||||
.padding(L.fallbackPadding), verticalArrangement = Arrangement.SpaceBetween) {
|
||||
Glyph(if (title.type == "movie") "film" else "nextup", C.fg.copy(alpha = L.fallbackIconOpacity), Modifier.size(L.fallbackIcon))
|
||||
Text(title.title, style = WolfskinTokens.Type.button, color = C.fg, maxLines = 4)
|
||||
}
|
||||
}
|
||||
|
||||
/** A measured prefix stays on one line, shrinking only to the approved type floor. */
|
||||
@Composable
|
||||
fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifier, visible: Boolean = true) {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val available = with(density) { (WolfskinTokens.Card.posterW - L.plateSide * 2).toPx() }
|
||||
val formatted = ratings.map { it.formatted() }
|
||||
fun width(count: Int, size: TextUnit): Float = (0 until count).sumOf { index ->
|
||||
val labelWidth = measurer.measure(formatted[index], WolfskinTokens.Type.ratingPlate.copy(fontSize = size)).size.width
|
||||
val pixels = with(density) { size.toPx() }
|
||||
(labelWidth + pixels * (if (ratings[index].source in listOf("imdb", "tmdb")) L.wordmarkRatio else 1f) + pixels * L.markGapEm + if (index > 0) pixels * L.plateGapEm else 0f).toDouble()
|
||||
}.toFloat()
|
||||
var size = WolfskinTokens.Type.ratingPlate.fontSize
|
||||
var count = ratings.size
|
||||
while (size > L.plateFloor.fontSize && width(count, size) > available) size = (size.value - L.plateStep).coerceAtLeast(L.plateFloor.fontSize.value).sp
|
||||
while (count > 1 && width(count, size) > available) count--
|
||||
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "library-plate")
|
||||
Row(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha }.background(Brush.verticalGradient(
|
||||
0f to C.bg.copy(alpha = L.plateOpacity), L.plateMidStop to C.bg.copy(alpha = L.plateMidOpacity), 1f to Color.Transparent))
|
||||
.padding(start = L.plateSide, end = L.plateSide, top = L.plateTop, bottom = L.plateBottom),
|
||||
horizontalArrangement = Arrangement.spacedBy(with(density) { (size.toPx() * L.plateGapEm).toDp() }), verticalAlignment = Alignment.CenterVertically) {
|
||||
repeat(count) { index ->
|
||||
val rating = ratings[index]
|
||||
val mark = 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 markHeight = with(density) { size.toDp() }
|
||||
val unknownCritic = rating.source == "rtCritics" && rating.label != "Fresh" && rating.label != "Certified Fresh" && rating.label != "Rotten"
|
||||
val desaturated = remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(markHeight * L.markGapEm), verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(androidx.compose.ui.res.painterResource(mark), rating.source,
|
||||
Modifier.size(markHeight * if (rating.source in listOf("imdb", "tmdb")) L.wordmarkRatio else 1f, markHeight)
|
||||
.graphicsLayer { this.alpha = if (unknownCritic) L.unknownCriticOpacity else 1f },
|
||||
colorFilter = if (unknownCritic) desaturated else null)
|
||||
Text(formatted[index], color = C.fg, style = WolfskinTokens.Type.ratingPlate.copy(fontSize = size), maxLines = 1, softWrap = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.delay
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as G
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
|
||||
|
||||
/** One lazy frame owns headers, toolbar, continuous poster rows and recoverable tails. */
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
shell: ShellState,
|
||||
grid: LibraryGridState,
|
||||
modifier: Modifier = Modifier,
|
||||
ratingsSignedIn: Boolean = false,
|
||||
cachedRatings: Set<String> = emptySet(),
|
||||
ratingsReachable: Boolean = true,
|
||||
offlineArt: ((String) -> ImageBitmap?)? = null,
|
||||
) {
|
||||
val view = shell.view
|
||||
val density = LocalDensity.current
|
||||
val context = LocalContext.current
|
||||
val active = !shell.navFocused && !shell.pickerVisible
|
||||
val items = grid.items
|
||||
val focused = grid.focusedTitle
|
||||
var plateKey by remember { mutableStateOf<String?>(null) }
|
||||
DisposableEffect(shell, grid) {
|
||||
shell.contentFocus = grid
|
||||
onDispose { if (shell.contentFocus === grid) shell.contentFocus = null }
|
||||
}
|
||||
LaunchedEffect(grid) {
|
||||
var revision = grid.pageRevision
|
||||
snapshotFlow { Triple(grid.index, grid.source.size, grid.pageRevision) }.collect { (_, _, nextRevision) ->
|
||||
grid.pagination?.readAhead(force = nextRevision != revision)
|
||||
revision = nextRevision
|
||||
}
|
||||
}
|
||||
LaunchedEffect(grid.index / grid.columns, items, offlineArt) {
|
||||
if (offlineArt == null) {
|
||||
val start = (grid.index / grid.columns + 1) * grid.columns
|
||||
for (index in start until (start + grid.columns).coerceAtMost(items.size)) {
|
||||
items[index].poster?.let { LibraryImages.loader(context).enqueue(LibraryImages.request(context, it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
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())
|
||||
plateKey = focused.id
|
||||
}
|
||||
}
|
||||
LaunchedEffect(grid.status) {
|
||||
if (grid.status == LibraryStatus.Loading) shell.focusNav(shell.currentTab)
|
||||
}
|
||||
val headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1
|
||||
val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap
|
||||
LaunchedEffect(grid.index, grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) {
|
||||
val index = if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index
|
||||
val row = index.coerceAtLeast(0) / grid.columns
|
||||
if (grid.toolbar || row == 0 && active) {
|
||||
animateLibraryScroll(view, headerCount, 0f, density)
|
||||
} else if (row > 0) {
|
||||
val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * row - L.pinnedLine).toPx() }
|
||||
animateLibraryScroll(view, headerCount, target, density)
|
||||
}
|
||||
}
|
||||
Box(modifier.semantics { stateDescription = "${grid.title}:${if (grid.toolbar) "tool-${grid.tool}" else if (grid.inRail) "rail-${grid.railLetter}" else focused?.id ?: "recovery"}" }.testTag("library-grid")) {
|
||||
LazyColumn(state = view.listState, modifier = Modifier.fillMaxSize().pointerInput(grid, headerCount) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
if (awaitPointerEvent().type == PointerEventType.Scroll) grid.pointerReentry = {
|
||||
val visible = view.listState.layoutInfo.visibleItemsInfo
|
||||
if (visible.none { it.index == headerCount + grid.index / grid.columns }) {
|
||||
visible.firstOrNull { it.index >= headerCount }?.let { ((it.index - headerCount) * grid.columns).coerceAtMost(grid.items.lastIndex) }
|
||||
} else null
|
||||
}
|
||||
}
|
||||
}
|
||||
}, contentPadding = PaddingValues(bottom = WolfskinTokens.Shell.stageHeight - L.pinnedLine - rowHeight)) {
|
||||
item(key = "header", contentType = "header") { LibraryHeader(grid, shell.person) }
|
||||
if (headerCount == 2) item(key = "toolbar", contentType = "chips") { LibraryToolbar(grid, active) }
|
||||
when (grid.status) {
|
||||
LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) {
|
||||
Box(Modifier.fillMaxWidth().height(rowHeight)) {
|
||||
Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
|
||||
repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
LibraryStatus.Unavailable -> item(key = "unavailable", contentType = "state") { LibraryRecovery(grid, active) }
|
||||
LibraryStatus.Ready -> if (items.isEmpty()) item(key = "empty", contentType = "state") { LibraryRecovery(grid, active && !grid.toolbar) }
|
||||
else {
|
||||
items((items.size + grid.columns - 1) / grid.columns, key = { row -> "grid-${items[row * grid.columns].id}" }, contentType = { "grid" }) { row ->
|
||||
Box(Modifier.fillMaxWidth().height(rowHeight)) {
|
||||
Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
|
||||
for (column in 0 until grid.columns) {
|
||||
val index = row * grid.columns + column
|
||||
val title = items.getOrNull(index) ?: break
|
||||
key(if (grid.catalogue) title.catalogueKey else title.id) {
|
||||
val isFocused = active && !grid.toolbar && !grid.inRail && grid.index == index
|
||||
LibraryPoster(title, isFocused, plateKey == title.id && isFocused, grid.catalogue,
|
||||
Modifier.zIndex(if (isFocused) 1f else 0f).pointerInput(index, grid) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
if ((event.type == PointerEventType.Move || event.type == PointerEventType.Enter) && event.changes.any { it.type == PointerType.Mouse }) {
|
||||
grid.index = index; grid.toolbar = false; shell.navFocused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}.clickable { grid.index = index; grid.toolbar = false; shell.navFocused = false; grid.onTitle(title) }, offlineArt)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (active && !grid.toolbar && !grid.inRail && grid.index / grid.columns == row) {
|
||||
val title = items.getOrNull(grid.index)
|
||||
if (title != null) Caption(title.title, title.facts(grid.catalogue), true,
|
||||
Modifier.offset(L.safeSide + K.posterPitch * (grid.index % grid.columns), K.posterH + L.captionOffset).width(G.captionWidth),
|
||||
accentLastFact = if (grid.catalogue) title.availability.isNotEmpty() else title.played || title.progress > 0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (grid.tail != GridTail.None) item(key = "tail", contentType = "grid") {
|
||||
LibraryTail(grid, active && grid.index == grid.tailIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (grid.railVisible) LibraryRail(grid)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryHeader(grid: LibraryGridState, person: ShellPerson) {
|
||||
val count = if (grid.catalogue) grid.items.size else grid.source.size
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) {
|
||||
Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) {
|
||||
val labels = if (grid.catalogue) listOf("All", "Movies", "Series") else listOf("Sort ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}", if (grid.unwatched) "Unwatched" else "All")
|
||||
labels.forEachIndexed { index, label ->
|
||||
val focused = active && grid.toolbar && grid.tool == index
|
||||
val selected = if (grid.catalogue) listOf("all", "movie", "tv")[index] == grid.mediaType else index == 1 && grid.unwatched
|
||||
Row(Modifier.height(G.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, CircleShape)
|
||||
.then(if (selected && !focused) Modifier.border(G.borderWidth, C.accent, CircleShape) else Modifier)
|
||||
.clickable { grid.toolbar = true; grid.tool = index; grid.key(ShellKey.Ok) }
|
||||
.padding(horizontal = G.chipPadding), horizontalArrangement = Arrangement.spacedBy(G.chipContentGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
val color = if (focused) C.onFocus else if (selected) C.accentText else C.fg
|
||||
if (!grid.catalogue) Glyph(if (index == 0) "advanced" else "eye", color, Modifier.size(G.chipIcon))
|
||||
if (!grid.catalogue && index == 0) Text(buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = color.copy(alpha = G.chipLabelOpacity), fontWeight = FontWeight.Medium)) { append("Sort") }
|
||||
append(" ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}")
|
||||
}, color = color, style = WolfskinTokens.Type.label)
|
||||
else Text(label, color = color, style = WolfskinTokens.Type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryRecovery(grid: LibraryGridState, focused: Boolean) {
|
||||
val lineBoxes = androidx.compose.ui.text.style.LineHeightStyle(
|
||||
androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center,
|
||||
androidx.compose.ui.text.style.LineHeightStyle.Trim.None,
|
||||
)
|
||||
val unavailable = grid.status == LibraryStatus.Unavailable
|
||||
val title = if (unavailable) "Couldn’t reach your library" else if (grid.unwatched) "Nothing unwatched in ${grid.title}" else "Nothing in ${grid.title} yet"
|
||||
val text = if (unavailable) "Jellyfin isn’t answering. Nothing here is lost; try again in a moment." else if (grid.unwatched) "Everything here has been watched." else "Titles you or others request for you land here."
|
||||
val button = if (unavailable) "Try again" else if (grid.unwatched) "Show all" else "Open Discover"
|
||||
Box(Modifier.fillMaxWidth().height(L.row_state).padding(start = L.safeSide, top = G.panelTop)) {
|
||||
Column(Modifier.width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.border(G.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(G.borderWidth)
|
||||
.padding(horizontal = G.panelPaddingX, vertical = G.panelPaddingY)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(G.panelIconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph(if (unavailable) "alert" else if (grid.title == "Movies") "film" else "nextup", if (unavailable) C.warn else C.accentText, Modifier.size(WolfskinTokens.Shell.panelIcon))
|
||||
Text(title, color = C.fg, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold, lineHeightStyle = lineBoxes))
|
||||
}
|
||||
Spacer(Modifier.height(G.panelTitleGap))
|
||||
Text(text, color = C.muted, style = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBoxes))
|
||||
Spacer(Modifier.height(G.panelTextGap))
|
||||
PickerButton(button, focused, !unavailable) { grid.key(ShellKey.Ok) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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)
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.LibraryRail(grid: LibraryGridState) {
|
||||
Column(Modifier.align(Alignment.TopEnd).padding(end = L.safeSide, top = L.navBand).width(G.railWidth)) {
|
||||
val letters = remember(grid.items) { grid.items.map { it.letter }.toSet() }
|
||||
for (letter in "#ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
||||
val current = if (grid.inRail) grid.railLetter == letter else grid.focusedTitle?.letter == letter
|
||||
Box(Modifier.width(G.railWidth).height(if (current && grid.inRail) G.railFocusHeight else G.railLetterHeight)
|
||||
.background(if (current && grid.inRail) C.fg else androidx.compose.ui.graphics.Color.Transparent, CircleShape)
|
||||
.clickable(enabled = letter in letters) { grid.railLetter = letter; grid.inRail = true; grid.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
|
||||
Text(letter.toString(), color = if (current) if (grid.inRail) C.onFocus else C.accent else if (letter in letters) C.muted else C.railEmpty,
|
||||
style = if (current && grid.inRail) G.railFocusedLetter else G.railLetter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int, target: Float, density: androidx.compose.ui.unit.Density) {
|
||||
val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap
|
||||
val first = view.listState.firstVisibleItemIndex
|
||||
val current = with(density) {
|
||||
(if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead +
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
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.*
|
||||
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.unit.Dp
|
||||
import androidx.tv.material3.Text
|
||||
|
||||
/** Shelves expose the full underlying list without displaying transport pagination. */
|
||||
@Composable
|
||||
fun LibrarySeeAllCard(count: Int, focused: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier,
|
||||
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)
|
||||
.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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -22,11 +22,18 @@ sealed interface ShellAction {
|
|||
enum class ShellKey { Up, Down, Left, Right, Ok, Back }
|
||||
enum class PeopleStatus { Ready, Loading, Unavailable }
|
||||
|
||||
/** Screens participate in the shell's hold guard and Back ladder without a second key adapter. */
|
||||
interface ShellContentFocus {
|
||||
val cardKey: String?
|
||||
fun key(key: ShellKey): Boolean
|
||||
}
|
||||
|
||||
/** A root destination retains focus, horizontal tracks and its actual Compose scroller. */
|
||||
class RememberedView {
|
||||
val listState = LazyListState()
|
||||
var row by mutableStateOf(0)
|
||||
val cards = mutableStateMapOf<String, Int>()
|
||||
var library: LibraryGridState? = null
|
||||
}
|
||||
|
||||
/** Focus and Back transitions are independent of drawing and server availability. */
|
||||
|
|
@ -54,6 +61,7 @@ class ShellState(
|
|||
private var pickerReturnRoute = "home"
|
||||
private val views = mutableStateMapOf<Pair<String, String>, RememberedView>()
|
||||
private var armedCard: String? = null
|
||||
var contentFocus: ShellContentFocus? = null
|
||||
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
|
||||
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
|
||||
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
|
||||
|
|
@ -70,7 +78,7 @@ 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? = null): Boolean {
|
||||
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = if (!pickerVisible && !navFocused && !requestsVisible) contentFocus?.cardKey else null): Boolean {
|
||||
if (key == ShellKey.Ok) {
|
||||
if (repeat > 0) return true
|
||||
if (card != null) {
|
||||
|
|
@ -154,6 +162,7 @@ class ShellState(
|
|||
}
|
||||
|
||||
private fun contentKey(key: ShellKey) {
|
||||
if (contentFocus?.key(key) == true) return
|
||||
when (key) {
|
||||
ShellKey.Up -> if (route == "settings" && settingsRow > 0) settingsRow-- else { navFocused = true; navIndex = currentTab }
|
||||
ShellKey.Down -> if (route == "settings") settingsRow = (settingsRow + 1).coerceAtMost(SETTINGS_GROUPS.lastIndex)
|
||||
|
|
@ -185,10 +194,12 @@ class ShellState(
|
|||
onAction(ShellAction.HandOff(group))
|
||||
}
|
||||
}
|
||||
else contentFocus?.key(ShellKey.Ok)
|
||||
}
|
||||
|
||||
/** Back never discards a root view, including the card below a focused nav. */
|
||||
fun back() {
|
||||
if (!pickerVisible && !navFocused && contentFocus?.key(ShellKey.Back) == true) return
|
||||
if (route == "settings") {
|
||||
route = pickerReturnRoute
|
||||
onAction(ShellAction.Pop)
|
||||
|
|
|
|||
|
|
@ -302,6 +302,53 @@ object WolfskinTokens {
|
|||
const val navBandOpacity = 0.72f
|
||||
const val navSolidStop = 0.84f
|
||||
}
|
||||
object Library {
|
||||
val chipHeight = 34.0.dp
|
||||
val chipPadding = 14.0.dp
|
||||
val chipGap = 8.0.dp
|
||||
val chipTop = 8.0.dp
|
||||
val chipIcon = 13.0.dp
|
||||
val captionWidth = 320.0.dp
|
||||
const val captionSeparatorOpacity = 0.4f
|
||||
val plateTop = 7.0.dp
|
||||
val plateSide = 8.0.dp
|
||||
val plateBottom = 23.0.dp
|
||||
val plateFloor = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
|
||||
const val plateStep = 0.25f
|
||||
const val plateGapEm = 0.64f
|
||||
const val markGapEm = 0.29f
|
||||
const val wordmarkRatio = 1.8f
|
||||
const val plateOpacity = 0.9f
|
||||
const val plateMidOpacity = 0.72f
|
||||
const val plateMidStop = 0.52f
|
||||
val railWidth = 26.0.dp
|
||||
val railLetterHeight = 15.0.dp
|
||||
val railFocusHeight = 22.0.dp
|
||||
val railLetter = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
|
||||
val railFocusedLetter = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 18.2.sp, letterSpacing = 0.0.em)
|
||||
val panelPaddingX = 20.0.dp
|
||||
val panelPaddingY = 17.0.dp
|
||||
val panelTextGap = 12.0.dp
|
||||
val panelTitleGap = 4.0.dp
|
||||
val panelIconGap = 7.0.dp
|
||||
val panelTop = 10.0.dp
|
||||
val borderWidth = 1.0.dp
|
||||
val chipContentGap = 6.0.dp
|
||||
const val chipLabelOpacity = 0.7f
|
||||
val tailIcon = 22.0.dp
|
||||
val tailPadding = 10.0.dp
|
||||
val tailGap = 5.0.dp
|
||||
const val imageReads = 4.0f
|
||||
const val unknownCriticOpacity = 0.55f
|
||||
val fallbackPadding = 11.0.dp
|
||||
val fallbackIcon = 22.0.dp
|
||||
const val fallbackHueMultiplier = 31.0f
|
||||
const val fallbackSaturationTop = 0.22f
|
||||
const val fallbackLightnessTop = 0.22f
|
||||
const val fallbackSaturationBottom = 0.26f
|
||||
const val fallbackLightnessBottom = 0.1f
|
||||
const val fallbackIconOpacity = 0.45f
|
||||
}
|
||||
object Title {
|
||||
val lineGap = 11.0.dp
|
||||
val lineLift = -2.0.dp
|
||||
|
|
@ -418,8 +465,4 @@ object WolfskinTokens {
|
|||
val border = 1.0.dp
|
||||
const val borderOpacity = 0.5f
|
||||
}
|
||||
object Caption {
|
||||
const val separatorOpacity = 0.4f
|
||||
val factsGap = 9.0.dp
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
wolfskin-design/src/main/res/drawable/wolfskin_down.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
1
wolfskin-design/src/main/res/drawable/wolfskin_eye.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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="M2,12 C2,12 5.6,5 12,5 C18.4,5 22,12 22,12 C22,12 18.4,19 12,19 C5.6,19 2,12 2,12 M15,12 A3,3 0,1 1,9 12 A3,3 0,1 1,15 12" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round"/></vector>
|
||||
1
wolfskin-design/src/main/res/drawable/wolfskin_film.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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.5,4 L18.5,4 Q21,4 21,6.5 L21,17.5 Q21,20 18.5,20 L5.5,20 Q3,20 3,17.5 L3,6.5 Q3,4 5.5,4 M7,4 L7,20 M17,4 L17,20 M3,9 L7,9 M3,15 L7,15 M17,9 L21,9 M17,15 L21,15" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round"/></vector>
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
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.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import org.junit.Before
|
||||
import org.junit.After
|
||||
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
|
||||
|
||||
/** Each native screen state uses offline prototype data and real shell focus drawing. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class LibraryCaptureTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val previousLocale = Locale.getDefault()
|
||||
private val fixture by lazy { libraryFixture() }
|
||||
|
||||
@Before fun fullscreen() {
|
||||
Locale.setDefault(Locale.US)
|
||||
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
|
||||
}
|
||||
@After fun restoreLocale() { Locale.setDefault(previousLocale) }
|
||||
|
||||
private fun capture(name: String, person: String = "shared", destination: String = "movies", catalogue: String? = null, ratings: Boolean = true, reachable: Boolean = true, cached: Boolean = false, configure: (LibraryGridState) -> Unit = {}) {
|
||||
val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination(destination) }
|
||||
val source = when (catalogue) {
|
||||
"picks" -> fixture.picks(person, "movie")
|
||||
"trending" -> fixture.trending()
|
||||
"duplicate" -> fixture.duplicatePage()
|
||||
else -> fixture.library(person, destination)
|
||||
}
|
||||
val grid = LibraryGridState(if (catalogue == "picks") "Movies for you" else if (catalogue != null) "Trending" else destination.replaceFirstChar { it.uppercase() }, source, catalogue != null)
|
||||
configure(grid)
|
||||
shell.view.library = grid
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
||||
LibraryScreen(shell, grid, modifier, ratingsSignedIn = ratings, ratingsReachable = reachable,
|
||||
cachedRatings = if (cached) setOf(grid.items[grid.index].id) else emptySet(), offlineArt = ::fixtureArt)
|
||||
}
|
||||
}
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory")).apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
||||
}
|
||||
|
||||
@Test fun st021_sharedMovies() = capture("st-021")
|
||||
@Test fun st022_ratingSort() = capture("st-022") { it.sort = 2 }
|
||||
@Test fun st023_rowEndClamps() = capture("st-023") { it.index = 5; it.key(ShellKey.Right) }
|
||||
@Test fun st024_anime() = capture("st-024", "justin", "anime")
|
||||
@Test fun st025_unwatched() = capture("st-025") { it.unwatched = true }
|
||||
@Test fun st026_empty() = capture("st-026", "mindel") { it.source = emptyList() }
|
||||
@Test fun st027_unavailable() = capture("st-027", "mindel", "shows") { it.status = LibraryStatus.Unavailable }
|
||||
@Test fun st028_loading() = capture("st-028") { it.status = LibraryStatus.Loading }
|
||||
@Test fun st030_fullPicks() = capture("st-030", "justin", "discover", "picks")
|
||||
@Test fun st035_duplicateOnlyTail() = capture("st-035", "justin", "discover", "duplicate") { it.tail = GridTail.ShowMore; it.index = it.tailIndex }
|
||||
@Test fun st039_trending() = capture("st-039", "justin", "discover", "trending")
|
||||
@Test fun st095_widestPosterPlate() = capture("st-095") { grid -> grid.index = grid.items.indexOfFirst { it.title == "Grave of the Fireflies" } }
|
||||
@Test fun st097_criticsPlate() = capture("st-097")
|
||||
@Test fun st098_tmdbPlate() = capture("st-098", "justin", "anime") { grid -> grid.index = grid.items.indexOfFirst { it.title == "Chainsaw Man" } }
|
||||
@Test fun st112_cachedWhileDown() = capture("st-112", reachable = false, cached = true)
|
||||
@Test fun st113_uncachedWhileDown() = capture("st-113", reachable = false)
|
||||
@Test fun unsignedRatingsHidden() = capture("library-unsigned", ratings = false)
|
||||
}
|
||||
|
||||
fun libraryFixture(): LibraryFixture = LibraryFixture(LibraryCaptureTest::class.java.getResourceAsStream("/wolfskin-library.json")!!.bufferedReader().use { it.readText() }, "/library/")
|
||||
|
||||
fun fixtureArt(path: String): ImageBitmap? = LibraryCaptureTest::class.java.getResourceAsStream(path)?.use { BitmapFactory.decodeStream(it)?.asImageBitmap() }
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import androidx.compose.runtime.remember
|
||||
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.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
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
|
||||
import java.util.Locale
|
||||
|
||||
/** Acceptance data drives Android keys through the real shell and grid controller. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class LibraryKeysTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
private val shell = ShellState(fixturePeople(), "justin")
|
||||
private val fixture by lazy { libraryFixture() }
|
||||
private var opened: LibraryTitle? = null
|
||||
|
||||
private fun render() {
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
ShellSurface(shell) { modifier ->
|
||||
if (shell.route in listOf("movies", "shows", "anime")) {
|
||||
val view = shell.view
|
||||
val grid = remember(shell.person.key, shell.route, view) {
|
||||
view.library ?: LibraryGridState(shell.route, fixture.library(shell.person.key, shell.route), onTitle = { opened = it }).also { view.library = it }
|
||||
}
|
||||
LibraryScreen(shell, grid, modifier, offlineArt = ::fixtureArt)
|
||||
} else ShellPlaceholder(shell.route, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun press(key: Key) { rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }; rule.waitForIdle() }
|
||||
private fun press(keys: List<Key>) { keys.forEach(::press) }
|
||||
|
||||
@Test fun spiritedAwayInApprovedTwentyPresses() {
|
||||
val document = LibraryKeysTest::class.java.getResourceAsStream("/library-screen.json")!!.bufferedReader().use { JSONObject(it.readText()) }
|
||||
val paths = document.getJSONArray("pressPaths")
|
||||
render()
|
||||
for (index in 0 until paths.length()) {
|
||||
val path = paths.getJSONObject(index)
|
||||
val keys = path.getJSONArray("keys")
|
||||
assertEquals(path.getInt("presses"), keys.length())
|
||||
for (keyIndex in 0 until keys.length()) press(when (keys.getString(keyIndex)) {
|
||||
"UP" -> Key.DirectionUp
|
||||
"DOWN" -> Key.DirectionDown
|
||||
"LEFT" -> Key.DirectionLeft
|
||||
"RIGHT" -> Key.DirectionRight
|
||||
"OK" -> Key.DirectionCenter
|
||||
else -> error("Unknown acceptance key")
|
||||
})
|
||||
assertEquals("shared", shell.person.key)
|
||||
assertEquals("movies", shell.route)
|
||||
assertFalse(shell.navFocused)
|
||||
assertEquals("Spirited Away", shell.view.library!!.focusedTitle!!.title)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun rowEdgesToolbarSortAndBackKeepExactCard() {
|
||||
render()
|
||||
press(listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter))
|
||||
val grid = shell.view.library!!
|
||||
press(Key.DirectionLeft)
|
||||
assertEquals(0, grid.index)
|
||||
repeat(6) { press(Key.DirectionRight) }
|
||||
assertEquals(5, grid.index)
|
||||
assertFalse(grid.inRail)
|
||||
press(listOf(Key.DirectionDown, Key.Back))
|
||||
assertTrue(shell.navFocused)
|
||||
assertEquals(11, grid.index)
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(11, grid.index)
|
||||
press(listOf(Key.DirectionUp, Key.DirectionUp))
|
||||
assertTrue(grid.toolbar)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(1, grid.sort)
|
||||
assertTrue(grid.toolbar)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(2, grid.sort)
|
||||
assertEquals("Grave of the Fireflies", grid.items.first().title)
|
||||
}
|
||||
|
||||
@Test fun cardActsOnReleaseAndHeldRepeatCannotOpenTwice() {
|
||||
render()
|
||||
press(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter))
|
||||
rule.runOnIdle {
|
||||
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
|
||||
assertNull(opened)
|
||||
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 1)))
|
||||
assertNull(opened)
|
||||
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
|
||||
assertEquals(shell.view.library!!.focusedTitle, opened)
|
||||
opened = null
|
||||
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
|
||||
assertNull(opened)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun railGateJumpAndBackRespectOriginalCard() {
|
||||
val titles = (0 until 200).map { index -> LibraryTitle("$index", "${('A'.code + index / 10).toChar()}Title $index", "movie") }
|
||||
val grid = LibraryGridState("Movies", titles)
|
||||
assertTrue(grid.railVisible)
|
||||
grid.index = 5
|
||||
grid.key(ShellKey.Right)
|
||||
assertTrue(grid.inRail)
|
||||
grid.key(ShellKey.Down)
|
||||
assertEquals('B', grid.railLetter)
|
||||
grid.key(ShellKey.Back)
|
||||
assertEquals(5, grid.index)
|
||||
assertFalse(grid.inRail)
|
||||
grid.key(ShellKey.Right); grid.key(ShellKey.Down); grid.key(ShellKey.Ok)
|
||||
assertEquals('B', grid.focusedTitle!!.letter)
|
||||
grid.source = titles.take(199)
|
||||
assertFalse(grid.railVisible)
|
||||
grid.source = titles; grid.sort = 1
|
||||
assertFalse(grid.railVisible)
|
||||
}
|
||||
|
||||
@Test fun mixedSeeAllPreservesFullListAndClampsShortRow() {
|
||||
val grid = LibraryGridState("Full list", (0 until 8).map { LibraryTitle("$it", "Title $it", if (it % 2 == 0) "movie" else "tv") }, catalogue = true)
|
||||
grid.index = 5
|
||||
grid.key(ShellKey.Down)
|
||||
assertEquals(7, grid.index)
|
||||
grid.key(ShellKey.Up); grid.key(ShellKey.Up)
|
||||
assertTrue(grid.toolbar)
|
||||
grid.key(ShellKey.Right); grid.key(ShellKey.Ok)
|
||||
assertEquals(listOf("0", "2", "4", "6"), grid.items.map { it.id })
|
||||
grid.key(ShellKey.Left); grid.key(ShellKey.Ok)
|
||||
assertEquals(8, grid.items.size)
|
||||
}
|
||||
|
||||
@Test fun downClampsShortLastRowBeforeEnteringRecoveryTail() {
|
||||
var recoveries = 0
|
||||
val grid = LibraryGridState("Trending",
|
||||
(0 until 20).map { LibraryTitle("$it", "Title $it", "movie") },
|
||||
catalogue = true, onRecover = { recoveries++ },
|
||||
).apply { tail = GridTail.Retry; index = 14 }
|
||||
shell.pickerVisible = false
|
||||
shell.enterDestination("discover")
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
ShellSurface(shell) { modifier ->
|
||||
LibraryScreen(shell, grid, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(19, grid.index)
|
||||
assertEquals("Title 19", grid.focusedTitle!!.title)
|
||||
assertEquals(0, recoveries)
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(24, grid.index)
|
||||
assertEquals("grid-tail", grid.cardKey)
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(24, grid.index)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(1, recoveries)
|
||||
press(Key.DirectionUp)
|
||||
assertEquals(18, grid.index)
|
||||
assertEquals("Title 18", grid.focusedTitle!!.title)
|
||||
}
|
||||
|
||||
@Test fun scoresFollowLocaleAndUnratedSortsLast() {
|
||||
assertEquals("7,8", LibraryRating("tmdb", 7.8).formatted(Locale.forLanguageTag("nl-NL")))
|
||||
assertEquals("7.8", LibraryRating("tmdb", 7.8).formatted(Locale.US))
|
||||
assertEquals("87%", LibraryRating("rtCritics", 87.9).formatted(Locale.forLanguageTag("nl-NL")))
|
||||
val grid = LibraryGridState("Movies", listOf(
|
||||
LibraryTitle("none", "The Apple", "movie"),
|
||||
LibraryTitle("imdb", "A Bee", "movie", ratings = listOf(LibraryRating("imdb", 8.0))),
|
||||
LibraryTitle("rt", "An Ant", "movie", ratings = listOf(LibraryRating("rtCritics", 80.0))),
|
||||
))
|
||||
grid.sort = 2
|
||||
assertEquals(listOf("rt", "imdb", "none"), grid.items.map { it.id })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/** Paging never loops through repeated pages or loses the readable list after a failure. */
|
||||
class LibraryPaginationTest {
|
||||
private fun title(id: Int) = LibraryTitle("$id", "Title $id", "movie")
|
||||
|
||||
@Test fun duplicateOnlyReadWaitsForExplicitRetryThenAppendsUniqueTitles() = runBlocking {
|
||||
val grid = LibraryGridState("Trending", (0 until 20).map(::title), catalogue = true)
|
||||
var calls = 0
|
||||
val pager = LibraryPagination(grid) { page ->
|
||||
calls++
|
||||
when (page) {
|
||||
2 -> LibraryPage((0 until 20).map(::title), true)
|
||||
3 -> LibraryPage((18 until 25).map(::title), false)
|
||||
else -> error("Unexpected extra read")
|
||||
}
|
||||
}
|
||||
grid.index = 9
|
||||
pager.readAhead()
|
||||
assertEquals(0, calls)
|
||||
grid.index = 10
|
||||
pager.readAhead()
|
||||
assertEquals(GridTail.ShowMore, grid.tail)
|
||||
pager.readAhead()
|
||||
assertEquals(1, calls)
|
||||
pager.readAhead(force = true)
|
||||
assertEquals((0 until 25).map { "$it" }, grid.source.map { it.id })
|
||||
assertEquals(GridTail.None, grid.tail)
|
||||
pager.readAhead(force = true)
|
||||
assertEquals(2, calls)
|
||||
}
|
||||
|
||||
@Test fun failedReadRetainsTitlesAndRetriesSamePage() = runBlocking {
|
||||
val grid = LibraryGridState("Trending", (0 until 12).map(::title), catalogue = true)
|
||||
var reachable = false
|
||||
val pages = mutableListOf<Int>()
|
||||
val pager = LibraryPagination(grid) { page ->
|
||||
pages += page
|
||||
if (!reachable) throw java.io.IOException("offline")
|
||||
LibraryPage(listOf(title(12)), false)
|
||||
}
|
||||
grid.index = 11
|
||||
pager.readAhead()
|
||||
assertEquals(GridTail.Retry, grid.tail)
|
||||
assertEquals((0 until 12).map { "$it" }, grid.source.map { it.id })
|
||||
reachable = true
|
||||
pager.readAhead(force = true)
|
||||
assertEquals(listOf(2, 2), pages)
|
||||
assertEquals("12", grid.source.last().id)
|
||||
}
|
||||
}
|
||||
494
wolfskin-design/src/test/resources/library-screen.json
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
{
|
||||
"id": "library",
|
||||
"title": "Libraries: Movies, Shows, Anime (one continuous 6-column grid)",
|
||||
"specSection": "6.4",
|
||||
"source": "index.html:1403-1426, 1563-1611",
|
||||
"layout": [
|
||||
{
|
||||
"id": "lhead",
|
||||
"type": "lhead",
|
||||
"token": null,
|
||||
"title": "Movies",
|
||||
"yPx": 0,
|
||||
"heightPx": 236,
|
||||
"cards": null,
|
||||
"focusable": false,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "ltools",
|
||||
"type": "chips",
|
||||
"token": null,
|
||||
"title": "",
|
||||
"yPx": 236,
|
||||
"heightPx": 104,
|
||||
"cards": null,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g0",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 340,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g1",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 828,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g2",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 1316,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g3",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 1804,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g4",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 2292,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g5",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 2780,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g6",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 3268,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g7",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 3756,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g8",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 4244,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g9",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 4732,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g10",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 5220,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g11",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 5708,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g12",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 6196,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g13",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 6684,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
{
|
||||
"id": "st-021",
|
||||
"name": "Shared Movies: 84, continuous grid",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-021-shared-movies-84-continuous-grid.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-021-shared-movies-84-continuous-grid.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Shared Movies: 84, continuous grid · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
|
||||
},
|
||||
{
|
||||
"id": "st-022",
|
||||
"name": "Shared Movies sorted by Rating (G10)",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-022-shared-movies-sorted-by-rating-g10.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-022-shared-movies-sorted-by-rating-g10.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Shared Movies sorted by Rating (G10) · Person: Shared · Screen: Movies · Focus: g0 › Grave of the Fireflies"
|
||||
},
|
||||
{
|
||||
"id": "st-023",
|
||||
"name": "Right at a row end clamps (no letter rail below 200)",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Right at a row end clamps (no letter rail below 200) · Person: Shared · Screen: Movies · Focus: g0 › Bohemian Rhapsody"
|
||||
},
|
||||
{
|
||||
"id": "st-024",
|
||||
"name": "Justin Anime: 18 series",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-024-justin-anime-18-series.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-024-justin-anime-18-series.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "justin",
|
||||
"focusReadout": "State: Justin Anime: 18 series · Person: Justin · Screen: Anime · Focus: g0 › Bleach"
|
||||
},
|
||||
{
|
||||
"id": "st-025",
|
||||
"name": "Unwatched filter",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-025-unwatched-filter.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-025-unwatched-filter.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Unwatched filter · Person: Shared · Screen: Movies · Focus: g0 › Annie Hall"
|
||||
},
|
||||
{
|
||||
"id": "st-026",
|
||||
"name": "Empty destination",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-026-empty-destination.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-026-empty-destination.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "mindel",
|
||||
"focusReadout": "State: Empty destination · Person: Mindel · Screen: Movies · Focus: Nothing in Movies yet › Open Discover"
|
||||
},
|
||||
{
|
||||
"id": "st-027",
|
||||
"name": "Jellyfin unreachable (error)",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-027-jellyfin-unreachable-error.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-027-jellyfin-unreachable-error.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "mindel",
|
||||
"focusReadout": "State: Jellyfin unreachable (error) · Person: Mindel · Screen: Shows · Focus: Couldn’t reach your library › Try again"
|
||||
},
|
||||
{
|
||||
"id": "st-028",
|
||||
"name": "Loading (skeleton, no saved copy)",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-028-loading-skeleton-no-saved-copy.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-028-loading-skeleton-no-saved-copy.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "nav",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Loading (skeleton, no saved copy) · Person: Shared · Screen: Movies · Focus: nav › Movies"
|
||||
},
|
||||
{
|
||||
"id": "st-095",
|
||||
"name": "Plate: widest real set on a 256 px poster",
|
||||
"statesGroup": "Ratings: one-line plate (round 3)",
|
||||
"capture": "st-095-plate-widest-real-set-on-a-256-px-poster.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-095-plate-widest-real-set-on-a-256-px-poster.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Plate: widest real set on a 256 px poster · Person: Shared · Screen: Movies · Focus: g4 › Grave of the Fireflies"
|
||||
},
|
||||
{
|
||||
"id": "st-097",
|
||||
"name": "Poster: RT critics on a library grid (Shared Movies)",
|
||||
"statesGroup": "Ratings (round 2)",
|
||||
"capture": "st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Poster: RT critics on a library grid (Shared Movies) · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
|
||||
},
|
||||
{
|
||||
"id": "st-098",
|
||||
"name": "Poster: TMDB only (Justin Anime)",
|
||||
"statesGroup": "Ratings (round 2)",
|
||||
"capture": "st-098-poster-tmdb-only-justin-anime.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-098-poster-tmdb-only-justin-anime.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "justin",
|
||||
"focusReadout": "State: Poster: TMDB only (Justin Anime) · Person: Justin · Screen: Anime · Focus: g0 › Chainsaw Man"
|
||||
},
|
||||
{
|
||||
"id": "st-112",
|
||||
"name": "Conductarr down: a cached rating still shows",
|
||||
"statesGroup": "Ratings (round 2)",
|
||||
"capture": "st-112-conductarr-down-a-cached-rating-still-shows.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-112-conductarr-down-a-cached-rating-still-shows.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Conductarr down: a cached rating still shows · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
|
||||
},
|
||||
{
|
||||
"id": "st-113",
|
||||
"name": "Conductarr down: an uncached title shows nothing",
|
||||
"statesGroup": "Ratings (round 2)",
|
||||
"capture": "st-113-conductarr-down-an-uncached-title-shows-nothing.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-113-conductarr-down-an-uncached-title-shows-nothing.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Conductarr down: an uncached title shows nothing · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
|
||||
}
|
||||
],
|
||||
"focusMap": {
|
||||
"entry": "first grid card (not the toolbar); returning: remembered card per person",
|
||||
"zones": {
|
||||
"toolbar": {
|
||||
"up": "nav",
|
||||
"down": "grid",
|
||||
"left": "previous chip",
|
||||
"right": "next chip",
|
||||
"ok": "Sort cycles A–Z -> Recently added -> Rating; Filter toggles All / Unwatched; focus returns to the toolbar",
|
||||
"back": "nav",
|
||||
"source": "index.html:445"
|
||||
},
|
||||
"grid": {
|
||||
"up": "row above, same column (top row -> toolbar)",
|
||||
"down": "row below, same column (shorter last row: its last card)",
|
||||
"left": "previous column (column 0: stays)",
|
||||
"right": "next column; at the row end: stays (no rail below 200 titles)",
|
||||
"ok": "title page (key-up)",
|
||||
"back": "nav, place kept",
|
||||
"ratings": "one-line plate after 350 ms",
|
||||
"source": "index.html:446"
|
||||
},
|
||||
"letterRail": "only at >= 200 titles and A–Z sort: Right at a row end enters; Up/Down move letters and scroll live; OK or Left drops focus on that letter's first title; Back returns to where you were (index.html:1586-1605)"
|
||||
},
|
||||
"exit": [
|
||||
"Up from toolbar -> nav",
|
||||
"Back -> nav"
|
||||
]
|
||||
},
|
||||
"pressPaths": [
|
||||
{
|
||||
"goal": "Reach “Spirited Away” (68th of 84) in Shared Movies by scrolling",
|
||||
"keys": [
|
||||
"RIGHT",
|
||||
"RIGHT",
|
||||
"OK",
|
||||
"UP",
|
||||
"RIGHT",
|
||||
"RIGHT",
|
||||
"OK",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"RIGHT",
|
||||
"RIGHT"
|
||||
],
|
||||
"presses": 20,
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"component": "Library grid",
|
||||
"source": "Jellyfin /Users/{id}/Items?ParentId=…&SortBy=SortName over the destination's libraries, read in full in 200-item chunks ahead of focus",
|
||||
"fields": [
|
||||
"SortName",
|
||||
"ProductionYear",
|
||||
"ImageTags.Primary",
|
||||
"UserData.Played",
|
||||
"UserData.PlaybackPositionTicks",
|
||||
"ProviderIds.Tmdb"
|
||||
],
|
||||
"cache": "LKG snapshot per destination; refresh on open if older than 10 min",
|
||||
"states": [
|
||||
"empty",
|
||||
"Jellyfin unreachable (LKG + Saved chip, or state row)",
|
||||
"missing art"
|
||||
],
|
||||
"pageRow": "index.html:469"
|
||||
},
|
||||
{
|
||||
"component": "Destinations",
|
||||
"source": "Conductarr GET /curation/library-audiences (owner, media_type, is_anime, libraries[], users[]); until sign-in, the name rule (parked board mapping-source = name-rule)",
|
||||
"pageRow": "index.html:468"
|
||||
}
|
||||
],
|
||||
"strings": {
|
||||
"count": "{n} films | {n} series",
|
||||
"empty": [
|
||||
"Nothing in {Destination} yet",
|
||||
"Titles you or others request for you land here.",
|
||||
"Open Discover"
|
||||
],
|
||||
"emptyUnwatched": [
|
||||
"Nothing unwatched in {Destination}",
|
||||
"Everything here has been watched.",
|
||||
"Show all"
|
||||
],
|
||||
"down": [
|
||||
"Couldn’t reach your library",
|
||||
"Jellyfin isn’t answering. Nothing here is lost; try again in a moment.",
|
||||
"Try again"
|
||||
],
|
||||
"sort": [
|
||||
"A–Z",
|
||||
"Recently added",
|
||||
"Rating"
|
||||
],
|
||||
"filter": [
|
||||
"All",
|
||||
"Unwatched"
|
||||
]
|
||||
},
|
||||
"ratingScoreFormat": {
|
||||
"rule": "TMDB, IMDb and \"Ep\" scores (0-10) are formatted with the TV locale and exactly one decimal, e.g. String.format(Locale.getDefault(), \"%.1f\", x): 7,8 on a Dutch-locale box, 7.8 on an English one. RT percentages unchanged (87%). The page prints 7.8 always: a stand-in for the English locale, not authoritative.",
|
||||
"acceptance": "on a Dutch-locale emulator a TMDB 7.8 renders 7,8 and the one-line plate rule (spec 4.8) still holds because the width is unchanged [INFERENCE: comma and point advance widths not measured]; on an English-locale emulator it renders 7.8 as in the captures",
|
||||
"source": "Justin 2026-11-05, corr=c248698d8546e1ab; spec 4.7 rule 5, 10.1 check 8"
|
||||
},
|
||||
"$schema": "wolfskin-tv-spec/screen@1",
|
||||
"tokens": "data/tv-spec/tokens.json",
|
||||
"captures": [
|
||||
"st-021-shared-movies-84-continuous-grid.jpg",
|
||||
"st-022-shared-movies-sorted-by-rating-g10.jpg",
|
||||
"st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg",
|
||||
"st-024-justin-anime-18-series.jpg",
|
||||
"st-025-unwatched-filter.jpg",
|
||||
"st-026-empty-destination.jpg",
|
||||
"st-027-jellyfin-unreachable-error.jpg",
|
||||
"st-028-loading-skeleton-no-saved-copy.jpg",
|
||||
"st-095-plate-widest-real-set-on-a-256-px-poster.jpg",
|
||||
"st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg",
|
||||
"st-098-poster-tmdb-only-justin-anime.jpg",
|
||||
"st-112-conductarr-down-a-cached-rating-still-shows.jpg",
|
||||
"st-113-conductarr-down-an-uncached-title-shows-nothing.jpg"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 12 KiB |