diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/episode/EpisodeScreenContentTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/episode/EpisodeScreenContentTest.kt index aa1ad6a..ffb071e 100644 --- a/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/episode/EpisodeScreenContentTest.kt +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/episode/EpisodeScreenContentTest.kt @@ -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() @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 diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/movie/MovieScreenContentTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/movie/MovieScreenContentTest.kt index e7078b9..3e30f4d 100644 --- a/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/movie/MovieScreenContentTest.kt +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/movie/MovieScreenContentTest.kt @@ -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() @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 { diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/series/SeriesScreenContentTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/series/SeriesScreenContentTest.kt index 36934a3..0bbaf43 100644 --- a/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/series/SeriesScreenContentTest.kt +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/series/SeriesScreenContentTest.kt @@ -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() @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 { diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt index bb7bd1a..31d21ca 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt @@ -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 ) } diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt index 65ae788..dd18414 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt @@ -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) ) diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt index d9edae5..afa4647 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt @@ -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 ) } diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt index 6c11656..d19c895 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt @@ -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) ) diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt index 5dbb030..3611675 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt @@ -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 ) } diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt index 6551036..ef8bee1 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt @@ -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) ) diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailShell.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailShell.kt index 48962a7..a84346b 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailShell.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailShell.kt @@ -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 { diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailsTopBar.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailsTopBar.kt index ef2becd..e0614e5 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailsTopBar.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailsTopBar.kt @@ -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(