Compare commits

..

18 commits

Author SHA1 Message Date
cd625b4227 Remove unreachable duplicate glyph branches and assets
All checks were successful
review/design-claude Round 2: 13/13 round-1 blockers fixed, no blockers on cd625b42
2026-11-05 21:55:04 +01:00
fc02ce8646 Integrate native Home and replay full title launch paths 2026-11-05 21:49:34 +01:00
2fdd459037 Match title CSS geometry and fix modal focus and subtitle choices 2026-11-05 21:38:46 +01:00
9dd321f07e Merge pull request 'Build the Wolfskin TV Home feed' (#31) from fm/tv-slice3-home into main 2026-11-05 21:34:29 +01:00
061670848f Keep overlapping screen captures in separate namespaces
All checks were successful
review/design-claude Design re-review: B1-B3 fixed, no blocking findings (head 06167084)
2026-11-05 21:07:22 +01:00
575103ce98 Integrate Discover with canonical shared UI 2026-11-05 20:57:57 +01:00
5033c171cb Preserve Requests recovery button activation 2026-11-05 20:24:10 +01:00
4cd75cfa26 Integrate Requests with the shared image cache 2026-11-05 20:20:06 +01:00
e99ed2d394 Use the locked Home See all spacing 2026-11-05 19:53:51 +01:00
9c8fceb18c Restore Home viewport across See all navigation 2026-11-05 19:28:09 +01:00
14a74ac43b Integrate libraries and correct Home review blockers 2026-11-05 19:03:33 +01:00
b54bcc148c Follow visible card centers and remember Home tracks
Some checks failed
review/design-claude Request changes: separator dot colour, vertical rhythm, focus entry after load
2026-11-05 17:16:06 +01:00
2b65ee7b53 Preserve the person identity guard on Home 2026-11-05 16:57:20 +01:00
5a0497c9ec Merge the reviewed shell from main 2026-11-05 16:50:59 +01:00
3b7249888f Match Home focus and recovery details 2026-11-05 16:50:32 +01:00
c534ea75c2 Use resolved weights for episode rating labels 2026-11-05 16:18:40 +01:00
c07bb2ca1d Merge the reviewed shell updates 2026-11-05 16:17:38 +01:00
0fd0ec035d Build the Wolfskin Home feed 2026-11-05 16:15:40 +01:00
715 changed files with 3215 additions and 214 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

@ -84,5 +84,43 @@ class TitleAsyncFocusTest {
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) } }
}

View file

@ -118,6 +118,7 @@ class TitleCaptureTest {
File(directory, "st-${number.toString().padStart(3, '0')}-layout.json").writeText(metrics.toString(2))
}
recordTextMetrics(directory, number)
recordElements(directory, number)
}
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))
}
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 open(state: TitleState, shell: ShellState, id: String) {
option(state, id)

View file

@ -300,5 +300,46 @@ class TitleKeysTest {
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
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)
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)
}
}

View file

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

View file

@ -166,4 +166,15 @@ class TitleReadFacadeTest {
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")) }
}
@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) }
}
}

View file

@ -18,11 +18,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotFocused
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
@ -131,6 +133,7 @@ class UpstreamContentFocusTest {
rule.runOnIdle { WolfskinScreenPreference.enabled = true }
rule.mainClock.advanceTimeBy(1500)
rule.waitForIdle()
rule.onNodeWithText("Sign in as Justin to open their Jellyfin library").assertIsDisplayed()
val on = rule.onRoot().captureToImage().toPixelMap()[1700, 900]
val background = org.justinvisser.wolfskin.design.WolfskinTokens.Color.bg
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(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))
}
}
}

View file

@ -212,8 +212,9 @@ fun WolfskinApp(
ShellSurface(state, handlesContentKeys = upstream == null,
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
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)
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
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)
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)

View file

@ -52,7 +52,7 @@ fun WolfskinLibraryContent(
seeAll: WolfskinSeeAllLists,
modifier: Modifier = Modifier,
) {
val view = shell.view
val view = shell.rememberedView(route)
val fixture by produceState<LibraryFixture?>(null, context) {
if (BuildConfig.DEBUG) value = withContext(Dispatchers.IO) {
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
}
}
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null)
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null, view = view)
}
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

@ -4,6 +4,7 @@ import android.content.Context
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
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.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.ImageUrlService
@ -107,7 +108,7 @@ class TitleReadFacade @Inject constructor(
val item = BaseItem(api.userLibraryApi.getItem(id).content)
var choice = repository.currentUser?.let { playback.itemPlaybackDao.getItem(it, id) }
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)
else -> Unit

View file

@ -59,9 +59,9 @@ cover the Wolfskin nav.
The independent `:wolfskin-design` module owns the shell components, fixed person
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
SHA and provenance. `shell-tokens.json` adds shell/grid/request measurements;
`title-tokens.json` and `discover-tokens.json` add their screen measurements from
that same prototype. Generate the committed Kotlin, CSS and checksum manifest together:
SHA and provenance. `shell-tokens.json` adds shared shell/grid/sheet measurements;
`title-tokens.json`, `home-tokens.json` and `discover-tokens.json` add their screen
measurements from the same locked prototype. Generate Kotlin, CSS and the checksum manifest together:
```sh
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
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,
then enters the nav. Turning the switch on restores the labelled placeholders
until the corresponding screen slices ship.
then enters the nav. Turning the switch on renders Wolfskin Home and Libraries.
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
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
`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
exercise the Android event adapter, including the 6/8/10/11-press shell paths.
the sampled Compose nav-spring curve to `build/wolfskin-captures/`.
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
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
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
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
30 seconds while started. The immutable debug projection is loaded once, not
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
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,
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
data replayed through the real shell. Home's hero content is not implemented
here: its launch-to-nav shell suffix is the acceptance seam.
data replayed through the real shell. The Requests-only harness starts at the
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
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.
`LibraryKeysTest` reads the approved JSON path and reaches Spirited Away in exactly
20 Android key presses. Paging and snapshot tests cover duplicate-only reads,
failure/retry, stale copies, and person/server isolation. Captures are written to
the same `build/wolfskin-captures/` directory as the shell captures.
failure/retry, stale copies, and person/server isolation. Library captures are written
to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`.
## 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
six-column filmography. Both reuse the shell/nav/back ladder, `LibraryPoster`,
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
title backdrop remains visible behind deep rows; only off-screen row images
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
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
compensation and no wrapping-parity claim. Untrimmed CSS line boxes are scoped to
title/person content and sheets; shared nav/library typography is unchanged.
`TitleKeysTest` replays real Android keys from title entry, including modal Back,
held OK, nearest-row entry, remembered columns, pending seasons, wheel re-entry
and native media-info line-box pitch. `TitleAsyncFocusTest` delays the service reply
through the production destination/view-model, then exercises loaded options and
hardware sheet Back through real focus handling.
compensation and no wrapping-parity claim. Explicit CSS line boxes constrain title
headers, sheets, cast and poster captions without changing shared type tokens.
Fractional stack placement rounds only the accumulated position, preventing a
one-pixel-per-row drift. Focused controls share the canonical lift shadow.
`TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row
entry, remembered columns, long-sheet visibility and full media-table geometry.
`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
paths. `title-press-paths.json` retains the full sequences and entry boundaries.
The full 4/8/15/11 launch replays will be added after the native Home slice merges;
the corresponding title-entry paths are 1/5/6/2. SDK cache, exact-episode,
track-index, identity, locale-layout and trailer-handler tests run offline with
in-process service replies and local art.
`TitleLaunchKeysTest` starts at Who's watching and replays the full 4/8/15/11
sequences through the real Home hero/shelves and title/person surfaces.
`title-press-paths.json` supplies every event; no prefix is skipped or padded.
The shorter 1/5/6/2 title-entry replays remain as focused regressions. SDK cache,
exact-episode, track-index, identity, locale-layout and trailer-handler tests
run offline with in-process service replies and local art.
## Toolchain and disk use

View file

@ -30,7 +30,7 @@ val cacheRobolectricSdk by tasks.registering(Copy::class) {
}
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 {
file("tokens.sha256").readLines().forEach { line ->
val (expected, name) = line.split(" ", limit = 2)

View file

@ -114,10 +114,11 @@ def main() -> None:
tokens = json.loads((ROOT / "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 / "home-tokens.json").read_text()))
tokens.update(json.loads((ROOT / "discover-tokens.json").read_text()))
kotlin, css = render_tokens(tokens)
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.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()))

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

View file

@ -374,6 +374,7 @@
"plateTop": {"$type":"dimension","px":14,"dp":7,"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"},
"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"},
"plateStep": {"$type":"number","$value":0.25,"source":"spec 4.8"},
"plateGapEm": {"$type":"number","$value":0.64,"source":"index.html:164"},

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

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