Fix TV detail top bar focus navigation

This commit is contained in:
2026-03-28 16:58:37 +01:00
parent cfff7c6403
commit 6de42dc65b
7 changed files with 262 additions and 202 deletions

View File

@@ -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
)
}

View File

@@ -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)
)
}
}

View File

@@ -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
)
}

View File

@@ -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)
)
}
}

View File

@@ -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<Season>,
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
}
)
}
}

View File

@@ -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<Season>(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)
)
}
}

View File

@@ -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
)
}
}
}