Use BringIntoViewSpec for TV home focus alignment

This commit is contained in:
2026-04-06 18:38:20 +02:00
parent 2937ee836b
commit 32d50f750f
4 changed files with 448 additions and 99 deletions

View File

@@ -9,20 +9,24 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.tv.home.TvHomeScreen import hu.bbara.purefin.tv.home.TvHomeScreen
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.UUID import java.util.UUID
@@ -140,6 +144,157 @@ class TvHomeContentTest {
.assertIsFocused() .assertIsFocused()
} }
@Test
fun tvHomeScreen_movesFocusedSectionToTopOfContent() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = sampleNextUp(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("The Train Job")
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Arrival")
assertSectionRowAlignedToViewportTop(tvHomeLibraryRowTag(sampleLibraryId()))
}
@Test
fun tvHomeScreen_skipsMissingSectionAndAlignsLibraryToTop() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Arrival")
assertSectionRowAlignedToViewportTop(tvHomeLibraryRowTag(sampleLibraryId()))
}
@Test
fun tvHomeScreen_movesPreviousSectionToTopWhenNavigatingUp() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = sampleNextUp(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionUp)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("The Train Job")
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
}
@Test
fun tvHomeScreen_horizontalMoveDoesNotChangeAlignedSectionPosition() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = sampleNextUpRow(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
val alignedTopBefore = rowTop(TvHomeNextUpRowTag)
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionRight)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Safe")
val alignedTopAfter = rowTop(TvHomeNextUpRowTag)
assertEquals(alignedTopBefore, alignedTopAfter, 2f)
}
private fun sampleContinueWatching(): List<ContinueWatchingItem> { private fun sampleContinueWatching(): List<ContinueWatchingItem> {
return listOf( return listOf(
ContinueWatchingItem( ContinueWatchingItem(
@@ -174,10 +329,39 @@ class TvHomeContentTest {
) )
} }
private fun sampleNextUp(): List<NextUpItem> {
return listOf(
NextUpItem(
episode = sampleEpisode(
id = "66666666-6666-6666-6666-666666666666",
title = "The Train Job"
)
)
)
}
private fun sampleNextUpRow(): List<NextUpItem> {
return listOf(
NextUpItem(
episode = sampleEpisode(
id = "66666666-6666-6666-6666-666666666666",
title = "The Train Job"
)
),
NextUpItem(
episode = sampleEpisode(
id = "77777777-7777-7777-7777-777777777777",
title = "Safe",
releaseDate = "2024-02-09"
)
)
)
}
private fun sampleLibraries(): List<LibraryItem> { private fun sampleLibraries(): List<LibraryItem> {
return listOf( return listOf(
LibraryItem( LibraryItem(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"), id = sampleLibraryId(),
name = "Movies", name = "Movies",
type = CollectionType.MOVIES, type = CollectionType.MOVIES,
posterUrl = "", posterUrl = "",
@@ -187,7 +371,7 @@ class TvHomeContentTest {
} }
private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> { private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> {
val libraryId = UUID.fromString("33333333-3333-3333-3333-333333333333") val libraryId = sampleLibraryId()
return mapOf( return mapOf(
libraryId to listOf( libraryId to listOf(
@@ -204,6 +388,32 @@ class TvHomeContentTest {
) )
} }
private fun sampleEpisode(
id: String,
title: String,
releaseDate: String = "2002-09-20",
progress: Double? = 18.0,
seriesId: UUID = UUID.fromString("88888888-8888-8888-8888-888888888888"),
seasonId: UUID = UUID.fromString("99999999-9999-9999-9999-999999999999")
): Episode {
return Episode(
id = UUID.fromString(id),
seriesId = seriesId,
seasonId = seasonId,
index = 1,
title = title,
synopsis = "A crew member takes the shuttle for a spin and makes a mess.",
releaseDate = releaseDate,
rating = "16+",
runtime = "44m",
progress = progress,
watched = false,
format = "HD",
imageUrlPrefix = "https://images.unsplash.com/photo-1511497584788-876760111969",
cast = emptyList()
)
}
private fun sampleMovie( private fun sampleMovie(
id: String, id: String,
title: String, title: String,
@@ -227,4 +437,29 @@ class TvHomeContentTest {
cast = emptyList() cast = emptyList()
) )
} }
private fun sampleLibraryId(): UUID {
return UUID.fromString("33333333-3333-3333-3333-333333333333")
}
private fun assertSectionRowAlignedToViewportTop(sectionRowTag: String) {
val expectedRowTop = viewportTop() + with(composeRule.density) {
TvHomeFocusedItemTopOffset.toPx()
}
assertEquals(expectedRowTop, rowTop(sectionRowTag), 2f)
}
private fun viewportTop(): Float {
return composeRule.onNodeWithTag(TvHomeContentViewportTag, useUnmergedTree = true)
.fetchSemanticsNode()
.boundsInRoot
.top
}
private fun rowTop(sectionRowTag: String): Float {
return composeRule.onNodeWithTag(sectionRowTag, useUnmergedTree = true)
.fetchSemanticsNode()
.boundsInRoot
.top
}
} }

View File

@@ -0,0 +1,63 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package hu.bbara.purefin.tv.home.ui
import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.ui.unit.dp
import kotlin.math.abs
internal val TvHomeFocusedItemTopOffset = 56.dp
internal val TvHomeBringIntoViewTrailingSpace = 120.dp
private const val TvHomeRowPivotParentFraction = 0.3f
private const val TvHomeRowPivotChildFraction = 0f
internal fun tvHomeColumnBringIntoViewSpec(topOffsetPx: Float): BringIntoViewSpec {
return object : BringIntoViewSpec {
override fun calculateScrollDistance(
offset: Float,
size: Float,
containerSize: Float,
): Float {
val leadingEdge = offset
val trailingEdge = offset + size
val childSize = abs(trailingEdge - leadingEdge)
val childSmallerThanParent = childSize <= containerSize
val spaceAvailableToShowItem = containerSize - topOffsetPx
val targetForLeadingEdge =
if (childSmallerThanParent && spaceAvailableToShowItem < childSize) {
containerSize - childSize
} else {
topOffsetPx
}
return leadingEdge - targetForLeadingEdge
}
}
}
internal val TvHomeRowBringIntoViewSpec: BringIntoViewSpec =
object : BringIntoViewSpec {
override fun calculateScrollDistance(
offset: Float,
size: Float,
containerSize: Float,
): Float {
val leadingEdge = offset
val trailingEdge = offset + size
val childSize = abs(trailingEdge - leadingEdge)
val childSmallerThanParent = childSize <= containerSize
val initialTargetForLeadingEdge =
TvHomeRowPivotParentFraction * containerSize -
(TvHomeRowPivotChildFraction * childSize)
val spaceAvailableToShowItem = containerSize - initialTargetForLeadingEdge
val targetForLeadingEdge =
if (childSmallerThanParent && spaceAvailableToShowItem < childSize) {
containerSize - childSize
} else {
initialTargetForLeadingEdge
}
return leadingEdge - targetForLeadingEdge
}
}

View File

@@ -1,14 +1,18 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.tv.home.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -17,10 +21,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
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.Companion.FocusRequesterFactory.component1 import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2 import androidx.compose.ui.platform.testTag
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem import hu.bbara.purefin.feature.shared.home.FocusableItem
@@ -30,6 +32,7 @@ import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item" internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
internal const val TvHomeContentViewportTag = "tv-home-content-viewport"
@Composable @Composable
fun TvHomeContent( fun TvHomeContent(
@@ -52,14 +55,18 @@ fun TvHomeContent(
nextUp = nextUp nextUp = nextUp
) )
val visibleLibraries = itemRegistry.visibleLibraries val visibleLibraries = itemRegistry.visibleLibraries
val (nextUpRef, continueWatchingRef) = remember { FocusRequester.createRefs() } val hasContinueWatching = continueWatching.isNotEmpty()
val libraryRefs = remember(visibleLibraries) { val hasNextUp = nextUp.isNotEmpty()
visibleLibraries.associate { it.id to FocusRequester() } val hasLibraries = visibleLibraries.isNotEmpty()
} val hasVisibleContent = hasContinueWatching || hasNextUp || hasLibraries
val initialFocusRequester = remember { FocusRequester() }
val firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id val firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id
val initialFocusRequester = remember { FocusRequester() }
val firstAvailableItemKey = itemRegistry.firstAvailableItemId val firstAvailableItemKey = itemRegistry.firstAvailableItemId
var initialFocusApplied by remember { mutableStateOf(false) } var initialFocusApplied by remember { mutableStateOf(false) }
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
val columnBringIntoViewSpec = remember(topOffsetPx) {
tvHomeColumnBringIntoViewSpec(topOffsetPx = topOffsetPx)
}
LaunchedEffect(firstAvailableItemKey, initialFocusApplied) { LaunchedEffect(firstAvailableItemKey, initialFocusApplied) {
if (!initialFocusApplied && firstAvailableItemKey != null) { if (!initialFocusApplied && firstAvailableItemKey != null) {
@@ -69,81 +76,90 @@ fun TvHomeContent(
} }
} }
LazyColumn( CompositionLocalProvider(LocalBringIntoViewSpec provides columnBringIntoViewSpec) {
modifier = modifier LazyColumn(
.fillMaxSize() modifier = modifier
.background(scheme.background), .fillMaxSize()
contentPadding = contentPadding .background(scheme.background)
) { .testTag(TvHomeContentViewportTag),
item { contentPadding = contentPadding
Spacer(modifier = Modifier.height(8.dp)) ) {
} item {
item { Spacer(modifier = Modifier.height(8.dp))
TvContinueWatchingSection( }
items = continueWatching, if (hasContinueWatching) {
onFocusedItem = onMediaFocused, item {
onMovieSelected = onMovieSelected, TvContinueWatchingSection(
onEpisodeSelected = onEpisodeSelected, items = continueWatching,
firstItemFocusRequester = initialFocusRequester.takeIf { continueWatching.isNotEmpty() }, onFocusedItem = onMediaFocused,
firstItemTestTag = TvHomeInitialFocusTag.takeIf { continueWatching.isNotEmpty() }, onMovieSelected = onMovieSelected,
modifier = Modifier.focusRequester(continueWatchingRef) onEpisodeSelected = onEpisodeSelected,
.focusProperties { firstItemFocusRequester = initialFocusRequester,
down = nextUpRef firstItemTestTag = TvHomeInitialFocusTag,
} rowTestTag = TvHomeContinueWatchingRowTag
) )
} }
item { }
Spacer(modifier = Modifier.height(20.dp))
} if (hasContinueWatching && (hasNextUp || hasLibraries)) {
item { item {
TvNextUpSection( Spacer(modifier = Modifier.height(20.dp))
items = nextUp, }
onFocusedItem = onMediaFocused, }
onEpisodeSelected = onEpisodeSelected,
firstItemFocusRequester = initialFocusRequester if (hasNextUp) {
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() }, item {
firstItemTestTag = TvHomeInitialFocusTag TvNextUpSection(
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() }, items = nextUp,
modifier = Modifier.focusRequester(nextUpRef) onFocusedItem = onMediaFocused,
.focusProperties { onEpisodeSelected = onEpisodeSelected,
up = continueWatchingRef firstItemFocusRequester = initialFocusRequester.takeIf { !hasContinueWatching },
libraryRefs.values.firstOrNull()?.let { down = it } firstItemTestTag = TvHomeInitialFocusTag.takeIf { !hasContinueWatching },
} rowTestTag = TvHomeNextUpRowTag
) )
} }
item { }
Spacer(modifier = Modifier.height(20.dp))
} if (hasLibraries && (hasContinueWatching || hasNextUp)) {
items( item {
items = visibleLibraries, Spacer(modifier = Modifier.height(20.dp))
key = { it.id } }
) { item -> }
val ref = libraryRefs[item.id]
TvLibraryPosterSection( itemsIndexed(
title = item.name, items = visibleLibraries,
items = libraryContent[item.id] ?: emptyList(), key = { _, library -> library.id }
onFocusedItem = onMediaFocused, ) { index, library ->
onMovieSelected = onMovieSelected, TvLibraryPosterSection(
onSeriesSelected = onSeriesSelected, title = library.name,
onEpisodeSelected = onEpisodeSelected, items = libraryContent[library.id].orEmpty(),
firstItemFocusRequester = initialFocusRequester.takeIf { onFocusedItem = onMediaFocused,
continueWatching.isEmpty() && firstItemFocusRequester = initialFocusRequester.takeIf {
nextUp.isEmpty() && !hasContinueWatching &&
item.id == firstVisibleLibraryId !hasNextUp &&
}, library.id == firstVisibleLibraryId
firstItemTestTag = TvHomeInitialFocusTag.takeIf { },
continueWatching.isEmpty() && firstItemTestTag = TvHomeInitialFocusTag.takeIf {
nextUp.isEmpty() && !hasContinueWatching &&
item.id == firstVisibleLibraryId !hasNextUp &&
}, library.id == firstVisibleLibraryId
modifier = (if (ref != null) Modifier.focusRequester(ref) else Modifier) },
.focusProperties { rowTestTag = tvHomeLibraryRowTag(library.id),
up = nextUpRef onMovieSelected = onMovieSelected,
libraryRefs.values.dropWhile { it != ref }.drop(1).firstOrNull() onSeriesSelected = onSeriesSelected,
?.let { down = it } onEpisodeSelected = onEpisodeSelected
} )
)
Spacer(modifier = Modifier.height(20.dp)) if (index < visibleLibraries.lastIndex) {
Spacer(modifier = Modifier.height(20.dp))
}
}
if (hasVisibleContent) {
item {
Spacer(modifier = Modifier.height(TvHomeBringIntoViewTrailingSpace))
}
}
} }
} }
} }

View File

@@ -1,9 +1,11 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.tv.home.ui
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
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.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
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -15,12 +17,14 @@ 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.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
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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
@@ -57,6 +61,12 @@ private val TvHomeSectionsHorizontalPadding = 32.dp
private val TvHomeSectionsRowSpacing = 18.dp private val TvHomeSectionsRowSpacing = 18.dp
private val TvHomeLandscapeCardWidth = 248.dp private val TvHomeLandscapeCardWidth = 248.dp
private val TvHomePosterCardWidth = 136.dp private val TvHomePosterCardWidth = 136.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 =
"${TvHomeSectionRowTagPrefix}library-$libraryId"
@Composable @Composable
fun TvContinueWatchingSection( fun TvContinueWatchingSection(
@@ -66,16 +76,16 @@ fun TvContinueWatchingSection(
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
firstItemFocusRequester: FocusRequester? = null, firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null, firstItemTestTag: String? = null,
rowTestTag: String? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
TvSectionHeader( TvSectionHeader(
title = "Continue Watching", title = "Continue Watching",
) )
LazyRow( TvHomeSectionRow(
modifier = modifier.fillMaxWidth(), modifier = modifier,
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding), rowTestTag = rowTestTag
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
) { ) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f) val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f)
@@ -135,16 +145,16 @@ fun TvNextUpSection(
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
firstItemFocusRequester: FocusRequester? = null, firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null, firstItemTestTag: String? = null,
rowTestTag: String? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
TvSectionHeader( TvSectionHeader(
title = "Next Up", title = "Next Up",
) )
LazyRow( TvHomeSectionRow(
modifier = modifier.fillMaxWidth(), modifier = modifier,
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding), rowTestTag = rowTestTag
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
) { ) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
TvHomeLandscapeCard( TvHomeLandscapeCard(
@@ -183,6 +193,7 @@ fun TvLibraryPosterSection(
onFocusedItem: (FocusableItem) -> Unit = {}, onFocusedItem: (FocusableItem) -> Unit = {},
firstItemFocusRequester: FocusRequester? = null, firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null, firstItemTestTag: String? = null,
rowTestTag: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit,
@@ -191,10 +202,9 @@ fun TvLibraryPosterSection(
TvSectionHeader( TvSectionHeader(
title = title, title = title,
) )
LazyRow( TvHomeSectionRow(
modifier = modifier.fillMaxWidth(), modifier = modifier,
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding), rowTestTag = rowTestTag
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
) { ) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
PosterCard( PosterCard(
@@ -227,6 +237,31 @@ fun TvLibraryPosterSection(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun TvHomeSectionRow(
modifier: Modifier = Modifier,
rowTestTag: String? = null,
content: LazyListScope.() -> Unit,
) {
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
LazyRow(
modifier = modifier
.fillMaxWidth()
.then(
if (rowTestTag != null) {
Modifier.testTag(rowTestTag)
} else {
Modifier
}
),
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing),
content = content
)
}
}
@Composable @Composable
fun TvSectionHeader( fun TvSectionHeader(
title: String, title: String,