fix(tv): streamline focus management and improve component structure in TV sections

This commit is contained in:
2026-05-01 19:35:55 +02:00
parent 72fea00444
commit 3b95185f98
3 changed files with 90 additions and 138 deletions

View File

@@ -29,12 +29,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.model.EpisodeUiModel import hu.bbara.purefin.ui.model.EpisodeUiModel
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.model.MovieUiModel import hu.bbara.purefin.ui.model.MovieUiModel
import hu.bbara.purefin.ui.model.SeriesUiModel import hu.bbara.purefin.ui.model.SeriesUiModel
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import java.util.UUID import java.util.UUID
@Composable @Composable
@@ -43,7 +43,6 @@ fun PosterCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier, imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp, posterWidth: Dp = 144.dp,
contentScale: Float = 1f,
showSecondaryText: Boolean = false, showSecondaryText: Boolean = false,
indicatorSize: Int = 28, indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp, indicatorPadding: Dp = 8.dp,
@@ -65,7 +64,6 @@ fun PosterCard(
modifier = modifier, modifier = modifier,
imageModifier = imageModifier, imageModifier = imageModifier,
posterWidth = posterWidth, posterWidth = posterWidth,
contentScale = contentScale,
showSecondaryText = showSecondaryText, showSecondaryText = showSecondaryText,
indicatorSize = indicatorSize, indicatorSize = indicatorSize,
indicatorPadding = indicatorPadding, indicatorPadding = indicatorPadding,
@@ -82,11 +80,10 @@ fun PosterCardContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier, imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp, posterWidth: Dp = 144.dp,
contentScale: Float = 1f,
showSecondaryText: Boolean = false, showSecondaryText: Boolean = false,
indicatorSize: Int = 28, indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp, indicatorPadding: Dp = 8.dp,
onFocused: () -> Unit = {}, onFocused: () -> Unit,
focusedScale: Float = 1f, focusedScale: Float = 1f,
focusedBorderWidth: Dp = 1.dp, focusedBorderWidth: Dp = 1.dp,
focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f) focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f)

View File

@@ -20,11 +20,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.model.LibraryUiModel import hu.bbara.purefin.ui.model.LibraryUiModel
import hu.bbara.purefin.ui.model.MediaUiModel
import java.util.UUID import java.util.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item" internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
@@ -46,9 +46,6 @@ fun TvHomeContent(
val hasContinueWatching = continueWatching.isNotEmpty() val hasContinueWatching = continueWatching.isNotEmpty()
val hasNextUp = nextUp.isNotEmpty() val hasNextUp = nextUp.isNotEmpty()
val hasLibraryItems = libraries.any { libraryContent[it.id].orEmpty().isNotEmpty() } val hasLibraryItems = libraries.any { libraryContent[it.id].orEmpty().isNotEmpty() }
val firstLibraryWithItemsId = libraries.firstOrNull {
libraryContent[it.id].orEmpty().isNotEmpty()
}?.id
val hasVisibleContent = hasContinueWatching || hasNextUp val hasVisibleContent = hasContinueWatching || hasNextUp
val hasInitialFocusableItem = hasContinueWatching || hasNextUp || hasLibraryItems val hasInitialFocusableItem = hasContinueWatching || hasNextUp || hasLibraryItems
val initialFocusRequester = remember { FocusRequester() } val initialFocusRequester = remember { FocusRequester() }
@@ -68,7 +65,7 @@ fun TvHomeContent(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(scheme.background) .background(scheme.background)
.testTag(TvHomeContentViewportTag), .focusRequester(initialFocusRequester),
contentPadding = contentPadding contentPadding = contentPadding
) { ) {
item(key = "tv-home-top-spacer") { item(key = "tv-home-top-spacer") {
@@ -80,9 +77,6 @@ fun TvHomeContent(
items = continueWatching, items = continueWatching,
onFocusedItem = onMediaFocused, onFocusedItem = onMediaFocused,
onMediaSelected = onMediaSelected, onMediaSelected = onMediaSelected,
firstItemFocusRequester = initialFocusRequester,
firstItemTestTag = TvHomeInitialFocusTag,
rowTestTag = TvHomeContinueWatchingRowTag
) )
} }
} }
@@ -93,9 +87,6 @@ fun TvHomeContent(
items = nextUp, items = nextUp,
onFocusedItem = onMediaFocused, onFocusedItem = onMediaFocused,
onMediaSelected = onMediaSelected, onMediaSelected = onMediaSelected,
firstItemFocusRequester = initialFocusRequester.takeIf { !hasContinueWatching },
firstItemTestTag = TvHomeInitialFocusTag.takeIf { !hasContinueWatching },
rowTestTag = TvHomeNextUpRowTag
) )
} }
} }
@@ -108,10 +99,6 @@ fun TvHomeContent(
title = library.name, title = library.name,
items = libraryContent[library.id].orEmpty(), items = libraryContent[library.id].orEmpty(),
onFocusedItem = onMediaFocused, onFocusedItem = onMediaFocused,
firstItemFocusRequester = initialFocusRequester.takeIf {
!hasContinueWatching && !hasNextUp && library.id == firstLibraryWithItemsId
},
firstItemTestTag = tvHomeLibraryFirstItemTag(library.id),
onMediaSelected = onMediaSelected onMediaSelected = onMediaSelected
) )
} }

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -17,9 +18,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
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
@@ -28,26 +29,27 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.bar.MediaProgressBar import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.card.PosterCardContent import hu.bbara.purefin.ui.common.card.PosterCardContent
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.model.MediaUiModel
import java.util.UUID import java.util.UUID
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -69,7 +71,6 @@ private val TvHomeLandscapeSupportingFontSize = (11f * TvHomeMediaCardScale).sp
private val TvHomePosterIndicatorSize = (24f * TvHomeMediaCardScale).roundToInt() private val TvHomePosterIndicatorSize = (24f * TvHomeMediaCardScale).roundToInt()
private val TvHomePosterIndicatorPadding = (6f * TvHomeMediaCardScale).dp private val TvHomePosterIndicatorPadding = (6f * TvHomeMediaCardScale).dp
internal const val TvHomeSectionRowTagPrefix = "tv-home-section-row-" internal const val TvHomeSectionRowTagPrefix = "tv-home-section-row-"
internal const val TvHomeContinueWatchingRowTag = "${TvHomeSectionRowTagPrefix}continue-watching"
internal const val TvHomeNextUpRowTag = "${TvHomeSectionRowTagPrefix}next-up" internal const val TvHomeNextUpRowTag = "${TvHomeSectionRowTagPrefix}next-up"
internal fun tvHomeLibraryRowTag(libraryId: UUID): String = internal fun tvHomeLibraryRowTag(libraryId: UUID): String =
@@ -81,57 +82,35 @@ internal fun tvHomeLibraryFirstItemTag(libraryId: UUID): String =
@Composable @Composable
fun TvContinueWatchingSection( fun TvContinueWatchingSection(
items: List<MediaUiModel>, items: List<MediaUiModel>,
onFocusedItem: (MediaUiModel) -> Unit = {}, onFocusedItem: (MediaUiModel) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
rowTestTag: String? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
TvSectionHeader(
title = "Continue Watching",
)
TvHomeSectionRow( TvHomeSectionRow(
items = items,
onFocused = onFocusedItem,
modifier = modifier, modifier = modifier,
rowTestTag = rowTestTag ) { item, onFocused, focusModifier ->
) { TvHomeLandscapeCard(
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> title = item.primaryText,
TvHomeLandscapeCard( supporting = item.secondaryText,
title = item.primaryText, imageUrl = item.primaryImageUrl,
supporting = item.secondaryText, progress = item.progress ?: 0f,
imageUrl = item.primaryImageUrl, imageModifier = focusModifier,
progress = item.progress ?: 0f, onFocusedItem = {
imageModifier = Modifier onFocused(item)
.then( },
if (index == 0 && firstItemFocusRequester != null) { onClick = { onMediaSelected(item) }
Modifier.focusRequester(firstItemFocusRequester) )
} else {
Modifier
}
)
.then(
if (index == 0 && firstItemTestTag != null) {
Modifier.testTag(firstItemTestTag)
} else {
Modifier
}
),
onFocusedItem = { onFocusedItem(item) },
onClick = { onMediaSelected(item) }
)
}
} }
} }
@Composable @Composable
fun TvNextUpSection( fun TvNextUpSection(
items: List<MediaUiModel>, items: List<MediaUiModel>,
onFocusedItem: (MediaUiModel) -> Unit = {}, onFocusedItem: (MediaUiModel) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
rowTestTag: String? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
@@ -139,33 +118,18 @@ fun TvNextUpSection(
title = "Next Up", title = "Next Up",
) )
TvHomeSectionRow( TvHomeSectionRow(
items = items,
onFocused = onFocusedItem,
modifier = modifier, modifier = modifier,
rowTestTag = rowTestTag ) { item, onFocused, focusModifier ->
) { TvHomeLandscapeCard(
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> title = item.primaryText,
TvHomeLandscapeCard( supporting = item.secondaryText,
title = item.primaryText, imageUrl = item.primaryImageUrl,
supporting = item.secondaryText, imageModifier = focusModifier,
imageUrl = item.primaryImageUrl, onFocusedItem = { onFocused(item) },
imageModifier = Modifier onClick = { onMediaSelected(item) }
.then( )
if (index == 0 && firstItemFocusRequester != null) {
Modifier.focusRequester(firstItemFocusRequester)
} else {
Modifier
}
)
.then(
if (index == 0 && firstItemTestTag != null) {
Modifier.testTag(firstItemTestTag)
} else {
Modifier
}
),
onFocusedItem = { onFocusedItem(item) },
onClick = { onMediaSelected(item) }
)
}
} }
} }
@@ -173,74 +137,75 @@ fun TvNextUpSection(
fun TvLibraryPosterSection( fun TvLibraryPosterSection(
title: String, title: String,
items: List<MediaUiModel>, items: List<MediaUiModel>,
onFocusedItem: (MediaUiModel) -> Unit = {}, onFocusedItem: (MediaUiModel) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
rowTestTag: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
) { ) {
if (items.isEmpty()) return
TvSectionHeader( TvSectionHeader(
title = title, title = title,
) )
TvHomeSectionRow( TvHomeSectionRow(
items = items,
onFocused = onFocusedItem,
modifier = modifier, modifier = modifier,
rowTestTag = rowTestTag ) { item, onFocused, focusModifier ->
) { PosterCardContent(
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> model = item,
PosterCardContent( onClick = { onMediaSelected(item) },
model = item, onFocused = { onFocused(item) },
onClick = { onMediaSelected(item) }, posterWidth = TvHomePosterCardWidth,
posterWidth = TvHomePosterCardWidth, showSecondaryText = true,
contentScale = TvHomeMediaCardScale, indicatorSize = TvHomePosterIndicatorSize,
showSecondaryText = true, indicatorPadding = TvHomePosterIndicatorPadding,
indicatorSize = TvHomePosterIndicatorSize, imageModifier = focusModifier,
indicatorPadding = TvHomePosterIndicatorPadding, focusedScale = 1.07f,
imageModifier = Modifier focusedBorderWidth = 2.dp
.then( )
if (index == 0 && firstItemFocusRequester != null) {
Modifier.focusRequester(firstItemFocusRequester)
} else {
Modifier
}
)
.then(
if (index == 0 && firstItemTestTag != null) {
Modifier.testTag(firstItemTestTag)
} else {
Modifier
}
),
onFocused = { onFocusedItem(item) },
focusedScale = 1.07f,
focusedBorderWidth = 2.dp
)
}
} }
} }
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun TvHomeSectionRow( private fun TvHomeSectionRow(
items: List<MediaUiModel>,
onFocused: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
rowTestTag: String? = null, childComponent: @Composable (
content: LazyListScope.() -> Unit, item: MediaUiModel,
onFocused: (MediaUiModel) -> Unit,
focusModifier: Modifier,
) -> Unit,
) { ) {
val listState = rememberLazyListState()
var focusedItemId by rememberSaveable { mutableStateOf(items.first().id) }
val rowScopedFocusRequester = remember { FocusRequester() }
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) { CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
LazyRow( LazyRow(
state = listState,
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.then( .focusRestorer(rowScopedFocusRequester)
if (rowTestTag != null) { .focusGroup(),
Modifier.testTag(rowTestTag)
} else {
Modifier
}
),
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding), contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing), horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
content = content ) {
) itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
val focusModifier =
if (item.id == focusedItemId) Modifier.focusRequester(rowScopedFocusRequester) else Modifier
childComponent(
item,
{
focusedItemId = item.id
onFocused(item)
},
focusModifier
)
}
}
} }
} }
@@ -279,7 +244,10 @@ private fun TvHomeLandscapeCard(
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.055f else 1f, label = "tv-home-landscape-scale") val scale by animateFloatAsState(
targetValue = if (isFocused) 1.055f else 1f,
label = "tv-home-landscape-scale"
)
val cardWidth = TvHomeLandscapeCardWidth val cardWidth = TvHomeLandscapeCardWidth
Column( Column(