Merge library slice and reuse shared image policy
Some checks failed
review/design-claude Changes requested: 7 design-parity findings (separator opacity, line-box rhythm, stage gap, mark chips, avatar, count, thumb fringe)
|
|
@ -0,0 +1,89 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import org.junit.Before
|
||||
import org.junit.After
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Each native screen state uses offline prototype data and real shell focus drawing. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class LibraryCaptureTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val previousLocale = Locale.getDefault()
|
||||
private val fixture by lazy { libraryFixture() }
|
||||
|
||||
@Before fun fullscreen() {
|
||||
Locale.setDefault(Locale.US)
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
}
|
||||
@After fun restoreLocale() { Locale.setDefault(previousLocale) }
|
||||
|
||||
private fun capture(name: String, person: String = "shared", destination: String = "movies", catalogue: String? = null, ratings: Boolean = true, reachable: Boolean = true, cached: Boolean = false, configure: (LibraryGridState) -> Unit = {}) {
|
||||
val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination(destination) }
|
||||
val source = when (catalogue) {
|
||||
"picks" -> fixture.picks(person, "movie")
|
||||
"trending" -> fixture.trending()
|
||||
"duplicate" -> fixture.duplicatePage()
|
||||
else -> fixture.library(person, destination)
|
||||
}
|
||||
val grid = LibraryGridState(if (catalogue == "picks") "Movies for you" else if (catalogue != null) "Trending" else destination.replaceFirstChar { it.uppercase() }, source, catalogue != null)
|
||||
configure(grid)
|
||||
shell.view.library = grid
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
||||
LibraryScreen(shell, grid, modifier, ratingsSignedIn = ratings, ratingsReachable = reachable,
|
||||
cachedRatings = if (cached) setOf(grid.items[grid.index].id) else emptySet(), offlineArt = ::fixtureArt)
|
||||
}
|
||||
}
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory")).apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
||||
}
|
||||
|
||||
@Test fun st021_sharedMovies() = capture("st-021")
|
||||
@Test fun st022_ratingSort() = capture("st-022") { it.sort = 2 }
|
||||
@Test fun st023_rowEndClamps() = capture("st-023") { it.index = 5; it.key(ShellKey.Right) }
|
||||
@Test fun st024_anime() = capture("st-024", "justin", "anime")
|
||||
@Test fun st025_unwatched() = capture("st-025") { it.unwatched = true }
|
||||
@Test fun st026_empty() = capture("st-026", "mindel") { it.source = emptyList() }
|
||||
@Test fun st027_unavailable() = capture("st-027", "mindel", "shows") { it.status = LibraryStatus.Unavailable }
|
||||
@Test fun st028_loading() = capture("st-028") { it.status = LibraryStatus.Loading }
|
||||
@Test fun st030_fullPicks() = capture("st-030", "justin", "discover", "picks")
|
||||
@Test fun st035_duplicateOnlyTail() = capture("st-035", "justin", "discover", "duplicate") { it.tail = GridTail.ShowMore; it.index = it.tailIndex }
|
||||
@Test fun st039_trending() = capture("st-039", "justin", "discover", "trending")
|
||||
@Test fun st095_widestPosterPlate() = capture("st-095") { grid -> grid.index = grid.items.indexOfFirst { it.title == "Grave of the Fireflies" } }
|
||||
@Test fun st097_criticsPlate() = capture("st-097")
|
||||
@Test fun st098_tmdbPlate() = capture("st-098", "justin", "anime") { grid -> grid.index = grid.items.indexOfFirst { it.title == "Chainsaw Man" } }
|
||||
@Test fun st112_cachedWhileDown() = capture("st-112", reachable = false, cached = true)
|
||||
@Test fun st113_uncachedWhileDown() = capture("st-113", reachable = false)
|
||||
@Test fun unsignedRatingsHidden() = capture("library-unsigned", ratings = false)
|
||||
}
|
||||
|
||||
fun libraryFixture(): LibraryFixture = LibraryFixture(LibraryCaptureTest::class.java.getResourceAsStream("/wolfskin-library.json")!!.bufferedReader().use { it.readText() }, "/library/")
|
||||
|
||||
fun fixtureArt(path: String): ImageBitmap? = LibraryCaptureTest::class.java.getResourceAsStream(path)?.use { BitmapFactory.decodeStream(it)?.asImageBitmap() }
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.Locale
|
||||
|
||||
/** Acceptance data drives Android keys through the real shell and grid controller. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class LibraryKeysTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
private val shell = ShellState(fixturePeople(), "justin")
|
||||
private val fixture by lazy { libraryFixture() }
|
||||
private var opened: LibraryTitle? = null
|
||||
|
||||
private fun render() {
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
ShellSurface(shell) { modifier ->
|
||||
if (shell.route in listOf("movies", "shows", "anime")) {
|
||||
val view = shell.view
|
||||
val grid = remember(shell.person.key, shell.route, view) {
|
||||
view.library ?: LibraryGridState(shell.route, fixture.library(shell.person.key, shell.route), onTitle = { opened = it }).also { view.library = it }
|
||||
}
|
||||
LibraryScreen(shell, grid, modifier, offlineArt = ::fixtureArt)
|
||||
} else ShellPlaceholder(shell.route, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun press(key: Key) { rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }; rule.waitForIdle() }
|
||||
private fun press(keys: List<Key>) { keys.forEach(::press) }
|
||||
|
||||
@Test fun spiritedAwayInApprovedTwentyPresses() {
|
||||
val document = LibraryKeysTest::class.java.getResourceAsStream("/library-screen.json")!!.bufferedReader().use { JSONObject(it.readText()) }
|
||||
val paths = document.getJSONArray("pressPaths")
|
||||
render()
|
||||
for (index in 0 until paths.length()) {
|
||||
val path = paths.getJSONObject(index)
|
||||
val keys = path.getJSONArray("keys")
|
||||
assertEquals(path.getInt("presses"), keys.length())
|
||||
for (keyIndex in 0 until keys.length()) press(when (keys.getString(keyIndex)) {
|
||||
"UP" -> Key.DirectionUp
|
||||
"DOWN" -> Key.DirectionDown
|
||||
"LEFT" -> Key.DirectionLeft
|
||||
"RIGHT" -> Key.DirectionRight
|
||||
"OK" -> Key.DirectionCenter
|
||||
else -> error("Unknown acceptance key")
|
||||
})
|
||||
assertEquals("shared", shell.person.key)
|
||||
assertEquals("movies", shell.route)
|
||||
assertFalse(shell.navFocused)
|
||||
assertEquals("Spirited Away", shell.view.library!!.focusedTitle!!.title)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun rowEdgesToolbarSortAndBackKeepExactCard() {
|
||||
render()
|
||||
press(listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter))
|
||||
val grid = shell.view.library!!
|
||||
press(Key.DirectionLeft)
|
||||
assertEquals(0, grid.index)
|
||||
repeat(6) { press(Key.DirectionRight) }
|
||||
assertEquals(5, grid.index)
|
||||
assertFalse(grid.inRail)
|
||||
press(listOf(Key.DirectionDown, Key.Back))
|
||||
assertTrue(shell.navFocused)
|
||||
assertEquals(11, grid.index)
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(11, grid.index)
|
||||
press(listOf(Key.DirectionUp, Key.DirectionUp))
|
||||
assertTrue(grid.toolbar)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(1, grid.sort)
|
||||
assertTrue(grid.toolbar)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(2, grid.sort)
|
||||
assertEquals("Grave of the Fireflies", grid.items.first().title)
|
||||
}
|
||||
|
||||
@Test fun cardActsOnReleaseAndHeldRepeatCannotOpenTwice() {
|
||||
render()
|
||||
press(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter))
|
||||
rule.runOnIdle {
|
||||
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
|
||||
assertNull(opened)
|
||||
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 1)))
|
||||
assertNull(opened)
|
||||
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
|
||||
assertEquals(shell.view.library!!.focusedTitle, opened)
|
||||
opened = null
|
||||
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
|
||||
assertNull(opened)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun railGateJumpAndBackRespectOriginalCard() {
|
||||
val titles = (0 until 200).map { index -> LibraryTitle("$index", "${('A'.code + index / 10).toChar()}Title $index", "movie") }
|
||||
val grid = LibraryGridState("Movies", titles)
|
||||
assertTrue(grid.railVisible)
|
||||
grid.index = 5
|
||||
grid.key(ShellKey.Right)
|
||||
assertTrue(grid.inRail)
|
||||
grid.key(ShellKey.Down)
|
||||
assertEquals('B', grid.railLetter)
|
||||
grid.key(ShellKey.Back)
|
||||
assertEquals(5, grid.index)
|
||||
assertFalse(grid.inRail)
|
||||
grid.key(ShellKey.Right); grid.key(ShellKey.Down); grid.key(ShellKey.Ok)
|
||||
assertEquals('B', grid.focusedTitle!!.letter)
|
||||
grid.source = titles.take(199)
|
||||
assertFalse(grid.railVisible)
|
||||
grid.source = titles; grid.sort = 1
|
||||
assertFalse(grid.railVisible)
|
||||
}
|
||||
|
||||
@Test fun mixedSeeAllPreservesFullListAndClampsShortRow() {
|
||||
val grid = LibraryGridState("Full list", (0 until 8).map { LibraryTitle("$it", "Title $it", if (it % 2 == 0) "movie" else "tv") }, catalogue = true)
|
||||
grid.index = 5
|
||||
grid.key(ShellKey.Down)
|
||||
assertEquals(7, grid.index)
|
||||
grid.key(ShellKey.Up); grid.key(ShellKey.Up)
|
||||
assertTrue(grid.toolbar)
|
||||
grid.key(ShellKey.Right); grid.key(ShellKey.Ok)
|
||||
assertEquals(listOf("0", "2", "4", "6"), grid.items.map { it.id })
|
||||
grid.key(ShellKey.Left); grid.key(ShellKey.Ok)
|
||||
assertEquals(8, grid.items.size)
|
||||
}
|
||||
|
||||
@Test fun downClampsShortLastRowBeforeEnteringRecoveryTail() {
|
||||
var recoveries = 0
|
||||
val grid = LibraryGridState("Trending",
|
||||
(0 until 20).map { LibraryTitle("$it", "Title $it", "movie") },
|
||||
catalogue = true, onRecover = { recoveries++ },
|
||||
).apply { tail = GridTail.Retry; index = 14 }
|
||||
shell.pickerVisible = false
|
||||
shell.enterDestination("discover")
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
ShellSurface(shell) { modifier ->
|
||||
LibraryScreen(shell, grid, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(19, grid.index)
|
||||
assertEquals("Title 19", grid.focusedTitle!!.title)
|
||||
assertEquals(0, recoveries)
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(24, grid.index)
|
||||
assertEquals("grid-tail", grid.cardKey)
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(24, grid.index)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(1, recoveries)
|
||||
press(Key.DirectionUp)
|
||||
assertEquals(18, grid.index)
|
||||
assertEquals("Title 18", grid.focusedTitle!!.title)
|
||||
}
|
||||
|
||||
@Test fun scoresFollowLocaleAndUnratedSortsLast() {
|
||||
assertEquals("7,8", LibraryRating("tmdb", 7.8).formatted(Locale.forLanguageTag("nl-NL")))
|
||||
assertEquals("7.8", LibraryRating("tmdb", 7.8).formatted(Locale.US))
|
||||
assertEquals("87%", LibraryRating("rtCritics", 87.9).formatted(Locale.forLanguageTag("nl-NL")))
|
||||
val grid = LibraryGridState("Movies", listOf(
|
||||
LibraryTitle("none", "The Apple", "movie"),
|
||||
LibraryTitle("imdb", "A Bee", "movie", ratings = listOf(LibraryRating("imdb", 8.0))),
|
||||
LibraryTitle("rt", "An Ant", "movie", ratings = listOf(LibraryRating("rtCritics", 80.0))),
|
||||
))
|
||||
grid.sort = 2
|
||||
assertEquals(listOf("rt", "imdb", "none"), grid.items.map { it.id })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/** Paging never loops through repeated pages or loses the readable list after a failure. */
|
||||
class LibraryPaginationTest {
|
||||
private fun title(id: Int) = LibraryTitle("$id", "Title $id", "movie")
|
||||
|
||||
@Test fun duplicateOnlyReadWaitsForExplicitRetryThenAppendsUniqueTitles() = runBlocking {
|
||||
val grid = LibraryGridState("Trending", (0 until 20).map(::title), catalogue = true)
|
||||
var calls = 0
|
||||
val pager = LibraryPagination(grid) { page ->
|
||||
calls++
|
||||
when (page) {
|
||||
2 -> LibraryPage((0 until 20).map(::title), true)
|
||||
3 -> LibraryPage((18 until 25).map(::title), false)
|
||||
else -> error("Unexpected extra read")
|
||||
}
|
||||
}
|
||||
grid.index = 9
|
||||
pager.readAhead()
|
||||
assertEquals(0, calls)
|
||||
grid.index = 10
|
||||
pager.readAhead()
|
||||
assertEquals(GridTail.ShowMore, grid.tail)
|
||||
pager.readAhead()
|
||||
assertEquals(1, calls)
|
||||
pager.readAhead(force = true)
|
||||
assertEquals((0 until 25).map { "$it" }, grid.source.map { it.id })
|
||||
assertEquals(GridTail.None, grid.tail)
|
||||
pager.readAhead(force = true)
|
||||
assertEquals(2, calls)
|
||||
}
|
||||
|
||||
@Test fun failedReadRetainsTitlesAndRetriesSamePage() = runBlocking {
|
||||
val grid = LibraryGridState("Trending", (0 until 12).map(::title), catalogue = true)
|
||||
var reachable = false
|
||||
val pages = mutableListOf<Int>()
|
||||
val pager = LibraryPagination(grid) { page ->
|
||||
pages += page
|
||||
if (!reachable) throw java.io.IOException("offline")
|
||||
LibraryPage(listOf(title(12)), false)
|
||||
}
|
||||
grid.index = 11
|
||||
pager.readAhead()
|
||||
assertEquals(GridTail.Retry, grid.tail)
|
||||
assertEquals((0 until 12).map { "$it" }, grid.source.map { it.id })
|
||||
reachable = true
|
||||
pager.readAhead(force = true)
|
||||
assertEquals(listOf(2, 2), pages)
|
||||
assertEquals("12", grid.source.last().id)
|
||||
}
|
||||
}
|
||||
494
wolfskin-design/src/test/resources/library-screen.json
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
{
|
||||
"id": "library",
|
||||
"title": "Libraries: Movies, Shows, Anime (one continuous 6-column grid)",
|
||||
"specSection": "6.4",
|
||||
"source": "index.html:1403-1426, 1563-1611",
|
||||
"layout": [
|
||||
{
|
||||
"id": "lhead",
|
||||
"type": "lhead",
|
||||
"token": null,
|
||||
"title": "Movies",
|
||||
"yPx": 0,
|
||||
"heightPx": 236,
|
||||
"cards": null,
|
||||
"focusable": false,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "ltools",
|
||||
"type": "chips",
|
||||
"token": null,
|
||||
"title": "",
|
||||
"yPx": 236,
|
||||
"heightPx": 104,
|
||||
"cards": null,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g0",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 340,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g1",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 828,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g2",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 1316,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g3",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 1804,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g4",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 2292,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g5",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 2780,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g6",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 3268,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g7",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 3756,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g8",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 4244,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g9",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 4732,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g10",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 5220,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g11",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 5708,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g12",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 6196,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
},
|
||||
{
|
||||
"id": "g13",
|
||||
"type": "grid",
|
||||
"token": "poster",
|
||||
"title": "",
|
||||
"yPx": 6684,
|
||||
"heightPx": 488,
|
||||
"cards": 6,
|
||||
"focusable": true,
|
||||
"endsInSeeAll": false,
|
||||
"sampleFill": false
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
{
|
||||
"id": "st-021",
|
||||
"name": "Shared Movies: 84, continuous grid",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-021-shared-movies-84-continuous-grid.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-021-shared-movies-84-continuous-grid.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Shared Movies: 84, continuous grid · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
|
||||
},
|
||||
{
|
||||
"id": "st-022",
|
||||
"name": "Shared Movies sorted by Rating (G10)",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-022-shared-movies-sorted-by-rating-g10.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-022-shared-movies-sorted-by-rating-g10.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Shared Movies sorted by Rating (G10) · Person: Shared · Screen: Movies · Focus: g0 › Grave of the Fireflies"
|
||||
},
|
||||
{
|
||||
"id": "st-023",
|
||||
"name": "Right at a row end clamps (no letter rail below 200)",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Right at a row end clamps (no letter rail below 200) · Person: Shared · Screen: Movies · Focus: g0 › Bohemian Rhapsody"
|
||||
},
|
||||
{
|
||||
"id": "st-024",
|
||||
"name": "Justin Anime: 18 series",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-024-justin-anime-18-series.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-024-justin-anime-18-series.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "justin",
|
||||
"focusReadout": "State: Justin Anime: 18 series · Person: Justin · Screen: Anime · Focus: g0 › Bleach"
|
||||
},
|
||||
{
|
||||
"id": "st-025",
|
||||
"name": "Unwatched filter",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-025-unwatched-filter.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-025-unwatched-filter.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Unwatched filter · Person: Shared · Screen: Movies · Focus: g0 › Annie Hall"
|
||||
},
|
||||
{
|
||||
"id": "st-026",
|
||||
"name": "Empty destination",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-026-empty-destination.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-026-empty-destination.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "mindel",
|
||||
"focusReadout": "State: Empty destination · Person: Mindel · Screen: Movies · Focus: Nothing in Movies yet › Open Discover"
|
||||
},
|
||||
{
|
||||
"id": "st-027",
|
||||
"name": "Jellyfin unreachable (error)",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-027-jellyfin-unreachable-error.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-027-jellyfin-unreachable-error.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "mindel",
|
||||
"focusReadout": "State: Jellyfin unreachable (error) · Person: Mindel · Screen: Shows · Focus: Couldn’t reach your library › Try again"
|
||||
},
|
||||
{
|
||||
"id": "st-028",
|
||||
"name": "Loading (skeleton, no saved copy)",
|
||||
"statesGroup": "Libraries",
|
||||
"capture": "st-028-loading-skeleton-no-saved-copy.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-028-loading-skeleton-no-saved-copy.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "nav",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Loading (skeleton, no saved copy) · Person: Shared · Screen: Movies · Focus: nav › Movies"
|
||||
},
|
||||
{
|
||||
"id": "st-095",
|
||||
"name": "Plate: widest real set on a 256 px poster",
|
||||
"statesGroup": "Ratings: one-line plate (round 3)",
|
||||
"capture": "st-095-plate-widest-real-set-on-a-256-px-poster.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-095-plate-widest-real-set-on-a-256-px-poster.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Plate: widest real set on a 256 px poster · Person: Shared · Screen: Movies · Focus: g4 › Grave of the Fireflies"
|
||||
},
|
||||
{
|
||||
"id": "st-097",
|
||||
"name": "Poster: RT critics on a library grid (Shared Movies)",
|
||||
"statesGroup": "Ratings (round 2)",
|
||||
"capture": "st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Poster: RT critics on a library grid (Shared Movies) · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
|
||||
},
|
||||
{
|
||||
"id": "st-098",
|
||||
"name": "Poster: TMDB only (Justin Anime)",
|
||||
"statesGroup": "Ratings (round 2)",
|
||||
"capture": "st-098-poster-tmdb-only-justin-anime.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-098-poster-tmdb-only-justin-anime.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "justin",
|
||||
"focusReadout": "State: Poster: TMDB only (Justin Anime) · Person: Justin · Screen: Anime · Focus: g0 › Chainsaw Man"
|
||||
},
|
||||
{
|
||||
"id": "st-112",
|
||||
"name": "Conductarr down: a cached rating still shows",
|
||||
"statesGroup": "Ratings (round 2)",
|
||||
"capture": "st-112-conductarr-down-a-cached-rating-still-shows.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-112-conductarr-down-a-cached-rating-still-shows.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Conductarr down: a cached rating still shows · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
|
||||
},
|
||||
{
|
||||
"id": "st-113",
|
||||
"name": "Conductarr down: an uncached title shows nothing",
|
||||
"statesGroup": "Ratings (round 2)",
|
||||
"capture": "st-113-conductarr-down-an-uncached-title-shows-nothing.jpg",
|
||||
"captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-113-conductarr-down-an-uncached-title-shows-nothing.jpg",
|
||||
"screenKind": "library",
|
||||
"overlay": null,
|
||||
"focusZone": "content",
|
||||
"person": "shared",
|
||||
"focusReadout": "State: Conductarr down: an uncached title shows nothing · Person: Shared · Screen: Movies · Focus: g0 › American Gangster"
|
||||
}
|
||||
],
|
||||
"focusMap": {
|
||||
"entry": "first grid card (not the toolbar); returning: remembered card per person",
|
||||
"zones": {
|
||||
"toolbar": {
|
||||
"up": "nav",
|
||||
"down": "grid",
|
||||
"left": "previous chip",
|
||||
"right": "next chip",
|
||||
"ok": "Sort cycles A–Z -> Recently added -> Rating; Filter toggles All / Unwatched; focus returns to the toolbar",
|
||||
"back": "nav",
|
||||
"source": "index.html:445"
|
||||
},
|
||||
"grid": {
|
||||
"up": "row above, same column (top row -> toolbar)",
|
||||
"down": "row below, same column (shorter last row: its last card)",
|
||||
"left": "previous column (column 0: stays)",
|
||||
"right": "next column; at the row end: stays (no rail below 200 titles)",
|
||||
"ok": "title page (key-up)",
|
||||
"back": "nav, place kept",
|
||||
"ratings": "one-line plate after 350 ms",
|
||||
"source": "index.html:446"
|
||||
},
|
||||
"letterRail": "only at >= 200 titles and A–Z sort: Right at a row end enters; Up/Down move letters and scroll live; OK or Left drops focus on that letter's first title; Back returns to where you were (index.html:1586-1605)"
|
||||
},
|
||||
"exit": [
|
||||
"Up from toolbar -> nav",
|
||||
"Back -> nav"
|
||||
]
|
||||
},
|
||||
"pressPaths": [
|
||||
{
|
||||
"goal": "Reach “Spirited Away” (68th of 84) in Shared Movies by scrolling",
|
||||
"keys": [
|
||||
"RIGHT",
|
||||
"RIGHT",
|
||||
"OK",
|
||||
"UP",
|
||||
"RIGHT",
|
||||
"RIGHT",
|
||||
"OK",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"RIGHT",
|
||||
"RIGHT"
|
||||
],
|
||||
"presses": 20,
|
||||
"note": "",
|
||||
"start": "app launch: Who's watching with Justin focused (index.html:357)",
|
||||
"replayed": "PASS",
|
||||
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
|
||||
}
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"component": "Library grid",
|
||||
"source": "Jellyfin /Users/{id}/Items?ParentId=…&SortBy=SortName over the destination's libraries, read in full in 200-item chunks ahead of focus",
|
||||
"fields": [
|
||||
"SortName",
|
||||
"ProductionYear",
|
||||
"ImageTags.Primary",
|
||||
"UserData.Played",
|
||||
"UserData.PlaybackPositionTicks",
|
||||
"ProviderIds.Tmdb"
|
||||
],
|
||||
"cache": "LKG snapshot per destination; refresh on open if older than 10 min",
|
||||
"states": [
|
||||
"empty",
|
||||
"Jellyfin unreachable (LKG + Saved chip, or state row)",
|
||||
"missing art"
|
||||
],
|
||||
"pageRow": "index.html:469"
|
||||
},
|
||||
{
|
||||
"component": "Destinations",
|
||||
"source": "Conductarr GET /curation/library-audiences (owner, media_type, is_anime, libraries[], users[]); until sign-in, the name rule (parked board mapping-source = name-rule)",
|
||||
"pageRow": "index.html:468"
|
||||
}
|
||||
],
|
||||
"strings": {
|
||||
"count": "{n} films | {n} series",
|
||||
"empty": [
|
||||
"Nothing in {Destination} yet",
|
||||
"Titles you or others request for you land here.",
|
||||
"Open Discover"
|
||||
],
|
||||
"emptyUnwatched": [
|
||||
"Nothing unwatched in {Destination}",
|
||||
"Everything here has been watched.",
|
||||
"Show all"
|
||||
],
|
||||
"down": [
|
||||
"Couldn’t reach your library",
|
||||
"Jellyfin isn’t answering. Nothing here is lost; try again in a moment.",
|
||||
"Try again"
|
||||
],
|
||||
"sort": [
|
||||
"A–Z",
|
||||
"Recently added",
|
||||
"Rating"
|
||||
],
|
||||
"filter": [
|
||||
"All",
|
||||
"Unwatched"
|
||||
]
|
||||
},
|
||||
"ratingScoreFormat": {
|
||||
"rule": "TMDB, IMDb and \"Ep\" scores (0-10) are formatted with the TV locale and exactly one decimal, e.g. String.format(Locale.getDefault(), \"%.1f\", x): 7,8 on a Dutch-locale box, 7.8 on an English one. RT percentages unchanged (87%). The page prints 7.8 always: a stand-in for the English locale, not authoritative.",
|
||||
"acceptance": "on a Dutch-locale emulator a TMDB 7.8 renders 7,8 and the one-line plate rule (spec 4.8) still holds because the width is unchanged [INFERENCE: comma and point advance widths not measured]; on an English-locale emulator it renders 7.8 as in the captures",
|
||||
"source": "Justin 2026-11-05, corr=c248698d8546e1ab; spec 4.7 rule 5, 10.1 check 8"
|
||||
},
|
||||
"$schema": "wolfskin-tv-spec/screen@1",
|
||||
"tokens": "data/tv-spec/tokens.json",
|
||||
"captures": [
|
||||
"st-021-shared-movies-84-continuous-grid.jpg",
|
||||
"st-022-shared-movies-sorted-by-rating-g10.jpg",
|
||||
"st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg",
|
||||
"st-024-justin-anime-18-series.jpg",
|
||||
"st-025-unwatched-filter.jpg",
|
||||
"st-026-empty-destination.jpg",
|
||||
"st-027-jellyfin-unreachable-error.jpg",
|
||||
"st-028-loading-skeleton-no-saved-copy.jpg",
|
||||
"st-095-plate-widest-real-set-on-a-256-px-poster.jpg",
|
||||
"st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg",
|
||||
"st-098-poster-tmdb-only-justin-anime.jpg",
|
||||
"st-112-conductarr-down-a-cached-rating-still-shows.jpg",
|
||||
"st-113-conductarr-down-an-uncached-title-shows-nothing.jpg"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 25 KiB |