Integrate libraries and correct Home review blockers
|
|
@ -25,12 +25,16 @@ class HomeFixture(context: Context) {
|
|||
val continuation = if (status == HomeAvailability.SignedOut || status == HomeAvailability.Unavailable) listOf(homeDiscoverState(status)) else continuation(profile, state)
|
||||
var rows = buildHomeRows(library, continuation, state != "jf-down", state == "loading")
|
||||
rows = rows.map { row ->
|
||||
if (!row.catalogue) row else row.copy(cards = row.cards.map { title ->
|
||||
val names = owners[title.tmdbId?.let { "${title.type}:$it" } ?: title.id].orEmpty()
|
||||
val held = title.held || profile.getString("name") in names
|
||||
val other = if (held) null else names.filter { it != profile.getString("name") }.takeIf { it.isNotEmpty() }?.joinToString(" and ")
|
||||
if (held == title.held && other == title.otherOwner) title else title.copy(held = held, otherOwner = other)
|
||||
})
|
||||
if (!row.catalogue) row else {
|
||||
fun owned(title: HomeTitle): HomeTitle {
|
||||
val names = owners[title.tmdbId?.let { "${title.type}:$it" } ?: title.id].orEmpty()
|
||||
val held = title.held || profile.getString("name") in names
|
||||
val other = if (held) null else names.filter { it != profile.getString("name") }.takeIf { it.isNotEmpty() }?.joinToString(" and ")
|
||||
return if (held == title.held && other == title.otherOwner) title else title.copy(held = held, otherOwner = other)
|
||||
}
|
||||
val cards = row.cards.map(::owned)
|
||||
row.copy(cards = cards, allCards = if (row.allCards === row.cards) cards else row.allCards.map(::owned))
|
||||
}
|
||||
}
|
||||
if (state == "missing") {
|
||||
val missing = profiles.flatMap { person -> listOf("movies", "shows", "anime", "discover", "nextUp", "recentlyAdded").flatMap { key -> person.items(key).filterIndexed { index, _ -> index % 3 == 0 } } }.map { it.getString("id") }.toSet()
|
||||
|
|
@ -60,10 +64,10 @@ class HomeFixture(context: Context) {
|
|||
val showRow = take(shows, minOf(12, kotlin.math.ceil(shows.size.toDouble() / (groupRows.size + 1)).toInt()))
|
||||
val remaining = (movies + shows).count { it.identity() !in seen }
|
||||
val size = if (groupRows.isEmpty()) 0 else minOf(8, kotlin.math.ceil(remaining.toDouble() / groupRows.size).toInt())
|
||||
val watchedRows = groupRows.map { (seed, items) -> Triple(seed, take(items, size), items.size) }.filter { it.second.size >= 3 }
|
||||
if (movieRow.isNotEmpty()) add(shelf("pk-m", "Movies for you", movieRow, movies.size))
|
||||
if (showRow.isNotEmpty()) add(shelf("pk-s", "Series for you", showRow, shows.size))
|
||||
watchedRows.take(2).forEachIndexed { index, (seed, items, total) -> add(shelf("pk-w$index", "Because you watched ${seed.getString("title")}", items, total)) }
|
||||
val watchedRows = groupRows.map { (seed, items) -> Triple(seed, take(items, size), items) }.filter { it.second.size >= 3 }
|
||||
if (movieRow.isNotEmpty()) add(shelf("pk-m", "Movies for you", movieRow, movies.size, movies))
|
||||
if (showRow.isNotEmpty()) add(shelf("pk-s", "Series for you", showRow, shows.size, shows))
|
||||
watchedRows.take(2).forEachIndexed { index, (seed, items, all) -> add(shelf("pk-w$index", "Because you watched ${seed.getString("title")}", items, all.size, all)) }
|
||||
}
|
||||
val excluded = flatMap { row -> row.cards }.map { it.id }.toSet()
|
||||
val base = pool.filter { it.getString("id") !in excluded }
|
||||
|
|
@ -72,15 +76,18 @@ class HomeFixture(context: Context) {
|
|||
val rotated = base.drop(shift) + base.take(shift)
|
||||
add(shelf("new-$index", "New on $service", rotated.take(12).sortedByDescending { it.optInt("year") }))
|
||||
}
|
||||
add(shelf("trending", "Trending", base.take(WolfskinTokens.Behaviour.rowLimit.toInt()), base.size))
|
||||
add(shelf("trending", "Trending", base.take(WolfskinTokens.Behaviour.rowLimit.toInt()), base.size, base))
|
||||
listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").forEach { genre ->
|
||||
val titles = base.filter { genre in genres(it) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.getString("type") == "movie") }
|
||||
if (titles.isNotEmpty()) add(shelf("genre-$genre", genre, titles.take(WolfskinTokens.Behaviour.rowLimit.toInt()), titles.size))
|
||||
if (titles.isNotEmpty()) add(shelf("genre-$genre", genre, titles.take(WolfskinTokens.Behaviour.rowLimit.toInt()), titles.size, titles))
|
||||
}
|
||||
add(HomeRow("explore", HomeRowKind.Explore, "Explore more in Discover"))
|
||||
}
|
||||
|
||||
private fun shelf(id: String, name: String, items: List<JSONObject>, total: Int = items.size) = HomeRow(id, HomeRowKind.Shelf, name, items.map(::title), catalogue = true, total = total, target = "see-all:$id")
|
||||
private fun shelf(id: String, name: String, items: List<JSONObject>, total: Int = items.size, allItems: List<JSONObject> = items): HomeRow {
|
||||
val cards = items.map(::title)
|
||||
return HomeRow(id, HomeRowKind.Shelf, name, cards, catalogue = true, total = total, allCards = if (allItems === items) cards else allItems.map(::title))
|
||||
}
|
||||
|
||||
private fun title(item: JSONObject): HomeTitle = HomeTitle(
|
||||
id = item.getString("id"), title = item.getString("title"), type = item.getString("type"),
|
||||
|
|
|
|||
|
|
@ -1,33 +1,49 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.tv.material3.Text
|
||||
|
||||
/** Captions fade without moving the reserved band under the focused poster. */
|
||||
@Composable
|
||||
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, availability: String? = null) {
|
||||
val alpha = remember(title, facts, availability) { Animatable(0f) }
|
||||
LaunchedEffect(alpha, visible) { alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))) }
|
||||
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
|
||||
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Row(Modifier.clipToBounds(), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Layout.captionOffset)) {
|
||||
val entries = facts.split(" · ") + listOfNotNull(availability)
|
||||
entries.forEachIndexed { index, fact ->
|
||||
if (index > 0) Text("·", color = WolfskinTokens.Color.fg.copy(alpha = WolfskinTokens.Home.factSeparatorOpacity), style = WolfskinTokens.Type.caption)
|
||||
Text(fact, color = if (availability != null && index == entries.lastIndex) WolfskinTokens.Color.accentText else WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false)
|
||||
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false,
|
||||
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
|
||||
titleOverflow: TextOverflow = TextOverflow.Clip) {
|
||||
val alpha = remember(title, facts) { Animatable(0f) }
|
||||
LaunchedEffect(alpha, visible) {
|
||||
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
||||
}
|
||||
val styledFacts = remember(facts, accentLastFact) {
|
||||
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)
|
||||
}
|
||||
val muted = WolfskinTokens.Color.muted
|
||||
val separatorStyle = SpanStyle(color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity))
|
||||
var start = facts.indexOf(separator)
|
||||
while (start >= 0) {
|
||||
val dot = start + separator.indexOf('·')
|
||||
addStyle(separatorStyle, dot, dot + 1)
|
||||
start = facts.indexOf(separator, start + separator.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
|
||||
Text(title, modifier = Modifier.exactLineBox(titleStyle), color = WolfskinTokens.Color.fg, style = titleStyle, maxLines = 1, overflow = titleOverflow)
|
||||
Text(styledFacts, modifier = Modifier.exactLineBox(factsStyle), color = WolfskinTokens.Color.muted, style = factsStyle, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
|||
"wifi" -> R.drawable.wolfskin_wifi
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -7,13 +7,12 @@ class HomeFocus(
|
|||
private val shell: ShellState,
|
||||
private val rows: List<HomeRow>,
|
||||
private val onAction: (HomeAction) -> Unit,
|
||||
) {
|
||||
private var armed: Pair<String, Int>? = null
|
||||
) : ShellContentFocus {
|
||||
override val cardKey: String? get() = row?.let { if (it.kind == HomeRowKind.Shelf) it.cards.getOrNull(column)?.id ?: it.id else null }
|
||||
val row: HomeRow? get() = rows.getOrNull(shell.view.row)
|
||||
val column: Int get() = row?.let { shell.view.cards[it.id] ?: 0 } ?: 0
|
||||
|
||||
fun focus(index: Int, column: Int) {
|
||||
armed = null
|
||||
shell.navFocused = false
|
||||
shell.view.row = index
|
||||
shell.view.home.focusedKey = rows[index].id
|
||||
|
|
@ -23,21 +22,10 @@ class HomeFocus(
|
|||
if (target.kind == HomeRowKind.Shelf) shell.view.home.firstCards[target.id] = target.adjustedFirst(bounded, shell.view.home.firstCards[target.id] ?: 0)
|
||||
}
|
||||
|
||||
fun key(key: ShellKey, down: Boolean, repeat: Int): Boolean {
|
||||
override fun key(key: ShellKey): Boolean {
|
||||
val current = row ?: return false
|
||||
if (key == ShellKey.Back) { armed = null; return false }
|
||||
if (key == ShellKey.Ok) {
|
||||
if (repeat > 0) return true
|
||||
if (current.kind == HomeRowKind.Shelf) {
|
||||
if (down) armed = current.id to column else {
|
||||
if (armed == (current.id to column)) activate(current)
|
||||
armed = null
|
||||
}
|
||||
} else if (down) activate(current)
|
||||
return true
|
||||
}
|
||||
if (!down) return true
|
||||
armed = null
|
||||
if (key == ShellKey.Back) return false
|
||||
if (key == ShellKey.Ok) { activate(current); return true }
|
||||
if (reenterVisible()) return true
|
||||
when (key) {
|
||||
ShellKey.Left -> focus(shell.view.row, (column - 1).coerceAtLeast(0))
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import androidx.tv.material3.Text
|
|||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Type as T
|
||||
import org.justinvisser.wolfskin.design.HomeType as T
|
||||
|
||||
/** The hero occupies one row while its backdrop extends beneath the first shelves. */
|
||||
@Composable
|
||||
|
|
@ -23,29 +23,29 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
|||
if (!row.skeleton) {
|
||||
val title = row.cards.first()
|
||||
val artModifier = Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)
|
||||
HomeArt(title, WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight, artModifier, landscape = true, hero = true)
|
||||
TitleArtwork(title.title, title.type, title.poster, WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight, artModifier, backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = true, hero = true)
|
||||
Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
|
||||
Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f))))
|
||||
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) {
|
||||
Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f)))
|
||||
Text(when (row.heroKind) { "resume" -> "Continue watching"; "next" -> "Up next"; "new" -> "New in your library"; else -> "For you" }, color = C.accentText, style = T.label)
|
||||
Text(when (row.heroKind) { "resume" -> "Continue watching"; "next" -> "Up next"; "new" -> "New in your library"; else -> "For you" }, modifier = Modifier.exactLineBox(T.label), color = C.accentText, style = T.label)
|
||||
}
|
||||
Spacer(Modifier.height(H.kickerBottom))
|
||||
Text(title.displayTitle, color = C.fg, style = T.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(title.displayTitle, modifier = Modifier.exactLineBox(T.displayHero), color = C.fg, style = T.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.height(H.titleBottom))
|
||||
Row(Modifier.heightIn(min = H.factsHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.factsGap)) {
|
||||
Text(if (title.isEpisode) title.subtitle else title.year?.toString().orEmpty(), color = C.muted, style = T.body, maxLines = 1)
|
||||
if (row.heroKind == "resume" || title.runtimeSeconds != null) Text(if (row.heroKind == "resume") title.remaining else title.runtime, color = C.muted, style = T.body, maxLines = 1)
|
||||
Text(if (title.isEpisode) title.subtitle else title.year?.toString().orEmpty(), modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1)
|
||||
if (row.heroKind == "resume" || title.runtimeSeconds != null) Text(if (row.heroKind == "resume") title.remaining else title.runtime, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1)
|
||||
HomeRatingsRow(ratings)
|
||||
}
|
||||
Spacer(Modifier.height(H.factsBottom))
|
||||
Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax))
|
||||
Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
|
||||
Spacer(Modifier.height(H.overviewBottom))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
||||
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, lift = true) { onButton(0) }
|
||||
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, lift = true) { onButton(1) }
|
||||
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, lift = true, textStyle = T.button) { onButton(0) }
|
||||
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, lift = true, textStyle = T.button) { onButton(1) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -69,7 +69,11 @@ fun HomeSection(person: ShellPerson, savedAt: String?) {
|
|||
Text("Discover", color = C.fg, style = T.sectionTitle)
|
||||
Avatar(person, H.sectionAvatar, T.avatarLetterSectionHeader)
|
||||
Text("Picks for you first, then what’s out there", color = C.muted, style = T.body)
|
||||
if (savedAt != null) Text("Saved $savedAt", color = C.warn, style = T.caption)
|
||||
if (savedAt != null) Box(Modifier.height(WolfskinTokens.Shell.settingsPillHeight)
|
||||
.border(H.hairline, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill))
|
||||
.padding(horizontal = WolfskinTokens.Shell.settingsPillPadding + H.hairline), contentAlignment = Alignment.Center) {
|
||||
Text("Saved $savedAt", color = C.warn, style = T.caption.copy(fontWeight = T.label.fontWeight))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -78,15 +82,15 @@ fun HomeSection(person: ShellPerson, savedAt: String?) {
|
|||
@Composable
|
||||
fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> Unit, onHover: () -> Unit) {
|
||||
Box(Modifier.fillMaxWidth().height(L.row_state)) {
|
||||
Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX, vertical = H.statePaddingY)) {
|
||||
Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX + H.hairline, vertical = H.statePaddingY + H.hairline)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.plateTop)) {
|
||||
Glyph(if (row.calm) "profile" else "alert", if (row.calm) C.accentText else C.warn, Modifier.size(H.stateIcon))
|
||||
Text(row.title, color = C.fg, style = T.stateTitle.copy(fontWeight = FontWeight.Bold))
|
||||
Text(row.title, modifier = Modifier.exactLineBox(T.stateTitle), color = C.fg, style = T.stateTitle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
Spacer(Modifier.height(H.stateGap))
|
||||
Text(row.text, color = C.muted, style = T.body)
|
||||
Text(row.text, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body)
|
||||
Spacer(Modifier.height(H.stateTextBottom))
|
||||
PickerButton("Try again", focused, row.calm, onFocus = onHover, lift = true, onClick = retry)
|
||||
PickerButton("Try again", focused, row.calm, onFocus = onHover, lift = true, textStyle = T.button, onClick = retry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ data class HomeRow(
|
|||
val heroKind: String = "next",
|
||||
val skeleton: Boolean = false,
|
||||
val calm: Boolean = false,
|
||||
val allCards: List<HomeTitle> = cards,
|
||||
) {
|
||||
val hasSeeAll: Boolean get() = total > cards.size
|
||||
val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick") 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 }
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import androidx.tv.material3.Text
|
|||
import java.util.Locale
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Type as T
|
||||
import org.justinvisser.wolfskin.design.HomeType as T
|
||||
|
||||
/** Formatting is measured before fitting, so decimal commas obey the same one-line rule. */
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ fun HomeScreen(
|
|||
val action by rememberUpdatedState(onAction)
|
||||
val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } }
|
||||
DisposableEffect(shell, focus) {
|
||||
shell.contentInput = focus::key
|
||||
onDispose { shell.contentInput = null }
|
||||
shell.contentFocus = focus
|
||||
onDispose { if (shell.contentFocus === focus) shell.contentFocus = null }
|
||||
}
|
||||
LaunchedEffect(snapshot.rows, view) {
|
||||
snapshot.rows.forEach { item ->
|
||||
|
|
@ -41,8 +41,15 @@ fun HomeScreen(
|
|||
}
|
||||
val remembered = snapshot.rows.indexOfFirst { it.id == view.home.focusedKey && it.focusable }
|
||||
if (remembered >= 0) view.row = remembered
|
||||
if (snapshot.rows.none { it.focusable }) { shell.navFocused = true; shell.navIndex = shell.currentTab }
|
||||
else if (snapshot.rows.getOrNull(view.row)?.focusable != true) view.row = snapshot.rows.indexOfFirst { it.focusable }
|
||||
if (snapshot.rows.none { it.focusable }) {
|
||||
if (view.home.focusedKey == null && !shell.navFocused) view.home.loadingEntryRevision = shell.inputRevision
|
||||
shell.navFocused = true
|
||||
shell.navIndex = shell.currentTab
|
||||
} else {
|
||||
if (view.home.loadingEntryRevision == shell.inputRevision) focus.focus(snapshot.rows.indexOfFirst { it.focusable }, 0)
|
||||
view.home.loadingEntryRevision = null
|
||||
if (snapshot.rows.getOrNull(view.row)?.focusable != true) view.row = snapshot.rows.indexOfFirst { it.focusable }
|
||||
}
|
||||
view.home.focusedKey = snapshot.rows.getOrNull(view.row)?.id
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -50,7 +57,7 @@ fun HomeScreen(
|
|||
val topInset = if (snapshot.rows.firstOrNull()?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(view.row, snapshot.rows) {
|
||||
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { HomeImages.prefetch(context, it) }
|
||||
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) }
|
||||
}
|
||||
LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) {
|
||||
val key = snapshot.rows.getOrNull(view.row)?.id
|
||||
|
|
@ -86,7 +93,7 @@ fun HomeScreen(
|
|||
LazyColumn(state = view.listState, modifier = modifier.testTag("wolfskin-home").semantics { stateDescription = "${row?.id}:${focus.column}" }, contentPadding = PaddingValues(top = topInset, bottom = trailingSpace)) {
|
||||
itemsIndexed(snapshot.rows, key = { _, item -> item.id }, contentType = { _, item -> item.kind.name.lowercase() }) { index, item ->
|
||||
val selected = index == view.row && !shell.navFocused
|
||||
fun activate(column: Int) { focus.focus(index, column); focus.key(ShellKey.Ok, true, 0); focus.key(ShellKey.Ok, false, 0) }
|
||||
fun activate(column: Int) { focus.focus(index, column); focus.key(ShellKey.Ok) }
|
||||
when (item.kind) {
|
||||
HomeRowKind.Hero -> HomeHero(item, selected, focus.column, if (showRatings) heroRatings else emptyList(), ::activate) { focus.focus(index, it) }
|
||||
HomeRowKind.Shelf -> HomeShelf(item, view.home, selected, view.cards[item.id] ?: 0, if (showRatings && selected) focusedRatings else emptyList(), ::activate) { focus.focus(index, it) }
|
||||
|
|
@ -94,10 +101,10 @@ fun HomeScreen(
|
|||
HomeRowKind.State -> HomeRecovery(item, selected, { activate(0) }) { focus.focus(index, 0) }
|
||||
HomeRowKind.Note -> Row(Modifier.fillMaxWidth().height(L.row_note).padding(start = L.safeSide, top = H.noteTop), horizontalArrangement = Arrangement.spacedBy(H.noteGap)) {
|
||||
Glyph(if (item.calm) "eye" else "alert", if (item.calm) C.muted else C.warn, Modifier.size(L.buttonIcon))
|
||||
Text(item.text, color = if (item.calm) C.muted else C.warn, style = WolfskinTokens.Type.body)
|
||||
Text(item.text, color = if (item.calm) C.muted else C.warn, style = HomeType.body)
|
||||
}
|
||||
HomeRowKind.Explore -> Box(Modifier.fillMaxWidth().height(L.row_endcta).padding(start = L.safeSide, top = H.endTop)) {
|
||||
PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }, lift = true) { activate(0) }
|
||||
PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }, lift = true, textStyle = HomeType.button) { activate(0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
|
|
@ -15,11 +14,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
|
@ -28,7 +28,7 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
|
|||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Type as T
|
||||
import org.justinvisser.wolfskin.design.HomeType as T
|
||||
|
||||
/** Every lazy row and card uses a stable source identity and an explicit content type. */
|
||||
@Composable
|
||||
|
|
@ -50,28 +50,25 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
|
|||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
|
||||
itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title ->
|
||||
val selected = focused && column == index
|
||||
Box(Modifier.size(width, height).zIndex(if (selected) 2f else 0f).focusScale(selected, WolfskinTokens.Focus.scaleCard).focusLift(selected, WolfskinTokens.Radius.card, H.cardLiftOffset, H.cardLiftBlur, H.cardLiftOpacity).focusRing(selected, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).testTag("home-card:${row.id}:$index").then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) }.clickable { onCard(index) } else Modifier)) {
|
||||
Box(Modifier.fillMaxSize().clip(RoundedCornerShape(WolfskinTokens.Radius.card))) {
|
||||
when {
|
||||
row.skeleton -> Box(Modifier.fillMaxSize().background(C.surface))
|
||||
title == null -> SeeAll(row.total)
|
||||
else -> {
|
||||
HomeArt(title, width, height, Modifier.fillMaxSize(), landscape = row.cardKind != HomeCardKind.Poster)
|
||||
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)
|
||||
if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) }
|
||||
if (title.resumeSeconds > 0 && title.runtimeSeconds != null) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = H.progressInset).padding(bottom = H.progressBottom).fillMaxWidth().height(L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
|
||||
Box(Modifier.fillMaxHeight().fillMaxWidth((title.resumeSeconds / title.runtimeSeconds).toFloat().coerceIn(0f, 1f)).background(C.accent))
|
||||
}
|
||||
HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth())
|
||||
}
|
||||
val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index")
|
||||
.then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier)
|
||||
if (title == null && !row.skeleton) {
|
||||
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label, lift = true)
|
||||
} else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height, lift = true) {
|
||||
if (title == null) Box(Modifier.fillMaxSize().background(C.surface)) else {
|
||||
TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster)
|
||||
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)
|
||||
if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) }
|
||||
if (title.resumeSeconds > 0 && title.runtimeSeconds != null) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = H.progressInset).padding(bottom = if (row.cardKind == HomeCardKind.Poster) H.posterProgressBottom else H.progressBottom).fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
|
||||
Box(Modifier.fillMaxHeight().fillMaxWidth((title.resumeSeconds / title.runtimeSeconds).toFloat().coerceIn(0f, 1f)).background(C.accent))
|
||||
}
|
||||
HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (row.cardKind == HomeCardKind.Poster && focused && !row.skeleton) {
|
||||
val title = row.cards.getOrNull(column)
|
||||
val facts = title?.facts ?: "${row.total} titles"
|
||||
val availability = when {
|
||||
title == null -> null
|
||||
row.catalogue -> if (title.held) "In your library" else title.otherOwner?.let { "In $it’s library" }
|
||||
|
|
@ -79,7 +76,10 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
|
|||
title.resumeSeconds > 0 -> title.remaining
|
||||
else -> null
|
||||
}
|
||||
Caption(title?.title ?: "See all ${row.title}", facts, true, Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(H.captionWidth), availability)
|
||||
val facts = remember(title, row.total, availability) {
|
||||
(title?.facts ?: "${row.total} titles").replace(" · ", " · ") + availability?.let { " · $it" }.orEmpty()
|
||||
}
|
||||
Caption(title?.title ?: "See all ${row.title}", facts, true, Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(H.captionWidth), accentLastFact = availability != null, titleStyle = T.button, factsStyle = T.caption, titleOverflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -88,20 +88,11 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
|
|||
private fun TileOverlay(title: HomeTitle) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
|
||||
Column(Modifier.fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))).padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) {
|
||||
Text(title.displayTitle, color = C.fg, style = T.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(title.subtitle, color = C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(title.displayTitle, modifier = Modifier.exactLineBox(T.button), color = C.fg, style = T.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(title.subtitle, modifier = Modifier.exactLineBox(T.caption), color = C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeeAll(total: Int) {
|
||||
Column(Modifier.fillMaxSize().background(C.surface).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)), verticalArrangement = Arrangement.spacedBy(H.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Glyph("interface", C.accent, Modifier.size(H.seeAllIcon))
|
||||
Text("See all", color = C.fg, style = T.listTitle)
|
||||
Text("$total", color = C.muted, style = T.label)
|
||||
}
|
||||
}
|
||||
|
||||
val HomeRow.cardWidth: Dp get() = when (cardKind) { HomeCardKind.Poster -> K.posterW; HomeCardKind.Tile -> K.tileW; HomeCardKind.Wide -> K.wideW }
|
||||
val HomeRow.cardHeight: Dp get() = when (cardKind) { HomeCardKind.Poster -> K.posterH; HomeCardKind.Tile -> K.tileH; HomeCardKind.Wide -> K.wideH }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.ui.text.PlatformTextStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
|
||||
/** Home keeps full CSS line boxes without moving retained shell or library typography. */
|
||||
object HomeType {
|
||||
private fun fullBox(style: TextStyle): TextStyle = style.copy(
|
||||
platformStyle = PlatformTextStyle(includeFontPadding = false),
|
||||
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None, LineHeightStyle.Mode.Tight),
|
||||
)
|
||||
val body = fullBox(WolfskinTokens.Type.body)
|
||||
val button = fullBox(WolfskinTokens.Type.button)
|
||||
val caption = fullBox(WolfskinTokens.Type.caption)
|
||||
val label = fullBox(WolfskinTokens.Type.label)
|
||||
val displayHero = fullBox(WolfskinTokens.Type.displayHero)
|
||||
val stateTitle = fullBox(WolfskinTokens.Type.stateTitle)
|
||||
val sectionTitle = fullBox(WolfskinTokens.Type.sectionTitle)
|
||||
val rowHeading = WolfskinTokens.Type.rowHeading
|
||||
val ratingPlate = WolfskinTokens.Type.ratingPlate
|
||||
val listTitle = fullBox(WolfskinTokens.Type.listTitle)
|
||||
val avatarLetterSectionHeader = WolfskinTokens.Type.avatarLetterSectionHeader
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import androidx.compose.runtime.setValue
|
|||
class HomeViewMemory {
|
||||
val ratings = HomeRatingCache()
|
||||
val firstCards = mutableStateMapOf<String, Int>()
|
||||
var loadingEntryRevision: Long? = null
|
||||
var focusedKey: String? = null
|
||||
var positionedKey: String? = null
|
||||
var resetScroll by mutableIntStateOf(0)
|
||||
|
|
|
|||
|
|
@ -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,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,115 @@
|
|||
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.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.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,
|
||||
) {
|
||||
TitleCard(focused, modifier.testTag("poster-${title.id}")) {
|
||||
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) { WolfskinImages.request(context, title.poster, (WolfskinTokens.Card.posterW.value * 2).toInt(), (WolfskinTokens.Card.posterH.value * 2).toInt(), true) }
|
||||
AsyncImage(request, title.title, imageLoader = WolfskinImages.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 { WolfskinImages.loader(context).enqueue(WolfskinImages.request(context, it, (K.posterW.value * 2).toInt(), (K.posterH.value * 2).toInt(), true)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
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,31 @@
|
|||
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.text.TextStyle
|
||||
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,
|
||||
titleStyle: TextStyle = WolfskinTokens.Type.listTitle, countStyle: TextStyle = WolfskinTokens.Type.label,
|
||||
lift: Boolean = false) {
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
TitleCard(focused, modifier.clickable(onClick = onClick), width, height, lift) {
|
||||
Column(Modifier.fillMaxSize().background(WolfskinTokens.Color.surface, shape)
|
||||
.border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape),
|
||||
verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge))
|
||||
Text("See all", modifier = Modifier.exactLineBox(titleStyle), color = WolfskinTokens.Color.fg, style = titleStyle)
|
||||
Text(count.toString(), modifier = Modifier.exactLineBox(countStyle), color = WolfskinTokens.Color.muted, style = countStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,12 @@ 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()
|
||||
|
|
@ -29,6 +35,7 @@ class RememberedView {
|
|||
val cards = mutableStateMapOf<String, Int>()
|
||||
var savedAt by mutableStateOf<String?>(null)
|
||||
val home by lazy { HomeViewMemory() }
|
||||
var library: LibraryGridState? = null
|
||||
}
|
||||
|
||||
/** Focus and Back transitions are independent of drawing and server availability. */
|
||||
|
|
@ -56,7 +63,9 @@ class ShellState(
|
|||
private var pickerReturnRoute = "home"
|
||||
private val views = mutableStateMapOf<Pair<String, String>, RememberedView>()
|
||||
private var armedCard: String? = null
|
||||
var contentInput: ((ShellKey, Boolean, Int) -> Boolean)? = null
|
||||
var contentFocus: ShellContentFocus? = null
|
||||
var inputRevision = 0L
|
||||
private set
|
||||
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
|
||||
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
|
||||
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
|
||||
|
|
@ -69,13 +78,12 @@ class ShellState(
|
|||
}
|
||||
|
||||
/** Pointer navigation uses the same transitions as D-pad selection. */
|
||||
fun focusNav(index: Int) { navIndex = index; navFocused = true }
|
||||
fun focusPerson(index: Int) { pickerIndex = index; gearFocused = false }
|
||||
fun focusNav(index: Int) { inputRevision++; navIndex = index; navFocused = true }
|
||||
fun focusPerson(index: Int) { inputRevision++; pickerIndex = index; gearFocused = false }
|
||||
|
||||
/** Repeats are consumed, and a row activation is cancelled when focus moves. */
|
||||
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = null): Boolean {
|
||||
if (!pickerVisible && !navFocused && !requestsVisible && route != "settings" &&
|
||||
contentInput?.invoke(key, down, repeat) == true) return true
|
||||
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = if (!pickerVisible && !navFocused && !requestsVisible) contentFocus?.cardKey else null): Boolean {
|
||||
if (down) inputRevision++
|
||||
if (key == ShellKey.Ok) {
|
||||
if (repeat > 0) return true
|
||||
if (card != null) {
|
||||
|
|
@ -159,6 +167,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)
|
||||
|
|
@ -190,10 +199,12 @@ class ShellState(
|
|||
onAction(ShellAction.HandOff(group))
|
||||
}
|
||||
}
|
||||
else contentFocus?.key(ShellKey.Ok)
|
||||
}
|
||||
|
||||
/** Back never discards a root view, including the card below a focused nav. */
|
||||
fun back() {
|
||||
if (!pickerVisible && !navFocused && contentFocus?.key(ShellKey.Back) == true) return
|
||||
if (route == "settings") {
|
||||
route = pickerReturnRoute
|
||||
onAction(ShellAction.Pop)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.AlignmentLine
|
||||
import androidx.compose.ui.layout.FirstBaseline
|
||||
import androidx.compose.ui.layout.LastBaseline
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
import androidx.compose.ui.unit.constrainHeight
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Android's intrinsic paragraph minimum must not enlarge an explicitly untrimmed CSS line box. */
|
||||
fun Modifier.exactLineBox(style: TextStyle): Modifier {
|
||||
if (style.lineHeightStyle?.trim != LineHeightStyle.Trim.None) return this
|
||||
return layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints.copy(minHeight = 0))
|
||||
val lineHeight = style.lineHeight.toPx()
|
||||
val first = placeable[FirstBaseline]
|
||||
val last = placeable[LastBaseline]
|
||||
val lines = if (first == AlignmentLine.Unspecified || last == AlignmentLine.Unspecified) 1
|
||||
else ((last - first) / lineHeight).roundToInt() + 1
|
||||
val height = constraints.constrainHeight((lineHeight * lines).roundToInt())
|
||||
layout(placeable.width, height) { placeable.placeRelative(0, (height - placeable.height) / 2) }
|
||||
}
|
||||
}
|
||||
|
|
@ -35,20 +35,21 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
|||
private fun SlotImage(source: String, width: Dp, height: Dp, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) {
|
||||
val context = LocalContext.current
|
||||
val request = remember(context, source, width, height, poster) {
|
||||
HomeImages.request(context, source, (width.value * 2).toInt(), (height.value * 2).toInt(), poster)
|
||||
WolfskinImages.request(context, source, (width.value * 2).toInt(), (height.value * 2).toInt(), poster)
|
||||
}
|
||||
AsyncImage(request, imageLoader = HomeImages.loader(context), contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, onError = { onError() })
|
||||
AsyncImage(request, imageLoader = WolfskinImages.loader(context), contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, onError = { onError() })
|
||||
}
|
||||
|
||||
/** Poster-only landscapes use the upright one-sheet over a dimmed wash of the same image. */
|
||||
@Composable
|
||||
fun HomeArt(title: HomeTitle, width: Dp, height: Dp, modifier: Modifier = Modifier, landscape: Boolean = false, hero: Boolean = false) {
|
||||
val source = if (landscape) title.backdrop ?: title.poster else title.poster
|
||||
fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height: Dp, modifier: Modifier = Modifier,
|
||||
backdrop: String? = null, posterOnly: Boolean = false, landscape: Boolean = false, hero: Boolean = false) {
|
||||
val source = if (landscape) backdrop ?: poster else poster
|
||||
val failed = remember(source) { androidx.compose.runtime.mutableStateOf(false) }
|
||||
val missing = source == null || failed.value
|
||||
val density = LocalDensity.current
|
||||
val fallback = if (missing && !hero) remember(title.title, width, height, density) {
|
||||
val hue = title.title.fold(0) { hash, character -> (hash * 31 + character.code) % 360 }.toFloat()
|
||||
val fallback = if (missing && !hero) remember(title, width, height, density) {
|
||||
val hue = title.fold(0) { hash, character -> (hash * 31 + character.code) % 360 }.toFloat()
|
||||
val radians = Math.toRadians(160.0)
|
||||
val directionX = kotlin.math.sin(radians).toFloat()
|
||||
val directionY = -kotlin.math.cos(radians).toFloat()
|
||||
|
|
@ -60,7 +61,7 @@ fun HomeArt(title: HomeTitle, width: Dp, height: Dp, modifier: Modifier = Modifi
|
|||
} else null
|
||||
Box(modifier.then(if (fallback != null) Modifier.background(fallback) else Modifier.background(C.surface2))) {
|
||||
if (!missing) {
|
||||
if (landscape && (title.posterOnly || title.backdrop == null) && title.poster != null) {
|
||||
if (landscape && (posterOnly || backdrop == null) && poster != null) {
|
||||
val wash = remember(hero) {
|
||||
val matrix = ColorMatrix().apply { setToSaturation(if (hero) 1.2f else 1f) }
|
||||
val brightness = if (hero) .55f else .5f
|
||||
|
|
@ -72,15 +73,15 @@ fun HomeArt(title: HomeTitle, width: Dp, height: Dp, modifier: Modifier = Modifi
|
|||
.requiredSize(width + inset * 2, height + inset * 2)
|
||||
.blur(if (hero) H.heroBlur else H.cardBlur)
|
||||
.graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } }
|
||||
SlotImage(title.poster, width, height, washModifier, colorFilter = wash, onError = { failed.value = true })
|
||||
SlotImage(poster, width, height, washModifier, colorFilter = wash, onError = { failed.value = true })
|
||||
val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset
|
||||
val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f)
|
||||
val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop)
|
||||
SlotImage(title.poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true })
|
||||
SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true })
|
||||
} else SlotImage(source, if (hero) WolfskinTokens.Card.posterW * 5 else width, if (hero) WolfskinTokens.Card.posterW * 5 * (9f / 16f) else height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true })
|
||||
} else if (!hero) {
|
||||
Glyph(if (title.type == "movie") "film" else "nextup", C.fg.copy(alpha = .45f), Modifier.offset(H.cardPadding, H.cardPadding).size(H.artGlyph))
|
||||
if (!landscape) Text(title.title, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 4, modifier = Modifier.align(Alignment.BottomStart).padding(H.cardPadding))
|
||||
Glyph(if (type == "movie") "film" else "nextup", C.fg.copy(alpha = .45f), Modifier.offset(H.cardPadding, H.cardPadding).size(H.artGlyph))
|
||||
if (!landscape) Text(title, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 4, modifier = Modifier.align(Alignment.BottomStart).padding(H.cardPadding))
|
||||
} else HomeAmbient(Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/** Shelf, grid and continuation cards share one fixed slot and draw-only focus surface. */
|
||||
@Composable
|
||||
fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = WolfskinTokens.Card.posterW,
|
||||
height: Dp = WolfskinTokens.Card.posterH, lift: Boolean = false, content: @Composable BoxScope.() -> Unit) {
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.then(if (lift) Modifier.focusLift(focused, WolfskinTokens.Radius.card, WolfskinTokens.Home.cardLiftOffset, WolfskinTokens.Home.cardLiftBlur, WolfskinTokens.Home.cardLiftOpacity) else Modifier)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent)) {
|
||||
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
|
||||
}
|
||||
}
|
||||
|
|
@ -102,7 +102,8 @@ private fun RecoveryPicker(state: ShellState) {
|
|||
|
||||
/** Recovery actions remain focusable while profiles cannot be read. */
|
||||
@Composable
|
||||
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, lift: Boolean = false, onClick: () -> Unit) {
|
||||
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, lift: Boolean = false,
|
||||
textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) {
|
||||
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
||||
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.then(if (lift) Modifier.focusLift(focused, WolfskinTokens.Radius.button, WolfskinTokens.Home.buttonLiftOffset, WolfskinTokens.Home.buttonLiftBlur, WolfskinTokens.Home.buttonLiftOpacity) else Modifier)
|
||||
|
|
@ -112,7 +113,7 @@ fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String
|
|||
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) {
|
||||
if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
|
||||
Text(label, color = if (primary) C.onFocus else C.fg, style = WolfskinTokens.Type.button)
|
||||
Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import coil3.request.ImageRequest
|
|||
import coil3.request.allowRgb565
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
/** Home shares one bounded loader; upstream's hidden-switch screens retain their existing loader. */
|
||||
object HomeImages {
|
||||
/** Wolfskin screens share one bounded loader; hidden-switch screens retain the upstream loader. */
|
||||
object WolfskinImages {
|
||||
private var application: Context? = null
|
||||
private var loader: ImageLoader? = null
|
||||
private const val MEBIBYTE = 1024L * 1024L
|
||||
|
|
@ -285,6 +285,53 @@ object WolfskinTokens {
|
|||
const val navBandOpacity = 0.72f
|
||||
const val navSolidStop = 0.84f
|
||||
}
|
||||
object Library {
|
||||
val chipHeight = 34.0.dp
|
||||
val chipPadding = 14.0.dp
|
||||
val chipGap = 8.0.dp
|
||||
val chipTop = 8.0.dp
|
||||
val chipIcon = 13.0.dp
|
||||
val captionWidth = 320.0.dp
|
||||
const val captionSeparatorOpacity = 0.4f
|
||||
val plateTop = 7.0.dp
|
||||
val plateSide = 8.0.dp
|
||||
val plateBottom = 23.0.dp
|
||||
val plateFloor = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
|
||||
const val plateStep = 0.25f
|
||||
const val plateGapEm = 0.64f
|
||||
const val markGapEm = 0.29f
|
||||
const val wordmarkRatio = 1.8f
|
||||
const val plateOpacity = 0.9f
|
||||
const val plateMidOpacity = 0.72f
|
||||
const val plateMidStop = 0.52f
|
||||
val railWidth = 26.0.dp
|
||||
val railLetterHeight = 15.0.dp
|
||||
val railFocusHeight = 22.0.dp
|
||||
val railLetter = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
|
||||
val railFocusedLetter = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 18.2.sp, letterSpacing = 0.0.em)
|
||||
val panelPaddingX = 20.0.dp
|
||||
val panelPaddingY = 17.0.dp
|
||||
val panelTextGap = 12.0.dp
|
||||
val panelTitleGap = 4.0.dp
|
||||
val panelIconGap = 7.0.dp
|
||||
val panelTop = 10.0.dp
|
||||
val borderWidth = 1.0.dp
|
||||
val chipContentGap = 6.0.dp
|
||||
const val chipLabelOpacity = 0.7f
|
||||
val tailIcon = 22.0.dp
|
||||
val tailPadding = 10.0.dp
|
||||
val tailGap = 5.0.dp
|
||||
const val imageReads = 4.0f
|
||||
const val unknownCriticOpacity = 0.55f
|
||||
val fallbackPadding = 11.0.dp
|
||||
val fallbackIcon = 22.0.dp
|
||||
const val fallbackHueMultiplier = 31.0f
|
||||
const val fallbackSaturationTop = 0.22f
|
||||
const val fallbackLightnessTop = 0.22f
|
||||
const val fallbackSaturationBottom = 0.26f
|
||||
const val fallbackLightnessBottom = 0.1f
|
||||
const val fallbackIconOpacity = 0.45f
|
||||
}
|
||||
object Home {
|
||||
val kickerGap = 6.0.dp
|
||||
val kickerBottom = 5.0.dp
|
||||
|
|
@ -351,6 +398,6 @@ object WolfskinTokens {
|
|||
val buttonLiftOffset = 6.0.dp
|
||||
val buttonLiftBlur = 20.0.dp
|
||||
const val buttonLiftOpacity = 0.5f
|
||||
const val factSeparatorOpacity = 0.4f
|
||||
val posterProgressBottom = 6.0.dp
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
wolfskin-design/src/main/res/drawable/wolfskin_down.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:pathData="M6,9 L12,15 L18,9" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round"/></vector>
|
||||
|
|
@ -1,3 +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:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:pathData="M2,12s3.6,-7 10,-7s10,7 10,7s-3.6,7 -10,7S2,12 2,12Z M15,12a3,3 0,1 1,-6 0a3,3 0,1 1,6 0" />
|
||||
</vector>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ class HomeArtTest {
|
|||
|
||||
@Test fun imageFailureShowsTheTitleFallbackInsteadOfAnEmptyCard() {
|
||||
val title = HomeTitle("missing-image", "A missing film", "movie", poster = "file:///android_asset/wolfskin-home/not-present.jpg")
|
||||
rule.setContent { WolfskinTheme { HomeArt(title, WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)) } }
|
||||
rule.setContent { WolfskinTheme { TitleArtwork(title.title, title.type, title.poster, WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)) } }
|
||||
rule.waitUntil(5000) { rule.onAllNodesWithText(title.title).fetchSemanticsNodes().isNotEmpty() }
|
||||
rule.onNodeWithText(title.title).assertIsDisplayed()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ import androidx.compose.foundation.layout.requiredSize
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
|
@ -52,9 +57,30 @@ class HomeCaptureTest {
|
|||
rule.waitForIdle()
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "home").apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
||||
recordTextLayouts(File(directory, "$name-layout.json"))
|
||||
} finally { Locale.setDefault(previous) }
|
||||
}
|
||||
|
||||
private fun recordTextLayouts(output: File) {
|
||||
val nodes = JSONArray()
|
||||
rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
|
||||
val results = mutableListOf<TextLayoutResult>()
|
||||
node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(results)
|
||||
results.forEach { layout ->
|
||||
val lines = JSONArray()
|
||||
repeat(layout.lineCount) { line ->
|
||||
lines.put(JSONObject().put("top", layout.getLineTop(line)).put("bottom", layout.getLineBottom(line)).put("baseline", layout.getLineBaseline(line)))
|
||||
}
|
||||
nodes.put(JSONObject().put("text", layout.layoutInput.text.text)
|
||||
.put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top)
|
||||
.put("width", layout.size.width).put("height", layout.size.height)
|
||||
.put("lineHeight", layout.layoutInput.style.lineHeight.toString())
|
||||
.put("lineHeightStyle", layout.layoutInput.style.lineHeightStyle.toString()).put("lines", lines))
|
||||
}
|
||||
}
|
||||
output.writeText(nodes.toString(2))
|
||||
}
|
||||
|
||||
private fun preload(snapshot: HomeSnapshot, focused: Int) = runBlocking {
|
||||
val rows = if (focused == 0) snapshot.rows.take(2) else snapshot.rows.drop(focused).take(3)
|
||||
for (row in rows) for (title in row.cards.take(8)) {
|
||||
|
|
@ -63,7 +89,7 @@ class HomeCaptureTest {
|
|||
val width = if (hero) 1280 else (row.cardWidth.value * 2).toInt()
|
||||
val height = if (hero) 720 else (row.cardHeight.value * 2).toInt()
|
||||
val source = if (landscape) title.backdrop ?: title.poster else title.poster
|
||||
if (source != null) HomeImages.loader(rule.activity).execute(HomeImages.request(rule.activity, source, width, height, !landscape))
|
||||
if (source != null) WolfskinImages.loader(rule.activity).execute(WolfskinImages.request(rule.activity, source, width, height, !landscape))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -152,6 +152,67 @@ class HomeKeysTest {
|
|||
assertEquals("A visited row retains its column", 3, shell.view.cards[posters.id])
|
||||
}
|
||||
|
||||
@Test fun asynchronousFreshHomePlaysNextInTwoPresses() {
|
||||
val fixture = HomeFixture(rule.activity)
|
||||
val shell = ShellState(fixturePeople(), "justin")
|
||||
val delivery = kotlinx.coroutines.CompletableDeferred<HomeSnapshot>()
|
||||
var action: HomeAction? = null
|
||||
rule.setContent {
|
||||
val snapshot by produceState(fixture.snapshot("justin", "loading")) { value = delivery.await() }
|
||||
WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } }
|
||||
}
|
||||
press("OK")
|
||||
rule.waitForIdle()
|
||||
assertTrue(shell.navFocused)
|
||||
rule.runOnIdle { delivery.complete(fixture.snapshot("justin")) }
|
||||
rule.waitForIdle()
|
||||
assertFalse(shell.navFocused)
|
||||
assertEquals(0, shell.view.cards["hero"])
|
||||
press("OK")
|
||||
val play = action as HomeAction.Play
|
||||
assertFalse(play.resume)
|
||||
assertEquals("Yu Yu Hakusho", play.title.seriesTitle)
|
||||
}
|
||||
|
||||
@Test fun asynchronousHomeDoesNotStealFocusAfterNavInput() {
|
||||
val fixture = HomeFixture(rule.activity)
|
||||
val shell = ShellState(fixturePeople(), "justin")
|
||||
val delivery = kotlinx.coroutines.CompletableDeferred<HomeSnapshot>()
|
||||
rule.setContent {
|
||||
val snapshot by produceState(fixture.snapshot("justin", "loading")) { value = delivery.await() }
|
||||
WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) {} } }
|
||||
}
|
||||
press("OK")
|
||||
press("RIGHT")
|
||||
rule.runOnIdle { delivery.complete(fixture.snapshot("justin")) }
|
||||
rule.waitForIdle()
|
||||
assertTrue(shell.navFocused)
|
||||
assertEquals(1, shell.navIndex)
|
||||
}
|
||||
|
||||
@Test fun heldOkRepeatEventsDoNotReplayButtonsOrActivateCardsBeforeRelease() {
|
||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
|
||||
val snapshot = HomeFixture(rule.activity).snapshot("justin")
|
||||
val actions = mutableListOf<HomeAction>()
|
||||
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { actions += it } } } }
|
||||
val input = rule.onNodeWithTag("wolfskin-shell")
|
||||
fun repeatOk() = rule.runOnIdle {
|
||||
rule.activity.dispatchKeyEvent(android.view.KeyEvent(0L, 0L, android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 3))
|
||||
}
|
||||
input.performKeyInput { keyDown(Key.DirectionCenter) }
|
||||
repeatOk()
|
||||
assertEquals(1, actions.size)
|
||||
assertTrue(actions.single() is HomeAction.Play)
|
||||
input.performKeyInput { keyUp(Key.DirectionCenter) }
|
||||
press("DOWN")
|
||||
input.performKeyInput { keyDown(Key.DirectionCenter) }
|
||||
repeatOk()
|
||||
assertEquals(1, actions.size)
|
||||
input.performKeyInput { keyUp(Key.DirectionCenter) }
|
||||
assertEquals(2, actions.size)
|
||||
assertTrue(actions.last() is HomeAction.Details)
|
||||
}
|
||||
|
||||
private fun press(name: String) {
|
||||
val key = when (name) {
|
||||
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown
|
||||
|
|
|
|||
|
|
@ -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 |