Compare commits
18 commits
e243894a20
...
cd625b4227
| Author | SHA1 | Date | |
|---|---|---|---|
| cd625b4227 | |||
| fc02ce8646 | |||
| 2fdd459037 | |||
| 9dd321f07e | |||
| 061670848f | |||
| 575103ce98 | |||
| 5033c171cb | |||
| 4cd75cfa26 | |||
| e99ed2d394 | |||
| 9c8fceb18c | |||
| 14a74ac43b | |||
| b54bcc148c | |||
| 2b65ee7b53 | |||
| 5a0497c9ec | |||
| 3b7249888f | |||
| c534ea75c2 | |||
| c07bb2ca1d | |||
| 0fd0ec035d |
|
|
@ -0,0 +1,8 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.justinvisser.wolfskin.design.HomeFixture
|
||||||
|
import org.justinvisser.wolfskin.design.HomeSnapshot
|
||||||
|
|
||||||
|
/** Only debug builds may read the locked prototype household projection. */
|
||||||
|
fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = HomeFixture(context).snapshot(person)
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.justinvisser.wolfskin.design.HomeSnapshot
|
||||||
|
|
||||||
|
/** Release Home always reads the selected Jellyfin identity, never prototype data. */
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
|
fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = null
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.justinvisser.wolfskin.design.HomeLibrary
|
||||||
|
import org.justinvisser.wolfskin.design.HomeTitle
|
||||||
|
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||||
|
|
||||||
|
/** A reopened Home cache preserves playable identity and episode/resume state without network reads. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class HomeLibraryCacheTest {
|
||||||
|
@Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42)
|
||||||
|
val film = HomeTitle("film-id", "A film", "movie", year = 2024, runtimeSeconds = 5400, played = true, held = true, tmdbId = 43)
|
||||||
|
val library = HomeLibrary(listOf(episode), listOf(episode.copy(id = "next", resumeSeconds = 0.0)), listOf(film), mapOf("movies" to listOf(film)), mapOf("movies" to 84))
|
||||||
|
HomeLibraryCache(context).write("server:person", library, 12345L)
|
||||||
|
val saved = HomeLibraryCache(context).read("server:person")!!
|
||||||
|
assertEquals(library, saved.first)
|
||||||
|
assertEquals(12345L, saved.second)
|
||||||
|
assertNull(HomeLibraryCache(context).read("server:someone-else"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.UUID
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** Home opens the complete shared grid and Back restores its original continuation card. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
class HomeSeeAllNavigationTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun personalMovieContinuationReachesTheEighteenthTitleAndReturnsToHome() = continuation("pk-m", 5) { grid ->
|
||||||
|
assertEquals(18, grid.items.size)
|
||||||
|
repeat(2) { press(Key.DirectionDown) }
|
||||||
|
repeat(5) { press(Key.DirectionRight) }
|
||||||
|
rule.onNodeWithText("Bleach the Movie: Fade to Black").assertIsDisplayed()
|
||||||
|
assertEquals("movie-bleach-the-movie-fade-to-black-2008", grid.focusedTitle?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun horizontallyScrolledContinuationRestoresTheExactHomeViewport() = continuation("trending", 20) { grid ->
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
assertEquals(6, grid.index)
|
||||||
|
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun continuation(rowId: String, expectedColumn: Int, exerciseGrid: (LibraryGridState) -> Unit) {
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val snapshot = HomeFixture(rule.activity).snapshot("justin")
|
||||||
|
val row = snapshot.rows.first { it.id == rowId }
|
||||||
|
val cardTag = "home-card:$rowId:$expectedColumn"
|
||||||
|
val navigation = NavigationManager()
|
||||||
|
val lists = WolfskinSeeAllLists()
|
||||||
|
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") {
|
||||||
|
if (it == ShellAction.Pop) navigation.goBack()
|
||||||
|
}.apply {
|
||||||
|
pickerVisible = false
|
||||||
|
view.row = snapshot.rows.indexOf(row)
|
||||||
|
view.home.focusedKey = row.id
|
||||||
|
view.cards[row.id] = row.cards.size
|
||||||
|
}
|
||||||
|
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
||||||
|
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
|
||||||
|
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists)
|
||||||
|
val facade = mockk<LibraryReadFacade>()
|
||||||
|
rule.setContent {
|
||||||
|
val destination = navigation.backStack.last()
|
||||||
|
val route = (destination as? Destination.Wolfskin)?.route ?: "home"
|
||||||
|
LaunchedEffect(destination) { shell.enterDestination(route) }
|
||||||
|
WolfskinTheme { ShellSurface(shell) { modifier ->
|
||||||
|
if (destination is Destination.Home) HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
|
||||||
|
else WolfskinLibraryContent(rule.activity, route, shell, user, "fixture", facade, navigation, lists, modifier)
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
settle()
|
||||||
|
rule.onNodeWithTag(cardTag).assertIsDisplayed()
|
||||||
|
val home = shell.view
|
||||||
|
val track = home.home.tracks.getValue(rowId)
|
||||||
|
val verticalIndex = home.listState.firstVisibleItemIndex
|
||||||
|
val verticalOffset = home.listState.firstVisibleItemScrollOffset
|
||||||
|
val rowOffset = home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset
|
||||||
|
val horizontalIndex = track.firstVisibleItemIndex
|
||||||
|
val horizontalOffset = track.firstVisibleItemScrollOffset
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals("seeall/justin/$rowId", (navigation.backStack.last() as Destination.Wolfskin).route)
|
||||||
|
val grid = requireNotNull(lists.get("justin/$rowId"))
|
||||||
|
try {
|
||||||
|
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||||
|
exerciseGrid(grid)
|
||||||
|
press(Key.Back)
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertTrue(navigation.backStack.last() is Destination.Home)
|
||||||
|
assertEquals(row.id, snapshot.rows[shell.view.row].id)
|
||||||
|
assertEquals(expectedColumn, shell.view.cards[row.id])
|
||||||
|
assertSame("Home retains its original route memory", home, shell.view)
|
||||||
|
assertSame("Home retains its original horizontal state", track, home.home.tracks.getValue(rowId))
|
||||||
|
assertEquals("Vertical first item", verticalIndex, home.listState.firstVisibleItemIndex)
|
||||||
|
assertEquals("Vertical pixel offset", verticalOffset, home.listState.firstVisibleItemScrollOffset)
|
||||||
|
assertEquals("Selected row pixel offset", rowOffset, home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset)
|
||||||
|
assertEquals("Horizontal first item", horizontalIndex, track.firstVisibleItemIndex)
|
||||||
|
assertEquals("Horizontal pixel offset", horizontalOffset, track.firstVisibleItemScrollOffset)
|
||||||
|
rule.onNodeWithTag(cardTag).assertIsDisplayed()
|
||||||
|
} catch (failure: AssertionError) {
|
||||||
|
throw AssertionError(
|
||||||
|
"Expected $rowId:$expectedColumn vertical=$verticalIndex/$verticalOffset row-offset=$rowOffset horizontal=$horizontalIndex/$horizontalOffset; " +
|
||||||
|
"actual route=${shell.route} row=${home.row} card=${home.cards[rowId]} nav=${shell.navFocused} handler=${shell.contentFocus?.javaClass?.simpleName} grid=${grid.index} " +
|
||||||
|
"vertical=${home.listState.firstVisibleItemIndex}/${home.listState.firstVisibleItemScrollOffset} horizontal=${track.firstVisibleItemIndex}/${track.firstVisibleItemScrollOffset} " +
|
||||||
|
"visible-rows=${home.listState.layoutInfo.visibleItemsInfo.map { it.key to it.offset }}",
|
||||||
|
failure,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun press(key: Key) {
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
||||||
|
settle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun settle() {
|
||||||
|
repeat(3) {
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
rule.mainClock.advanceTimeBy(1_000)
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -84,5 +84,43 @@ class TitleAsyncFocusTest {
|
||||||
assertFalse(shell.navFocused)
|
assertFalse(shell.navFocused)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun loadingPersonFocusesItsFirstCreditBeforeRealRemoteNavigation() {
|
||||||
|
val id = UUID.fromString("00000000-0000-0000-0000-000000000013")
|
||||||
|
val reply = CompletableDeferred<org.justinvisser.wolfskin.tv.read.PersonRead>()
|
||||||
|
val facade = mockk<TitleReadFacade>()
|
||||||
|
coEvery { facade.readPersonSaved(id) } returns null
|
||||||
|
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
|
||||||
|
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true))
|
||||||
|
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
|
val route = personRoute(id.toString())
|
||||||
|
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
||||||
|
pickerVisible = false
|
||||||
|
pickerLaunch = false
|
||||||
|
enterDestination(route)
|
||||||
|
}
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel)
|
||||||
|
} }
|
||||||
|
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertTrue(shell.navFocused)
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
assertTrue(shell.navFocused)
|
||||||
|
rule.runOnIdle {
|
||||||
|
reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead(
|
||||||
|
org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"),
|
||||||
|
listOf(TitleItem("first-credit", "First credit", "movie"), TitleItem("second-credit", "Second credit", "movie")),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable }
|
||||||
|
assertNotNull("A real person reply must populate the performer destination", viewModel.person)
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused)
|
||||||
|
assertEquals(0, viewModel.person!!.index)
|
||||||
|
press(Key.DirectionRight)
|
||||||
|
assertEquals(1, viewModel.person!!.index)
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
}
|
||||||
|
|
||||||
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
|
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ class TitleCaptureTest {
|
||||||
File(directory, "st-${number.toString().padStart(3, '0')}-layout.json").writeText(metrics.toString(2))
|
File(directory, "st-${number.toString().padStart(3, '0')}-layout.json").writeText(metrics.toString(2))
|
||||||
}
|
}
|
||||||
recordTextMetrics(directory, number)
|
recordTextMetrics(directory, number)
|
||||||
|
recordElements(directory, number)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun recordTextMetrics(directory: File, number: Int) {
|
private fun recordTextMetrics(directory: File, number: Int) {
|
||||||
|
|
@ -143,6 +144,20 @@ class TitleCaptureTest {
|
||||||
File(directory, "st-${number.toString().padStart(3, '0')}-text.json").writeText(records.toString(2))
|
File(directory, "st-${number.toString().padStart(3, '0')}-text.json").writeText(records.toString(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun recordElements(directory: File, number: Int) {
|
||||||
|
val tag = androidx.compose.ui.semantics.SemanticsProperties.TestTag
|
||||||
|
val description = androidx.compose.ui.semantics.SemanticsProperties.ContentDescription
|
||||||
|
val matcher = SemanticsMatcher.keyIsDefined(tag) or SemanticsMatcher.keyIsDefined(description)
|
||||||
|
val records = org.json.JSONArray()
|
||||||
|
rule.onAllNodes(matcher, useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
|
||||||
|
records.put(JSONObject()
|
||||||
|
.put("tag", if (node.config.contains(tag)) node.config[tag] else JSONObject.NULL)
|
||||||
|
.put("description", if (node.config.contains(description)) org.json.JSONArray(node.config[description]) else JSONObject.NULL)
|
||||||
|
.put("boundsPx", org.json.JSONArray(listOf(node.boundsInRoot.left, node.boundsInRoot.top, node.boundsInRoot.right, node.boundsInRoot.bottom))))
|
||||||
|
}
|
||||||
|
File(directory, "st-${number.toString().padStart(3, '0')}-elements.json").writeText(records.toString(2))
|
||||||
|
}
|
||||||
|
|
||||||
private fun option(state: TitleState, id: String) { state.focus("header", state.options.indexOfFirst { it.id == id }) }
|
private fun option(state: TitleState, id: String) { state.focus("header", state.options.indexOfFirst { it.id == id }) }
|
||||||
private fun open(state: TitleState, shell: ShellState, id: String) {
|
private fun open(state: TitleState, shell: ShellState, id: String) {
|
||||||
option(state, id)
|
option(state, id)
|
||||||
|
|
|
||||||
|
|
@ -300,5 +300,46 @@ class TitleKeysTest {
|
||||||
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||||
val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2
|
val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2
|
||||||
assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f)
|
assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f)
|
||||||
|
val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||||
|
assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Test fun filmHeaderKeepsItsFixedCssStack() {
|
||||||
|
render()
|
||||||
|
assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f)
|
||||||
|
assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f)
|
||||||
|
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Test fun episodeProgressReclaimsItsNegativeMarginBeforeTheActionRow() {
|
||||||
|
render("Andor")
|
||||||
|
assertEquals(603f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
||||||
|
render(transform = { data ->
|
||||||
|
val source = data.versions.first().copy(
|
||||||
|
audio = List(3) { org.justinvisser.wolfskin.design.TitleTrack(it + 3, "Audio ${it + 1}") },
|
||||||
|
subtitles = List(15) { org.justinvisser.wolfskin.design.TitleTrack(it + 20, "Subtitle ${it + 1}") },
|
||||||
|
)
|
||||||
|
data.copy(versions = listOf(source))
|
||||||
|
})
|
||||||
|
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "tracks" }) }
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
val bottom = rule.onRoot().fetchSemanticsNode().boundsInRoot.bottom
|
||||||
|
val labels = (1..3).map { "Audio $it" } + "Off" + (1..15).map { "Subtitle $it" } + "Done"
|
||||||
|
labels.forEachIndexed { index, label ->
|
||||||
|
if (index > 0) press(Key.DirectionDown)
|
||||||
|
rule.waitForIdle()
|
||||||
|
val bounds = rule.onNodeWithText(label, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||||
|
assertTrue("$label must have visible height: $bounds", bounds.height > 20)
|
||||||
|
assertTrue("$label stays above the viewport bottom: $bounds / $bottom", bounds.bottom <= bottom)
|
||||||
|
assertTrue("$label stays below the viewport top: $bounds", bounds.top >= 0)
|
||||||
|
}
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertNull(state.sheet)
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** Full launch paths render the real picker, Home hero/shelves, title and person surfaces. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
class TitleLaunchKeysTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private lateinit var fixture: TitleFixture
|
||||||
|
private lateinit var home: HomeFixture
|
||||||
|
private lateinit var shell: ShellState
|
||||||
|
private var title by mutableStateOf<TitleState?>(null)
|
||||||
|
private var person by mutableStateOf<PersonState?>(null)
|
||||||
|
private val actions = mutableListOf<TitleAction>()
|
||||||
|
private val shellActions = mutableListOf<ShellAction>()
|
||||||
|
private lateinit var paths: List<JSONObject>
|
||||||
|
|
||||||
|
@Before fun launch() {
|
||||||
|
fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
|
||||||
|
home = HomeFixture(rule.activity)
|
||||||
|
val source = javaClass.getResourceAsStream("/title-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) }
|
||||||
|
paths = List(source.length()) { source.getJSONObject(it) }
|
||||||
|
shell = ShellState(fixture.people, "justin") { action ->
|
||||||
|
shellActions.add(action)
|
||||||
|
if (action is ShellAction.Person) { title = null; person = null }
|
||||||
|
}
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
val performer = person
|
||||||
|
val page = title
|
||||||
|
val snapshot = remember(shell.person.key) { home.snapshot(shell.person.key) }
|
||||||
|
when {
|
||||||
|
performer != null -> PersonSurface(performer, shell)
|
||||||
|
page != null -> TitleSurface(page, shell)
|
||||||
|
else -> ShellSurface(shell) { modifier ->
|
||||||
|
HomeScreen(shell, snapshot, modifier) { action ->
|
||||||
|
if (action is HomeAction.Details) openTitle(action.title.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openTitle(id: String) {
|
||||||
|
val data = fixture.title(id, shell.person.key)
|
||||||
|
title = TitleState(data, shell.person, ::act)
|
||||||
|
person = null
|
||||||
|
shell.enterDestination(titleRoute(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun act(action: TitleAction) {
|
||||||
|
actions.add(action)
|
||||||
|
when (action) {
|
||||||
|
is TitleAction.Person -> {
|
||||||
|
val (actor, credits) = fixture.person(action.person.id, shell.person.key)
|
||||||
|
person = PersonState(actor, credits, ::act)
|
||||||
|
shell.enterDestination(personRoute(actor.id))
|
||||||
|
}
|
||||||
|
is TitleAction.OpenTitle -> openTitle(action.item.id)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replay(end: String, expectedPresses: Int) {
|
||||||
|
val path = paths.single { it.getString("end") == end }
|
||||||
|
val keys = path.getJSONArray("keys")
|
||||||
|
assertEquals(expectedPresses, path.getInt("presses"))
|
||||||
|
assertEquals(expectedPresses, keys.length())
|
||||||
|
for (index in 0 until keys.length()) {
|
||||||
|
val key = when (keys.getString(index)) {
|
||||||
|
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown
|
||||||
|
"LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight
|
||||||
|
"OK" -> Key.DirectionCenter
|
||||||
|
else -> error("Unknown launch replay key")
|
||||||
|
}
|
||||||
|
rule.onRoot().performKeyInput { pressKey(key) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun titleToNavTakesFourPressesFromTheRealHomeHero() {
|
||||||
|
replay("nav", 4)
|
||||||
|
assertNotNull(title)
|
||||||
|
assertTrue(shell.navFocused)
|
||||||
|
assertEquals(shell.currentTab, shell.navIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun switchingPersonFromATitleTakesEightLaunchPresses() {
|
||||||
|
replay("person-mindel", 8)
|
||||||
|
assertEquals("mindel", shell.person.key)
|
||||||
|
assertFalse(shell.pickerVisible)
|
||||||
|
assertEquals("home", shell.route)
|
||||||
|
assertTrue(shellActions.last() is ShellAction.Person)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun lockeVersionTwoAndPlayTakesFifteenLaunchPresses() {
|
||||||
|
replay("play-version-2", 15)
|
||||||
|
val page = requireNotNull(title)
|
||||||
|
assertEquals("Locke", page.data.item.name)
|
||||||
|
assertEquals("v2", page.versionId)
|
||||||
|
assertNull(page.sheet)
|
||||||
|
assertEquals("play", page.options[page.column].id)
|
||||||
|
assertEquals("Locke", (actions.last() as TitleAction.Play).item.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun lockeToTomHardyTakesElevenLaunchPresses() {
|
||||||
|
replay("person-Tom Hardy", 11)
|
||||||
|
assertEquals("Tom Hardy", requireNotNull(person).person.name)
|
||||||
|
assertEquals(0, person!!.index)
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
assertTrue(actions.last() is TitleAction.Person)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -166,4 +166,15 @@ class TitleReadFacadeTest {
|
||||||
facade.apply(org.justinvisser.wolfskin.design.TitleAction.Version(data.watchTarget, data.versions.single()))
|
facade.apply(org.justinvisser.wolfskin.design.TitleAction.Version(data.watchTarget, data.versions.single()))
|
||||||
coVerify(exactly = 1) { playback.savePlayVersion(firstId, UUID.fromString("00000000-0000-0000-0000-000000000005")) }
|
coVerify(exactly = 1) { playback.savePlayVersion(firstId, UUID.fromString("00000000-0000-0000-0000-000000000005")) }
|
||||||
}
|
}
|
||||||
|
@Test fun subtitleOffDisablesPlaybackRatherThanRestoringThePreferredLanguage() = runTest {
|
||||||
|
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||||
|
coEvery { playback.saveTrackSelection(any(), any(), any(), MediaStreamType.SUBTITLE) } returns
|
||||||
|
com.github.damontecres.wholphin.data.model.ItemPlayback(userId = 1, itemId = firstId)
|
||||||
|
val data = facade.read(firstId, null, preferences)
|
||||||
|
facade.apply(org.justinvisser.wolfskin.design.TitleAction.Track(data.watchTarget, data.versions.single(), audio = null, subtitle = -1))
|
||||||
|
coVerify(exactly = 1) { playback.saveTrackSelection(any(), any(), com.github.damontecres.wholphin.data.model.TrackIndex.DISABLED, MediaStreamType.SUBTITLE) }
|
||||||
|
coVerify(exactly = 0) { playback.saveTrackSelection(any(), any(), com.github.damontecres.wholphin.data.model.TrackIndex.UNSPECIFIED, MediaStreamType.SUBTITLE) }
|
||||||
|
facade.apply(org.justinvisser.wolfskin.design.TitleAction.Track(data.watchTarget, data.versions.single(), audio = null, subtitle = 8))
|
||||||
|
coVerify(exactly = 1) { playback.saveTrackSelection(any(), any(), 8, MediaStreamType.SUBTITLE) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,13 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toPixelMap
|
import androidx.compose.ui.graphics.toPixelMap
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsFocused
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
import androidx.compose.ui.test.assertIsNotFocused
|
import androidx.compose.ui.test.assertIsNotFocused
|
||||||
import androidx.compose.ui.test.captureToImage
|
import androidx.compose.ui.test.captureToImage
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.test.performKeyInput
|
import androidx.compose.ui.test.performKeyInput
|
||||||
import androidx.compose.ui.test.pressKey
|
import androidx.compose.ui.test.pressKey
|
||||||
|
|
@ -131,6 +133,7 @@ class UpstreamContentFocusTest {
|
||||||
rule.runOnIdle { WolfskinScreenPreference.enabled = true }
|
rule.runOnIdle { WolfskinScreenPreference.enabled = true }
|
||||||
rule.mainClock.advanceTimeBy(1500)
|
rule.mainClock.advanceTimeBy(1500)
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
|
rule.onNodeWithText("Sign in as Justin to open their Jellyfin library").assertIsDisplayed()
|
||||||
val on = rule.onRoot().captureToImage().toPixelMap()[1700, 900]
|
val on = rule.onRoot().captureToImage().toPixelMap()[1700, 900]
|
||||||
val background = org.justinvisser.wolfskin.design.WolfskinTokens.Color.bg
|
val background = org.justinvisser.wolfskin.design.WolfskinTokens.Color.bg
|
||||||
org.junit.Assert.assertEquals("Owned background red", background.red, on.red, 1f / 255f)
|
org.junit.Assert.assertEquals("Owned background red", background.red, on.red, 1f / 255f)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||||
|
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
||||||
|
|
||||||
|
/** The Home entry owns its source reads and upstream navigation, never the design module. */
|
||||||
|
@HiltViewModel
|
||||||
|
class WolfskinHomeViewModel @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val home: HomeReadFacade,
|
||||||
|
private val cache: HomeLibraryCache,
|
||||||
|
private val navigation: NavigationManager,
|
||||||
|
private val seeAll: WolfskinSeeAllLists,
|
||||||
|
) : ViewModel() {
|
||||||
|
var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading))
|
||||||
|
private set
|
||||||
|
var toast by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
|
||||||
|
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return }
|
||||||
|
val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut))
|
||||||
|
if (person != user.name.orEmpty().lowercase()) {
|
||||||
|
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val scope = "${server.id}:${user.id}"
|
||||||
|
val saved = withContext(Dispatchers.IO) { cache.read(scope) }
|
||||||
|
if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation))
|
||||||
|
try {
|
||||||
|
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
||||||
|
val savedAt = System.currentTimeMillis()
|
||||||
|
withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) }
|
||||||
|
snapshot = HomeSnapshot(buildHomeRows(library, continuation))
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
if (exception is CancellationException) throw exception
|
||||||
|
snapshot = if (saved != null) HomeSnapshot(buildHomeRows(saved.first, continuation), jellyfinAvailable = false, savedAt = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(saved.second)))
|
||||||
|
else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
|
||||||
|
if (retry) showToast("Still can’t reach it. The last saved view stays on screen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) {
|
||||||
|
when (action) {
|
||||||
|
HomeAction.Discover -> openRoot(shell, "discover")
|
||||||
|
HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person.key, retry = true) }
|
||||||
|
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else seeAll.open(shell.person.key, action.row.id, action.row.title, action.row.allCards.map { it.libraryTitle() }, navigation)
|
||||||
|
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
|
||||||
|
is HomeAction.Play -> viewModelScope.launch {
|
||||||
|
if (!snapshot.jellyfinAvailable || shell.person.key != user.name.orEmpty().lowercase()) {
|
||||||
|
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
|
||||||
|
if (id == null) showToast("Can’t reach Jellyfin to play. Check the network and try again.")
|
||||||
|
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
if (exception is CancellationException) throw exception
|
||||||
|
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openRoot(shell: ShellState, route: String) {
|
||||||
|
shell.enterDestination(route)
|
||||||
|
shell.focusNav(shell.tabs.indexOf(route))
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showToast(message: String) {
|
||||||
|
toast = message
|
||||||
|
viewModelScope.launch { delay(WolfskinTokens.Motion.toast.toLong()); if (toast == message) toast = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete catalogue projections feed the shared See all grid without another source read. */
|
||||||
|
internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle(
|
||||||
|
id = id, title = title, type = type, year = year, runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasons,
|
||||||
|
poster = poster, played = played, progress = runtimeSeconds?.let { if (it > 0) (resumeSeconds / it).toFloat() else 0f } ?: 0f,
|
||||||
|
addedAt = addedAt, tmdbId = tmdbId?.toString(),
|
||||||
|
availability = if (held) "In your library" else otherOwner?.let { "In $it’s library" }.orEmpty(),
|
||||||
|
badge = if (held) "held" else null,
|
||||||
|
ratings = ratings.map { rating ->
|
||||||
|
when (rating.mark) {
|
||||||
|
"fresh" -> LibraryRating("rtCritics", rating.score, "Fresh")
|
||||||
|
"rotten" -> LibraryRating("rtCritics", rating.score, "Rotten")
|
||||||
|
"tomato" -> LibraryRating("rtCritics", rating.score)
|
||||||
|
"upright" -> LibraryRating("rtAudience", rating.score, "Upright")
|
||||||
|
"spilled" -> LibraryRating("rtAudience", rating.score, "Spilled")
|
||||||
|
else -> LibraryRating(rating.mark, rating.score)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The shell keeps its focus memory while playback or later-slice screens are pushed above Home. */
|
||||||
|
@Composable
|
||||||
|
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
||||||
|
LaunchedEffect(shell.person.key, server.id, user.id) { model.load(server, user, shell.person.key) }
|
||||||
|
Box(modifier) {
|
||||||
|
HomeScreen(shell, model.snapshot, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
|
||||||
|
model.toast?.let { message ->
|
||||||
|
Text(message, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.body, modifier = Modifier.align(Alignment.TopEnd).padding(top = WolfskinTokens.Home.toastTop, end = WolfskinTokens.Layout.safeSide).widthIn(max = WolfskinTokens.Home.toastWidth).background(WolfskinTokens.Color.surface2).padding(WolfskinTokens.Home.toastPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -212,8 +212,9 @@ fun WolfskinApp(
|
||||||
ShellSurface(state, handlesContentKeys = upstream == null,
|
ShellSurface(state, handlesContentKeys = upstream == null,
|
||||||
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
||||||
when {
|
when {
|
||||||
upstream != null && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
(upstream != null || destination is Destination.Home) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||||
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||||
|
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
|
||||||
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
|
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
|
||||||
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
|
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
|
||||||
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ fun WolfskinLibraryContent(
|
||||||
seeAll: WolfskinSeeAllLists,
|
seeAll: WolfskinSeeAllLists,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val view = shell.view
|
val view = shell.rememberedView(route)
|
||||||
val fixture by produceState<LibraryFixture?>(null, context) {
|
val fixture by produceState<LibraryFixture?>(null, context) {
|
||||||
if (BuildConfig.DEBUG) value = withContext(Dispatchers.IO) {
|
if (BuildConfig.DEBUG) value = withContext(Dispatchers.IO) {
|
||||||
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
|
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
|
||||||
|
|
@ -87,7 +87,7 @@ fun WolfskinLibraryContent(
|
||||||
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
|
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null)
|
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null, view = view)
|
||||||
}
|
}
|
||||||
|
|
||||||
val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime")
|
val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
package org.justinvisser.wolfskin.tv.read
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONException
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.justinvisser.wolfskin.conductarr.CacheEntry
|
||||||
|
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.HomeLibrary
|
||||||
|
import org.justinvisser.wolfskin.design.HomeTitle
|
||||||
|
|
||||||
|
/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */
|
||||||
|
@Singleton
|
||||||
|
class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) {
|
||||||
|
private val store = FileCacheStore(File(context.filesDir, "wolfskin-home"))
|
||||||
|
|
||||||
|
fun read(scope: String): Pair<HomeLibrary, Long>? {
|
||||||
|
val entry = store.read(scope, KEY) ?: return null
|
||||||
|
return try {
|
||||||
|
val json = JSONObject(entry.body)
|
||||||
|
val destinations = json.getJSONObject("destinations")
|
||||||
|
val totals = json.getJSONObject("totals")
|
||||||
|
HomeLibrary(
|
||||||
|
decode(json.getJSONArray("resume")), decode(json.getJSONArray("nextUp")), decode(json.getJSONArray("newest")),
|
||||||
|
destinations.keys().asSequence().associateWith { decode(destinations.getJSONArray(it)) },
|
||||||
|
totals.keys().asSequence().associateWith { totals.getInt(it) },
|
||||||
|
) to entry.savedAtMillis
|
||||||
|
} catch (_: JSONException) { store.remove(scope, KEY); null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun write(scope: String, library: HomeLibrary, savedAtMillis: Long) {
|
||||||
|
val json = JSONObject().put("resume", encode(library.resume)).put("nextUp", encode(library.nextUp)).put("newest", encode(library.newest))
|
||||||
|
.put("destinations", JSONObject().apply { library.destinations.forEach { (key, titles) -> put(key, encode(titles)) } })
|
||||||
|
.put("totals", JSONObject(library.destinationTotals))
|
||||||
|
store.write(scope, KEY, CacheEntry(json.toString(), null, savedAtMillis, savedAtMillis, group = "home"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encode(titles: List<HomeTitle>): JSONArray = JSONArray().apply {
|
||||||
|
titles.forEach { title -> put(JSONObject().apply {
|
||||||
|
put("id", title.id); put("title", title.title); put("type", title.type); put("year", title.year)
|
||||||
|
put("runtime", title.runtimeSeconds); put("seasons", title.seasons); put("overview", title.overview)
|
||||||
|
put("poster", title.poster); put("backdrop", title.backdrop); put("posterOnly", title.posterOnly)
|
||||||
|
put("seriesTitle", title.seriesTitle); put("season", title.season); put("episode", title.episode)
|
||||||
|
put("resume", title.resumeSeconds); put("played", title.played); put("addedAt", title.addedAt)
|
||||||
|
put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId)
|
||||||
|
}) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decode(items: JSONArray): List<HomeTitle> = List(items.length()) { index ->
|
||||||
|
val item = items.getJSONObject(index)
|
||||||
|
fun string(key: String): String? = if (item.isNull(key)) null else item.getString(key)
|
||||||
|
fun number(key: String): Int? = if (item.isNull(key)) null else item.getInt(key)
|
||||||
|
val genres = item.getJSONArray("genres")
|
||||||
|
HomeTitle(
|
||||||
|
item.getString("id"), item.getString("title"), item.getString("type"), number("year"), number("runtime"), number("seasons"), item.getString("overview"),
|
||||||
|
string("poster"), string("backdrop"), item.getBoolean("posterOnly"), string("seriesTitle"), number("season"), number("episode"), item.getDouble("resume"), item.getBoolean("played"), item.getString("addedAt"),
|
||||||
|
List(genres.length()) { genres.getString(it) }, held = true, tmdbId = number("tmdb"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object { private const val KEY = "home:jellyfin" }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package org.justinvisser.wolfskin.tv.read
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
|
import org.justinvisser.wolfskin.design.HomeLibrary
|
||||||
|
import org.justinvisser.wolfskin.design.HomeTitle
|
||||||
|
|
||||||
|
/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */
|
||||||
|
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService) {
|
||||||
|
suspend fun read(userId: UUID): HomeLibrary {
|
||||||
|
val resume = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items.map(::title)
|
||||||
|
val next = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items.map(::title)
|
||||||
|
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
||||||
|
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
|
||||||
|
val newest = mutableListOf<HomeTitle>()
|
||||||
|
val totals = linkedMapOf<String, Int>()
|
||||||
|
for (library in libraries) {
|
||||||
|
val key = when {
|
||||||
|
"anime" in library.name.orEmpty().lowercase() -> "anime"
|
||||||
|
library.collectionType == CollectionType.MOVIES -> "movies"
|
||||||
|
library.collectionType == CollectionType.TVSHOWS -> "shows"
|
||||||
|
else -> continue
|
||||||
|
}
|
||||||
|
val response = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.IS_PLAYED, ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.ASCENDING, SortOrder.DESCENDING), limit = 20).content
|
||||||
|
destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map(::title))
|
||||||
|
totals[key] = (totals[key] ?: 0) + response.totalRecordCount
|
||||||
|
newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map(::title))
|
||||||
|
}
|
||||||
|
return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun playableId(userId: UUID, title: HomeTitle): UUID? {
|
||||||
|
title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id ->
|
||||||
|
val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content
|
||||||
|
if (item.type == BaseItemKind.SERIES) {
|
||||||
|
return (api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1).content.items.firstOrNull()
|
||||||
|
?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1).content.items.firstOrNull())
|
||||||
|
?.takeIf { it.type == BaseItemKind.EPISODE }?.id
|
||||||
|
}
|
||||||
|
return item.id.takeIf { item.type == BaseItemKind.MOVIE || item.type == BaseItemKind.EPISODE }
|
||||||
|
}
|
||||||
|
val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE)
|
||||||
|
return api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull {
|
||||||
|
it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode))
|
||||||
|
}?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun title(item: BaseItemDto): HomeTitle {
|
||||||
|
val wrapped = BaseItem(item)
|
||||||
|
return HomeTitle(
|
||||||
|
id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" },
|
||||||
|
year = item.productionYear, runtimeSeconds = item.runTimeTicks?.div(TICKS_PER_SECOND)?.toInt(), seasons = item.childCount,
|
||||||
|
overview = item.overview.orEmpty(), poster = images.getItemImageUrl(wrapped, ImageType.PRIMARY, fillWidth = 256, fillHeight = 384),
|
||||||
|
backdrop = images.getItemImageUrl(wrapped, ImageType.BACKDROP, fillWidth = 1280, fillHeight = 720),
|
||||||
|
seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber,
|
||||||
|
resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true,
|
||||||
|
addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, tmdbId = item.providerIds?.get("Tmdb")?.toIntOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TICKS_PER_SECOND = 10_000_000L
|
||||||
|
private val FIELDS = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.DATE_CREATED, ItemFields.GENRES, ItemFields.CHILD_COUNT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
|
@ -107,7 +108,7 @@ class TitleReadFacade @Inject constructor(
|
||||||
val item = BaseItem(api.userLibraryApi.getItem(id).content)
|
val item = BaseItem(api.userLibraryApi.getItem(id).content)
|
||||||
var choice = repository.currentUser?.let { playback.itemPlaybackDao.getItem(it, id) }
|
var choice = repository.currentUser?.let { playback.itemPlaybackDao.getItem(it, id) }
|
||||||
action.audio?.let { choice = playback.saveTrackSelection(item, choice, it, MediaStreamType.AUDIO) }
|
action.audio?.let { choice = playback.saveTrackSelection(item, choice, it, MediaStreamType.AUDIO) }
|
||||||
playback.saveTrackSelection(item, choice, action.subtitle, MediaStreamType.SUBTITLE)
|
playback.saveTrackSelection(item, choice, if (action.subtitle == -1) TrackIndex.DISABLED else action.subtitle, MediaStreamType.SUBTITLE)
|
||||||
}
|
}
|
||||||
is TitleAction.Watched -> watched.setWatched(UUID.fromString(action.item.id), action.watched)
|
is TitleAction.Watched -> watched.setWatched(UUID.fromString(action.item.id), action.watched)
|
||||||
else -> Unit
|
else -> Unit
|
||||||
|
|
|
||||||
128
docker/README.md
|
|
@ -59,9 +59,9 @@ cover the Wolfskin nav.
|
||||||
The independent `:wolfskin-design` module owns the shell components, fixed person
|
The independent `:wolfskin-design` module owns the shell components, fixed person
|
||||||
colours, focus policy, per-person root-view memory, palette and motion. Its
|
colours, focus policy, per-person root-view memory, palette and motion. Its
|
||||||
`tokens.json` is the copied approved TV-spec source, retaining the locked prototype
|
`tokens.json` is the copied approved TV-spec source, retaining the locked prototype
|
||||||
SHA and provenance. `shell-tokens.json` adds shell/grid/request measurements;
|
SHA and provenance. `shell-tokens.json` adds shared shell/grid/sheet measurements;
|
||||||
`title-tokens.json` and `discover-tokens.json` add their screen measurements from
|
`title-tokens.json`, `home-tokens.json` and `discover-tokens.json` add their screen
|
||||||
that same prototype. Generate the committed Kotlin, CSS and checksum manifest together:
|
measurements from the same locked prototype. Generate Kotlin, CSS and the checksum manifest together:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 wolfskin-design/generate_tokens.py
|
python3 wolfskin-design/generate_tokens.py
|
||||||
|
|
@ -89,8 +89,8 @@ Coming-in sheet described below. **Advanced settings → Wolfskin screens** is t
|
||||||
rollout switch: turning it off renders upstream Home and library destinations with
|
rollout switch: turning it off renders upstream Home and library destinations with
|
||||||
the upstream Backdrop and top scrim, while retaining Wolfskin's nav, picker and
|
the upstream Backdrop and top scrim, while retaining Wolfskin's nav, picker and
|
||||||
Settings list. Native Up traversal stays in content until the first focusable row,
|
Settings list. Native Up traversal stays in content until the first focusable row,
|
||||||
then enters the nav. Turning the switch on restores the labelled placeholders
|
then enters the nav. Turning the switch on renders Wolfskin Home and Libraries.
|
||||||
until the corresponding screen slices ship.
|
Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled.
|
||||||
|
|
||||||
Debug people, tabs and initials come from the shell-only projection of the locked
|
Debug people, tabs and initials come from the shell-only projection of the locked
|
||||||
prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources
|
prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources
|
||||||
|
|
@ -144,8 +144,10 @@ destination or nav selection.
|
||||||
|
|
||||||
Native Roborazzi captures use Robolectric SDK 34 and
|
Native Roborazzi captures use Robolectric SDK 34 and
|
||||||
`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and
|
`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and
|
||||||
the sampled Compose nav-spring curve to `build/wolfskin-captures/`. Key replays
|
the sampled Compose nav-spring curve to `build/wolfskin-captures/`.
|
||||||
exercise the Android event adapter, including the 6/8/10/11-press shell paths.
|
Home, Library and Discover captures use `home/`, `library/` and `discover/`
|
||||||
|
subdirectories so shared spec state numbers cannot overwrite another renderer's image.
|
||||||
|
Key replays exercise the Android event adapter, including the 6/8/10/11-press shell paths.
|
||||||
The profile hand-off capture targets the real `HomeSettingsPage`; Playback is a
|
The profile hand-off capture targets the real `HomeSettingsPage`; Playback is a
|
||||||
separate press-count path.
|
separate press-count path.
|
||||||
|
|
||||||
|
|
@ -166,6 +168,78 @@ artifact provider and AAPT2. It checks all string, plural and array configuratio
|
||||||
rejecting user-visible upstream branding while preserving the upstream legal
|
rejecting user-visible upstream branding while preserving the upstream legal
|
||||||
attribution and project URL. The check does not depend on incremental merge files.
|
attribution and project URL. The check does not depend on incremental merge files.
|
||||||
|
|
||||||
|
## Wolfskin Home
|
||||||
|
|
||||||
|
Home is one lazy frame: a non-rotating resume → Next up → newest → pick hero,
|
||||||
|
Jellyfin shelves, per-destination "Your …" shelves and the Discover continuation.
|
||||||
|
The final action opens the real merged Discover screen at its top through the
|
||||||
|
shell's root re-selection transition. Home's See all cards open `WolfskinSeeAllLists`, carrying
|
||||||
|
the complete underlying list rather than the visible shelf window. Title content
|
||||||
|
remains the later-slice target.
|
||||||
|
|
||||||
|
All Home components and immutable screen models live in `:wolfskin-design`.
|
||||||
|
The shell's remembered view owns vertical position, each row's selected card and
|
||||||
|
actual horizontal `LazyListState`, and the bounded six-hour ratings session cache.
|
||||||
|
Composing destinations bind `rememberedView(route)` before navigation effects run, so a
|
||||||
|
new See all grid cannot borrow or clamp Home's scroller. Re-entry restores the
|
||||||
|
remembered track before the page with `scrollToItem`, preserving exact indices and
|
||||||
|
pixel offsets rather than animating back. Android focus stays with the shell;
|
||||||
|
cards use its logical focus, without requests against uncomposed lazy items.
|
||||||
|
Back preserves the viewport; returning to Home does not re-run focus-follow over
|
||||||
|
a manually scrolled saved view. The next arrow re-enters visible content when
|
||||||
|
scrolling moved focus off-screen.
|
||||||
|
Pointing changes focus without activation, cards activate on key-up, and held
|
||||||
|
OK events cannot activate twice.
|
||||||
|
Between shelves, an unvisited row selects the nearest visible card center even
|
||||||
|
when card widths differ. A visited row restores its column. Horizontal movement
|
||||||
|
keeps the track still while focus remains in its visible middle.
|
||||||
|
The initial skeleton parks focus on the nav. When the first content arrives,
|
||||||
|
an untouched fresh entry focuses hero button zero; intervening nav input prevents
|
||||||
|
that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus`
|
||||||
|
release/repeat guard and Back ladder.
|
||||||
|
|
||||||
|
`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release
|
||||||
|
variants read the selected user's Resume, Next up, newest titles and destination
|
||||||
|
shelves, with app-private last-known-good projections through the shared atomic
|
||||||
|
disk cache. A missing saved library becomes a recovery panel; a saved library
|
||||||
|
stays visible when Jellyfin is unreachable. Playback resolves an actual film or
|
||||||
|
episode and checks Jellyfin before opening upstream's player. No season,
|
||||||
|
recommendation stub or different person's session is promoted to playback.
|
||||||
|
An unresolved person switch shows the same sign-in guard for Home as for native
|
||||||
|
library content, before mounting Home's source reads.
|
||||||
|
|
||||||
|
Debug variants additionally package the locked fixture and local artwork under
|
||||||
|
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
|
||||||
|
The fixture provider supplies the approved household data and Home states without
|
||||||
|
network reads in tests. Conductarr sign-in is not implemented here: real signed-out
|
||||||
|
Home hides every rating and shows "Discover needs sign-in". No Seerr fallback is
|
||||||
|
used. Catalogue data and ratings in debug captures are fixture-backed.
|
||||||
|
|
||||||
|
Home, Libraries and Discover reuse `TitleCard`, `Caption` and `LibrarySeeAllCard`;
|
||||||
|
those screens and Requests share one `WolfskinImages` loader. Exact-slot requests use RGB565 for posters,
|
||||||
|
disable crossfades, share a 64 MiB memory/128 MiB disk cache, limit image work to
|
||||||
|
four reads and prefetch the next shelf. Rating plates start at 14 sp, shrink to 12 sp
|
||||||
|
and drop trailing entries rather than wrap. Their uncached read gate is 350 ms;
|
||||||
|
cached focus returns without that delay.
|
||||||
|
|
||||||
|
Focus rings and native lift draw outside fixed slots. The Saved nav chip uses its
|
||||||
|
own geometry; the Discover section uses a warning pill. Caption separators multiply
|
||||||
|
the existing muted colour alpha by the shared 40% token. Home-only untrimmed styles
|
||||||
|
and `exactLineBox` honor the declared CSS line boxes despite Android's intrinsic
|
||||||
|
paragraph minimums; shell and library typography retain their existing defaults.
|
||||||
|
The shared See all component uses its canonical locked 8px gap, not the generic
|
||||||
|
10px tail spacing. Focus lifts use the merged draw-only `FocusLift` implementation;
|
||||||
|
the obsolete Home shadow helper and duplicate gap/lift measurements are removed.
|
||||||
|
Vertical moves clamp to short target rows, including a final See all card.
|
||||||
|
|
||||||
|
`make test` includes `HomeCaptureTest` (23 native acceptance states) and the
|
||||||
|
data-driven paths in `wolfskin-home-presses.json`: play next 2, resume 3, Discover
|
||||||
|
4, deep-row Back/Down 9, Trending 13 and Trending's See all card 33 presses.
|
||||||
|
Home captures are written to `build/wolfskin-captures/home/`. Compare those images
|
||||||
|
side by side with the frozen spec captures; Compose Roboto/JPEG rasterisation,
|
||||||
|
the approved button-scale consolidation and removal of prototype sample pills
|
||||||
|
are recorded differences, not a claim of browser pixel equality.
|
||||||
|
|
||||||
## Requests under Settings
|
## Requests under Settings
|
||||||
|
|
||||||
The Requests row and Coming-in sheet share one immutable household snapshot.
|
The Requests row and Coming-in sheet share one immutable household snapshot.
|
||||||
|
|
@ -187,7 +261,7 @@ the generated-client data path. There is no Seerr fallback or handwritten HTTP.
|
||||||
Foreground refreshes once; an open sheet or unsettled requests refresh every
|
Foreground refreshes once; an open sheet or unsettled requests refresh every
|
||||||
30 seconds while started. The immutable debug projection is loaded once, not
|
30 seconds while started. The immutable debug projection is loaded once, not
|
||||||
decoded again on every poll.
|
decoded again on every poll.
|
||||||
Thumbnails reuse `LibraryImages` with a 96×144-pixel request, RGB565, no crossfade,
|
Thumbnails reuse `WolfskinImages` with a 96×144-pixel request, RGB565, no crossfade,
|
||||||
the shared 64 MB memory/128 MB disk cache and four-read limit. Actor separator
|
the shared 64 MB memory/128 MB disk cache and four-read limit. Actor separator
|
||||||
dots use the surrounding text colour; the 40% rule applies only to poster captions.
|
dots use the surrounding text colour; the 40% rule applies only to poster captions.
|
||||||
|
|
||||||
|
|
@ -197,8 +271,8 @@ page and spec 6.12, resolving the contradictory no-dwell sentence in spec 7.8.
|
||||||
Deterministic coroutine-clock tests cover cancellation, reentry, cached return,
|
Deterministic coroutine-clock tests cover cancellation, reentry, cached return,
|
||||||
sign-out and refreshed locale scores; a thin Compose test verifies actual Dutch
|
sign-out and refreshed locale scores; a thin Compose test verifies actual Dutch
|
||||||
rendering and sign-out suppression. The approved nine-key Requests path is JSON
|
rendering and sign-out suppression. The approved nine-key Requests path is JSON
|
||||||
data replayed through the real shell. Home's hero content is not implemented
|
data replayed through the real shell. The Requests-only harness starts at the
|
||||||
here: its launch-to-nav shell suffix is the acceptance seam.
|
Home-to-nav boundary; Home's actual hero and asynchronous entry have separate key replays.
|
||||||
|
|
||||||
Sheet dimensions, padding, glyphs, scales, colours and durations are generated
|
Sheet dimensions, padding, glyphs, scales, colours and durations are generated
|
||||||
tokens. Scoped sheet line boxes retain CSS heights and baselines independently
|
tokens. Scoped sheet line boxes retain CSS heights and baselines independently
|
||||||
|
|
@ -265,8 +339,8 @@ music keep their upstream destinations.
|
||||||
`LibraryCaptureTest` renders the sixteen approved library/See all states offline.
|
`LibraryCaptureTest` renders the sixteen approved library/See all states offline.
|
||||||
`LibraryKeysTest` reads the approved JSON path and reaches Spirited Away in exactly
|
`LibraryKeysTest` reads the approved JSON path and reaches Spirited Away in exactly
|
||||||
20 Android key presses. Paging and snapshot tests cover duplicate-only reads,
|
20 Android key presses. Paging and snapshot tests cover duplicate-only reads,
|
||||||
failure/retry, stale copies, and person/server isolation. Captures are written to
|
failure/retry, stale copies, and person/server isolation. Library captures are written
|
||||||
the same `build/wolfskin-captures/` directory as the shell captures.
|
to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`.
|
||||||
|
|
||||||
## Title pages and people
|
## Title pages and people
|
||||||
|
|
||||||
|
|
@ -274,7 +348,7 @@ the same `build/wolfskin-captures/` directory as the shell captures.
|
||||||
cast and related shelves. `PersonSurface` owns the real biography and continuous
|
cast and related shelves. `PersonSurface` owns the real biography and continuous
|
||||||
six-column filmography. Both reuse the shell/nav/back ladder, `LibraryPoster`,
|
six-column filmography. Both reuse the shell/nav/back ladder, `LibraryPoster`,
|
||||||
the adaptive `LibraryRatingPlate`, shared captions and the single bounded
|
the adaptive `LibraryRatingPlate`, shared captions and the single bounded
|
||||||
`LibraryImages` loader. Image requests use exact slot sizes, RGB565 for opaque
|
`WolfskinImages` loader. Image requests use exact slot sizes, RGB565 for opaque
|
||||||
posters, no crossfade, four concurrent reads and next-row prefetch. The fixed
|
posters, no crossfade, four concurrent reads and next-row prefetch. The fixed
|
||||||
title backdrop remains visible behind deep rows; only off-screen row images
|
title backdrop remains visible behind deep rows; only off-screen row images
|
||||||
leave composition.
|
leave composition.
|
||||||
|
|
@ -310,20 +384,22 @@ pretend those operations succeeded or fall back to Seerr.
|
||||||
`TitleCaptureTest` writes the 34 approved title/person/options frames plus native
|
`TitleCaptureTest` writes the 34 approved title/person/options frames plus native
|
||||||
text weights, colours, line boxes and bounds at 1920×1080, density 2.0. The approved
|
text weights, colours, line boxes and bounds at 1920×1080, density 2.0. The approved
|
||||||
typeface ruling keeps platform Roboto, not the browser's DejaVu Sans: no per-title
|
typeface ruling keeps platform Roboto, not the browser's DejaVu Sans: no per-title
|
||||||
compensation and no wrapping-parity claim. Untrimmed CSS line boxes are scoped to
|
compensation and no wrapping-parity claim. Explicit CSS line boxes constrain title
|
||||||
title/person content and sheets; shared nav/library typography is unchanged.
|
headers, sheets, cast and poster captions without changing shared type tokens.
|
||||||
`TitleKeysTest` replays real Android keys from title entry, including modal Back,
|
Fractional stack placement rounds only the accumulated position, preventing a
|
||||||
held OK, nearest-row entry, remembered columns, pending seasons, wheel re-entry
|
one-pixel-per-row drift. Focused controls share the canonical lift shadow.
|
||||||
and native media-info line-box pitch. `TitleAsyncFocusTest` delays the service reply
|
`TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row
|
||||||
through the production destination/view-model, then exercises loaded options and
|
entry, remembered columns, long-sheet visibility and full media-table geometry.
|
||||||
hardware sheet Back through real focus handling.
|
`TitleAsyncFocusTest` delays title and person replies through the real destination
|
||||||
|
and view-model, then exercises content focus and hardware Back. Subtitles Off
|
||||||
|
maps to the player's explicit disabled sentinel, not its unspecified default.
|
||||||
|
|
||||||
The approved current gate uses exact title-entry suffixes, not padded launch
|
`TitleLaunchKeysTest` starts at Who's watching and replays the full 4/8/15/11
|
||||||
paths. `title-press-paths.json` retains the full sequences and entry boundaries.
|
sequences through the real Home hero/shelves and title/person surfaces.
|
||||||
The full 4/8/15/11 launch replays will be added after the native Home slice merges;
|
`title-press-paths.json` supplies every event; no prefix is skipped or padded.
|
||||||
the corresponding title-entry paths are 1/5/6/2. SDK cache, exact-episode,
|
The shorter 1/5/6/2 title-entry replays remain as focused regressions. SDK cache,
|
||||||
track-index, identity, locale-layout and trailer-handler tests run offline with
|
exact-episode, track-index, identity, locale-layout and trailer-handler tests
|
||||||
in-process service replies and local art.
|
run offline with in-process service replies and local art.
|
||||||
|
|
||||||
|
|
||||||
## Toolchain and disk use
|
## Toolchain and disk use
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ val cacheRobolectricSdk by tasks.registering(Copy::class) {
|
||||||
}
|
}
|
||||||
|
|
||||||
val checkTokenDrift by tasks.registering {
|
val checkTokenDrift by tasks.registering {
|
||||||
inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "discover-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") })
|
inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") })
|
||||||
doLast {
|
doLast {
|
||||||
file("tokens.sha256").readLines().forEach { line ->
|
file("tokens.sha256").readLines().forEach { line ->
|
||||||
val (expected, name) = line.split(" ", limit = 2)
|
val (expected, name) = line.split(" ", limit = 2)
|
||||||
|
|
|
||||||
|
|
@ -114,10 +114,11 @@ def main() -> None:
|
||||||
tokens = json.loads((ROOT / "tokens.json").read_text())
|
tokens = json.loads((ROOT / "tokens.json").read_text())
|
||||||
tokens.update(json.loads((ROOT / "shell-tokens.json").read_text()))
|
tokens.update(json.loads((ROOT / "shell-tokens.json").read_text()))
|
||||||
tokens.update(json.loads((ROOT / "title-tokens.json").read_text()))
|
tokens.update(json.loads((ROOT / "title-tokens.json").read_text()))
|
||||||
|
tokens.update(json.loads((ROOT / "home-tokens.json").read_text()))
|
||||||
tokens.update(json.loads((ROOT / "discover-tokens.json").read_text()))
|
tokens.update(json.loads((ROOT / "discover-tokens.json").read_text()))
|
||||||
kotlin, css = render_tokens(tokens)
|
kotlin, css = render_tokens(tokens)
|
||||||
outputs = {str(KOTLIN): kotlin, "tokens.css": css}
|
outputs = {str(KOTLIN): kotlin, "tokens.css": css}
|
||||||
inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "discover-tokens.json", "generate_tokens.py"]
|
inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py"]
|
||||||
checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs}
|
checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs}
|
||||||
checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()})
|
checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()})
|
||||||
outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items()))
|
outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items()))
|
||||||
|
|
|
||||||
317
wolfskin-design/home-tokens.json
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
{
|
||||||
|
"home": {
|
||||||
|
"kickerGap": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 12,
|
||||||
|
"dp": 6.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"kickerBottom": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 10,
|
||||||
|
"dp": 5.0,
|
||||||
|
"source": "locked index.html:101"
|
||||||
|
},
|
||||||
|
"titleBottom": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 16,
|
||||||
|
"dp": 8.0,
|
||||||
|
"source": "locked index.html:103"
|
||||||
|
},
|
||||||
|
"factsHeight": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 38,
|
||||||
|
"dp": 19.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"factsBottom": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 14,
|
||||||
|
"dp": 7.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"factsGap": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 22,
|
||||||
|
"dp": 11.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"overviewBottom": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 30,
|
||||||
|
"dp": 15.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"cardPadding": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 22,
|
||||||
|
"dp": 11.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"cardOverlayTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 54,
|
||||||
|
"dp": 27.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"cardOverlayBottom": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 18,
|
||||||
|
"dp": 9.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"sectionTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 40,
|
||||||
|
"dp": 20.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"sectionAvatar": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 56,
|
||||||
|
"dp": 28.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"sectionGap": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 22,
|
||||||
|
"dp": 11.0,
|
||||||
|
"source": "locked index.html:180"
|
||||||
|
},
|
||||||
|
"stateTextBottom": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 24,
|
||||||
|
"dp": 12.0,
|
||||||
|
"source": "locked index.html:198"
|
||||||
|
},
|
||||||
|
"stateTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 20,
|
||||||
|
"dp": 10.0,
|
||||||
|
"source": "locked index.html:194"
|
||||||
|
},
|
||||||
|
"statePaddingX": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 40,
|
||||||
|
"dp": 20.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"statePaddingY": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 34,
|
||||||
|
"dp": 17.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"stateGap": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 8,
|
||||||
|
"dp": 4.0,
|
||||||
|
"source": "locked index.html:195"
|
||||||
|
},
|
||||||
|
"stateIcon": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 36,
|
||||||
|
"dp": 18.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"captionWidth": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 640,
|
||||||
|
"dp": 320.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"plateTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 14,
|
||||||
|
"dp": 7.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"plateSide": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 16,
|
||||||
|
"dp": 8.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"plateFade": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 46,
|
||||||
|
"dp": 23.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"ratingGap": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 20,
|
||||||
|
"dp": 10.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"ratingMarkGap": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 8,
|
||||||
|
"dp": 4.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"heroSkeletonWidth": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 640,
|
||||||
|
"dp": 320.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"heroSkeletonHeight": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 84,
|
||||||
|
"dp": 42.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"heroSkeletonFactsWidth": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 460,
|
||||||
|
"dp": 230.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"heroSkeletonFactsHeight": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 34,
|
||||||
|
"dp": 17.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"heroSkeletonButtonWidth": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 260,
|
||||||
|
"dp": 130.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"heroSkeletonGap": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 22,
|
||||||
|
"dp": 11.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"heroSkeletonButtonGap": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 40,
|
||||||
|
"dp": 20.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"artGlyph": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 44,
|
||||||
|
"dp": 22.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"cardSheetLeft": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 22,
|
||||||
|
"dp": 11.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"cardSheetTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 18,
|
||||||
|
"dp": 9.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"cardSheetInset": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 36,
|
||||||
|
"dp": 18.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"heroSheetRight": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 180,
|
||||||
|
"dp": 90.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"heroSheetTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 150,
|
||||||
|
"dp": 75.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"cardBlur": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 26,
|
||||||
|
"dp": 13.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"heroBlur": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 60,
|
||||||
|
"dp": 30.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"progressInset": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 18,
|
||||||
|
"dp": 9.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"progressBottom": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 10,
|
||||||
|
"dp": 5.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"endTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 40,
|
||||||
|
"dp": 20.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"frameBottom": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 80,
|
||||||
|
"dp": 40.0,
|
||||||
|
"source": "locked index.html:1042"
|
||||||
|
},
|
||||||
|
"toastPadding": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 28,
|
||||||
|
"dp": 14.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"toastTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 150,
|
||||||
|
"dp": 75.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"toastWidth": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 760,
|
||||||
|
"dp": 380.0,
|
||||||
|
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||||
|
},
|
||||||
|
"cardWashInset": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 30,
|
||||||
|
"dp": 15.0,
|
||||||
|
"source": "locked index.html:129"
|
||||||
|
},
|
||||||
|
"heroWashInset": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 80,
|
||||||
|
"dp": 40.0,
|
||||||
|
"source": "locked index.html:96"
|
||||||
|
},
|
||||||
|
"noteTop": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 14,
|
||||||
|
"dp": 7.0,
|
||||||
|
"source": "locked index.html:191"
|
||||||
|
},
|
||||||
|
"noteGap": {
|
||||||
|
"$type": "dimension",
|
||||||
|
"px": 16,
|
||||||
|
"dp": 8.0,
|
||||||
|
"source": "locked index.html:191"
|
||||||
|
},
|
||||||
|
"hairline": { "$type": "dimension", "px": 2, "dp": 1.0, "source": "locked index.html:87,146,194" },
|
||||||
|
"savedHeight": { "$type": "dimension", "px": 52, "dp": 26.0, "source": "locked index.html:87" },
|
||||||
|
"savedPadding": { "$type": "dimension", "px": 20, "dp": 10.0, "source": "locked index.html:87" },
|
||||||
|
"savedGap": { "$type": "dimension", "px": 10, "dp": 5.0, "source": "locked index.html:87" },
|
||||||
|
"savedIcon": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:88" },
|
||||||
|
"savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" },
|
||||||
|
"posterProgressBottom": { "$type": "dimension", "px": 12, "dp": 6.0, "source": "locked index.html:138-140" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -374,6 +374,7 @@
|
||||||
"plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"},
|
"plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"},
|
||||||
"plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"},
|
"plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"},
|
||||||
"plateBottom": {"$type":"dimension","px":46,"dp":23,"source":"index.html:162"},
|
"plateBottom": {"$type":"dimension","px":46,"dp":23,"source":"index.html:162"},
|
||||||
|
"plateInlineDescent": {"$type":"dimension","px":6.6,"dp":3.3,"source":"index.html:156,162 inline-flex baseline strut; proto-104 rplate 100.2px minus 14+33.6+46"},
|
||||||
"plateFloor": {"$type":"typography","px":24,"sp":12,"weight":700,"$value":{"fontWeight":700},"source":"index.html:802"},
|
"plateFloor": {"$type":"typography","px":24,"sp":12,"weight":700,"$value":{"fontWeight":700},"source":"index.html:802"},
|
||||||
"plateStep": {"$type":"number","$value":0.25,"source":"spec 4.8"},
|
"plateStep": {"$type":"number","$value":0.25,"source":"spec 4.8"},
|
||||||
"plateGapEm": {"$type":"number","$value":0.64,"source":"index.html:164"},
|
"plateGapEm": {"$type":"number","$value":0.64,"source":"index.html:164"},
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 71 KiB |