diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt index 9809967f..0ef13b60 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt @@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity import androidx.compose.ui.input.key.Key import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performKeyInput @@ -69,9 +70,12 @@ class TitleAsyncFocusTest { rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } } rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } 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) - 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()) rule.runOnIdle { @@ -109,9 +113,12 @@ class TitleAsyncFocusTest { } } rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } rule.waitForIdle() - assertTrue(shell.navFocused) + assertFalse(shell.navFocused) + rule.onNodeWithTag("title-page").assertIsFocused() 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 { reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead( 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 } assertNotNull("A real person reply must populate the performer destination", viewModel.person) rule.waitForIdle() - assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused) + assertFalse("The loaded person retains destination focus on its first credit", shell.navFocused) assertEquals(0, viewModel.person!!.index) press(Key.DirectionRight) assertEquals(1, viewModel.person!!.index) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index 4db5512d..37c320fa 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -90,7 +90,7 @@ class WolfskinTitleViewModel @Inject constructor( 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 && - 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) && !destination.startsWith("person/") && fixture == null) { loadJob?.cancel() diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index 2cbca740..fc3b8f93 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -77,7 +77,7 @@ Row reveals are grouped rather than the browser prototype's per-card staging: th 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 **3** bounded owners (outgoing art and two copy blocks) above an opaque artwork base. Interruptions retain the painted composite without resetting alpha; each outgoing owner keeps its own original 320 ms artwork / 200 ms copy lifetime rather than restarting with later presses. 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 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 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. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index 4a79a147..fa87184b 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -54,7 +54,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi val view = shell.view LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt } 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 else when (readShellKey(event)) { ShellKey.Down -> true @@ -121,7 +121,6 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset } } LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) { - if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab) val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0) val y = rowOffsets[index] val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt index 12d6ddad..7e648d28 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot @@ -67,7 +68,13 @@ class MotionSurfaceTest { rule.setContent { WolfskinTheme { TitleSurface(state, shell) } } rule.mainClock.advanceTimeBy(2100) 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.runOnIdle { assertEquals(listOf(TitleAction.Pop), actions); assertFalse(shell.pickerVisible) } }