Fix TV detail screen entry focus

This commit is contained in:
2026-03-29 19:36:35 +02:00
parent cc1e1d7df9
commit 1751755f31
11 changed files with 89 additions and 18 deletions

View File

@@ -1,13 +1,18 @@
package hu.bbara.purefin.app.content.episode
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.input.key.Key
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode
@@ -16,13 +21,14 @@ import org.junit.Rule
import org.junit.Test
import java.util.UUID
@OptIn(ExperimentalTestApi::class)
class EpisodeScreenContentTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun episodeScreenContent_showsSeriesContext_andFocusesPlayButton() {
fun episodeScreenContent_showsSeriesContext_andMovesFromBackToPlayButton() {
composeRule.setContent {
AppTheme {
EpisodeScreenContent(
@@ -42,8 +48,15 @@ class EpisodeScreenContentTest {
composeRule.onNodeWithText("Episode 4").assertIsDisplayed()
composeRule.onNodeWithText("Overview").assertIsDisplayed()
composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed().assertIsFocused()
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed()
composeRule.onNodeWithText("Series").assertIsDisplayed()
composeRule.onNodeWithContentDescription("Back")
.assertIsDisplayed()
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsFocused()
}
@Test

View File

@@ -1,14 +1,18 @@
package hu.bbara.purefin.app.content.movie
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.input.key.Key
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.ui.theme.AppTheme
@@ -16,13 +20,14 @@ import org.junit.Rule
import org.junit.Test
import java.util.UUID
@OptIn(ExperimentalTestApi::class)
class MovieScreenContentTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun movieScreenContent_showsTvHeader_andFocusesPlayButton() {
fun movieScreenContent_focusesBack_thenMovesToPlayButton() {
composeRule.setContent {
AppTheme {
MovieScreenContent(
@@ -38,8 +43,14 @@ class MovieScreenContentTest {
composeRule.onNodeWithText("Blade Runner 2049").assertIsDisplayed()
composeRule.onNodeWithText("Overview").assertIsDisplayed()
composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsDisplayed().assertIsFocused()
composeRule.onNodeWithContentDescription("Back").assertIsDisplayed()
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsDisplayed()
composeRule.onNodeWithContentDescription("Back")
.assertIsDisplayed()
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsFocused()
}
private fun sampleMovie(progress: Double?): Movie {

View File

@@ -1,11 +1,16 @@
package hu.bbara.purefin.app.content.series
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.input.key.Key
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Season
@@ -15,13 +20,14 @@ import org.junit.Rule
import org.junit.Test
import java.util.UUID
@OptIn(ExperimentalTestApi::class)
class SeriesScreenContentTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() {
fun seriesScreenContent_movesFromBackToPrimaryAction_whenNextUpExists() {
composeRule.setContent {
AppTheme {
SeriesScreenContent(
@@ -37,13 +43,20 @@ class SeriesScreenContentTest {
composeRule.onNodeWithText("Severance").assertIsDisplayed()
composeRule.onNodeWithText("Overview").assertIsDisplayed()
composeRule.onNodeWithText("Up Next").assertIsDisplayed()
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed().assertIsFocused()
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed()
composeRule.onNodeWithText("Season 1").assertIsDisplayed()
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
composeRule.onNodeWithContentDescription("Back")
.assertIsDisplayed()
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
}
@Test
fun seriesScreenContent_focusesFirstSeason_whenNoPlayableEpisodeExists() {
fun seriesScreenContent_movesFromBackToFirstSeason_whenNoPlayableEpisodeExists() {
composeRule.setContent {
AppTheme {
SeriesScreenContent(
@@ -58,7 +71,14 @@ class SeriesScreenContentTest {
composeRule.onNodeWithText("Overview").assertIsDisplayed()
composeRule.onNodeWithText("Library Status").assertIsDisplayed()
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsDisplayed().assertIsFocused()
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsDisplayed()
composeRule.onNodeWithContentDescription("Back")
.assertIsDisplayed()
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsFocused()
}
private fun sampleSeriesWithEpisodes(): Series {

View File

@@ -53,12 +53,14 @@ internal fun EpisodeTopBar(
onBack: () -> Unit,
shortcut: EpisodeTopBarShortcut? = null,
modifier: Modifier = Modifier,
backFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null
) {
MediaDetailsTopBar(
onBack = onBack,
shortcut = shortcut?.let { MediaDetailsTopBarShortcut(label = it.label, onClick = it.onClick) },
modifier = modifier,
backFocusRequester = backFocusRequester,
downFocusRequester = downFocusRequester
)
}

View File

@@ -83,19 +83,22 @@ internal fun EpisodeScreenContent(
onPlay: () -> Unit,
modifier: Modifier = Modifier,
) {
val backFocusRequester = remember { FocusRequester() }
val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(episode.id) {
playFocusRequester.requestFocus()
backFocusRequester.requestFocus()
}
TvMediaDetailScaffold(
heroImageUrl = episode.heroImageUrl,
resetScrollKey = episode.id,
modifier = modifier,
topBar = {
EpisodeTopBar(
onBack = onBack,
shortcut = topBarShortcut,
backFocusRequester = backFocusRequester,
downFocusRequester = playFocusRequester,
modifier = Modifier.align(Alignment.TopStart)
)

View File

@@ -31,11 +31,13 @@ internal const val MoviePlayButtonTag = "movie-play-button"
internal fun MovieTopBar(
onBack: () -> Unit,
modifier: Modifier = Modifier,
backFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null
) {
MediaDetailsTopBar(
onBack = onBack,
modifier = modifier,
backFocusRequester = backFocusRequester,
downFocusRequester = downFocusRequester
)
}

View File

@@ -52,18 +52,21 @@ internal fun MovieScreenContent(
onPlay: () -> Unit,
modifier: Modifier = Modifier,
) {
val backFocusRequester = remember { FocusRequester() }
val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(movie.id) {
playFocusRequester.requestFocus()
backFocusRequester.requestFocus()
}
TvMediaDetailScaffold(
heroImageUrl = movie.heroImageUrl,
resetScrollKey = movie.id,
modifier = modifier,
topBar = {
MovieTopBar(
onBack = onBack,
backFocusRequester = backFocusRequester,
downFocusRequester = playFocusRequester,
modifier = Modifier.align(Alignment.TopStart)
)

View File

@@ -74,11 +74,13 @@ internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
internal fun SeriesTopBar(
onBack: () -> Unit,
modifier: Modifier = Modifier,
backFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null
) {
MediaDetailsTopBar(
onBack = onBack,
modifier = modifier,
backFocusRequester = backFocusRequester,
downFocusRequester = downFocusRequester
)
}

View File

@@ -71,23 +71,22 @@ internal fun SeriesScreenContent(
season.episodes.firstOrNull { !it.watched }
} ?: series.seasons.firstOrNull()?.episodes?.firstOrNull()
}
val backFocusRequester = remember { FocusRequester() }
val playFocusRequester = remember { FocusRequester() }
val firstContentFocusRequester = remember { FocusRequester() }
LaunchedEffect(series.id, nextUpEpisode?.id) {
if (nextUpEpisode != null) {
playFocusRequester.requestFocus()
} else {
firstContentFocusRequester.requestFocus()
}
LaunchedEffect(series.id) {
backFocusRequester.requestFocus()
}
TvMediaDetailScaffold(
heroImageUrl = series.heroImageUrl,
resetScrollKey = series.id,
modifier = modifier,
topBar = {
SeriesTopBar(
onBack = onBack,
backFocusRequester = backFocusRequester,
downFocusRequester = nextUpEpisode?.let { playFocusRequester } ?: firstContentFocusRequester,
modifier = Modifier.align(Alignment.TopStart)
)

View File

@@ -14,10 +14,12 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@@ -32,6 +34,7 @@ private val MediaDetailPanelShape = RoundedCornerShape(28.dp)
@Composable
internal fun TvMediaDetailScaffold(
heroImageUrl: String,
resetScrollKey: Any,
modifier: Modifier = Modifier,
heroHeightFraction: Float = 0.48f,
topBar: @Composable BoxScope.() -> Unit,
@@ -40,6 +43,11 @@ internal fun TvMediaDetailScaffold(
) {
val scheme = MaterialTheme.colorScheme
val contentPadding = Modifier.padding(horizontal = MediaDetailHorizontalPadding)
val listState = rememberLazyListState()
LaunchedEffect(resetScrollKey) {
listState.scrollToItem(0)
}
Box(
modifier = modifier
@@ -47,6 +55,7 @@ internal fun TvMediaDetailScaffold(
.background(scheme.background)
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
item {

View File

@@ -29,6 +29,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
@@ -49,6 +50,7 @@ internal fun MediaDetailsTopBar(
shortcut: MediaDetailsTopBarShortcut? = null,
onCastClick: () -> Unit = {},
onMoreClick: () -> Unit = {},
backFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null
) {
val downModifier = if (downFocusRequester != null) {
@@ -56,6 +58,11 @@ internal fun MediaDetailsTopBar(
} else {
Modifier
}
val backModifier = if (backFocusRequester != null) {
Modifier.focusRequester(backFocusRequester)
} else {
Modifier
}
Row(
modifier = modifier
@@ -70,7 +77,7 @@ internal fun MediaDetailsTopBar(
icon = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Back",
onClick = onBack,
modifier = downModifier
modifier = backModifier.then(downModifier)
)
if (shortcut != null) {
GhostTextButton(