From 6de42dc65bf02117c1d18064481f0fdf3892fe2a Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sat, 28 Mar 2026 16:58:37 +0100 Subject: [PATCH] Fix TV detail top bar focus navigation --- .../app/content/episode/EpisodeComponents.kt | 7 +- .../app/content/episode/EpisodeScreen.kt | 153 +++++++++--------- .../app/content/movie/MovieComponents.kt | 7 +- .../purefin/app/content/movie/MovieScreen.kt | 139 ++++++++-------- .../app/content/series/SeriesComponents.kt | 18 ++- .../app/content/series/SeriesScreen.kt | 112 +++++++------ .../ui/components/MediaDetailsTopBar.kt | 28 +++- 7 files changed, 262 insertions(+), 202 deletions(-) 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 947895c..ba2f971 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 @@ -2,15 +2,18 @@ package hu.bbara.purefin.app.content.episode import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import hu.bbara.purefin.common.ui.components.MediaDetailsTopBar @Composable internal fun EpisodeTopBar( onBack: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + downFocusRequester: FocusRequester? = null ) { MediaDetailsTopBar( onBack = onBack, - modifier = modifier + modifier = modifier, + 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 90f5b33..b066b8e 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 @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -94,98 +95,104 @@ private fun EpisodeScreenInternal( playFocusRequester.requestFocus() } - LazyColumn( + Box( modifier = modifier .fillMaxSize() .background(scheme.background) ) { - item { - Box { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { MediaHero( imageUrl = episode.heroImageUrl, backgroundColor = scheme.background, heightFraction = 0.30f, modifier = Modifier.fillMaxWidth() ) - EpisodeTopBar(onBack = onBack) } - } - item { - Column(modifier = hPad) { - Text( - text = episode.title, - color = scheme.onBackground, - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - lineHeight = 38.sp - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Episode ${episode.index}", - color = scheme.onBackground, - fontSize = 14.sp, - fontWeight = FontWeight.Medium - ) - Spacer(modifier = Modifier.height(16.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - MediaMetaChip(text = episode.releaseDate) - MediaMetaChip(text = episode.rating) - MediaMetaChip(text = episode.runtime) - MediaMetaChip( - text = episode.format, - background = scheme.primary.copy(alpha = 0.2f), - border = scheme.primary.copy(alpha = 0.3f), - textColor = scheme.primary - ) - } - } - } - item { - Spacer(modifier = Modifier.height(24.dp)) - MediaSynopsis( - synopsis = episode.synopsis, - modifier = hPad - ) - } - item { - Spacer(modifier = Modifier.height(24.dp)) - Row(modifier = hPad) { - MediaResumeButton( - text = if (episode.progress == null) "Play" else "Resume", - progress = episode.progress?.div(100)?.toFloat() ?: 0f, - onClick = onPlay, - modifier = Modifier.sizeIn(maxWidth = 200.dp).focusRequester(playFocusRequester) - ) - } - } - item { - Spacer(modifier = Modifier.height(24.dp)) - MediaPlaybackSettings( - backgroundColor = scheme.surface, - foregroundColor = scheme.onSurface, - audioTrack = "ENG", - subtitles = "ENG", - modifier = hPad - ) - } - if (episode.cast.isNotEmpty()) { item { Column(modifier = hPad) { - Spacer(modifier = Modifier.height(24.dp)) Text( - text = "Cast", + text = episode.title, color = scheme.onBackground, - fontSize = 18.sp, - fontWeight = FontWeight.Bold + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + lineHeight = 38.sp + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Episode ${episode.index}", + color = scheme.onBackground, + fontSize = 14.sp, + fontWeight = FontWeight.Medium ) - Spacer(modifier = Modifier.height(12.dp)) - MediaCastRow(cast = episode.cast) Spacer(modifier = Modifier.height(16.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MediaMetaChip(text = episode.releaseDate) + MediaMetaChip(text = episode.rating) + MediaMetaChip(text = episode.runtime) + MediaMetaChip( + text = episode.format, + background = scheme.primary.copy(alpha = 0.2f), + border = scheme.primary.copy(alpha = 0.3f), + textColor = scheme.primary + ) + } + } + } + item { + Spacer(modifier = Modifier.height(24.dp)) + MediaSynopsis( + synopsis = episode.synopsis, + modifier = hPad + ) + } + item { + Spacer(modifier = Modifier.height(24.dp)) + Row(modifier = hPad) { + MediaResumeButton( + text = if (episode.progress == null) "Play" else "Resume", + progress = episode.progress?.div(100)?.toFloat() ?: 0f, + onClick = onPlay, + modifier = Modifier.sizeIn(maxWidth = 200.dp).focusRequester(playFocusRequester) + ) + } + } + item { + Spacer(modifier = Modifier.height(24.dp)) + MediaPlaybackSettings( + backgroundColor = scheme.surface, + foregroundColor = scheme.onSurface, + audioTrack = "ENG", + subtitles = "ENG", + modifier = hPad + ) + } + if (episode.cast.isNotEmpty()) { + item { + Column(modifier = hPad) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Cast", + color = scheme.onBackground, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(12.dp)) + MediaCastRow(cast = episode.cast) + Spacer(modifier = Modifier.height(16.dp)) + } } } } + EpisodeTopBar( + onBack = onBack, + 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 d887bfe..1f35c2a 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 @@ -2,15 +2,18 @@ package hu.bbara.purefin.app.content.movie import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import hu.bbara.purefin.common.ui.components.MediaDetailsTopBar @Composable internal fun MovieTopBar( onBack: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + downFocusRequester: FocusRequester? = null ) { MediaDetailsTopBar( onBack = onBack, - modifier = modifier + modifier = modifier, + 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 90ce332..cdf942c 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 @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -76,91 +77,97 @@ private fun MovieScreenInternal( playFocusRequester.requestFocus() } - LazyColumn( + Box( modifier = modifier .fillMaxSize() .background(scheme.background) ) { - item { - Box { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { MediaHero( imageUrl = movie.heroImageUrl, backgroundColor = scheme.background, heightFraction = 0.30f, modifier = Modifier.fillMaxWidth() ) - MovieTopBar(onBack = onBack) } - } - item { - Column(modifier = hPad) { - Text( - text = movie.title, - color = scheme.onBackground, - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - lineHeight = 38.sp - ) - Spacer(modifier = Modifier.height(16.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - MediaMetaChip(text = movie.year) - MediaMetaChip(text = movie.rating) - MediaMetaChip(text = movie.runtime) - MediaMetaChip( - text = movie.format, - background = scheme.primary.copy(alpha = 0.2f), - border = scheme.primary.copy(alpha = 0.3f), - textColor = scheme.primary - ) - } - } - } - item { - Spacer(modifier = Modifier.height(24.dp)) - MediaSynopsis( - synopsis = movie.synopsis, - modifier = hPad - ) - } - item { - Spacer(modifier = Modifier.height(24.dp)) - Row(modifier = hPad) { - MediaResumeButton( - text = if (movie.progress == null) "Play" else "Resume", - progress = movie.progress?.div(100)?.toFloat() ?: 0f, - onClick = onPlay, - modifier = Modifier.sizeIn(maxWidth = 200.dp).focusRequester(playFocusRequester) - ) - } - } - item { - Spacer(modifier = Modifier.height(24.dp)) - MediaPlaybackSettings( - backgroundColor = scheme.surface, - foregroundColor = scheme.onSurface, - audioTrack = movie.audioTrack, - subtitles = movie.subtitles, - modifier = hPad - ) - } - if (movie.cast.isNotEmpty()) { item { Column(modifier = hPad) { - Spacer(modifier = Modifier.height(24.dp)) Text( - text = "Cast", + text = movie.title, color = scheme.onBackground, - fontSize = 18.sp, - fontWeight = FontWeight.Bold + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + lineHeight = 38.sp ) - Spacer(modifier = Modifier.height(12.dp)) - MediaCastRow(cast = movie.cast) Spacer(modifier = Modifier.height(16.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MediaMetaChip(text = movie.year) + MediaMetaChip(text = movie.rating) + MediaMetaChip(text = movie.runtime) + MediaMetaChip( + text = movie.format, + background = scheme.primary.copy(alpha = 0.2f), + border = scheme.primary.copy(alpha = 0.3f), + textColor = scheme.primary + ) + } + } + } + item { + Spacer(modifier = Modifier.height(24.dp)) + MediaSynopsis( + synopsis = movie.synopsis, + modifier = hPad + ) + } + item { + Spacer(modifier = Modifier.height(24.dp)) + Row(modifier = hPad) { + MediaResumeButton( + text = if (movie.progress == null) "Play" else "Resume", + progress = movie.progress?.div(100)?.toFloat() ?: 0f, + onClick = onPlay, + modifier = Modifier.sizeIn(maxWidth = 200.dp).focusRequester(playFocusRequester) + ) + } + } + item { + Spacer(modifier = Modifier.height(24.dp)) + MediaPlaybackSettings( + backgroundColor = scheme.surface, + foregroundColor = scheme.onSurface, + audioTrack = movie.audioTrack, + subtitles = movie.subtitles, + modifier = hPad + ) + } + if (movie.cast.isNotEmpty()) { + item { + Column(modifier = hPad) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Cast", + color = scheme.onBackground, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(12.dp)) + MediaCastRow(cast = movie.cast) + Spacer(modifier = Modifier.height(16.dp)) + } } } } + MovieTopBar( + onBack = onBack, + 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 d132703..efb4779 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 @@ -38,6 +38,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer @@ -62,11 +64,13 @@ import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel @Composable internal fun SeriesTopBar( onBack: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + downFocusRequester: FocusRequester? = null ) { MediaDetailsTopBar( onBack = onBack, - modifier = modifier + modifier = modifier, + downFocusRequester = downFocusRequester ) } @@ -88,6 +92,7 @@ internal fun SeasonTabs( seasons: List, selectedSeason: Season?, modifier: Modifier = Modifier, + firstItemFocusRequester: FocusRequester? = null, onSelect: (Season) -> Unit ) { Row( @@ -96,11 +101,16 @@ internal fun SeasonTabs( .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(20.dp) ) { - seasons.forEach { season -> + seasons.forEachIndexed { index, season -> SeasonTab( name = season.name, isSelected = season == selectedSeason, - onSelect = { onSelect(season) } + onSelect = { onSelect(season) }, + modifier = if (index == 0 && firstItemFocusRequester != null) { + Modifier.focusRequester(firstItemFocusRequester) + } else { + Modifier + } ) } } 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 8aa79cc..af56e9e 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 @@ -16,7 +16,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -71,77 +73,85 @@ private fun SeriesScreenInternal( return series.seasons.first() } val selectedSeason = remember { mutableStateOf(getDefaultSeason()) } + val firstContentFocusRequester = remember { FocusRequester() } - LazyColumn( + Box( modifier = modifier .fillMaxSize() .background(scheme.background) ) { - item { - Box { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { MediaHero( imageUrl = series.heroImageUrl, heightFraction = 0.30f, backgroundColor = scheme.background, modifier = Modifier.fillMaxWidth() ) - SeriesTopBar(onBack = onBack) } - } - item { - Column(modifier = hPad) { - Text( - text = series.name, - color = scheme.onBackground, - fontSize = 30.sp, - fontWeight = FontWeight.Bold, - lineHeight = 36.sp - ) - Spacer(modifier = Modifier.height(16.dp)) - SeriesMetaChips(series = series) - } - } - item { - Spacer(modifier = Modifier.height(24.dp)) - MediaSynopsis( - synopsis = series.synopsis, - bodyColor = textMutedStrong, - bodyFontSize = 13.sp, - bodyLineHeight = null, - titleSpacing = 8.dp, - modifier = hPad - ) - } - item { - Spacer(modifier = Modifier.height(24.dp)) - SeasonTabs( - seasons = series.seasons, - selectedSeason = selectedSeason.value, - onSelect = { selectedSeason.value = it }, - modifier = hPad - ) - } - item { - EpisodeCarousel( - episodes = selectedSeason.value.episodes, - modifier = hPad - ) - } - if (series.cast.isNotEmpty()) { item { Column(modifier = hPad) { - Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Cast", + text = series.name, color = scheme.onBackground, - fontSize = 18.sp, - fontWeight = FontWeight.Bold + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + lineHeight = 36.sp ) - Spacer(modifier = Modifier.height(12.dp)) - CastRow(cast = series.cast) Spacer(modifier = Modifier.height(16.dp)) + SeriesMetaChips(series = series) + } + } + item { + Spacer(modifier = Modifier.height(24.dp)) + MediaSynopsis( + synopsis = series.synopsis, + bodyColor = textMutedStrong, + bodyFontSize = 13.sp, + bodyLineHeight = null, + titleSpacing = 8.dp, + modifier = hPad + ) + } + item { + Spacer(modifier = Modifier.height(24.dp)) + SeasonTabs( + seasons = series.seasons, + selectedSeason = selectedSeason.value, + firstItemFocusRequester = firstContentFocusRequester, + onSelect = { selectedSeason.value = it }, + modifier = hPad + ) + } + item { + EpisodeCarousel( + episodes = selectedSeason.value.episodes, + modifier = hPad + ) + } + if (series.cast.isNotEmpty()) { + item { + Column(modifier = hPad) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Cast", + color = scheme.onBackground, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(12.dp)) + CastRow(cast = series.cast) + Spacer(modifier = Modifier.height(16.dp)) + } } } } + SeriesTopBar( + onBack = onBack, + downFocusRequester = firstContentFocusRequester, + modifier = Modifier.align(Alignment.TopStart) + ) } } 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 c51d2c1..603acd7 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 @@ -12,6 +12,8 @@ import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.unit.dp @Composable @@ -19,8 +21,15 @@ fun MediaDetailsTopBar( onBack: () -> Unit, modifier: Modifier = Modifier, onCastClick: () -> Unit = {}, - onMoreClick: () -> Unit = {} + onMoreClick: () -> Unit = {}, + downFocusRequester: FocusRequester? = null ) { + val downModifier = if (downFocusRequester != null) { + Modifier.focusProperties { down = downFocusRequester } + } else { + Modifier + } + Row( modifier = modifier .fillMaxWidth() @@ -32,11 +41,22 @@ fun MediaDetailsTopBar( GhostIconButton( icon = Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back", - onClick = onBack + onClick = onBack, + modifier = downModifier ) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = onCastClick) - GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = onMoreClick) + GhostIconButton( + icon = Icons.Outlined.Cast, + contentDescription = "Cast", + onClick = onCastClick, + modifier = downModifier + ) + GhostIconButton( + icon = Icons.Outlined.MoreVert, + contentDescription = "More", + onClick = onMoreClick, + modifier = downModifier + ) } } }