mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
fix(tv): streamline focus management and improve component structure in TV sections
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
) { item, onFocused, focusModifier ->
|
||||
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) },
|
||||
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,109 +118,95 @@ fun TvNextUpSection(
|
||||
title = "Next Up",
|
||||
)
|
||||
TvHomeSectionRow(
|
||||
items = items,
|
||||
onFocused = onFocusedItem,
|
||||
modifier = modifier,
|
||||
rowTestTag = rowTestTag
|
||||
) {
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
) { item, onFocused, focusModifier ->
|
||||
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) },
|
||||
imageModifier = focusModifier,
|
||||
onFocusedItem = { onFocused(item) },
|
||||
onClick = { onMediaSelected(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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 ->
|
||||
) { item, onFocused, focusModifier ->
|
||||
PosterCardContent(
|
||||
model = item,
|
||||
onClick = { onMediaSelected(item) },
|
||||
onFocused = { onFocused(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) },
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user