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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,23 +71,22 @@ internal fun SeriesScreenContent(
season.episodes.firstOrNull { !it.watched } season.episodes.firstOrNull { !it.watched }
} ?: series.seasons.firstOrNull()?.episodes?.firstOrNull() } ?: series.seasons.firstOrNull()?.episodes?.firstOrNull()
} }
val backFocusRequester = remember { FocusRequester() }
val playFocusRequester = remember { FocusRequester() } val playFocusRequester = remember { FocusRequester() }
val firstContentFocusRequester = remember { FocusRequester() } val firstContentFocusRequester = remember { FocusRequester() }
LaunchedEffect(series.id, nextUpEpisode?.id) { LaunchedEffect(series.id) {
if (nextUpEpisode != null) { backFocusRequester.requestFocus()
playFocusRequester.requestFocus()
} else {
firstContentFocusRequester.requestFocus()
}
} }
TvMediaDetailScaffold( TvMediaDetailScaffold(
heroImageUrl = series.heroImageUrl, heroImageUrl = series.heroImageUrl,
resetScrollKey = series.id,
modifier = modifier, modifier = modifier,
topBar = { topBar = {
SeriesTopBar( SeriesTopBar(
onBack = onBack, onBack = onBack,
backFocusRequester = backFocusRequester,
downFocusRequester = nextUpEpisode?.let { playFocusRequester } ?: firstContentFocusRequester, downFocusRequester = nextUpEpisode?.let { playFocusRequester } ?: firstContentFocusRequester,
modifier = Modifier.align(Alignment.TopStart) 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.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Brush import androidx.compose.ui.graphics.Brush
@@ -32,6 +34,7 @@ private val MediaDetailPanelShape = RoundedCornerShape(28.dp)
@Composable @Composable
internal fun TvMediaDetailScaffold( internal fun TvMediaDetailScaffold(
heroImageUrl: String, heroImageUrl: String,
resetScrollKey: Any,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
heroHeightFraction: Float = 0.48f, heroHeightFraction: Float = 0.48f,
topBar: @Composable BoxScope.() -> Unit, topBar: @Composable BoxScope.() -> Unit,
@@ -40,6 +43,11 @@ internal fun TvMediaDetailScaffold(
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val contentPadding = Modifier.padding(horizontal = MediaDetailHorizontalPadding) val contentPadding = Modifier.padding(horizontal = MediaDetailHorizontalPadding)
val listState = rememberLazyListState()
LaunchedEffect(resetScrollKey) {
listState.scrollToItem(0)
}
Box( Box(
modifier = modifier modifier = modifier
@@ -47,6 +55,7 @@ internal fun TvMediaDetailScaffold(
.background(scheme.background) .background(scheme.background)
) { ) {
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
item { item {

View File

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