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.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.MediaUiModel
import hu.bbara.purefin.ui.model.MovieUiModel
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
@Composable
@@ -43,7 +43,6 @@ fun PosterCard(
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp,
contentScale: Float = 1f,
showSecondaryText: Boolean = false,
indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp,
@@ -65,7 +64,6 @@ fun PosterCard(
modifier = modifier,
imageModifier = imageModifier,
posterWidth = posterWidth,
contentScale = contentScale,
showSecondaryText = showSecondaryText,
indicatorSize = indicatorSize,
indicatorPadding = indicatorPadding,
@@ -82,11 +80,10 @@ fun PosterCardContent(
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp,
contentScale: Float = 1f,
showSecondaryText: Boolean = false,
indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp,
onFocused: () -> Unit = {},
onFocused: () -> Unit,
focusedScale: Float = 1f,
focusedBorderWidth: Dp = 1.dp,
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.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
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.MediaUiModel
import java.util.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
@@ -46,9 +46,6 @@ fun TvHomeContent(
val hasContinueWatching = continueWatching.isNotEmpty()
val hasNextUp = nextUp.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 hasInitialFocusableItem = hasContinueWatching || hasNextUp || hasLibraryItems
val initialFocusRequester = remember { FocusRequester() }
@@ -68,7 +65,7 @@ fun TvHomeContent(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
.testTag(TvHomeContentViewportTag),
.focusRequester(initialFocusRequester),
contentPadding = contentPadding
) {
item(key = "tv-home-top-spacer") {
@@ -80,9 +77,6 @@ fun TvHomeContent(
items = continueWatching,
onFocusedItem = onMediaFocused,
onMediaSelected = onMediaSelected,
firstItemFocusRequester = initialFocusRequester,
firstItemTestTag = TvHomeInitialFocusTag,
rowTestTag = TvHomeContinueWatchingRowTag
)
}
}
@@ -93,9 +87,6 @@ fun TvHomeContent(
items = nextUp,
onFocusedItem = onMediaFocused,
onMediaSelected = onMediaSelected,
firstItemFocusRequester = initialFocusRequester.takeIf { !hasContinueWatching },
firstItemTestTag = TvHomeInitialFocusTag.takeIf { !hasContinueWatching },
rowTestTag = TvHomeNextUpRowTag
)
}
}
@@ -108,10 +99,6 @@ fun TvHomeContent(
title = library.name,
items = libraryContent[library.id].orEmpty(),
onFocusedItem = onMediaFocused,
firstItemFocusRequester = initialFocusRequester.takeIf {
!hasContinueWatching && !hasNextUp && library.id == firstLibraryWithItemsId
},
firstItemTestTag = tvHomeLibraryFirstItemTag(library.id),
onMediaSelected = onMediaSelected
)
}

View File

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