Compare commits

...
Sign in to create a new pull request.

15 commits

Author SHA1 Message Date
79f64f6e18 Restore sequential hero copy and first-appearance entry
All checks were successful
review/code-claude Code APPROVE: sequential copy hand-off, no blocker
review/design-claude Design PASS: copy hand-off matches approved frames/tokens
2026-11-07 18:44:00 +01:00
7bfa431350 Retain destination focus in the initial title loading regression
Some checks failed
review/code-claude Code review: approve, no blocking finding (9 non-blocking notes)
review/design-claude Blocked: hero copy counter-fade overlaps text; approved sequential hand-off differs (B1)
2026-11-07 17:48:54 +01:00
aa31199b4f Merge title follow-up integration before final gates 2026-11-07 17:10:07 +01:00
2b142af8d1 Merge pull request 'Show episode ratings and smoothly collapse title readiness' (#57) from fm/tv-r2-title-2 into fm/tv-round2 2026-11-07 17:07:19 +01:00
f9bd630ea7 Keep title destination focus and preserve ready catalogue refreshes 2026-11-07 16:56:49 +01:00
e6ddf1d7e5 Show episode ratings and animate title readiness without empty space
All checks were successful
review/code-claude Reviewed e6ddf1d7: his board picks implemented, no blocking finding
review/design-claude Reviewed e6ddf1d7: his board picks implemented, no blocking finding
2026-11-07 16:41:07 +01:00
98dd54e03b Merge the TV round two integration branch 2026-11-07 16:22:09 +01:00
9988a193de Hold the full painted backdrop until incoming hero art is ready 2026-11-07 16:22:09 +01:00
174e1ae5c9 Merge current TV integration into title follow-up 2026-11-07 16:20:47 +01:00
ae613216d0 Merge main into the round-two integration branch 2026-11-07 16:01:56 +01:00
60b0c307aa Keep refresh capture artwork on the local fixture server 2026-11-07 15:59:00 +01:00
12e908389f Smooth Discover hero handoffs and scope lane focus to each visit 2026-11-07 15:56:19 +01:00
5046727b2a Merge pull request 'Refine TV title stacking, episode cards and action menus' (#56) from fm/tv-r2-title into fm/tv-round2 2026-11-07 15:53:33 +01:00
a159c835c5 Preserve episode height and fix title overlay and poster collisions
All checks were successful
review/code-claude Blockers fixed on a159c835, delta reviewed
review/design-claude Blockers fixed on a159c835, delta reviewed
2026-11-07 15:46:56 +01:00
6d7513bd0e Refine title episode cards, section stacking and action menus
Some checks failed
review/code-claude Code review: pass, no blocking findings (9 non-blocking notes)
review/design-claude Blocked: card height 126 vs 140 dp, Menu to Media info flash, poster/cast overlap
2026-11-07 15:11:02 +01:00
38 changed files with 1643 additions and 200 deletions

View file

@ -70,7 +70,7 @@ class ConductarrTitlePagesTest {
assertEquals(TitleConnection.SignedOut, revoked.connection) assertEquals(TitleConnection.SignedOut, revoked.connection)
assertTrue(revoked.item.ratings.isEmpty()) assertTrue(revoked.item.ratings.isEmpty())
assertTrue(revoked.playTarget!!.ratings.isEmpty()) assertTrue(revoked.playTarget!!.ratings.isEmpty())
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" }) assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.menuOptions.any { it.id == "add" })
} }
} }
} }

View file

@ -76,6 +76,9 @@ class ConductarrTitleSurfaceTest {
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath) rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) } rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) }
rule.mainClock.advanceTimeBy(300)
rule.onNodeWithText("Add for someone").assertIsDisplayed()
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
rule.onNodeWithText("Request for Mindel").assertIsDisplayed() rule.onNodeWithText("Request for Mindel").assertIsDisplayed()
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) } rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
rule.waitUntil(30_000) { rule.waitUntil(30_000) {
@ -97,7 +100,8 @@ class ConductarrTitleSurfaceTest {
viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable
} }
assertTrue(viewModel.title!!.data.item.ratings.isEmpty()) assertTrue(viewModel.title!!.data.item.ratings.isEmpty())
assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") }) assertFalse(viewModel.title!!.options.any { it.id == "request" })
assertFalse(viewModel.title!!.menuOptions.any { it.id == "add" })
} finally { viewModel.viewModelScope.cancel() } } finally { viewModel.viewModelScope.cancel() }
} }
} }

View file

@ -0,0 +1,115 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
/** Refreshing a loaded Discover title must preserve its real header instead of rebuilding a skeleton. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverRefreshMotionTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun loadedCatalogueRefreshKeepsReadyActionsOpaqueAndStationary() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
rule.mainClock.autoAdvance = false
FakeConductarr().use { fake ->
fake.localArt = true
ConductarrSession(MemoryCacheStore()).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true))
val person = ShellPerson("shared", "Shared", "S", false)
val route = titleRoute("tv:44")
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
val refreshLookup = CompletableDeferred<java.util.UUID?>()
try {
rule.setContent { WolfskinTheme {
model.title?.let { state ->
val bitmaps = listOfNotNull(state.data.item.poster, state.data.item.backdrop).associateWith { art }
CompositionLocalProvider(LocalTitleBitmaps provides bitmaps) {
TitleSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
}
}
} }
rule.runOnIdle { model.load(route, person, preferences, fixtures = false) }
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.status == TitleReadStatus.Ready && model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true
}
rule.mainClock.advanceTimeBy(640)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-refresh").apply { mkdirs() }
val bounds = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt())
val baseline = brightness(File(directory, "loaded.png"), bounds, pixels)
coEvery { facade.findHeld(any(), any()) } coAnswers { refreshLookup.await() }
rule.runOnIdle {
model.load(route, person, preferences, fixtures = false, force = true)
assertEquals("An already-loaded catalogue title must never emit Loading on refresh", TitleReadStatus.Ready, model.title!!.data.status)
}
repeat(12) { index ->
rule.mainClock.advanceTimeBy(16)
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
assertFalse("A refresh cannot focus the root navigation", shell.navFocused)
assertEquals("Loaded actions cannot replay their rise", bounds, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
assertEquals("Loaded actions cannot replay their fade", baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels))
}
} finally {
model.viewModelScope.cancel()
refreshLookup.complete(null)
}
}
}
}
private fun brightness(file: File, bounds: Rect, pixels: IntArray): Long {
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
return try {
val width = bounds.width.toInt()
val height = bounds.height.toInt()
bitmap.getPixels(pixels, 0, width, bounds.left.toInt(), bounds.top.toInt(), width, height)
pixels.sumOf { (android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it)).toLong() }
} finally { bitmap.recycle() }
}
}

View file

@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
@ -69,9 +70,12 @@ class TitleAsyncFocusTest {
rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } } rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } }
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
rule.waitForIdle() rule.waitForIdle()
assertTrue(shell.navFocused) assertFalse("Loading retains destination focus instead of focusing the root nav", shell.navFocused)
rule.onNodeWithTag("title-page").assertIsFocused()
press(Key.DirectionDown) press(Key.DirectionDown)
assertTrue("The initial skeleton cannot receive nav Down", shell.navFocused) assertFalse("Down cannot transfer focus to nav or an initial skeleton", shell.navFocused)
assertEquals("header", viewModel.title!!.row)
assertEquals(0, viewModel.title!!.column)
val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList()) val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList())
rule.runOnIdle { rule.runOnIdle {
@ -109,9 +113,12 @@ class TitleAsyncFocusTest {
} } } }
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
rule.waitForIdle() rule.waitForIdle()
assertTrue(shell.navFocused) assertFalse(shell.navFocused)
rule.onNodeWithTag("title-page").assertIsFocused()
press(Key.DirectionDown) press(Key.DirectionDown)
assertTrue(shell.navFocused) assertFalse("Down cannot enter a pending person skeleton", shell.navFocused)
assertEquals("header", viewModel.title!!.row)
assertEquals(0, viewModel.title!!.column)
rule.runOnIdle { rule.runOnIdle {
reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead( reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead(
org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"), org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"),
@ -121,7 +128,7 @@ class TitleAsyncFocusTest {
rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable } 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) assertNotNull("A real person reply must populate the performer destination", viewModel.person)
rule.waitForIdle() rule.waitForIdle()
assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused) assertFalse("The loaded person retains destination focus on its first credit", shell.navFocused)
assertEquals(0, viewModel.person!!.index) assertEquals(0, viewModel.person!!.index)
press(Key.DirectionRight) press(Key.DirectionRight)
assertEquals(1, viewModel.person!!.index) assertEquals(1, viewModel.person!!.index)

View file

@ -87,7 +87,7 @@ class TitleCaptureTest {
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } } data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } } data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
data.episodes.forEach { item -> data.episodes.forEach { item ->
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) } (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt())) }
} }
} }
return requests.distinctBy { it.first }.associate { (path, width, height) -> return requests.distinctBy { it.first }.associate { (path, width, height) ->
@ -170,7 +170,10 @@ class TitleCaptureTest {
@Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") } @Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") }
@Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") } @Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") }
@Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") } @Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") }
@Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") } @Test fun st047_mediaInfo() = capture(47) { state, shell ->
open(state, shell, "menu")
state.clickSheet(0)
}
@Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." } @Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." }
@Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") } @Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") }
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") } @Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
@ -216,4 +219,10 @@ class TitleCaptureTest {
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel") @Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man") @Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) } @Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
@Test fun round2Menu() = capture(120) { state, shell -> open(state, shell, "menu") }
@Test fun round2MussoliniResume() = capture(121, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) })
@Test fun round2Episodes() = capture(122, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) }) { state, _ -> state.focus("episodes", 0) }
@Test fun round2NotHeldSeries() = capture(123, "90 Day Fiancé: Before the 90 Days")
@Test fun round2HeldPosterFallback() = capture(124, "The Girl Who Leapt Through Time", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
@Test fun round2NotHeldShortOverview() = capture(125, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
} }

View file

@ -12,11 +12,9 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performMouseInput import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import org.justinvisser.wolfskin.design.WolfskinTokens import org.justinvisser.wolfskin.design.WolfskinTokens
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@ -36,7 +34,6 @@ import org.justinvisser.wolfskin.design.TitleSurface
import org.justinvisser.wolfskin.design.WolfskinTheme import org.justinvisser.wolfskin.design.WolfskinTheme
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */ /** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@ -284,24 +281,19 @@ class TitleKeysTest {
press(Key.DirectionDown) press(Key.DirectionDown)
assertEquals(expected, state.row) assertEquals(expected, state.row)
} }
@Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() {
render("Andor")
val layouts = mutableListOf<TextLayoutResult>()
rule.onNodeWithText("Add for someone", useUnmergedTree = true)
.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
val layout = layouts.single()
assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth)
assertEquals(1, layout.lineCount)
}
@Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() { @Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() {
render(transform = { it.copy(status = TitleReadStatus.Loading) }) render(transform = { it.copy(status = TitleReadStatus.Loading) })
rule.onNodeWithText("Cast").assertDoesNotExist() rule.onNodeWithText("Cast").assertDoesNotExist()
press(Key.DirectionDown) press(Key.DirectionDown)
assertTrue(shell.navFocused) assertFalse(shell.navFocused)
rule.onNodeWithTag("title-page").assertIsFocused()
assertEquals("header", state.row) assertEquals("header", state.row)
assertEquals(0, state.column)
assertFalse(shell.pickerVisible)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertEquals(listOf(TitleAction.Pop), effects) assertEquals(listOf(TitleAction.Pop), effects)
assertFalse(shell.pickerVisible)
} }
@ -315,26 +307,6 @@ class TitleKeysTest {
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f) assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
} }
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() {
render()
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) }
press(Key.DirectionCenter)
val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2
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)
}
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() { @Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {

View file

@ -0,0 +1,288 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.BitmapFactory
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import org.json.JSONObject
import org.junit.Assert.*
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
import org.robolectric.annotation.GraphicsMode
import java.io.File
/** Native title interactions keep secondary actions, focus and section geometry intact. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitleRound2Test {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val effects = mutableListOf<TitleAction>()
private lateinit var state: TitleState
private lateinit var shell: ShellState
private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
private fun render(title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }) {
val fixture = fixture()
shell = ShellState(fixture.people, person).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/test") }
state = TitleState(transform(fixture.title(title, person)), shell.person, effects::add)
rule.setContent { WolfskinTheme {
BackHandler { state.back(shell) }
TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp))
} }
}
private fun press(key: Key) = rule.onRoot().performKeyInput { pressKey(key) }
private fun menu() {
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "menu" }) }
press(Key.DirectionCenter)
rule.mainClock.advanceTimeBy(300)
rule.waitForIdle()
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertIsDisplayed()
}
@Test fun menuBackRestoresItsButtonAndKeepsMediaInfoAndRequestReachable() {
render()
menu()
val opener = state.column
press(Key.Back)
assertNull(state.sheet)
assertEquals(opener, state.column)
assertFalse(shell.navFocused)
press(Key.DirectionCenter)
assertEquals(TitleSheet.Menu, state.sheet)
press(Key.DirectionCenter)
assertEquals(TitleSheet.MediaInfo, state.sheet)
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
press(Key.Back)
assertEquals(TitleSheet.Menu, state.sheet)
press(Key.DirectionDown)
press(Key.DirectionCenter)
assertNull(state.sheet)
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
assertEquals(opener, state.column)
}
@Test fun notHeldMovieKeepsRequestAndHideOutsideTheMenu() {
render("The Amazing Spider-Man")
rule.onNodeWithTag("title-option-request").assertIsDisplayed()
rule.onNodeWithTag("title-option-hide").assertIsDisplayed()
menu()
rule.onNodeWithText("Add for someone").assertIsDisplayed()
press(Key.DirectionCenter)
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
}
@Test fun movieCastStacksAfterActionsInsteadOfAReservedHeaderHeight() {
render("The Amazing Spider-Man")
val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
val cast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
val gap = cast.top - actions.bottom
assertTrue("Cast follows the actions without the old fixed header reservation", gap > 0 && cast.top < WolfskinTokens.Layout.row_dhead.value * 2)
rule.runOnIdle { state.update(state.data.copy(requestedStage = "Searching", requestActor = "Justin")) }
val requestedActions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
val requestedCast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
assertTrue(requestedActions.bottom < requestedCast.top)
assertTrue("A readiness line cannot move Cast backwards or recreate the old reservation", requestedCast.top >= cast.top && requestedCast.top < WolfskinTokens.Layout.row_dhead.value * 2)
}
@Test fun mussoliniResumeHasOnlyFactsRatingsAndNoRedundantSeasonsAction() {
render("episode-episode-1-2025-s1e1", "shared") { data -> data.copy(episodes = data.episodes.distinctBy { it.id }) }
assertTrue(state.data.playTarget!!.resumeSeconds > 0)
rule.onNodeWithTag("title-progress").assertIsDisplayed()
rule.onAllNodesWithContentDescription("rtCritics", useUnmergedTree = true).assertCountEquals(1)
rule.onNodeWithText("Ep", useUnmergedTree = true).assertIsDisplayed()
rule.onAllNodesWithText("7.4", useUnmergedTree = true).assertCountEquals(1)
rule.onNode(hasText("7.4") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed()
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
press(Key.DirectionDown)
assertEquals("seasons", state.row)
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
}
@Test fun seriesUsesDownToReachSeasonsWithoutARedundantShortcut() {
render("Andor")
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
menu()
rule.onNodeWithText("Add for someone").assertIsDisplayed()
press(Key.Back)
press(Key.DirectionDown)
assertEquals("seasons", state.row)
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
}
@Test fun episodeFactsUseTheEpisodeSetRatherThanTheSeriesSet() {
render("episode-episode-1-2025-s1e1", "shared") { data ->
data.copy(item = data.item.copy(ratings = listOf(LibraryRating("rtCritics", 20.0, "Rotten"))),
watchTarget = data.watchTarget.copy(ratings = listOf(LibraryRating("rtCritics", 84.0, "Fresh"), LibraryRating("tmdb", 8.7, episode = true))),
episodes = data.episodes.distinctBy { it.id })
}
rule.onAllNodesWithText("84%", useUnmergedTree = true).assertCountEquals(1)
rule.onAllNodesWithText("8.7", useUnmergedTree = true).assertCountEquals(1)
rule.onNodeWithText("20%", useUnmergedTree = true).assertDoesNotExist()
rule.onNode(hasText("8.7") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed()
}
@Test fun episodeSkeletonAndReadySlotsMatchWhileMetadataFitsOneLine() {
render("Andor") { it.copy(episodesStatus = TitleReadStatus.Loading) }
rule.runOnIdle { shell.view.listState.requestScrollToItem(2) }
rule.waitForIdle()
val skeleton = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
rule.runOnIdle { state.update(state.data.copy(episodesStatus = TitleReadStatus.Ready)) }
val ready = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
assertEquals(skeleton, ready)
assertEquals(448f, ready.width, 0.1f)
assertEquals("Wider cards retain the approved taller episode art", 280f, ready.height, 0.1f)
rule.runOnIdle { state.focus("episodes", 0) }
val episode = state.data.episodes.first()
rule.onNodeWithText(episode.episodeLabel + " · " + episode.runtime).assertIsDisplayed()
rule.onNode(hasText(episode.seriesName!!) and hasAnyAncestor(hasTestTag("title-episode-slot-0")), useUnmergedTree = true).assertDoesNotExist()
}
@Test fun revokedRequestsDisappearFromMenuWithoutRemovingMediaInfo() {
render()
menu()
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
rule.onNodeWithText("Add for someone").assertDoesNotExist()
press(Key.DirectionCenter)
assertEquals(TitleSheet.MediaInfo, state.sheet)
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
}
@Test fun shortOverviewMoviesKeepCastBelowThePosterForHeldAndNotAddedTitles() {
render("The Amazing Spider-Man") { it.copy(item = it.item.copy(overview = "Short overview.", backdrop = null, isPosterBackdrop = true)) }
for (held in listOf(false, true)) {
rule.runOnIdle { state.update(state.data.copy(item = state.data.item.copy(held = held))) }
val poster = rule.onNodeWithTag("title-poster-backdrop").fetchSemanticsNode().boundsInRoot
val firstActor = rule.onNodeWithTag("title-cast-${state.data.cast.first().id}").fetchSemanticsNode().boundsInRoot
assertTrue("Cast clears the poster on held=$held: $firstActor vs $poster", firstActor.top >= poster.bottom + 20f - 0.5f)
}
}
@Test fun revokingTheOnlyMenuActionClosesAnOtherwiseEmptyMenu() {
render("The Amazing Spider-Man")
menu()
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
assertNull(state.sheet)
rule.mainClock.advanceTimeBy(300)
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertDoesNotExist()
}
@Test fun mediaInfoKeepsTableTextSeparatedInsteadOfAccumulatingOverlap() {
render()
menu()
press(Key.DirectionCenter)
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
rule.onNodeWithText("Dutch", useUnmergedTree = true).assertIsDisplayed()
val text = rule.onAllNodes(SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsActions.GetTextLayoutResult) and hasAnyAncestor(hasTestTag("title-sheet")), useUnmergedTree = true)
.fetchSemanticsNodes().map { it.boundsInRoot }.filter { it.width > 0 && it.height > 0 }
text.forEachIndexed { index, first -> text.drop(index + 1).forEach { second ->
if (minOf(first.right, second.right) > maxOf(first.left, second.left)) {
assertTrue("Media info text must never collide vertically: $first vs $second", minOf(first.bottom, second.bottom) <= maxOf(first.top, second.top))
}
} }
}
@Test fun menuToMediaInfoNeverDoubleDimsOrPopsAndBackKeepsOneScrim() {
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
menu()
rule.mainClock.autoAdvance = false
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-info-motion").apply { mkdirs() }
rule.runOnIdle { state.clickSheet(0) }
var waited = 0
while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
waited += 16
}
val records = mutableListOf<String>()
fun frame(phase: String, index: Int): Pair<Int, Int> {
val path = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(path.absolutePath)
val bitmap = BitmapFactory.decodeFile(path.absolutePath)
fun brightness(x: Int, y: Int): Int {
val pixel = bitmap.getPixel(x, y)
return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
}
return try {
(brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") }
} finally { bitmap.recycle() }
}
val info = List(14) { index ->
rule.waitForIdle()
frame("info", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
}
info.forEach { assertEquals("No extra scrim or disappearing overlay may darken or pop over Media info", info.last(), it) }
rule.runOnIdle { state.back(shell) }
repeat(14) { index ->
rule.mainClock.advanceTimeBy(16)
rule.waitForIdle()
assertEquals("Returning to Menu keeps exactly one scrim", info.last().second, frame("back", index).second)
}
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertExists()
File(directory, "samples.csv").writeText("phase,timeMs,sheetBrightness,leftBrightness\n" + records.joinToString("\n"))
rule.mainClock.autoAdvance = true
}
@Test fun menuFadeFramesStayMonotonicAndNeverTranslate() {
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
rule.mainClock.autoAdvance = false
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-motion").apply { mkdirs() }
rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "menu" }) }
var waited = 0
while (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
waited += 16
}
val bounds = rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val samples = mutableListOf<String>()
fun frame(phase: String, index: Int): Int {
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val image = BitmapFactory.decodeFile(file.absolutePath)
val brightness = try {
val pixel = image.getPixel((bounds.left + 40).toInt(), (bounds.top + 6).toInt())
android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
} finally { image.recycle() }
samples.add("$phase,${index * 16},$brightness,${bounds.left},${bounds.top}")
return brightness
}
fun assertMonotonic(values: List<Int>) {
assertNotEquals("Fade must visibly change the menu pixel", values.first(), values.last())
val direction = if (values.last() > values.first()) 1 else -1
values.zipWithNext().forEach { (before, after) ->
assertTrue("Menu alpha must not flicker or snap back: $values", (after - before) * direction >= -1)
}
}
val entering = List(14) { index ->
rule.waitForIdle()
assertEquals(bounds, rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot)
frame("open", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
}
assertMonotonic(entering)
rule.runOnIdle { state.back(shell) }
val leaving = mutableListOf<Int>()
repeat(9) { index ->
rule.waitForIdle()
if (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()) leaving.add(frame("close", index))
rule.mainClock.advanceTimeBy(16)
}
assertMonotonic(leaving)
rule.onNodeWithTag("title-menu").assertDoesNotExist()
File(directory, "samples.csv").writeText("phase,timeMs,brightness,leftPx,topPx\n" + samples.joinToString("\n"))
rule.mainClock.autoAdvance = true
press(Key.DirectionCenter)
assertEquals(TitleSheet.Menu, state.sheet)
}
}

View file

@ -90,7 +90,7 @@ class WolfskinTitleViewModel @Inject constructor(
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) { fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key && if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key &&
title?.data?.status == TitleReadStatus.Ready && title?.data?.item?.held == true && title?.data?.status == TitleReadStatus.Ready &&
(title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) && (title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) &&
!destination.startsWith("person/") && fixture == null) { !destination.startsWith("person/") && fixture == null) {
loadJob?.cancel() loadJob?.cancel()

View file

@ -54,6 +54,8 @@ Read-only source measurement: Desperate Housewives has a 2732×1536 Backdrop. Wi
Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged. Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged.
Round-2 Discover navigation amendment: Up/Down into an untouched lane starts at its leftmost item, not the previous lane's nearest column. A moved lane restores its own selected item and horizontal track for this visit. Pushed titles and service/collection pages preserve that visit; selecting another root tab or rebuilding Discover clears its lane positions. Root vertical position, filters and hero selection retain their existing shell policy. Home's nearest-centre shelves and Library's column-preserving continuous grids are unchanged pending a separate behavior decision.
## Motion and loading — revision 6 ## Motion and loading — revision 6
Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episodes and cast, and person headers/filmography use shaped placeholders without loading sentences. The placeholder fill is `surface2` (`#1b1b25`). A complete shelf shares **one** alpha layer: **0.55 → 1 → 0.55 over 1400 ms**, CSS ease-in-out (`.42,0,.58,1`), 700 ms each way. All loading shelves under the screen's theme share one clock, so late arrivals join the current phase. The clock stops when the last loading shelf leaves. No per-card clocks, staggered pulse phases, shimmer, moving gradient, or animated blur. Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episodes and cast, and person headers/filmography use shaped placeholders without loading sentences. The placeholder fill is `surface2` (`#1b1b25`). A complete shelf shares **one** alpha layer: **0.55 → 1 → 0.55 over 1400 ms**, CSS ease-in-out (`.42,0,.58,1`), 700 ms each way. All loading shelves under the screen's theme share one clock, so late arrivals join the current phase. The clock stops when the last loading shelf leaves. No per-card clocks, staggered pulse phases, shimmer, moving gradient, or animated blur.
@ -64,17 +66,18 @@ Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episo
| Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry only; exit holds its position, including when entry was interrupted | | Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry only; exit holds its position, including when entry was interrupted |
| Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant | | Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant |
| Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise | | Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise |
| Initial hero/title copy | 180 ms, ease-out | 16 px rise | | Initial hero/title copy | 180 ms, ease-out | 16 px rise (`copyEntryRise`); saved hero entry state suppresses replay on remount, not first appearance |
| Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready | | Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready |
| Hero copy hand-off | Old copy out 120 ms ease (`.25,.1,.25,1`); new copy starts at 140 ms and enters over 200 ms ease-out | 14 px rise on entry | | Hero copy hand-off | Outgoing 120 ms ease (`.25,.1,.25,1`); incoming starts after 140 ms and enters over 200 ms ease-out, gated on artwork or final fallback readiness | One copy slot: no overlapping text; 14 px incoming rise, outgoing translation held where entry stopped |
| Late ratings, genres, overview and readiness | 200 ms, ease | Alpha only, inside existing measured slots; rating plates keep their existing plate fade rather than adding a nested fade | | Late ratings, genres, overview and readiness | 200 ms, ease | Alpha only, inside existing measured slots; rating plates keep their existing plate fade rather than adding a nested fade |
| Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry | | Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry |
| Discover pager | 180 ms, ease-out, retargeting current width and opacity | Buttons and pager remain outside the copy transition; reduced motion snaps width and caps opacity fades at 80 ms |
Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the lower backdrop changes without an animated screen-sized layer. Hero copy is a separate bounded block. Image reveal uses Coil's painter, not a new full-screen compositing layer. Existing poster-only blur is static. Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the entire painted backdrop stays static while incoming artwork loads, and the lower backdrop changes only when artwork or its final fallback is ready, without an animated screen-sized layer. Hero copy is a separate bounded block with stationary action controls. Ordinary image reveal uses Coil's painter; hero-controlled requests bypass that independent fade so only the token-backed hero blend owns the transition. Existing poster-only blur is static.
The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. A hero hand-off adds at most **2** bounded owners (outgoing art and copy). Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen. The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. An ordinary hero hand-off animates **2** bounded owners (outgoing art and one sequential copy block) above an opaque artwork base. Art interruptions retain the painted composite without resetting alpha; each outgoing art owner keeps its original 320 ms lifetime. Copy retargets its current alpha, fades out before replacing text, then uses the approved delay/entry tokens; never two copy layers are visible together. Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; ordinary artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen.
The series readiness band reserves **56 px (28 dp)** from its first content paint, including when empty or when readiness later disappears. Ratings remain inside the fixed-height facts band. The series header's measured height therefore cannot change when late ratings/readiness arrive; overview, actions, season and episode anchors retain their coordinates. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial loading keeps nav focus and Back still pops the title/person route. The approved title follow-up (2026-11-07) supersedes the fixed readiness reservation: an absent readiness line occupies **0 px**, while a present line occupies **56 px (28 dp)**. Late arrival/removal animates that height over the **200 ms late-line duration with token ease-out**; reduced motion snaps. Overview, actions and following rows move monotonically with that band; an already-pinned focused row stays pinned through same-remeasure scroll compensation. Ratings remain inside the fixed-height facts band and appear once: an explicit episode page uses its opened episode's own set, including the episode score, rather than the series-only set. Series and episode pages have no redundant Seasons shortcut; Down reaches the season chips. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial title/person loading retains destination focus rather than focusing the root nav; Down cannot enter a skeleton, and Back still pops the route without opening the picker. An already-ready catalogue or held title stays Ready while a same-route, same-person refresh reads new data; the existing signed-in authentication guard remains authoritative, and loaded copy/rows do not remount or replay their entry motion.
Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range. Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range.
@ -82,6 +85,8 @@ Discover key traversal skips pending lanes in both directions. If every remainin
Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target. Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target.
The [round-2 Discover native proof](https://pages.justinvisser.org/wolfskin/tv-round2-discover/) publishes paired 1920×1080 frame strips and exact 16 ms alpha/translation/position series for ready, slow, failed, sustained rapid-selection and reduced-motion scenarios. The reviewed counter-fade remains available separately for comparison with the corrected sequential copy; initial-entry/remount and Home hero series are also published. The regression suite checks an opaque artwork base, at most one visible copy, monotonic per-phase motion, stationary controls and token-bounded outgoing artwork lifetimes. The approved fade-through may briefly have no visible copy; it never paints overlapping titles. These are native-rendering proofs, not on-box frame-pacing measurements.
## Card metadata and bottom state line — revision 6, option B ## Card metadata and bottom state line — revision 6, option B

View file

@ -643,18 +643,24 @@ retain the approved frames except for the requested feedback changes:
that may still be cancelled; first mount restores the intended shelf item. that may still be cancelled; first mount restores the intended shelf item.
Discover uses all six approved poster slots. Person keeps the final column Discover uses all six approved poster slots. Person keeps the final column
at full poster width. at full poster width.
- Episode images choose episode Primary, then Thumb, then the already-read - Episode images choose episode Primary, then Thumb, then the already-read series Backdrop.
series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance. Title episode cards now use 448×280 px, preserving the taller approved crop.
Episode context uses the episode overview, with two summary lines on the Focused summaries ellipsise to one line, the show-name overlay is absent, and
focused card. Held-series headers measure their copy/actions and leave 40 px `S1 E3 · duration` plus the watched check share one line.
before seasons instead of reserving a fixed empty 900 px header. Ready title headers measure their copy/actions and leave 40 px before the
following section. Missing readiness lines collapse completely on every title
kind; late lines expand/collapse over the 200 ms late-line token with ease-out,
or snap with reduced motion. Already-pinned rows compensate in the same
remeasure rather than bouncing through a second focus-follow animation.
Poster-only movies floor that stack so Cast circles clear the poster by at
least 20 px, including held movie fallbacks and short overviews.
- Every Discover heading uses a 104 px band, including a 60 px title-to-card - Every Discover heading uses a 104 px band, including a 60 px title-to-card
gap. Home heading geometry is unchanged. gap. Home heading geometry is unchanged.
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request - Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
420×630. The shared request builder replaces conflicting Jellyfin size/encoding 420×630. The shared request builder replaces conflicting Jellyfin size/encoding
parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG
quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and
400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and 448×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
authentication query parameters remain intact. TMDB uses w342 for posters and authentication query parameters remain intact. TMDB uses w342 for posters and
portraits, w780 for landscape cards and w1280 for hero backdrops, never original. portraits, w780 for landscape cards and w1280 for hero backdrops, never original.
Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks
@ -706,6 +712,28 @@ pop a pushed title/person from content **or** nav. Initial loading has no conten
target: Down stays in nav, while Back can still leave the title. A missing rating target: Down stays in nav, while Back can still leave the title. A missing rating
set emits no layout node, so it cannot reserve an invisible extra facts gap. set emits no layout node, so it cannot reserve an invisible extra facts gap.
The title Menu replaces the old More/media-info icon action and contains Media
info plus Add for someone when requests are permitted. It is shared by held
movie, series and episode pages; non-held pages expose Add for someone in the
same menu while Request/status and Not interested/Show again stay in the row.
Version, tracks, watched and trailer controls keep their existing positions.
Back from Media info returns to the menu; Back from the menu restores its
opening button. The compact menu fades in for 160 ms and out for 100 ms using
the motion ease-out token, with reduced-motion fades capped at 80 ms. Only the
bounded menu panel owns an alpha layer; the scrim is static. Opening another
sheet immediately removes the menu panel and scrim, preventing double darkening.
An empty menu closes if its last permitted action disappears. Controls
use the shared 4 px straddle focus ring. Neither series nor episode details have
a redundant Seasons shortcut; Down reaches the season chips directly. Ratings
appear only in the header facts line, not again beside progress. Explicit episode
pages use the opened episode's own set, including its `Ep` score; movie and series
pages keep their own sets.
Episode shelf skeletons use the same width, height and pitch as ready cards.
Person filmography and related-title shelves remain posters; Home continuation
tiles are a separate component and retain their series-name context.
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies, `TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
person credits and playback mutations to one boundary. `ConductarrTitlePages` person credits and playback mutations to one boundary. `ConductarrTitlePages`
adds real detail, cast, related titles, performer/filmography, readiness/receipts adds real detail, cast, related titles, performer/filmography, readiness/receipts
@ -730,7 +758,10 @@ headers, sheets, cast and poster captions without changing shared type tokens.
Fractional stack placement rounds only the accumulated position, preventing a Fractional stack placement rounds only the accumulated position, preventing a
one-pixel-per-row drift. Focused controls share the canonical lift shadow. one-pixel-per-row drift. Focused controls share the canonical lift shadow.
`TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row `TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row
entry, remembered columns, long-sheet visibility and full media-table geometry. entry, remembered columns and long-sheet visibility. `TitleRound2Test` covers
menu Back/focus, preserved media info and household requests, movie stacking,
the Mussolini resume example, request revocation, episode skeleton geometry,
and native menu fade frames sampled in 16 ms steps.
`TitleAsyncFocusTest` delays title and person replies through the real destination `TitleAsyncFocusTest` delays title and person replies through the real destination
and view-model, then exercises content focus and hardware Back. Subtitles Off and view-model, then exercises content focus and hardware Back. Subtitles Off
maps to the player's explicit disabled sentinel, not its unspecified default. maps to the player's explicit disabled sentinel, not its unspecified default.

View file

@ -33,6 +33,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import coil3.request.transitionFactory
import coil3.transition.Transition
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
@ -50,13 +52,14 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) } this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
}) else null } }) else null }
val context = LocalContext.current val context = LocalContext.current
val request = remember(context, path, poster, dim) { val heroReady = LocalHeroArtReady.current
val request = remember(context, path, poster, dim, heroReady != null) {
val upright = poster || dim val upright = poster || dim
WolfskinImages.request(context, path, WolfskinImages.request(context, path,
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth, if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright) if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
.let { if (heroReady != null) it.newBuilder().transitionFactory(Transition.Factory.NONE).build() else it }
} }
val heroReady = LocalHeroArtReady.current
if (painter != null) { if (painter != null) {
androidx.compose.runtime.SideEffect { heroReady?.invoke() } androidx.compose.runtime.SideEffect { heroReady?.invoke() }
Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)

View file

@ -1,6 +1,8 @@
// SPDX-License-Identifier: GPL-2.0-only // SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -47,7 +49,10 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
val focus = remember(state) { object : ShellContentFocus by state {} } val focus = remember(state) { object : ShellContentFocus by state {} }
DisposableEffect(focus) { DisposableEffect(focus) {
shell.contentFocus = focus shell.contentFocus = focus
onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } } onDispose {
if (shell.tabs.getOrNull(shell.currentTab) != "discover") state.resetLaneMemory()
if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false }
}
} }
LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt } LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt }
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } } LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
@ -103,6 +108,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset) animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset)
} }
} }
HeroMotionScope {
Box(modifier.fillMaxSize().testTag("discover-screen")) { Box(modifier.fillMaxSize().testTag("discover-screen")) {
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { title -> if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { title ->
HeroArtCrossfade(title.key, title, L.row_phero, Modifier.offset { IntOffset(0, -list.firstVisibleItemScrollOffset) }) { DiscoverBackdrop(it) } HeroArtCrossfade(title.key, title, L.row_phero, Modifier.offset { IntOffset(0, -list.firstVisibleItemScrollOffset) }) { DiscoverBackdrop(it) }
@ -129,13 +135,15 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
} }
} }
} }
}
} }
@Composable @Composable
private fun DiscoverHero(state: DiscoverState) { private fun DiscoverHero(state: DiscoverState) {
val title = state.hero ?: return val title = state.hero ?: return
HeroCopyHandoff(title.key, title) { copy ->
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) { Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
HeroCopyHandoff(title.key, title) { copy ->
Column {
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon)) Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon))
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label) Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label)
@ -151,17 +159,32 @@ private fun DiscoverHero(state: DiscoverState) {
} }
Spacer(Modifier.height(D.heroMetaGap)) Spacer(Modifier.height(D.heroMetaGap))
Text(copy.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis) Text(copy.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
Spacer(Modifier.height(D.heroOverviewGap)) Spacer(Modifier.height(D.heroOverviewGap))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) } DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron)) Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
state.heroes.forEachIndexed { index, _ -> Box(Modifier.size(if (index == state.slide) D.dotActive else D.dotSize, D.dotSize).background(if (index == state.slide) C.fg else C.fg.copy(alpha = .3f), CircleShape)) } state.heroes.forEachIndexed { index, _ -> DiscoverHeroDot(index, index == state.slide) }
Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron)) Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
} }
} }
} }
}
/** Pager geometry retargets from its current value independently of artwork loading. */
@Composable
private fun DiscoverHeroDot(index: Int, selected: Boolean) {
val progress = remember { Animatable(if (selected) 1f else 0f) }
val reduced = reducedMotion()
LaunchedEffect(selected, reduced) {
progress.animateTo(if (selected) 1f else 0f,
tween(motionDuration(WolfskinTokens.Motion.focusMove, reduced), easing = WolfskinTokens.Motion.easeOut))
} }
val widthProgress = if (reduced) { if (selected) 1f else 0f } else progress.value
Box(Modifier.testTag("hero-dot-$index").size(D.dotSize + (D.dotActive - D.dotSize) * widthProgress, D.dotSize)
.background(C.fg.copy(alpha = .3f + .7f * progress.value), CircleShape))
} }
@Composable @Composable

View file

@ -40,14 +40,14 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
val height = if (services) K.entryH else K.posterH val height = if (services) K.entryH else K.posterH
val cards = rememberLazyListState() val cards = rememberLazyListState()
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1 val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0)) val column = state.laneColumn(lane.id).coerceIn(0, (count - 1).coerceAtLeast(0))
val active = state.contentFocused && state.focusedRow == row val active = state.contentFocused && state.focusedRow == row
val slots = K.posterSlots val slots = K.posterSlots
val first = state.shell.view.cards["first:${lane.id}"] ?: 0 val first = state.laneFirsts[lane.id] ?: 0
val density = LocalDensity.current val density = LocalDensity.current
LaunchedEffect(column, count, active) { LaunchedEffect(state.service?.id, column, count, active) {
val target = shelfFirst(column, first, slots, count) val target = shelfFirst(column, first, slots, count)
state.shell.view.cards["first:${lane.id}"] = target state.laneFirsts[lane.id] = target
cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() }) cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() })
} }
Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) { Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {

View file

@ -10,7 +10,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
/** One person owns a Discover view; the shell retains its root list and card memory. */ /** Each person retains the root viewport; lane movement belongs only to this Discover visit. */
class DiscoverState( class DiscoverState(
val shell: ShellState, val shell: ShellState,
initial: DiscoverSnapshot, initial: DiscoverSnapshot,
@ -75,6 +75,20 @@ class DiscoverState(
private val pendingDismissals = mutableMapOf<String, DiscoverTitle?>() private val pendingDismissals = mutableMapOf<String, DiscoverTitle?>()
private var rootFilter = "all" private var rootFilter = "all"
private val rootLaneMemory = DiscoverLaneMemory()
private val serviceLaneMemory = mutableMapOf<Long, DiscoverLaneMemory>()
private val laneMemory get() = service?.let { serviceLaneMemory.getValue(it.id) } ?: rootLaneMemory
private val laneColumns get() = laneMemory.columns
internal val laneFirsts get() = laneMemory.firsts
internal fun laneColumn(id: String): Int = laneColumns[id] ?: 0
/** Pushed titles preserve this visit; selecting another root tab discards lane movement. */
internal fun resetLaneMemory() {
rootLaneMemory.clear()
serviceLaneMemory.values.forEach { it.clear() }
}
fun updateSnapshot(value: DiscoverSnapshot) { fun updateSnapshot(value: DiscoverSnapshot) {
snapshot = value snapshot = value
if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime() if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime()
@ -139,7 +153,7 @@ class DiscoverState(
val rowCount: Int get() = firstShelf + lanes.size val rowCount: Int get() = firstShelf + lanes.size
val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0)) val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0))
val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf) val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf)
val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn()) val column: Int get() = (if (focusedRow >= firstShelf) laneColumn(rowKey) else shell.view.cards[rowKey].orZero()).coerceAtMost(maxColumn())
val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() } val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() }
val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
@ -152,7 +166,8 @@ class DiscoverState(
fun focus(row: Int, column: Int = 0) { fun focus(row: Int, column: Int = 0) {
shell.navFocused = false; shell.view.row = row shell.navFocused = false; shell.view.row = row
shell.view.cards[rowKey] = column.coerceIn(0, maxColumn()) val selected = column.coerceIn(0, maxColumn())
if (focusedRow >= firstShelf) laneColumns[rowKey] = selected else shell.view.cards[rowKey] = selected
} }
private fun moveRow(direction: Int) { private fun moveRow(direction: Int) {
@ -161,10 +176,7 @@ class DiscoverState(
if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return } if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return }
if (target >= rowCount) return if (target >= rowCount) return
val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id } val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id }
val nearest = if (focusedRow >= firstShelf && target >= firstShelf) focus(target, if (target >= firstShelf) laneColumn(key) else shell.view.cards[key].orZero())
column - shell.view.cards["first:$rowKey"].orZero() + shell.view.cards["first:$key"].orZero()
else 0
focus(target, shell.view.cards[key] ?: nearest.coerceAtLeast(0))
} }
fun openTitle(item: DiscoverTitle) { fun openTitle(item: DiscoverTitle) {
@ -225,12 +237,14 @@ class DiscoverState(
fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item) fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item)
fun openService(item: DiscoverService) { fun openService(item: DiscoverService) {
serviceLaneMemory.getOrPut(item.id) { DiscoverLaneMemory() }
rootFilter = filter; service = item; filter = "movie"; revision++ rootFilter = filter; service = item; filter = "movie"; revision++
onPush("discover-service/${item.id}") onPush("discover-service/${item.id}")
focus(firstShelf) focus(firstShelf)
} }
fun enterRoute(route: String) { fun enterRoute(route: String) {
if (route in shell.tabs && route != "discover") resetLaneMemory()
if (route == "discover") { title = null; collection = null; if (service != null) filter = rootFilter; service = null } if (route == "discover") { title = null; collection = null; if (service != null) filter = rootFilter; service = null }
else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null
} }
@ -261,6 +275,7 @@ class DiscoverState(
ShellKey.Left, ShellKey.Right -> { ShellKey.Left, ShellKey.Right -> {
val direction = if (key == ShellKey.Left) -1 else 1 val direction = if (key == ShellKey.Left) -1 else 1
if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0)) if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0))
else if (focusedRow >= firstShelf) laneColumns[rowKey] = (column + direction).coerceIn(0, maxColumn())
else shell.view.cards[rowKey] = (column + direction).coerceIn(0, maxColumn()) else shell.view.cards[rowKey] = (column + direction).coerceIn(0, maxColumn())
} }
ShellKey.Ok -> when { ShellKey.Ok -> when {
@ -417,6 +432,12 @@ class DiscoverState(
} }
} }
private class DiscoverLaneMemory {
val columns = mutableStateMapOf<String, Int>()
val firsts = mutableStateMapOf<String, Int>()
fun clear() { columns.clear(); firsts.clear() }
}
private fun Int?.orZero(): Int = this ?: 0 private fun Int?.orZero(): Int = this ?: 0
private val DISCOVER_FILTERS = listOf("all", "movie", "tv") private val DISCOVER_FILTERS = listOf("all", "movie", "tv")

View file

@ -6,95 +6,156 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M import androidx.compose.ui.semantics.SemanticsPropertyKey
import kotlinx.coroutines.launch import androidx.compose.ui.semantics.semantics
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.unit.Dp
import androidx.compose.runtime.compositionLocalOf import kotlinx.coroutines.coroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
/** Artwork success or its final fallback releases a pending hero hand-off. */ /** Artwork success or its final fallback releases a pending hero hand-off. */
internal val LocalHeroArtReady = compositionLocalOf<(() -> Unit)?> { null } internal val LocalHeroArtReady = compositionLocalOf<(() -> Unit)?> { null }
internal val HeroLayerAlpha = SemanticsPropertyKey<Float>("HeroLayerAlpha")
internal val HeroLayerTranslation = SemanticsPropertyKey<Float>("HeroLayerTranslation")
internal val HeroLayerIdentity = SemanticsPropertyKey<String>("HeroLayerIdentity")
/** Only the upper hero region blends; the lower backdrop changes without an animated screen layer. */ private class HeroPresentation {
var identity by mutableStateOf<Any?>(null)
}
private val LocalHeroPresentation = compositionLocalOf<HeroPresentation?> { null }
/** Artwork and copy share readiness without coupling either layer to a screen's selection state. */
@Composable
internal fun HeroMotionScope(content: @Composable () -> Unit) {
val presentation = remember { HeroPresentation() }
CompositionLocalProvider(LocalHeroPresentation provides presentation, content = content)
}
private class HeroLayer<T>(val identity: Any, var target: T, initialAlpha: Float) {
val alpha = Animatable(initialAlpha)
var ready by mutableStateOf(false)
var presented = false
var outgoing by mutableStateOf(false)
}
private class HeroCopyTarget<T>(val identity: Any, var target: T)
/** Keep the painted composite while loading; interruptions fade each retained layer from its current alpha. */
@Composable @Composable
fun <T> HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) { fun <T> HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
var displayed by remember { mutableStateOf(target) } val initial = remember { HeroLayer(identity, target, 1f).apply { presented = true } }
var displayedIdentity by remember { mutableStateOf(identity) } val layers = remember { mutableStateListOf(initial) }
var outgoing by remember { mutableStateOf<T?>(null) } val selected = remember(identity) { layers.firstOrNull { it.identity == identity && it.presented && !it.outgoing } ?: HeroLayer(identity, target, 1f) }
val alpha = remember { Animatable(0f) }
val reduced = reducedMotion() val reduced = reducedMotion()
var readyIdentity by remember { mutableStateOf(identity) } val presentation = LocalHeroPresentation.current
val latestIdentity by rememberUpdatedState(identity) LaunchedEffect(selected, reduced) {
val onReady = remember(identity) { { if (latestIdentity == identity) readyIdentity = identity } } layers.removeAll { it !== selected && !it.presented }
LaunchedEffect(identity, reduced) { if (selected !in layers) layers.add(0, selected)
if (displayedIdentity != identity) { snapshotFlow { selected.ready }.first { it }
outgoing = displayed selected.presented = true
alpha.snapTo(1f) presentation?.identity = identity
snapshotFlow { readyIdentity == identity }.first { it } layers.forEach { if (it !== selected) it.outgoing = true }
displayed = target
displayedIdentity = identity
}
if (outgoing != null) {
alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut))
outgoing = null
}
} }
Box(modifier) { Box(modifier) {
CompositionLocalProvider(LocalHeroArtReady provides onReady) { content(target) } layers.forEach { layer -> key(layer) {
outgoing?.let { old -> SideEffect { if (layer.identity == identity) layer.target = target }
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height).graphicsLayer { this.alpha = alpha.value; clip = true }.testTag("hero-outgoing")) { LaunchedEffect(layer.outgoing, reduced) {
Box(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { content(old) } if (layer.outgoing) {
layer.alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut))
layers.remove(layer)
} }
} }
val outgoing = layer.outgoing
val onReady = remember(layer) { { layer.ready = true } }
val bounds = if (outgoing) Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height) else Modifier
Box(bounds.graphicsLayer { alpha = layer.alpha.value; clip = outgoing }
.testTag(if (outgoing) "hero-outgoing" else "hero-incoming")
.semantics { this[HeroLayerAlpha] = layer.alpha.value; this[HeroLayerTranslation] = 0f; this[HeroLayerIdentity] = layer.identity.toString() }) {
CompositionLocalProvider(LocalHeroArtReady provides onReady) {
Box(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) {
content(if (layer.identity == identity) target else layer.target)
}
}
}
} }
} }
} }
/** One bounded copy layer fades out, hands off, then rises; controls keep their logical owner. */ /** One copy slot fades through after artwork readiness; no two titles can be painted together. */
@Composable @Composable
fun <T> HeroCopyHandoff(identity: Any, target: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) { fun <T> HeroCopyHandoff(identity: Any, target: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
val reduced = reducedMotion() var entered by rememberSaveable { mutableStateOf(false) }
val alpha = remember { Animatable(0f) } val selected = remember(identity) { HeroCopyTarget(identity, target) }
val progress = remember { Animatable(0f) } val currentSelected by rememberUpdatedState(selected)
var displayed by remember { mutableStateOf(target) } var displayed by remember { mutableStateOf(selected) }
var previous by remember { mutableStateOf(identity) } val alpha = remember { Animatable(if (entered) 1f else 0f) }
val progress = remember { Animatable(if (entered) 1f else 0f) }
var initialEntry by remember { mutableStateOf(!entered) }
var handingOff by remember { mutableStateOf(false) } var handingOff by remember { mutableStateOf(false) }
var entered by remember { mutableStateOf(false) } var outgoing by remember { mutableStateOf(false) }
val rise = with(androidx.compose.ui.platform.LocalDensity.current) { (if (entered) M.copyRise else M.copyEntryRise).toPx() } val reduced = reducedMotion()
LaunchedEffect(identity, reduced) { val presentation = LocalHeroPresentation.current
if (previous != identity || handingOff) { val rise = with(LocalDensity.current) { (if (initialEntry) M.copyEntryRise else M.copyRise).toPx() }
SideEffect { selected.target = target }
LaunchedEffect(presentation, reduced) {
snapshotFlow { currentSelected.takeIf { presentation == null || presentation.identity == it.identity } }
.filterNotNull()
.distinctUntilChangedBy { it.identity }
.collectLatest { next ->
if (!entered) {
displayed = next
entered = true
} else if (displayed.identity != next.identity || handingOff) {
handingOff = true handingOff = true
outgoing = true
coroutineScope {
val fade = if (alpha.value > 0f) launch {
alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease)) alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease))
displayed = target } else null
previous = identity if (!reduced) kotlinx.coroutines.delay(M.heroCopyDelay.toLong())
fade?.join()
}
outgoing = false
displayed = next
initialEntry = false
progress.snapTo(0f) progress.snapTo(0f)
if (!reduced) kotlinx.coroutines.delay((M.heroCopyDelay - M.heroCopyOut).toLong()) }
kotlinx.coroutines.coroutineScope { if (alpha.value != 1f || progress.value != 1f) coroutineScope {
launch { alpha.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) } val duration = motionDuration(if (initialEntry) M.rowEnter else M.heroCopyIn, reduced)
launch { progress.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) } launch { alpha.animateTo(1f, tween(duration, easing = M.easeOut)) }
launch { progress.animateTo(1f, tween(duration, easing = M.easeOut)) }
} }
handingOff = false handingOff = false
} else if (!entered) {
kotlinx.coroutines.coroutineScope {
launch { alpha.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
launch { progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
} }
} }
entered = true Box(modifier.graphicsLayer {
displayed = target this.alpha = alpha.value
} translationY = if (reduced) 0f else rise * (1f - progress.value)
Box(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - progress.value) }) { }.testTag(if (outgoing) "hero-copy-outgoing" else "hero-copy").semantics {
content(if (handingOff || previous != identity) displayed else target) this[HeroLayerAlpha] = alpha.value
} this[HeroLayerTranslation] = if (reduced) 0f else rise * (1f - progress.value)
this[HeroLayerIdentity] = displayed.identity.toString()
}) { content(if (displayed.identity == identity) target else displayed.target) }
} }

View file

@ -19,6 +19,7 @@ import org.justinvisser.wolfskin.design.HomeType as T
/** The hero occupies one row while its backdrop extends beneath the first shelves. */ /** The hero occupies one row while its backdrop extends beneath the first shelves. */
@Composable @Composable
fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) { fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) {
HeroMotionScope {
Box(Modifier.fillMaxWidth().height(L.row_hero)) { Box(Modifier.fillMaxWidth().height(L.row_hero)) {
if (!row.skeleton) { if (!row.skeleton) {
val title = row.cards.first() val title = row.cards.first()
@ -28,7 +29,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
} }
Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg))) Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f)))) Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f))))
HeroCopyHandoff(title.id, title, Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) { copy -> Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) {
HeroCopyHandoff(title.id, title) { copy ->
Column { Column {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) {
Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f))) Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f)))
@ -44,6 +46,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
} }
Spacer(Modifier.height(H.factsBottom)) Spacer(Modifier.height(H.factsBottom))
Text(copy.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body)) Text(copy.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
}
}
Spacer(Modifier.height(H.overviewBottom)) Spacer(Modifier.height(H.overviewBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
val detailsOnly = row.heroKind == "pick" || !row.watchReady val detailsOnly = row.heroKind == "pick" || !row.watchReady
@ -52,7 +56,6 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) } if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
} }
} }
}
} else { } else {
HomeAmbient(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) HomeAmbient(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).skeletonPulse()) { Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).skeletonPulse()) {
@ -64,6 +67,7 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
} }
} }
} }
}
} }
/** Discover continues in the same scroller, under the active person's identity. */ /** Discover continues in the same scroller, under the active person's identity. */

View file

@ -32,10 +32,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.request.transitionFactory
import coil3.transition.Transition
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
@ -54,8 +57,9 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo
} }
else if (path != null) { else if (path != null) {
val context = LocalContext.current val context = LocalContext.current
val request = remember(context, path, width, height, poster) { val request = remember(context, path, width, height, poster, heroReady != null) {
WolfskinImages.request(context, path, width, height, poster) WolfskinImages.request(context, path, width, height, poster)
.let { if (heroReady != null) it.newBuilder().transitionFactory(Transition.Factory.NONE).build() else it }
} }
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), AsyncImage(model = request, imageLoader = WolfskinImages.loader(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter,
@ -110,7 +114,7 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) } val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(), TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop) Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape), poster = true) .size(L.heroSheetWidth, L.heroSheetHeight).testTag("title-poster-backdrop").dropShadow(posterShape, posterShadow).clip(posterShape), poster = true)
} }
Box(Modifier.fillMaxSize().background(horizontal)) Box(Modifier.fillMaxSize().background(horizontal))
Box(Modifier.fillMaxSize().background(vertical)) Box(Modifier.fillMaxSize().background(vertical))

View file

@ -1,5 +1,9 @@
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.getValue
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
@ -35,6 +39,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) { fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) {
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return } if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
val item = state.data.item val item = state.data.item
val factsItem = state.data.watchTarget.takeIf { it.kind == "episode" } ?: item
val readinessHeight by animateDpAsState(
if (state.data.requestedStage == null) WolfskinTokens.Shell.zero else WolfskinTokens.Motion.readinessSlot,
tween(if (reducedMotion()) 0 else WolfskinTokens.Motion.lateLineFade, easing = WolfskinTokens.Motion.easeOut),
label = "title-readiness-height")
val episode = when { val episode = when {
state.row == "episodes" -> state.data.episodes.getOrNull(state.column) state.row == "episodes" -> state.data.episodes.getOrNull(state.column)
state.data.watchTarget.kind == "episode" -> state.data.watchTarget state.data.watchTarget.kind == "episode" -> state.data.watchTarget
@ -56,10 +65,10 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) } item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) }
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings) if (state.data.connection != TitleConnection.SignedOut) TitleRatings(factsItem.ratings)
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres)) if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres))
} }
Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).testTag("title-readiness-slot")) { Box(Modifier.fillMaxWidth().height(readinessHeight).clipToBounds().testTag("title-readiness-slot")) {
state.data.requestedStage?.let { stage -> state.data.requestedStage?.let { stage ->
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted } val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
@ -74,7 +83,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.", Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap)) color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
} }
state.data.playTarget?.let { target -> state.data.playTarget?.takeIf { it.resumeSeconds > 0 || it.kind == "episode" }?.let { target ->
Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
if (target.resumeSeconds > 0) { if (target.resumeSeconds > 0) {
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) { Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
@ -83,7 +92,6 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
} }
Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress) Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress)
} else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress) } else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress)
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = TitleTextStyles.progress)
} }
} }
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {

View file

@ -0,0 +1,69 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.graphics.graphicsLayer
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** A compact modal keeps secondary actions reachable without growing the header row. */
@Composable
internal fun TitleMenu(state: TitleState, shell: ShellState) {
val visible = state.sheet == TitleSheet.Menu && !shell.pickerVisible
val focus = remember { FocusRequester() }
val reduced = reducedMotion()
val opacity = remember { Animatable(0f) }
LaunchedEffect(visible, reduced) {
if (visible) focus.requestFocus()
opacity.animateTo(if (visible) 1f else 0f,
tween(motionDuration(if (visible) WolfskinTokens.Motion.base else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut))
}
if ((visible || opacity.value > 0f) && (state.sheet == null || state.sheet == TitleSheet.Menu)) {
Box(Modifier.fillMaxSize().background(C.sheetScrim)
.clickable { if (visible) state.back(shell) }
.focusRequester(focus).onPreviewKeyEvent { event ->
val key = readShellKey(event)
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
}.focusable()) {
Column(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, D.heroSheetTop).width(D.actionMenuWidth)
.graphicsLayer { alpha = opacity.value }
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(D.receiptSide).testTag("title-menu"), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
Text("Menu", color = C.fg, style = WolfskinTokens.Type.rowHeading)
state.menuOptions.forEachIndexed { index, option ->
val focused = visible && state.sheetIndex == index
TitleControl(option, focused,
Modifier.fillMaxWidth(),
onFocus = { if (visible) state.sheetIndex = index }, overArtwork = false) {
if (visible) state.clickSheet(index)
}
}
}
}
}
}

View file

@ -63,7 +63,7 @@ data class TitleVersion(
enum class TitleReadStatus { Ready, Loading, Unavailable } enum class TitleReadStatus { Ready, Loading, Unavailable }
enum class TitleConnection { SignedIn, SignedOut, Offline } enum class TitleConnection { SignedIn, SignedOut, Offline }
enum class TitleSheet { Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt } enum class TitleSheet { Menu, Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt }
/** A title read includes related titles and the selected season; reads happen outside composition. */ /** A title read includes related titles and the selected season; reads happen outside composition. */
@Immutable @Immutable

View file

@ -62,7 +62,7 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
TitleSheet.MediaInfo -> MediaInformation(state) TitleSheet.MediaInfo -> MediaInformation(state)
TitleSheet.ConfirmWatched -> WatchedConfirmation(state) TitleSheet.ConfirmWatched -> WatchedConfirmation(state)
TitleSheet.HiddenReceipt -> HiddenReceipt(state) TitleSheet.HiddenReceipt -> HiddenReceipt(state)
null -> Unit TitleSheet.Menu, null -> Unit
} }
} }
} }

View file

@ -54,7 +54,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
val view = shell.view val view = shell.view
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt } LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
Box(modifier.fillMaxSize().onPreviewKeyEvent { event -> Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
// No content target exists during an initial load; nav Down must not enter a skeleton. // No content target exists during an initial load; Down must not enter a skeleton.
if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false
else when (readShellKey(event)) { else when (readShellKey(event)) {
ShellKey.Down -> true ShellKey.Down -> true
@ -69,7 +69,8 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) { androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) } ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
} }
if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell) if (!shell.pickerVisible && state.sheet != null && state.sheet != TitleSheet.Menu) TitleOptionsSheet(state, shell)
TitleMenu(state, shell)
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null } if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
} }
} }
@ -91,9 +92,11 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
var wheelPending by remember { mutableStateOf(false) } var wheelPending by remember { mutableStateOf(false) }
val focus = remember { FocusRequester() } val focus = remember { FocusRequester() }
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) } var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held && val compactHeader = state.data.status == TitleReadStatus.Ready
state.data.item.kind == "tv" && state.data.item.seasons > 0 val item = state.data.item
val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead val posterFloor = if (item.kind == "movie" && item.poster != null && (item.backdrop == null || item.isPosterBackdrop))
D.heroSheetTop + L.heroSheetHeight + D.posterCastGap - L.rowHeadingBand else WolfskinTokens.Shell.zero
val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) else L.row_dhead
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) { LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
} }
@ -117,8 +120,18 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
val position by remember(list, rowOffsets) { derivedStateOf { val position by remember(list, rowOffsets) { derivedStateOf {
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
} } } }
val headerPixels = with(density) { headerHeight.roundToPx() }
val previousHeaderPixels = remember(list, item.id, density) { intArrayOf(headerPixels) }
val compensateHeader = previousHeaderPixels[0] != headerPixels && !shell.navFocused && !list.isScrollInProgress &&
list.firstVisibleItemIndex == 0 && list.firstVisibleItemScrollOffset > 0 &&
list.layoutInfo.visibleItemsInfo.any { it.key == state.row && kotlin.math.abs(it.offset - with(density) { L.pinnedLine.roundToPx() }) <= 1 }
// Compensate in the same remeasure; an asynchronous scroll would chase the animated header.
androidx.compose.runtime.SideEffect {
if (compensateHeader) list.requestScrollToItem(0, (list.firstVisibleItemScrollOffset + headerPixels - previousHeaderPixels[0]).coerceAtLeast(0))
previousHeaderPixels[0] = headerPixels
}
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) { LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) {
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab) if (compensateHeader) return@LaunchedEffect
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0) val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
val y = rowOffsets[index] val y = rowOffsets[index]
val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() } val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() }
@ -154,7 +167,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
val active = state.row == id && !shell.navFocused && state.sheet == null val active = state.row == id && !shell.navFocused && state.sheet == null
when (id) { when (id) {
"header" -> Box(Modifier.height(headerHeight)) { "header" -> Box(Modifier.height(headerHeight)) {
TitleHeader(state, active, if (compactSeries) Modifier.onSizeChanged { size -> TitleHeader(state, active, if (compactHeader) Modifier.onSizeChanged { size ->
measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap
} else Modifier) } else Modifier)
} }

View file

@ -51,7 +51,7 @@ fun TitleCast(state: TitleState, active: Boolean) {
LaunchedEffect(state.column, active, state.data.cast.size) { LaunchedEffect(state.column, active, state.data.cast.size) {
if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size) if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size)
} }
Box(Modifier.height(D.castHeight)) { Box(Modifier.height(D.castHeight).testTag("title-cast")) {
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2).skeletonPulse(state.data.castStatus == TitleReadStatus.Loading), LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2).skeletonPulse(state.data.castStatus == TitleReadStatus.Loading),
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) { contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
@ -80,11 +80,11 @@ fun TitleCast(state: TitleState, active: Boolean) {
fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
val titles = if (episodes) state.data.episodes else state.data.related val titles = if (episodes) state.data.episodes else state.data.related
val id = if (episodes) "episodes" else "related" val id = if (episodes) "episodes" else "related"
val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW val width = if (episodes) D.episodeCardWidth else WolfskinTokens.Card.posterW
val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH
val track = rememberLazyListState() val track = rememberLazyListState()
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots val visible = if (episodes) ((WolfskinTokens.Shell.stageWidth - L.safeSide * 2 + WolfskinTokens.Card.tileGap) / (D.episodeCardWidth + WolfskinTokens.Card.tileGap)).toInt() else WolfskinTokens.Card.posterSlots
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() } val pitch = with(LocalDensity.current) { (if (episodes) D.episodeCardWidth + WolfskinTokens.Card.tileGap else WolfskinTokens.Card.posterPitch).toPx() }
LaunchedEffect(state.column, active, titles.size) { LaunchedEffect(state.column, active, titles.size) {
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size) if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
} }
@ -92,13 +92,13 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter) LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2).skeletonPulse(episodes && state.data.episodesStatus == TitleReadStatus.Loading), .height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2).skeletonPulse(episodes && state.data.episodesStatus == TitleReadStatus.Loading),
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(if (episodes) WolfskinTokens.Card.tileGap else WolfskinTokens.Card.posterGap)) {
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) { if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { index ->
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) Box(Modifier.size(width, height).testTag("title-episode-slot-$index").background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
} }
} else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item -> } else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
TitlePoster(item, active && state.column == index, Modifier.width(width) TitlePoster(item, active && state.column == index, Modifier.width(width).then(if (episodes) Modifier.testTag("title-episode-slot-$index") else Modifier)
.onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) }, .onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) },
episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) } episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
} }
@ -117,17 +117,16 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true, LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true,
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
} else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, } else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() },
WolfskinTokens.Card.tileW, D.episodeCardHeight, watched = item.watched, D.episodeCardWidth, D.episodeCardHeight, watched = item.watched,
progress = item.progress.takeIf { it < 1f } ?: 0f) { progress = item.progress.takeIf { it < 1f } ?: 0f) {
TitleTileArtwork(item) TitleTileArtwork(item)
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) { .padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis) CardMetadata(null, item.episodeLabel + if (item.runtimeSeconds > 0) " · ${item.runtime}" else "", item.watched && item.resumeSeconds <= 0)
if (focused) CardMetadata(null, if (item.runtimeSeconds > 0) item.runtime else null, item.watched && item.resumeSeconds <= 0)
} }
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) { androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()), easing = WolfskinTokens.Motion.easeOut)), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()), easing = WolfskinTokens.Motion.easeOut))) {
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth()) LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
} }
} }

View file

@ -24,6 +24,13 @@ class TitleState(
var sheet by mutableStateOf<TitleSheet?>(null) var sheet by mutableStateOf<TitleSheet?>(null)
private set private set
var sheetIndex by mutableStateOf(0) var sheetIndex by mutableStateOf(0)
private var mediaInfoFromMenu = false
val menuOptions: List<TitleOption> by derivedStateOf { buildList {
if (target != null) add(TitleOption("info", "Media info", "", icon = false))
if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) {
add(TitleOption("add", "Add for someone", "users", icon = false))
}
} }
var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1) var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
var versionId by mutableStateOf(initial.selectedVersion) var versionId by mutableStateOf(initial.selectedVersion)
var audioIndex by mutableStateOf(initial.selectedAudio) var audioIndex by mutableStateOf(initial.selectedAudio)
@ -65,6 +72,7 @@ class TitleState(
if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle
if (row !in rows) { row = "header"; column = 0 } if (row !in rows) { row = "header"; column = 0 }
column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0)) column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0))
if (sheet == TitleSheet.Menu && menuOptions.isEmpty()) sheet = null
} }
/** Pointer focus and D-pad use the same row memory. */ /** Pointer focus and D-pad use the same row memory. */
@ -140,7 +148,7 @@ class TitleState(
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */ /** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
fun back(shell: ShellState) { fun back(shell: ShellState) {
when { when {
sheet != null -> sheet = null sheet != null -> closeSheet()
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back) shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
else -> onAction(TitleAction.Pop) else -> onAction(TitleAction.Pop)
} }
@ -182,13 +190,12 @@ class TitleState(
"play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) } "play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version } "version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks } "tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
"more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo } "menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }
"watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched() "watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched()
"trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) } "trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) }
"seasons" -> focus("seasons", data.seasonNumbers.indexOf(season).coerceAtLeast(0))
"retry" -> onAction(TitleAction.Retry) "retry" -> onAction(TitleAction.Retry)
"back" -> onAction(TitleAction.Pop) "back" -> onAction(TitleAction.Pop)
"request", "other", "add" -> onAction(TitleAction.Request(data.item, id != "request")) "request", "add" -> onAction(TitleAction.Request(data.item, id != "request"))
"status" -> onAction(TitleAction.Requests(data.item)) "status" -> onAction(TitleAction.Requests(data.item))
"hide", "unhide" -> { "hide", "unhide" -> {
val hidden = id == "hide" val hidden = id == "hide"
@ -205,9 +212,10 @@ class TitleState(
} }
private fun sheetKey(key: ShellKey) { private fun sheetKey(key: ShellKey) {
if (key == ShellKey.Back) { sheet = null; return } if (key == ShellKey.Back) { closeSheet(); return }
val current = version val current = version
val size = when (sheet) { val size = when (sheet) {
TitleSheet.Menu -> menuOptions.size
TitleSheet.Version -> data.versions.size TitleSheet.Version -> data.versions.size
TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2 TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2
TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2 TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2
@ -217,6 +225,16 @@ class TitleState(
ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0) ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0)
ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1) ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1)
ShellKey.Ok -> when (sheet) { ShellKey.Ok -> when (sheet) {
TitleSheet.Menu -> menuOptions.getOrNull(sheetIndex)?.let { option ->
if (option.id == "info") {
mediaInfoFromMenu = true
sheetIndex = 0
sheet = TitleSheet.MediaInfo
} else {
sheet = null
activateOption(option.id)
}
}
TitleSheet.Version -> { TitleSheet.Version -> {
val selected = data.versions[sheetIndex] val selected = data.versions[sheetIndex]
versionId = selected.id versionId = selected.id
@ -234,11 +252,19 @@ class TitleState(
onAction(TitleAction.Hide(data.item, false)) onAction(TitleAction.Hide(data.item, false))
} else onAction(TitleAction.Pop) } else onAction(TitleAction.Pop)
} }
else -> sheet = null else -> closeSheet()
} }
} }
} }
private fun closeSheet() {
if (sheet == TitleSheet.MediaInfo && mediaInfoFromMenu) {
mediaInfoFromMenu = false
sheetIndex = 0
sheet = TitleSheet.Menu
} else sheet = null
}
private fun selectTrack(current: TitleVersion) { private fun selectTrack(current: TitleVersion) {
when { when {
sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index
@ -274,14 +300,12 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
val series = data.watchTarget.kind == "tv" val series = data.watchTarget.kind == "tv"
add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched)) add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched))
if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer")) if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer"))
if (target != null) add(TitleOption("more", "More", "more", "Media info")) if (target != null || (data.connection == TitleConnection.SignedIn && data.requestsAllowed)) add(TitleOption("menu", "Menu", "more"))
if (item.kind == "tv" && item.seasons > 0) add(TitleOption("seasons", "Seasons", "tv", icon = false))
if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) add(TitleOption("add", "Add for someone", "users", icon = false))
} else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) { } else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) {
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false)) add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
} else { } else {
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false)) add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false))
add(TitleOption("other", "For someone else", "users", icon = false)) add(TitleOption("menu", "Menu", "more"))
if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false)) if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false))
else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false)) else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false))
} }

View file

@ -262,7 +262,7 @@ object WolfskinTokens {
const val heroRequestHeight = 1080 const val heroRequestHeight = 1080
const val heroPosterRequestWidth = 420 const val heroPosterRequestWidth = 420
const val heroPosterRequestHeight = 630 const val heroPosterRequestHeight = 630
const val episodeRequestWidth = 400 const val episodeRequestWidth = 448
const val episodeRequestHeight = 280 const val episodeRequestHeight = 280
const val castRequestWidth = 200 const val castRequestWidth = 200
const val castRequestHeight = 200 const val castRequestHeight = 200
@ -451,8 +451,11 @@ object WolfskinTokens {
const val sheetOpacity = 0.5f const val sheetOpacity = 0.5f
} }
object Title { object Title {
val episodeCardWidth = 224.0.dp
val episodeCardHeight = 140.0.dp val episodeCardHeight = 140.0.dp
val actionMenuWidth = 260.0.dp
val seriesHeaderGap = 20.0.dp val seriesHeaderGap = 20.0.dp
val posterCastGap = 10.0.dp
val lineGap = 11.0.dp val lineGap = 11.0.dp
val lineLift = -2.0.dp val lineLift = -2.0.dp
val overviewGap = 17.0.dp val overviewGap = 17.0.dp

View file

@ -0,0 +1,65 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.GraphicsMode
import org.robolectric.annotation.Config
/** A catalogue read has no right to move the destination's focus into the root navigation. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverDetailsFocusTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun heroDetailsRetainsDestinationFocusDuringLoadingAndLateMetadata() {
rule.mainClock.autoAdvance = false
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
val snapshot = discoverTestFixture().snapshot("justin")
val discover = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination)
val selected = requireNotNull(discover.hero)
val title = TitleState(TitlePageData(TitleItem(selected.key, selected.title, selected.type), status = TitleReadStatus.Loading), shell.person) {}
val art = android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
rule.setContent { WolfskinTheme {
CompositionLocalProvider(LocalDiscoverArt provides { art }) {
val modifier = Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)
if (shell.route.startsWith("title/")) TitleSurface(title, shell, modifier)
else ShellSurface(shell, modifier) { DiscoverScreen(discover, it) }
}
} }
rule.mainClock.advanceTimeBy(640)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
repeat(6) {
rule.mainClock.advanceTimeBy(16)
rule.waitForIdle()
assertFalse("Loading cannot steal destination focus on a later frame", shell.navFocused)
}
assertEquals("title/${android.net.Uri.encode(selected.key)}", shell.route)
assertFalse("The destination owns focus even while its read is pending", shell.navFocused)
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
assertFalse("Down on a loading title must not enter nav or a skeleton", shell.navFocused)
rule.runOnIdle { title.update(title.data.copy(status = TitleReadStatus.Ready)) }
rule.mainClock.advanceTimeBy(640)
val before = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
rule.runOnIdle { title.update(title.data.copy(item = title.data.item.copy(overview = "Late overview", ratings = listOf(LibraryRating("rt", 88.0))))) }
repeat(24) {
rule.mainClock.advanceTimeBy(16)
assertFalse(shell.navFocused)
assertEquals("Loaded actions cannot replay their entry or move after late metadata", before, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
}
}
}

View file

@ -0,0 +1,272 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import coil3.intercept.Interceptor
import coil3.request.ErrorResult
import com.github.takahirom.roborazzi.captureRoboImage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
import java.io.IOException
/** Real Discover artwork, copy and pager are sampled on every manually advanced native frame. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverHeroSequenceTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val samples = JSONArray()
private lateinit var directory: File
private lateinit var state: DiscoverState
private lateinit var shell: ShellState
private val artAvailable = mutableStateOf(true)
private val reduced = mutableStateOf(false)
private var previousPixel: Int? = null
private val mounted = mutableStateOf(true)
private var lowerPixel = 0
private var largestJump = 0
private var previousLayers = emptyMap<String, Pair<Float, Float>>()
private val previousDotWidths = mutableMapOf<Int, Float>()
private var previousSelection: Int? = null
private fun launch(scenario: String, failedArt: Boolean = false, settle: Boolean = true) {
directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-hero/$scenario").apply { mkdirs() }
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
rule.mainClock.autoAdvance = false
WolfskinImages.configureNetwork(rule.activity) { add(Interceptor { chain ->
if (chain.request.data.toString().startsWith("sequence:")) {
if (failedArt) ErrorResult(image = null, request = chain.request, throwable = IOException("Controlled artwork failure")) else awaitCancellation()
} else chain.proceed()
}) }
val fixture = discoverTestFixture()
val colors = listOf(android.graphics.Color.RED, android.graphics.Color.BLUE, android.graphics.Color.GREEN)
val titles = fixture.snapshot("justin").picks.take(3).mapIndexed { index, title ->
title.copy(backdrop = "sequence:$index", poster = null, posterOnly = false, score = 100.0 - index)
}
val art = colors.map { color -> Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(color) }.asImageBitmap() }
val snapshot = fixture.snapshot("justin").copy(picks = titles)
shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination)
rule.setContent { WolfskinTheme {
val saved = rememberSaveableStateHolder()
CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalDiscoverArt provides { path ->
path.removePrefix("sequence:").toIntOrNull()?.let { if (it != 1 || artAvailable.value) art[it] else null }
}) {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
if (mounted.value) saved.SaveableStateProvider("discover") { DiscoverScreen(state, modifier) }
}
}
} }
if (settle) rule.mainClock.advanceTimeBy(640) else rule.mainClock.advanceTimeByFrame()
}
@Test fun initialCopyEntersOnceAndDoesNotReplayOnDestinationRemount() {
launch("initial", settle = false)
sample("initial", 0)
fun copy() = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true)
.fetchSemanticsNodes().single { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
assertEquals(0f, copy().config[HeroLayerAlpha], .001f)
assertEquals(with(rule.density) { WolfskinTokens.Motion.copyEntryRise.toPx() }, copy().config[HeroLayerTranslation], .001f)
frames("entry", 16)
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
rule.runOnIdle { mounted.value = false }
frames("absent", 1)
rule.runOnIdle { mounted.value = true }
repeat(8) { frame ->
rule.mainClock.advanceTimeBy(16)
sample("remount", frame)
assertEquals("Returning to a saved destination cannot replay its entrance", 1f, copy().config[HeroLayerAlpha], .001f)
assertEquals(0f, copy().config[HeroLayerTranslation], .001f)
}
finish()
}
@Test fun interruptedHeroNeverSnapsToAnIntermediateSelection() {
launch("interrupted")
sample("settled", 0)
press(Key.DirectionRight)
frames("first", 12)
press(Key.DirectionRight)
frames("interrupted", 28)
press(Key.DirectionLeft)
frames("reverse", 28)
finish()
assertSmoothPixels()
assertEquals(1, state.slide)
}
@Test fun slowArtRetainsBothThePaintedHeroAndItsCopyUntilReady() {
launch("slow")
sample("settled", 0)
val original = previousPixel
val originalLower = lowerPixel
rule.runOnIdle { artAvailable.value = false }
press(Key.DirectionRight)
repeat(24) { frame ->
rule.mainClock.advanceTimeBy(16)
sample("pending", frame)
assertEquals("Pending art cannot expose a placeholder beneath the hero", originalLower, lowerPixel)
}
assertEquals("Pending art must not replace the painted backdrop", original, previousPixel)
assertEquals(state.heroes.first().key, copyIdentities().single())
rule.runOnIdle { artAvailable.value = true }
frames("ready", 28)
finish()
assertSmoothPixels()
assertEquals(state.hero!!.key, copyIdentities().single())
}
@Test fun failedArtBlendsToItsFinalFallbackWithoutErasingThePreviousHero() {
launch("failed", failedArt = true)
sample("settled", 0)
rule.runOnIdle { artAvailable.value = false }
press(Key.DirectionRight)
frames("fallback", 28)
rule.onNodeWithTag("discover-missing-art").assertExists()
finish()
assertSmoothPixels()
assertEquals(state.hero!!.key, copyIdentities().single())
}
@Test fun repeatedPressesRetargetTheCurrentCompositeWithoutSnapBack() {
launch("rapid")
sample("settled", 0)
listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionLeft, Key.DirectionLeft, Key.DirectionRight, Key.DirectionRight).forEachIndexed { index, key ->
press(key)
frames("press-$index", 3)
}
repeat(48) { index ->
press(if (index % 2 == 0) Key.DirectionLeft else Key.DirectionRight)
frames("burst-$index", 1)
}
frames("settled-latest", 28)
finish()
assertSmoothPixels()
assertEquals(state.hero!!.key, copyIdentities().single())
}
@Test fun reducedMotionKeepsTheBlendButRemovesCopyTranslation() {
launch("reduced")
rule.runOnIdle { reduced.value = true }
press(Key.DirectionRight)
frames("reduced", (WolfskinTokens.Motion.heroFade + 15) / 16)
finish()
assertEquals(state.hero!!.key, copyIdentities().single())
assertEquals(1, rule.onAllNodesWithTag("hero-incoming", useUnmergedTree = true).fetchSemanticsNodes().size)
assertTrue(rule.onAllNodesWithTag("hero-outgoing", useUnmergedTree = true).fetchSemanticsNodes().isEmpty())
val moving = (0 until samples.length()).map(samples::getJSONObject).filter { row ->
val layers = row.getJSONArray("layers")
(0 until layers.length()).map(layers::getJSONObject).any {
it.getString("kind") == "hero-outgoing" && it.getDouble("alpha") > 0.0 && it.getDouble("alpha") < 1.0
}
}.map { it.getLong("timeMs") }
assertTrue("Reduced artwork fade cannot retain the ordinary 320ms lifetime", moving.last() - moving.first() <= WolfskinTokens.Motion.reducedFade)
}
private fun copyIdentities() = rule.onAllNodesWithTag("hero-copy", useUnmergedTree = true).fetchSemanticsNodes().map { it.config[HeroLayerIdentity] }
private fun press(key: Key) = rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(key); keyUp(key) }
private fun frames(phase: String, count: Int) = repeat(count) { index -> rule.mainClock.advanceTimeBy(16); sample(phase, index) }
private fun finish() = File(directory, "series.json").writeText(samples.toString(2))
private fun assertSmoothPixels() = assertTrue("A 16ms hero frame cannot snap to a full intermediate selection; largest channel jump=$largestJump", largestJump <= 48)
private fun sample(phase: String, index: Int) {
rule.waitForIdle()
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
val pixel = try {
lowerPixel = bitmap.getPixel(1600, 800)
bitmap.getPixel(1600, 200)
} finally { bitmap.recycle() }
val channels = channels(pixel)
previousPixel?.let { old -> largestJump = maxOf(largestJump, channels.zip(channels(old)).maxOf { (a, b) -> kotlin.math.abs(a - b) }) }
previousPixel = pixel
val copy = JSONArray()
state.heroes.forEach { title -> rule.onAllNodesWithText(title.title, useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
copy.put(JSONObject().put("title", title.key).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top))
} }
val layers = layerSamples()
val dots = JSONArray()
state.heroes.indices.forEach { dot -> rule.onAllNodesWithTag("hero-dot-$dot", useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
previousDotWidths[dot]?.let { previous ->
val range = with(rule.density) { (WolfskinTokens.Discover.dotActive - WolfskinTokens.Discover.dotSize).toPx() }
if (!reduced.value) assertTrue("Pager retarget must not snap a full dot-width step", kotlin.math.abs(node.boundsInRoot.width - previous) <= range / 2)
if (reduced.value) {
val rest = with(rule.density) { WolfskinTokens.Discover.dotSize.toPx() }
assertTrue("Reduced motion snaps pager geometry instead of moving it", node.boundsInRoot.width == rest || node.boundsInRoot.width == rest + range)
}
if (previousSelection == state.slide) {
if (dot == state.slide) assertTrue("The selected pager dot cannot shrink backwards", node.boundsInRoot.width >= previous - .01f)
else assertTrue("An outgoing pager dot cannot grow backwards", node.boundsInRoot.width <= previous + .01f)
}
}
previousDotWidths[dot] = node.boundsInRoot.width
dots.put(JSONObject().put("index", dot).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top).put("width", node.boundsInRoot.width))
} }
previousSelection = state.slide
samples.put(JSONObject().put("phase", phase).put("frame", index).put("timeMs", rule.mainClock.currentTime)
.put("selection", state.slide).put("mounted", mounted.value).put("rgb", JSONArray(channels)).put("lowerRgb", JSONArray(channels(lowerPixel))).put("copy", copy).put("layers", layers).put("dots", dots).put("navFocused", shell.navFocused))
}
private fun layerSamples(): JSONArray {
val result = JSONArray()
val current = mutableMapOf<String, Pair<Float, Float>>()
val nodes = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes()
val unique = nodes.groupBy { it.config[SemanticsProperties.TestTag] + ":" + it.id }
if (mounted.value) assertTrue("The artwork composite always has an opaque base", nodes.any { it.config[SemanticsProperties.TestTag] == "hero-incoming" && it.config[HeroLayerAlpha] == 1f })
val visibleCopy = nodes.count { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") && it.config[HeroLayerAlpha] > 0f }
assertTrue("Sequential copy cannot paint overlapping titles, including interruptions", visibleCopy <= 1)
val artCount = nodes.count { !it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
assertTrue("Sustained presses cannot extend outgoing art lifetimes", artCount <= (WolfskinTokens.Motion.heroFade + 15) / 16 + 3)
unique.forEach { (key, matching) -> matching.forEach { node ->
val alpha = node.config[HeroLayerAlpha]
val translation = node.config[HeroLayerTranslation]
val tag = node.config[SemanticsProperties.TestTag]
if (matching.size == 1) {
previousLayers[key]?.let { (previousAlpha, previousTranslation) ->
if (tag == "hero-outgoing" || tag == "hero-copy-outgoing") assertTrue("Outgoing layers cannot restart at full opacity: $key", alpha <= previousAlpha + .001f)
if (tag == "hero-copy-outgoing" && alpha > 0f) assertEquals("Visible outgoing copy must fade where its entry stopped", previousTranslation, translation, .001f)
if (tag == "hero-copy") {
assertTrue("Copy alpha cannot snap backwards: $key", alpha >= previousAlpha - .001f)
if (alpha > 0f && previousAlpha > 0f) assertTrue("Visible copy translation cannot snap backwards", translation <= previousTranslation + .001f)
}
}
current[key] = alpha to translation
}
if (reduced.value) assertEquals(0f, translation, .001f)
result.put(JSONObject().put("kind", tag).put("identity", node.config[HeroLayerIdentity]).put("layerId", node.id).put("alpha", alpha)
.put("translationY", translation).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top))
} }
previousLayers = current
return result
}
private fun channels(pixel: Int) = listOf(android.graphics.Color.red(pixel), android.graphics.Color.green(pixel), android.graphics.Color.blue(pixel))
}

View file

@ -2,6 +2,9 @@
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import android.app.Application import android.app.Application
import android.graphics.Bitmap
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.asImageBitmap
import android.view.KeyEvent as AndroidKeyEvent import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEvent
@ -19,10 +22,12 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Acceptance data drives real Android events through the shell's production focus adapter. */ /** Acceptance data drives real Android events through the shell's production focus adapter. */
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverKeysTest { class DiscoverKeysTest {
@get:Rule val rule = createComposeRule() @get:Rule val rule = createComposeRule()
private val fixture = discoverTestFixture() private val fixture = discoverTestFixture()
@ -33,14 +38,17 @@ class DiscoverKeysTest {
{ shell.enterDestination("discover") }, fixture::service) { shell.enterDestination("discover") }, fixture::service)
private fun launch() { private fun launch() {
val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
rule.setContent { rule.setContent {
WolfskinTheme { WolfskinTheme {
CompositionLocalProvider(LocalDiscoverArt provides { art }) {
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier -> ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier) if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
} }
} }
} }
} }
}
private fun press(first: Key, second: Key? = null) = listOfNotNull(first, second).forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } } private fun press(first: Key, second: Key? = null) = listOfNotNull(first, second).forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
@ -256,7 +264,69 @@ class DiscoverKeysTest {
assertNull(state.collection) assertNull(state.collection)
assertSame(origin, shell.view) assertSame(origin, shell.view)
assertEquals(3, shell.view.row) assertEquals(3, shell.view.row)
assertEquals(2, shell.view.cards[state.rowKey]) assertEquals(2, state.column)
}
@Test fun firstEntryStartsLeftmostAndOnlyMovedLanesRememberTheirPosition() {
replay("Open Discover")
press(Key.DirectionDown, Key.DirectionDown)
assertEquals(0, state.column)
repeat(7) { press(Key.DirectionRight) }
val moved = state.column
assertTrue(moved > 0)
press(Key.DirectionDown)
assertEquals("An untouched lane starts at its own left edge, not the previous lane's column", 0, state.column)
press(Key.DirectionUp)
assertEquals(moved, state.column)
}
@Test fun leavingTheTabResetsLaneMovementButPushedTitlesDoNot() {
replay("Open Discover")
press(Key.DirectionDown, Key.DirectionDown)
press(Key.DirectionDown)
repeat(4) { press(Key.DirectionRight) }
val row = state.focusedRow
val moved = state.column
press(Key.DirectionCenter)
assertNotNull("This round trip must open a title, not a service lane", state.title)
press(Key.Back)
assertEquals(moved, state.column)
press(Key.Back)
press(Key.DirectionLeft, Key.DirectionCenter)
assertEquals("home", shell.route)
press(Key.DirectionUp, Key.DirectionRight)
press(Key.DirectionCenter)
assertEquals("discover", shell.route)
assertEquals(row, state.focusedRow)
assertEquals(0, state.column)
assertEquals(0, state.laneFirsts[state.rowKey] ?: 0)
}
@Test fun rebuildingDiscoverDoesNotReuseThePreviousInstancesLaneColumns() {
shell.pickerVisible = false
shell.enterDestination("discover")
state.focus(state.firstShelf, 4)
val rebuilt = DiscoverState(shell, snapshot, operations, CoroutineScope(Dispatchers.Main.immediate))
assertEquals(0, rebuilt.column)
}
@Test fun serviceLanesKeepIndependentPositionsAndResetWithTheRootVisit() {
shell.pickerVisible = false
shell.enterDestination("discover")
state.focus(state.firstShelf + 1, 3)
state.openService(snapshot.services[0])
state.focus(state.firstShelf + 1, 4)
state.key(ShellKey.Back)
assertEquals("A service must not overwrite the root lane", 3, state.column)
state.openService(snapshot.services[1])
state.key(ShellKey.Down)
assertEquals("Different services can reuse lane ids without sharing columns", 0, state.column)
state.key(ShellKey.Back)
state.openService(snapshot.services[0])
state.key(ShellKey.Down)
assertEquals(4, state.column)
state.enterRoute("home")
assertEquals("Leaving Discover clears service positions too", 0, state.column)
} }
@Test @Test

View file

@ -0,0 +1,91 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import coil3.asImage
import coil3.decode.DataSource
import coil3.intercept.Interceptor
import coil3.request.SuccessResult
import com.github.takahirom.roborazzi.captureRoboImage
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
/** Network-success artwork must not add Coil's independent alpha ramp beneath the hero blend. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverNetworkHeroTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun delayedNetworkSuccessCannotDipThePaintedHeroOpacity() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
rule.mainClock.autoAdvance = false
val red = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.RED) }.asImageBitmap()
val blue = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.BLUE) }.asImage()
val release = CompletableDeferred<Unit>()
WolfskinImages.configureNetwork(rule.activity) { add(Interceptor { chain ->
if (chain.request.data == "network:blue") {
release.await()
SuccessResult(blue, chain.request, DataSource.NETWORK)
} else chain.proceed()
}) }
val fixture = discoverTestFixture()
val titles = fixture.snapshot("justin").picks.take(2).mapIndexed { index, title ->
title.copy(backdrop = if (index == 0) "network:red" else "network:blue", poster = null, posterOnly = false, score = 100.0 - index)
}
val snapshot = fixture.snapshot("justin").copy(picks = titles)
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate))
rule.setContent { WolfskinTheme {
CompositionLocalProvider(LocalDiscoverArt provides { path -> if (path == "network:red") red else null }) {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) }
}
} }
rule.mainClock.advanceTimeBy(640)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(Key.DirectionRight); keyUp(Key.DirectionRight) }
rule.mainClock.advanceTimeBy(192)
rule.onNodeWithText(titles.first().title).assertIsDisplayed()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-network-hero").apply { mkdirs() }
val samples = JSONArray()
rule.runOnIdle { release.complete(Unit) }
var lastPixel = 0
repeat(40) { index ->
rule.mainClock.advanceTimeBy(16)
val file = File(directory, "network-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
lastPixel = try { bitmap.getPixel(1600, 200) } finally { bitmap.recycle() }
val redChannel = android.graphics.Color.red(lastPixel)
val blueChannel = android.graphics.Color.blue(lastPixel)
samples.put(JSONObject().put("timeMs", rule.mainClock.currentTime).put("red", redChannel).put("blue", blueChannel))
assertTrue("A second network-image fade exposes the dark placeholder beneath both heroes at frame $index: red=$redChannel blue=$blueChannel", redChannel + blueChannel >= 250)
}
File(directory, "series.json").writeText(samples.toString(2))
assertEquals(android.graphics.Color.BLUE, lastPixel)
rule.onNodeWithText(titles.last().title).assertIsDisplayed()
}
}

View file

@ -13,6 +13,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
@ -59,16 +62,22 @@ class HeroMotionTest {
val probe = BitmapFactory.decodeFile(probeFile.absolutePath) val probe = BitmapFactory.decodeFile(probeFile.absolutePath)
try { try {
for (y in listOf(380, 400, 420, 600, 800)) { for (y in listOf(380, 400, 420, 600, 800)) {
assertEquals("The outgoing hero must stop at its 400px boundary: y=$y", assertEquals("Pending artwork cannot replace any part of the painted backdrop: y=$y",
(if (y < 400) Color.Red else Color.Blue).toArgb(), probe.getPixel(100, y)) Color.Red.toArgb(), probe.getPixel(100, y))
} }
assertEquals(400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f)
} finally { probe.recycle() } } finally { probe.recycle() }
update { ready.value = true } update { ready.value = true }
rule.mainClock.advanceTimeBy(176) rule.mainClock.advanceTimeBy(176)
val mixed = pixel(directory, 100) val mixed = pixel(directory, 100)
assertNotEquals(Color.Red.toArgb(), mixed) assertNotEquals(Color.Red.toArgb(), mixed)
assertNotEquals(Color.Blue.toArgb(), mixed) assertNotEquals(Color.Blue.toArgb(), mixed)
val blendedProbe = BitmapFactory.decodeFile(probeFile.absolutePath)
try {
assertEquals("Only the outgoing blend is clipped at its 400px boundary",
400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f)
assertEquals(Color.Blue.toArgb(), blendedProbe.getPixel(100, 400))
assertEquals(Color.Blue.toArgb(), blendedProbe.getPixel(100, 800))
} finally { blendedProbe.recycle() }
rule.mainClock.advanceTimeBy(400) rule.mainClock.advanceTimeBy(400)
assertEquals(Color.Blue.toArgb(), pixel(directory, 100)) assertEquals(Color.Blue.toArgb(), pixel(directory, 100))
update { selected.value = "green"; ready.value = false } update { selected.value = "green"; ready.value = false }
@ -82,6 +91,35 @@ class HeroMotionTest {
} finally { directory.deleteRecursively() } } finally { directory.deleteRecursively() }
} }
@Test fun homeHeroRetainsTheOutgoingCopyAnchorWhenTheNextTitleWraps() {
rule.mainClock.autoAdvance = false
val first = HomeTitle("first", "Short title", "movie", backdrop = "red")
val next = HomeTitle("next", "A much longer title that must wrap onto a second line in this hero", "movie", backdrop = "blue")
val selected = mutableStateOf(first)
val art = mapOf("red" to Color.Red, "blue" to Color.Blue).mapValues { (_, color) ->
android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888).apply { eraseColor(color.toArgb()) }.asImageBitmap()
}
rule.setContent { WolfskinTheme {
CompositionLocalProvider(LocalTitleBitmaps provides art) {
Box(Modifier.requiredSize(960.dp, 540.dp)) {
HomeHero(HomeRow("hero", HomeRowKind.Hero, cards = listOf(selected.value)), true, 0, emptyList(), {}, {})
}
}
} }
rule.mainClock.advanceTimeBy(640)
val anchor = rule.onNodeWithText(first.title).fetchSemanticsNode().boundsInRoot
val actionAnchor = rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot
update { selected.value = next }
repeat(WolfskinTokens.Motion.heroCopyOut / 16) {
rule.mainClock.advanceTimeBy(16)
assertEquals("A taller incoming copy cannot move the outgoing title", anchor, rule.onNodeWithText(first.title).fetchSemanticsNode().boundsInRoot)
assertEquals("Hero actions remain stationary outside the copy handoff", actionAnchor, rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot)
}
rule.mainClock.advanceTimeBy(320)
rule.onNodeWithText(first.title).assertDoesNotExist()
rule.onNodeWithText(next.title).assertIsDisplayed()
}
private fun update(block: () -> Unit) { private fun update(block: () -> Unit) {
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame() rule.mainClock.advanceTimeByFrame()

View file

@ -0,0 +1,132 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.github.takahirom.roborazzi.captureRoboImage
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
/** Real Home hero entry, interrupted fade-through and saved remount sampled at native 16 ms steps. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class HomeHeroSequenceTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val first = HomeTitle("first", "Short title", "movie", backdrop = "red")
private val next = HomeTitle("next", "A much longer title that must wrap onto a second line in this hero", "movie", backdrop = "blue")
private val third = HomeTitle("third", "The final selected title", "movie", backdrop = "green")
private val selected = mutableStateOf(first)
private val reduced = mutableStateOf(false)
private val mounted = mutableStateOf(true)
private val samples = JSONArray()
private lateinit var directory: File
private lateinit var actionBounds: Rect
@Test fun initialSequentialBurstAndRemountKeepHomeCopyReadable() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
rule.mainClock.autoAdvance = false
directory = File(System.getProperty("wolfskin.captureDirectory"), "home-hero-copy").apply { mkdirs() }
val art = mapOf("red" to android.graphics.Color.RED, "blue" to android.graphics.Color.BLUE, "green" to android.graphics.Color.GREEN).mapValues { (_, color) ->
Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(color) }.asImageBitmap()
}
rule.setContent { WolfskinTheme {
val saved = rememberSaveableStateHolder()
CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalTitleBitmaps provides art) {
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) {
if (mounted.value) saved.SaveableStateProvider("home-hero") {
HomeHero(HomeRow("hero", HomeRowKind.Hero, cards = listOf(selected.value)), true, 0, emptyList(), {}, {})
}
}
}
} }
rule.mainClock.advanceTimeByFrame()
actionBounds = rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot
sample("initial", 0)
assertEquals(0f, copy().config[HeroLayerAlpha], .001f)
assertEquals(with(rule.density) { WolfskinTokens.Motion.copyEntryRise.toPx() }, copy().config[HeroLayerTranslation], .001f)
frames("entry", 16)
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
rule.runOnIdle { selected.value = next }
frames("handoff", 26)
assertEquals(next.id, copy().config[HeroLayerIdentity])
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
repeat(24) { index ->
rule.runOnIdle { selected.value = if (index % 2 == 0) first else third }
frames("burst-$index", 1)
}
frames("settled", 28)
assertEquals(third.id, copy().config[HeroLayerIdentity])
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
rule.runOnIdle { reduced.value = true; selected.value = next }
frames("reduced", 20)
assertEquals(next.id, copy().config[HeroLayerIdentity])
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
rule.runOnIdle { mounted.value = false }
frames("absent", 1)
rule.runOnIdle { mounted.value = true }
repeat(8) { frame ->
rule.mainClock.advanceTimeBy(16)
sample("remount", frame)
assertEquals("Returning Home cannot replay the initial entrance", 1f, copy().config[HeroLayerAlpha], .001f)
assertEquals(0f, copy().config[HeroLayerTranslation], .001f)
}
File(directory, "series.json").writeText(samples.toString(2))
}
private fun frames(phase: String, count: Int) = repeat(count) { frame -> rule.mainClock.advanceTimeBy(16); sample(phase, frame) }
private fun copy() = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes()
.single { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
private fun sample(phase: String, frame: Int) {
rule.waitForIdle()
val file = File(directory, "$phase-${frame.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
val pixel = try { bitmap.getPixel(1600, 200) } finally { bitmap.recycle() }
val nodes = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes()
val copyNodes = nodes.filter { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
assertTrue("Home must never overlap visible titles, even during bursts", copyNodes.count { it.config[HeroLayerAlpha] > 0f } <= 1)
if (mounted.value) {
assertTrue("Home artwork retains an opaque base", nodes.any { it.config[SemanticsProperties.TestTag] == "hero-incoming" && it.config[HeroLayerAlpha] == 1f })
assertEquals("Copy motion cannot move Home's controls", actionBounds, rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot)
}
val layers = JSONArray()
nodes.forEach { node ->
val translation = node.config[HeroLayerTranslation]
if (reduced.value) assertEquals(0f, translation, .001f)
layers.put(JSONObject().put("kind", node.config[SemanticsProperties.TestTag]).put("identity", node.config[HeroLayerIdentity])
.put("layerId", node.id).put("alpha", node.config[HeroLayerAlpha]).put("translationY", translation)
.put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top))
}
samples.put(JSONObject().put("phase", phase).put("frame", frame).put("timeMs", rule.mainClock.currentTime)
.put("mounted", mounted.value).put("selection", selected.value.id).put("reduced", reduced.value)
.put("rgb", JSONArray(listOf(android.graphics.Color.red(pixel), android.graphics.Color.green(pixel), android.graphics.Color.blue(pixel))))
.put("actionX", actionBounds.left).put("actionY", actionBounds.top).put("layers", layers))
}
}

View file

@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
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.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
@ -44,20 +45,19 @@ class MotionSurfaceTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale) @get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
private val viewer = ShellPerson("justin", "Justin", "J", false) private val viewer = ShellPerson("justin", "Justin", "J", false)
@Test fun lateSeriesReadinessAndRatingsLeaveActionsOverviewAndEpisodesAtTheSameCoordinates() { @Test fun reducedMotionReadinessSnapsAndRemovingItRestoresTheCollapsedStack() {
val shell = shell() val shell = shell()
val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview") val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview")
val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview") val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview")
val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {} val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {}
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } rule.setContent { CompositionLocalProvider(LocalReducedMotion provides true) { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } }
rule.waitForIdle()
val before = bounds() val before = bounds()
for (stage in listOf("Ready", "Downloading", null)) { assertEquals(0f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f)
rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Mindel", item = series.copy(ratings = listOf(LibraryRating("rt", 88.0))))) } rule.runOnIdle { state.update(state.data.copy(requestedStage = "Ready", requestActor = "Mindel")) }
rule.mainClock.advanceTimeBy(3400) assertEquals(56f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f)
rule.waitForIdle() bounds().zip(before).forEach { (after, initial) -> assertEquals(56f, after.top - initial.top, 1f) }
assertEquals("Late metadata cannot change reserved header geometry", before, bounds()) rule.runOnIdle { state.update(state.data.copy(requestedStage = null)) }
} assertEquals(before, bounds())
} }
@Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() { @Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() {
@ -67,7 +67,13 @@ class MotionSurfaceTest {
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } } rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
rule.mainClock.advanceTimeBy(2100) rule.mainClock.advanceTimeBy(2100)
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible) } rule.onNodeWithTag("title-page").assertIsFocused()
rule.runOnIdle {
assertFalse(shell.navFocused)
assertFalse(shell.pickerVisible)
assertEquals("header", state.row)
assertEquals(0, state.column)
}
rule.onRoot().performKeyInput { pressKey(Key.Back) } rule.onRoot().performKeyInput { pressKey(Key.Back) }
rule.runOnIdle { assertEquals(listOf(TitleAction.Pop), actions); assertFalse(shell.pickerVisible) } rule.runOnIdle { assertEquals(listOf(TitleAction.Pop), actions); assertFalse(shell.pickerVisible) }
} }

View file

@ -0,0 +1,100 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
/** Readiness changes move real title content continuously, without reserving an empty band. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitleReadinessMotionTest {
private val animatorScale = object : MotionDurationScale { override var scaleFactor by mutableFloatStateOf(1f) }
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
private lateinit var state: TitleState
private fun render() {
val viewer = ShellPerson("justin", "Justin", "J", false)
val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") }
val series = TitleItem("series", "A series", "tv", seasons = 1, held = true, overview = "A series overview")
val episode = TitleItem("episode", "Pilot", "episode", held = true, season = 1, episode = 1, overview = "Episode overview")
state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {}
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
rule.mainClock.advanceTimeBy(500)
rule.waitForIdle()
}
private fun positions(): List<Float> = listOf("title-readiness-slot", "title-overview", "title-actions", "title-shelf:episodes").mapIndexed { index, tag ->
val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot
if (index == 0) bounds.height else bounds.top
}
private fun frames(stage: String?, name: String, count: Int = 22): List<List<Float>> {
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-readiness-motion/$name").apply { mkdirs() }
rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Justin")); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
val values = List(count) { index ->
rule.waitForIdle()
positions().also {
rule.onRoot().captureRoboImage(File(directory, "${index.toString().padStart(2, '0')}.png").absolutePath)
rule.mainClock.advanceTimeBy(16)
rule.waitForIdle()
}
}
File(directory, "samples.csv").writeText("timeMs,slotHeightPx,overviewTopPx,actionsTopPx,episodesTopPx\n" + values.mapIndexed { index, row -> "${index * 16},${row.joinToString(",")}" }.joinToString("\n"))
return values
}
@Test fun lateReadinessExpansionAndRemovalStayMonotonicAndMoveTheWholeStack() {
render()
val collapsed = positions()
assertEquals(0f, collapsed[0], 0.1f)
rule.mainClock.autoAdvance = false
val opening = frames("Ready", "expand")
val expanded = opening.last()
assertEquals(56f, expanded[0], 0.1f)
expanded.drop(1).zip(collapsed.drop(1)).forEach { (after, before) -> assertEquals(56f, after - before, 1f) }
val closing = frames(null, "collapse")
assertEquals(collapsed, closing.last())
for ((samples, direction) in listOf(opening to 1f, closing to -1f)) {
samples.zipWithNext().forEach { (before, after) -> before.zip(after).forEach { (first, second) ->
assertTrue("Readiness content must not reverse or overshoot: $samples", (second - first) * direction >= -0.1f)
assertTrue("Height change must not jump in one frame", kotlin.math.abs(second - first) < 56f)
} }
samples.forEach { assertTrue(it[0] in 0f..56f) }
}
assertFalse(state.options.any { it.id == "seasons" })
rule.mainClock.autoAdvance = true
}
@Test fun aPinnedEpisodeShelfDoesNotBounceWhenReadinessChangesAboveIt() {
render()
rule.runOnIdle { state.focus("episodes", 0) }
rule.mainClock.advanceTimeBy(500)
rule.waitForIdle()
val pinned = positions().last()
rule.mainClock.autoAdvance = false
for (samples in listOf(frames("Ready", "pinned", count = 34), frames(null, "pinned-collapse", count = 34))) {
samples.forEach { assertEquals("A header change cannot bounce a pinned focused shelf", pinned, it.last(), 1f) }
}
assertEquals("episodes", state.row)
rule.mainClock.autoAdvance = true
}
}

View file

@ -18,7 +18,7 @@ class WolfskinImagesTest {
Surface("grid/Home/Discover/person poster", 256, 384, "w342"), Surface("grid/Home/Discover/person poster", 256, 384, "w342"),
Surface("Home tile", 400, 225, "w780"), Surface("Home tile", 400, 225, "w780"),
Surface("Home wide", 544, 306, "w780"), Surface("Home wide", 544, 306, "w780"),
Surface("episode still", 400, 280, "w780"), Surface("episode still", 448, 280, "w780"),
Surface("cast portrait", 200, 200, "w342"), Surface("cast portrait", 200, 200, "w342"),
Surface("person portrait", 240, 240, "w342"), Surface("person portrait", 240, 240, "w342"),
Surface("hero poster", 420, 630, "w342", true), Surface("hero poster", 420, 630, "w342", true),

View file

@ -1,7 +1,10 @@
{ {
"title": { "title": {
"episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: episode art height +24.4%"}, "episodeCardWidth": {"dp": 224, "px": 448, "source": "tv-round2: episode width +12%"},
"episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: preserve taller episode cards"},
"actionMenuWidth": {"dp": 260, "px": 520, "source": "tv-round2: compact title action menu"},
"seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"}, "seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"},
"posterCastGap": {"dp": 10, "px": 20, "source": "tv-round2: minimum poster-to-cast clearance"},
"lineGap": {"dp": 11, "px": 22, "source": "index.html:214"}, "lineGap": {"dp": 11, "px": 22, "source": "index.html:214"},
"lineLift": {"dp": -2, "px": -4, "source": "index.html:214"}, "lineLift": {"dp": -2, "px": -4, "source": "index.html:214"},
"overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"}, "overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"},

View file

@ -290,8 +290,11 @@
--lift-heroY: 30.0px; --lift-heroY: 30.0px;
--lift-sheetBlur: 80.0px; --lift-sheetBlur: 80.0px;
--lift-sheetX: -30.0px; --lift-sheetX: -30.0px;
--title-episodeCardWidth: 448.0px;
--title-episodeCardHeight: 280.0px; --title-episodeCardHeight: 280.0px;
--title-actionMenuWidth: 520.0px;
--title-seriesHeaderGap: 40.0px; --title-seriesHeaderGap: 40.0px;
--title-posterCastGap: 20.0px;
--title-lineGap: 22.0px; --title-lineGap: 22.0px;
--title-lineLift: -4.0px; --title-lineLift: -4.0px;
--title-overviewGap: 34.0px; --title-overviewGap: 34.0px;

View file

@ -1543,9 +1543,9 @@
"source": "feedback-2026-11-06: largest upright hero poster slot" "source": "feedback-2026-11-06: largest upright hero poster slot"
}, },
"episodeRequest": { "episodeRequest": {
"px": [400, 280], "px": [448, 280],
"decode": "ARGB_8888", "decode": "ARGB_8888",
"source": "feedback-2026-11-06: taller episode cards" "source": "tv-round2: wider episode cards at their preserved height"
}, },
"castRequest": { "castRequest": {
"px": [ "px": [

View file

@ -2,7 +2,7 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-token
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json 192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
3e2ad9a57c15c2ed4713abf383807bb567b27b38eddaaa36c00025e078864e12 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 0d69fccfbfada103268bfc38186205cf5f94daeb345a00a6aec585609e7ddb1e src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json 98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json
d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css a68f336e8b4373e97a7acaeb02383b4a40dc9f7cc6f6e5413341f14fb2d308eb tokens.css
ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json b1cf8f8caea9bfacb7994e0ba39e71ffab33452f6f5227a524ba71874b045b4d tokens.json