Merge library slice and reuse shared image policy
Some checks failed
review/design-claude Changes requested: 7 design-parity findings (separator opacity, line-box rhythm, stage gap, mark chips, avatar, count, thumb fringe)
|
|
@ -1,21 +1,47 @@
|
|||
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.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.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
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")
|
||||
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)
|
||||
Text(facts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ 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
|
||||
|
|
@ -120,6 +121,8 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
|
|||
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, L.thumbHeight) }
|
||||
Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
.background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.pointerInput(item.id) {
|
||||
|
|
@ -129,14 +132,14 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
|
|||
}.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 = item.poster, contentDescription = null,
|
||||
AsyncImage(model = request, imageLoader = LibraryImages.loader(context), contentDescription = null,
|
||||
modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb)).background(C.surface3),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(item.title, style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Row {
|
||||
Text("for ${item.recipient(viewer)}", style = ComingInTypography.caption, color = secondary)
|
||||
Text(" · ", style = ComingInTypography.caption, color = secondary.copy(alpha = secondary.alpha * T.separatorAlpha))
|
||||
Text(" · ", style = ComingInTypography.caption, color = secondary.copy(alpha = secondary.alpha * WolfskinTokens.Library.captionSeparatorOpacity))
|
||||
Text("by ${item.actor(viewer)}", style = ComingInTypography.caption, color = secondary)
|
||||
}
|
||||
FlowRow(Modifier.padding(top = T.stageMargin), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
|||
"clock" -> R.drawable.wolfskin_clock
|
||||
"check" -> R.drawable.wolfskin_check
|
||||
"download" -> R.drawable.wolfskin_download
|
||||
"film" -> R.drawable.wolfskin_film
|
||||
"eye" -> R.drawable.wolfskin_eye
|
||||
"down" -> R.drawable.wolfskin_down
|
||||
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,34 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.unit.Dp
|
||||
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: Any?, width: Dp = WolfskinTokens.Card.posterW, height: Dp = WolfskinTokens.Card.posterH): ImageRequest =
|
||||
ImageRequest.Builder(context).data(poster)
|
||||
.size((width.value * 2).toInt(), (height.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)
|
||||
}
|
||||
}
|
||||
|
|
@ -24,11 +24,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. */
|
||||
|
|
@ -58,6 +65,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() }
|
||||
|
|
@ -78,7 +86,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) {
|
||||
|
|
@ -169,6 +177,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)
|
||||
|
|
@ -201,6 +210,7 @@ class ShellState(
|
|||
onAction(ShellAction.HandOff(group))
|
||||
}
|
||||
}
|
||||
else contentFocus?.key(ShellKey.Ok)
|
||||
}
|
||||
|
||||
/** Requested buttons may name an existing request without changing its authenticated actor. */
|
||||
|
|
@ -231,6 +241,7 @@ class ShellState(
|
|||
onAction(ShellAction.Pop)
|
||||
return
|
||||
}
|
||||
if (!pickerVisible && !navFocused && contentFocus?.key(ShellKey.Back) == true) return
|
||||
if (route == "settings") {
|
||||
route = pickerReturnRoute
|
||||
onAction(ShellAction.Pop)
|
||||
|
|
|
|||
|
|
@ -247,7 +247,6 @@ object WolfskinTokens {
|
|||
val readinessGlyph = 13.0.dp
|
||||
val chevron = 15.0.dp
|
||||
const val chevronAlpha = 0.6f
|
||||
const val separatorAlpha = 0.4f
|
||||
val receiptPaddingY = 14.0.dp
|
||||
val receiptPaddingX = 15.0.dp
|
||||
val receiptGap = 5.0.dp
|
||||
|
|
@ -321,4 +320,51 @@ 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
wolfskin-design/src/main/res/drawable/ic_rating_imdb.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="575dp"
|
||||
android:height="289.83dp"
|
||||
android:viewportWidth="575"
|
||||
android:viewportHeight="289.83">
|
||||
<path
|
||||
android:pathData="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z"
|
||||
android:fillColor="#f6c700"/>
|
||||
<path
|
||||
android:pathData="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z"
|
||||
android:fillColor="#000000"/>
|
||||
</vector>
|
||||
16
wolfskin-design/src/main/res/drawable/ic_rating_tmdb.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="185.04dp"
|
||||
android:height="133.4dp"
|
||||
android:viewportWidth="185.04"
|
||||
android:viewportHeight="133.4">
|
||||
<path android:pathData="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient android:type="linear" android:startX="0" android:startY="66.7" android:endX="185.04" android:endY="66.7">
|
||||
<item android:offset="0" android:color="#90cea1"/>
|
||||
<item android:offset="0.56" android:color="#3cbec9"/>
|
||||
<item android:offset="1" android:color="#00b3e5"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="138.75dp"
|
||||
android:height="141.25dp"
|
||||
android:viewportWidth="138.75"
|
||||
android:viewportHeight="141.25">
|
||||
<path
|
||||
android:pathData="m20.15,40.83c-28.15,27.62 -13.66,61.01 -5.73,71.93 35.25,41.95 92.79,25.34 111.89,-5.91 4.76,-8.2 22.55,-53.47 -23.98,-78.01z"
|
||||
android:fillColor="#f93208"/>
|
||||
<path
|
||||
android:pathData="m39.61,39.26 l4.78,-8.86 28.41,-5.04 11.12,9.21z"
|
||||
android:fillColor="#f93208"/>
|
||||
<path
|
||||
android:pathData="m39.44,8.57 l8.97,-5.28 6.76,15.48c3.79,-6.32 13.79,-16.32 24.94,-4.67 -4.73,1.26 -7.52,3.86 -7.74,8.48 15.15,-4.17 31.34,3.21 33.54,9.09 -10.95,-4.31 -27.69,10.38 -41.77,2.33 0.01,15.05 -12.62,16.64 -19.9,17.08 2.08,-5 5.59,-9.99 1.47,-14.99 -7.62,8.17 -13.87,10.67 -33.17,4.67 4.88,-1.68 14.84,-11.39 24.45,-11.43 -6.78,-2.47 -12.29,-2.09 -17.81,-1.48 2.92,-3.96 12.15,-15.2 28.63,-8.48z"
|
||||
android:fillColor="#02902e"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="145dp"
|
||||
android:height="140dp"
|
||||
android:viewportWidth="145"
|
||||
android:viewportHeight="140">
|
||||
<path
|
||||
android:pathData="M47.4,35.34c-13.61,-7.93 -12.32,-25.2 2.1,-31.88 26.12,-6.53 29.12,13.78 22.65,30.41 -6.54,24.11 18.09,23.66 19.92,10.07 3.61,-18.41 19.39,-26.69 31.67,-16.36 12.6,12.14 7.07,36.58 -17.83,34.19 -16.03,-1.54 -19.55,19.58 0.84,21.18 32.23,1.91 42.49,22.17 31.04,35.87 -15.99,15.15 -37.69,-4.44 -45.51,-19.5 -6.8,-9.31 -17.32,0.11 -13.42,6.5 12.98,19.47 2.92,31.23 -10.91,30.62 -13.37,-0.85 -20.96,-9.06 -13.21,-29.15 3.9,-12.48 -8.6,-15.39 -16.57,-5.45 -11.71,19.61 -28.86,13.68 -33.98,4.19 -3.24,-7.62 -2.92,-25.85 24.12,-23.7 16.69,4.14 11.78,-12.56 -0.63,-13.63 -9.24,-0.44 -30.5,-7.3 -22.86,-24.54 7.34,-11.06 24.96,-11.77 33.35,6.29 3.04,4.23 8.36,11.04 18.04,5.03 3.51,-5.2 1.21,-13.9 -8.81,-20.14z"
|
||||
android:fillColor="#0fc755"/>
|
||||
</vector>
|
||||
73
wolfskin-design/src/main/res/drawable/ic_rt_spilled.xml
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="143.75dp"
|
||||
android:height="108.75dp"
|
||||
android:viewportWidth="143.75"
|
||||
android:viewportHeight="108.75">
|
||||
<path
|
||||
android:pathData="m96.641 2.9657c28.149 1.101 27.459 97.814 0.825 97.194l-74.45-9.973c-25.51-7.211-25.922-69.313-0.534-76.178z"
|
||||
android:fillColor="#07a23b"/>
|
||||
<path
|
||||
android:pathData="m85.419 8.8789-63.171 8.9751c-4.2681 3.3648-6.9679 8.2192-7.5687 9.3296l65.017-6.963c1.3226-3.3762 4.3015-9.5395 5.7202-11.342z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m78.042 28.008-65.329 5.5498c-2.494 4.757-3.169 10.65-3.3147 15.17l66.739-1.5147c0.0074-3.2891 0.55003-13.318 1.9052-19.206z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m85.595 94.456-63.251-8.403c-4.2975-3.326-7.0398-8.1557-7.6503-9.2607l65.082 6.3737c1.3522 3.3644 4.3852 9.4999 5.8189 11.29z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m78.051 75.394-65.375-4.957c-2.536-4.734-3.2627-10.621-3.4481-15.14l66.749 0.9101c0.03629 3.2889 0.66694 13.312 2.0737 19.188z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m100.36 10.836c-13.099 0.685-19.878 48.223-11.732 71.195l21.342-4.561c8.39-24.044 1.28-66.986-9.61-66.634z"
|
||||
android:fillColor="#03621e"/>
|
||||
<path
|
||||
android:pathData="m99.087 78.942a6.1255 6.1255 0 0 1 -6.1238 6.1255 6.1255 6.1255 0 0 1 -6.1273 -6.122 6.1255 6.1255 0 0 1 6.1202 -6.1291 6.1255 6.1255 0 0 1 6.1309 6.1184"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m112.96 75.406a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m120.39 74.743a4.9323 4.9323 0 0 1 -4.9309 4.9323 4.9323 4.9323 0 0 1 -4.9337 -4.9294 4.9323 4.9323 0 0 1 4.928 -4.9352 4.9323 4.9323 0 0 1 4.9366 4.9266"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m124.1 78.942a3.7391 3.7391 0 0 1 -3.738 3.7391 3.7391 3.7391 0 0 1 -3.7402 -3.7369 3.7391 3.7391 0 0 1 3.7358 -3.7412 3.7391 3.7391 0 0 1 3.7423 3.7347"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m131.08 83.14a5.0207 5.0207 0 0 1 -5.0192 5.0207 5.0207 5.0207 0 0 1 -5.0222 -5.0178 5.0207 5.0207 0 0 1 5.0163 -5.0236 5.0207 5.0207 0 0 1 5.0251 5.0148"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m135.86 91.67a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m140.36 97.327a3.9158 3.9158 0 0 1 -3.9147 3.9158 3.9158 3.9158 0 0 1 -3.917 -3.9136 3.9158 3.9158 0 0 1 3.9124 -3.9181 3.9158 3.9158 0 0 1 3.9192 3.9113"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m140.45 99.625a4.181 4.181 0 0 1 -4.1798 4.181 4.181 4.181 0 0 1 -4.1822 -4.1786 4.181 4.181 0 0 1 4.1773 -4.1834 4.181 4.181 0 0 1 4.1846 4.1761"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m134.44 100.55a4.8439 4.8439 0 0 1 -4.8425 4.8439 4.8439 4.8439 0 0 1 -4.8453 -4.8411 4.8439 4.8439 0 0 1 4.8397 -4.8467 4.8439 4.8439 0 0 1 4.8481 4.8383"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m126.84 100.24a4.0042 4.0042 0 0 1 -4.0031 4.0042 4.0042 4.0042 0 0 1 -4.0054 -4.0019 4.0042 4.0042 0 0 1 4.0007 -4.0065 4.0042 4.0042 0 0 1 4.0077 3.9996"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m125.43 97.636a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m117.12 98.078a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m110.49 97.459a3.6065 3.6065 0 0 1 -3.6054 3.6065 3.6065 3.6065 0 0 1 -3.6075 -3.6044 3.6065 3.6065 0 0 1 3.6033 -3.6086 3.6065 3.6065 0 0 1 3.6096 3.6023"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m105.72 96.929a4.0484 4.0484 0 0 1 -4.0472 4.0484 4.0484 4.0484 0 0 1 -4.0496 -4.0461 4.0484 4.0484 0 0 1 4.0449 -4.0508 4.0484 4.0484 0 0 1 4.052 4.0437"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m94.71 80.271c2.1568-1.7217 5.4319-2.8842 9.5881-3.6062l11.579 0.61872 15.203 13.612-2.0329 9.6343-27.047-2.5633-4.7288 1.4584-11.183-17.899z"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m85.913 71.627c3.2472 12.036 7.0507 22.57 12.64 28.284l-9.9879-1.591s-5.5685-25.456-4.8614-25.544c0.70711-0.08839 2.2097-1.149 2.2097-1.149z"
|
||||
android:fillColor="#09a339"/>
|
||||
</vector>
|
||||
52
wolfskin-design/src/main/res/drawable/ic_rt_upright.xml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="106.25dp"
|
||||
android:height="140dp"
|
||||
android:viewportWidth="106.25"
|
||||
android:viewportHeight="140">
|
||||
<path
|
||||
android:pathData="M2.727 39.537c-.471-21.981 100.88-25.089 100.88-.42L92.91 117.56c-7.605 26.86-72.064 27.007-79.07.21z"
|
||||
android:fillColor="#fa3106"/>
|
||||
<path
|
||||
android:pathData="M8.809 51.911l9.018 66.639c3.472 4.515 8.498 7.384 9.648 8.022l-6.921-68.576c-3.498-1.41-9.881-4.579-11.745-6.083zM28.629 59.776l5.453 68.898c4.926 2.652 11.04 3.391 15.73 3.566l-1.258-70.366c-3.414-.024-13.82-.642-19.925-2.098zM97.632 52.121l-9.019 66.643c-3.472 4.515-8.498 7.384-9.647 8.022l6.92-68.583c3.5-1.41 9.882-4.579 11.746-6.082zM77.812 59.986l-5.453 68.898c-4.926 2.652-11.04 3.391-15.73 3.566l1.258-70.366c3.414-.024 13.82-.642 19.925-2.098z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M6.397,31.252a6.816,6.816 0 1,0 13.632,0a6.816,6.816 0 1,0 -13.632,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M15.415,27.687a6.607,6.607 0 1,0 13.214,0a6.607,6.607 0 1,0 -13.214,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M24.434,19.769a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M28.943,15.155a6.03,6.03 0 1,0 12.06,0a6.03,6.03 0 1,0 -12.06,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M40.164,17.095a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M44.883,9.597a6.24,6.24 0 1,0 12.48,0a6.24,6.24 0 1,0 -12.48,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M54.636,9.387a6.554,6.554 0 1,0 13.108,0a6.554,6.554 0 1,0 -13.108,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M63.025,13.635a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M70.156,17.672a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M74.561,22.706a4.352,4.352 0 1,0 8.704,0a4.352,4.352 0 1,0 -8.704,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M78.232,26.324a5.243,5.243 0 1,0 10.486,0a5.243,5.243 0 1,0 -10.486,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M82.426,34.398a5.768,5.768 0 1,0 11.536,0a5.768,5.768 0 1,0 -11.536,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M87.355 35.447c5.79 2.799 1.352-2.213 10.696 2.097-9.574 15.338-74.774 16.892-90.291.525l-.21-3.985L38.59 16.99l22.863-6.606 15.52 9.962z"
|
||||
android:fillColor="#ffd600"/>
|
||||
</vector>
|
||||
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 |