Build the Wolfskin TV Home feed #31

Merged
justinzeus merged 14 commits from fm/tv-slice3-home into main 2026-11-05 21:34:30 +01:00
689 changed files with 2860 additions and 118 deletions

View file

@ -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)

View file

@ -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

View file

@ -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"))
}
}

View file

@ -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()
}
}

View file

@ -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)

View file

@ -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("title:${action.title.type}:${action.title.tmdbId ?: 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))
}
}
}

View file

@ -206,8 +206,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.startsWith("library-title/") -> route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") || route.startsWith("library-title/") ->
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)

View file

@ -50,7 +50,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/") }
@ -89,7 +89,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")

View file

@ -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" }
}

View file

@ -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)
}
}

View file

@ -59,8 +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` and `discover-tokens.json` add measurements SHA and provenance. `shell-tokens.json` adds shared shell, grid and sheet measurements;
from that same prototype. Generate the committed Kotlin, CSS and checksum manifest together: `home-tokens.json` and `discover-tokens.json` add their screen geometry from that
same locked prototype. Generate the committed Kotlin, CSS and checksum manifest together:
```sh ```sh
python3 wolfskin-design/generate_tokens.py python3 wolfskin-design/generate_tokens.py
@ -88,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
@ -143,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.
@ -165,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.
@ -186,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.
@ -196,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
@ -262,8 +337,8 @@ the title-screen implementation; this slice does not implement title pages.
`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/`.
## Toolchain and disk use ## Toolchain and disk use

View file

@ -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", "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", "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)

View file

@ -105,10 +105,11 @@ def main() -> None:
arguments = parser.parse_args() arguments = parser.parse_args()
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 / "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", "discover-tokens.json", "generate_tokens.py"] inputs = ["tokens.json", "shell-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()))

View 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" }
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Some files were not shown because too many files have changed in this diff Show more