Align TV layouts and close recovery gaps

This commit is contained in:
justinzeus 2026-11-06 02:15:09 +01:00
parent bfe3dbbb16
commit 04b1bdd6aa
22 changed files with 522 additions and 53 deletions

View file

@ -27,7 +27,8 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.layout.Layout
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
@ -49,7 +50,7 @@ fun DiscoverOverlay(state: DiscoverState) {
is DiscoverOverlayState.Hidden -> {
ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.")
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title) }
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title, showToast = false) }
DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) }
}
}
@ -59,7 +60,7 @@ fun DiscoverOverlay(state: DiscoverState) {
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body),
color = C.muted, style = DiscoverType.body)
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title) }
DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title, showToast = true) }
DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null }
}
}
@ -72,15 +73,10 @@ fun DiscoverOverlay(state: DiscoverState) {
@Composable
private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
Text(if (sheet.held) "Add for someone" else "Request", color = C.fg, style = DiscoverType.sheetTitle)
Text(if (sheet.held) "Add for someone" else "Request", Modifier.exactLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle)
Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}",
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop), color = C.muted, style = DiscoverType.body)
if (state.isSignedIn) DiscoverRatings(sheet.title.ratings, Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height + (D.sheetRatingTop + D.sheetRatingBottom).roundToPx()) {
placeable.placeRelative(0, D.sheetRatingTop.roundToPx())
}
})
Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body)
if (state.isSignedIn) SheetRatings(sheet.title.ratings)
when (sheet.phase) {
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet)
RequestPhase.LoadingRecipients -> {
@ -100,6 +96,28 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
}
}
/** The inline ratings baseline includes the first mark; the inherited text strut keeps its descent. */
@Composable
private fun SheetRatings(ratings: List<DiscoverRating>) {
if (ratings.isEmpty()) return
Layout(content = {
DiscoverRatings(ratings)
Text(" ", color = C.muted, style = DiscoverType.body)
}) { children, constraints ->
val row = children[0].measure(constraints.copy(minHeight = 0))
val strut = children[1].measure(constraints.copy(minWidth = 0, minHeight = 0))
val mark = DiscoverType.ratings.fontSize.toPx()
val baseline = (row.height + mark) / 2f
val descent = maxOf(row.height - baseline, (strut.height - strut[FirstBaseline]).toFloat())
val height = maxOf(baseline, strut[FirstBaseline].toFloat()) + descent
val top = D.sheetRatingTop.roundToPx()
val bottom = D.sheetRatingBottom.roundToPx()
layout(row.width, kotlin.math.round(height).toInt() + top + bottom) {
row.placeRelative(0, top)
}
}
}
@Composable
private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)

View file

@ -300,7 +300,7 @@ class DiscoverState(
ShellKey.Ok -> when (titleButton) {
0 -> if (requests[requireNotNull(title).key]?.any { it.requester == shell.person.key } == true) shell.requestsVisible = true else openRequest(false)
1 -> openRequest(true)
2 -> title?.let { if (it.key in hidden) restore(it) else hide(it) }
2 -> title?.let { if (it.key in hidden) restore(it, showToast = true) else hide(it) }
}
else -> Unit
}
@ -328,13 +328,13 @@ class DiscoverState(
ShellKey.Left, ShellKey.Up -> sheet.focus = 0
ShellKey.Right, ShellKey.Down -> sheet.focus = 1
ShellKey.Back -> overlay = null
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else { overlay = null; title = null; onPop() }
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = false) else { overlay = null; title = null; onPop() }
}
is DiscoverOverlayState.Restore -> when (key) {
ShellKey.Left, ShellKey.Up -> sheet.focus = 0
ShellKey.Right, ShellKey.Down -> sheet.focus = 1
ShellKey.Back -> overlay = null
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else overlay = null
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = true) else overlay = null
}
null -> Unit
}
@ -380,9 +380,9 @@ class DiscoverState(
}
}
fun restore(item: DiscoverTitle) {
fun restore(item: DiscoverTitle, showToast: Boolean) {
if (!canRequest) { refresh(); return }
hidden.remove(item.key); revision++; overlay = null; toast = "Back in your picks."
hidden.remove(item.key); revision++; overlay = null; toast = if (showToast) "Back in your picks." else null
updateCollectionCards()
val profile = shell.person.key
val version = (dismissalVersions[item.key] ?: 0) + 1
@ -412,7 +412,7 @@ class DiscoverState(
val lanes = service?.let {
serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() }
} ?: projectDiscoverLanes(snapshot, heroes, filter, hidden)
return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "Try again", emptyList(), tail = DiscoverTail.Retry) else lanes
return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "More picks", emptyList(), tail = DiscoverTail.Retry) else lanes
}
}

View file

@ -4,23 +4,45 @@ package org.justinvisser.wolfskin.design
class HomeRatingCache(private val clock: () -> Long = System::currentTimeMillis) {
private data class Entry(val ratings: List<HomeRating>, val readAt: Long)
private val entries = linkedMapOf<String, Entry>()
private val seasons = linkedMapOf<String, MutableMap<Int, Entry>>()
fun read(title: HomeTitle): List<HomeRating>? {
val key = key(title)
val entry = entries[key] ?: return null
if (clock() - entry.readAt >= lifetimeMillis) { entries.remove(key); return null }
val season = seasonKey(title)
val bucket = season?.let { seasons[it] }
val entry = if (season != null) bucket?.get(title.episode) else entries[key(title)]
if (entry == null) return null
if (clock() - entry.readAt >= lifetimeMillis) {
if (season == null) entries.remove(key(title)) else {
bucket?.remove(title.episode)
if (bucket.isNullOrEmpty()) seasons.remove(season)
}
return null
}
return entry.ratings
}
fun save(title: HomeTitle, ratings: List<HomeRating>) {
val key = key(title)
entries.remove(key)
entries[key] = Entry(ratings, clock())
while (entries.size > capacity) entries.remove(entries.keys.first())
val entry = Entry(ratings, clock())
val season = seasonKey(title)
if (season != null) {
val bucket = seasons.remove(season) ?: mutableMapOf()
bucket[requireNotNull(title.episode)] = entry
seasons[season] = bucket
while (seasons.size > capacity) seasons.remove(seasons.keys.first())
} else {
val key = key(title)
entries.remove(key)
entries[key] = entry
while (entries.size > capacity) entries.remove(entries.keys.first())
}
}
private fun key(title: HomeTitle): String = if (title.isEpisode) "episode:${title.id}" else "${title.type}:${title.tmdbId ?: title.id}"
private fun seasonKey(title: HomeTitle): String? =
if (title.isEpisode && title.tmdbId != null && title.season != null && title.episode != null)
"${title.tmdbId}:${title.season}" else null
companion object {
private const val capacity = 300
private val lifetimeMillis = (WolfskinTokens.Behaviour.cacheRatingsHours * 60 * 60 * 1000).toLong()

View file

@ -84,7 +84,7 @@ fun LibraryScreen(
}
LaunchedEffect(grid.savedAt) { view.savedAt = grid.savedAt }
LaunchedEffect(grid.status) {
if (grid.status == LibraryStatus.Loading) shell.focusNav(shell.currentTab)
if (grid.status == LibraryStatus.Loading && !shell.navFocused) 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
@ -128,7 +128,10 @@ fun LibraryScreen(
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 ->
items((items.size + grid.columns - 1) / grid.columns, key = { row ->
val title = items[row * grid.columns]
"grid-${if (grid.catalogue) title.catalogueKey else title.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) {
@ -174,7 +177,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav
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(title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle)
Text(title, modifier = Modifier.alignByBaseline().exactLineBox(WolfskinTokens.Type.screenTitle), color = C.fg, style = WolfskinTokens.Type.screenTitle)
if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle)
savedAt?.let { SavedPill(it) }
}

View file

@ -98,8 +98,8 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
Column(Modifier.weight(1f)) {
val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else ""
Text(group.title + count, color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
Text(group.description, color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (group.wolfskin) SettingsPill("Wolfskin", focused)
SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused)

View file

@ -91,6 +91,22 @@ class DiscoverKeysTest {
assertNull(state.overlay)
assertEquals("The Amazing Spider-Man", state.hero?.title)
assertEquals(original, state.lanes.map { lane -> lane.id to lane.titles.map { it.key } })
assertNull("Undo restores silently; only Show again announces the return", state.toast)
}
@Test fun showAgainRestoresTheHiddenPickAndAnnouncesItsReturn() {
replay("Round 3 · Not interested on a pick (Discover hero)")
val title = (state.overlay as DiscoverOverlayState.Hidden).title
press(Key.DirectionRight, Key.DirectionCenter)
rule.runOnIdle {
val lane = state.lanes.first { it.hidden }
state.focus(state.lanes.indexOf(lane) + 2, lane.titles.indexOfFirst { it.key == title.key })
}
press(Key.DirectionCenter)
assertTrue(state.overlay is DiscoverOverlayState.Restore)
press(Key.DirectionCenter)
assertFalse(state.isHidden(title))
assertEquals("Back in your picks.", state.toast)
}
@Test fun heldOkOpensSheetButNeverSubmits() {

View file

@ -30,6 +30,34 @@ class HomeRatingCacheTest {
assertEquals(ratings, cache.read(title.copy(id = "last", tmdbId = 399)))
}
@Test fun episodeScoresShareSeriesSeasonIdentityWithoutCrossingEpisodesOrSeasons() {
var now = 0L
val cache = HomeRatingCache { now }
val episode = HomeTitle("episode-a", "Pilot", "episode", tmdbId = 42, season = 1, episode = 1)
val pilot = listOf(HomeRating("tmdb", 8.1, episode = true))
val second = listOf(HomeRating("tmdb", 7.2, episode = true))
cache.save(episode, pilot)
cache.save(episode.copy(id = "episode-b", episode = 2), second)
assertEquals(pilot, cache.read(episode.copy(id = "another-jellyfin-copy")))
assertEquals(second, cache.read(episode.copy(id = "another-second-copy", episode = 2)))
assertNull(cache.read(episode.copy(season = 2)))
assertNull(cache.read(episode.copy(tmdbId = 43)))
now = 6L * 60 * 60 * 1000
assertNull(cache.read(episode))
}
@Test fun seasonCapacityCountsSeasonsNotEpisodesAndIsIndependentOfTitleCapacity() {
val cache = HomeRatingCache { 0L }
val episode = HomeTitle("episode-a", "Pilot", "episode", tmdbId = 42, season = 1, episode = 1)
repeat(301) { index -> cache.save(episode.copy(id = "episode-$index", episode = index + 1), ratings) }
repeat(300) { index -> cache.save(title.copy(tmdbId = index + 100), ratings) }
assertEquals(ratings, cache.read(episode))
repeat(300) { index -> cache.save(episode.copy(season = index + 2), ratings) }
assertNull(cache.read(episode))
assertEquals(ratings, cache.read(episode.copy(season = 301)))
assertEquals(ratings, cache.read(title.copy(tmdbId = 100)))
}
@Test fun decimalScoresFollowLocaleButPercentagesDoNot() {
assertEquals("7,8", ratings.single().format(Locale.forLanguageTag("nl-NL")))
assertEquals("7.8", ratings.single().format(Locale.US))

View file

@ -172,6 +172,23 @@ class LibraryKeysTest {
assertEquals("Title 18", grid.focusedTitle!!.title)
}
@Test fun mixedCatalogueRowsKeepMovieAndSeriesWithTheSameNumericIdentityDistinct() {
val titles = List(12) { index ->
val type = if (index < 6) "movie" else "tv"
LibraryTitle((index % 6).toString(), "$type title ${index % 6}", type, tmdbId = (index % 6).toString())
}
val grid = LibraryGridState("Trending", titles, catalogue = true, onTitle = { opened = it })
shell.pickerVisible = false
shell.enterDestination("discover")
rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it) } } }
press(Key.DirectionDown)
assertEquals(6, grid.index)
press(Key.DirectionCenter)
assertEquals("tv", opened!!.type)
assertEquals("0", opened!!.tmdbId)
assertEquals("tv title 0", opened!!.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))

View file

@ -0,0 +1,66 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import java.io.File
import org.junit.Assert.*
import org.junit.Test
/** The normal JVM gate rejects untokened styling, undersized typography and internal screen wording. */
class OwnedSourceAuditTest {
@Test fun ownedScreensUseTokensReadableTextAndViewerFacingNames() {
val roots = listOf(File("src/main/java"), File("../app/src/wolfskin"))
roots.forEach { assertTrue("Missing owned source root: $it", it.isDirectory) }
val violations = roots.flatMap { root ->
root.walkTopDown().filter { it.isFile && it.extension == "kt" }.flatMap { source ->
audit(source.name, source.readText()).map { "$source:$it" }
}.toList()
}
assertTrue(violations.joinToString("\n"), violations.isEmpty())
}
@Test fun plantedViolationsFailEachDoneBarRule() {
val cases = listOf(
Triple("Screen.kt", "Box(Modifier.width(12.dp))", "unit"),
Triple("Screen.kt", "Box(Modifier.width(12f.dp))", "unit"),
Triple("Screen.kt", "Text(fontSize = 14.sp)", "unit"),
Triple("Screen.kt", "Box(Modifier.width((width + gap).dp))", "unit"),
Triple("Screen.kt", "Text(color = Color(0xFF123456))", "color"),
Triple("Screen.kt", "Text(color = ComposeColor(red = 1f, green = 0f, blue = 0f))", "color"),
Triple("WolfskinTokens.kt", "val tiny = TextStyle(fontSize = 11.5.sp)", "font-floor"),
Triple("Screen.kt", "Text(\"Recs Movies\")", "wording"),
Triple("Screen.kt", "Text(\"DGiC\")", "wording"),
Triple("Screen.kt", "Text(\"Justin Shows\")", "wording"),
Triple("Screen.kt", "Text(\"Next page\")", "wording"),
Triple("Screen.kt", "Text(\"Previous page\")", "wording"),
Triple("Screen.kt", "Text(\"Page 2\")", "wording"),
)
cases.forEach { (name, source, rule) ->
assertTrue("$rule accepted $source", audit(name, source).any { it.endsWith(":$rule") })
}
assertTrue(audit("WolfskinTokens.kt", "val body = TextStyle(fontSize = 12.sp); val fill = Color(0xFF123456)").isEmpty())
assertTrue(audit("Screen.kt", "import androidx.compose.ui.unit.dp\nText(\"This title can’t be read\", style = WolfskinTokens.Type.body)").isEmpty())
}
private fun audit(name: String, source: String): List<String> {
val generated = name == "WolfskinTokens.kt"
return source.lineSequence().mapIndexedNotNull { index, line ->
val code = line.trimStart()
if (code.startsWith("import ") || code.startsWith("//") || code.startsWith("*") || code.startsWith("/*")) return@mapIndexedNotNull null
val rules = buildList {
if (!generated && units.containsMatchIn(line)) add("unit")
if (!generated && colors.containsMatchIn(line)) add("color")
if (fonts.findAll(line).any { it.groupValues[1].toDouble() < 12 }) add("font-floor")
if (strings.findAll(line).any { forbidden.containsMatchIn(it.value) }) add("wording")
}
rules.map { "${index + 1}:$it" }
}.flatten().toList()
}
companion object {
private val units = Regex("\\.(?:dp|sp)\\b")
private val colors = Regex("\\b(?:Compose)?Color\\s*\\(\\s*(?:0[xX]|[0-9.]|(?:red|green|blue|alpha|color)\\s*=)")
private val fonts = Regex("\\bfontSize\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)[fF]?\\.sp\\b")
private val strings = Regex("\"(?:\\\\.|[^\"\\\\])*\"")
private val forbidden = Regex("Recs (?:Movies|Shows)|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page (?:[0-9]|\\$)|\\bpagination\\b", RegexOption.IGNORE_CASE)
}
}