Keep title destination focus and preserve ready catalogue refreshes
This commit is contained in:
parent
98dd54e03b
commit
f9bd630ea7
5 changed files with 23 additions and 10 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -121,7 +121,6 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
|
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
|
||||||
} }
|
} }
|
||||||
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)
|
|
||||||
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() }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -67,7 +68,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) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue