diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvHomeContentTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvHomeContentTest.kt index e2413dad..08d0f762 100644 --- a/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvHomeContentTest.kt +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvHomeContentTest.kt @@ -9,20 +9,24 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.pressKey import androidx.compose.ui.input.key.Key import androidx.compose.ui.unit.dp +import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem 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.tv.home.TvHomeScreen import hu.bbara.purefin.ui.theme.AppTheme import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType +import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import java.util.UUID @@ -140,6 +144,157 @@ class TvHomeContentTest { .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 { return listOf( ContinueWatchingItem( @@ -174,10 +329,39 @@ class TvHomeContentTest { ) } + private fun sampleNextUp(): List { + return listOf( + NextUpItem( + episode = sampleEpisode( + id = "66666666-6666-6666-6666-666666666666", + title = "The Train Job" + ) + ) + ) + } + + private fun sampleNextUpRow(): List { + 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 { return listOf( LibraryItem( - id = UUID.fromString("33333333-3333-3333-3333-333333333333"), + id = sampleLibraryId(), name = "Movies", type = CollectionType.MOVIES, posterUrl = "", @@ -187,7 +371,7 @@ class TvHomeContentTest { } private fun sampleLibraryContent(): Map> { - val libraryId = UUID.fromString("33333333-3333-3333-3333-333333333333") + val libraryId = sampleLibraryId() return mapOf( 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( id: String, title: String, @@ -227,4 +437,29 @@ class TvHomeContentTest { 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 + } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeBringIntoView.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeBringIntoView.kt new file mode 100644 index 00000000..626b682b --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeBringIntoView.kt @@ -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 + } + } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeContent.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeContent.kt index 33ed743e..156c3504 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeContent.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeContent.kt @@ -1,14 +1,18 @@ +@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) + package hu.bbara.purefin.tv.home.ui import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height 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.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -17,10 +21,8 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1 -import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2 -import androidx.compose.ui.focus.focusProperties -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.feature.shared.home.ContinueWatchingItem 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 internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item" +internal const val TvHomeContentViewportTag = "tv-home-content-viewport" @Composable fun TvHomeContent( @@ -52,14 +55,18 @@ fun TvHomeContent( nextUp = nextUp ) val visibleLibraries = itemRegistry.visibleLibraries - val (nextUpRef, continueWatchingRef) = remember { FocusRequester.createRefs() } - val libraryRefs = remember(visibleLibraries) { - visibleLibraries.associate { it.id to FocusRequester() } - } - val initialFocusRequester = remember { FocusRequester() } + val hasContinueWatching = continueWatching.isNotEmpty() + val hasNextUp = nextUp.isNotEmpty() + val hasLibraries = visibleLibraries.isNotEmpty() + val hasVisibleContent = hasContinueWatching || hasNextUp || hasLibraries val firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id + val initialFocusRequester = remember { FocusRequester() } val firstAvailableItemKey = itemRegistry.firstAvailableItemId var initialFocusApplied by remember { mutableStateOf(false) } + val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() } + val columnBringIntoViewSpec = remember(topOffsetPx) { + tvHomeColumnBringIntoViewSpec(topOffsetPx = topOffsetPx) + } LaunchedEffect(firstAvailableItemKey, initialFocusApplied) { if (!initialFocusApplied && firstAvailableItemKey != null) { @@ -69,81 +76,90 @@ fun TvHomeContent( } } - LazyColumn( - modifier = modifier - .fillMaxSize() - .background(scheme.background), - contentPadding = contentPadding - ) { - item { - Spacer(modifier = Modifier.height(8.dp)) - } - item { - TvContinueWatchingSection( - items = continueWatching, - onFocusedItem = onMediaFocused, - onMovieSelected = onMovieSelected, - onEpisodeSelected = onEpisodeSelected, - firstItemFocusRequester = initialFocusRequester.takeIf { continueWatching.isNotEmpty() }, - firstItemTestTag = TvHomeInitialFocusTag.takeIf { continueWatching.isNotEmpty() }, - modifier = Modifier.focusRequester(continueWatchingRef) - .focusProperties { - down = nextUpRef - } - ) - } - item { - Spacer(modifier = Modifier.height(20.dp)) - } - item { - TvNextUpSection( - items = nextUp, - onFocusedItem = onMediaFocused, - onEpisodeSelected = onEpisodeSelected, - firstItemFocusRequester = initialFocusRequester - .takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() }, - firstItemTestTag = TvHomeInitialFocusTag - .takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() }, - modifier = Modifier.focusRequester(nextUpRef) - .focusProperties { - up = continueWatchingRef - libraryRefs.values.firstOrNull()?.let { down = it } - } - ) - } - item { - Spacer(modifier = Modifier.height(20.dp)) - } - items( - items = visibleLibraries, - key = { it.id } - ) { item -> - val ref = libraryRefs[item.id] - TvLibraryPosterSection( - title = item.name, - items = libraryContent[item.id] ?: emptyList(), - onFocusedItem = onMediaFocused, - onMovieSelected = onMovieSelected, - onSeriesSelected = onSeriesSelected, - onEpisodeSelected = onEpisodeSelected, - firstItemFocusRequester = initialFocusRequester.takeIf { - continueWatching.isEmpty() && - nextUp.isEmpty() && - item.id == firstVisibleLibraryId - }, - firstItemTestTag = TvHomeInitialFocusTag.takeIf { - continueWatching.isEmpty() && - nextUp.isEmpty() && - item.id == firstVisibleLibraryId - }, - modifier = (if (ref != null) Modifier.focusRequester(ref) else Modifier) - .focusProperties { - up = nextUpRef - libraryRefs.values.dropWhile { it != ref }.drop(1).firstOrNull() - ?.let { down = it } - } - ) - Spacer(modifier = Modifier.height(20.dp)) + CompositionLocalProvider(LocalBringIntoViewSpec provides columnBringIntoViewSpec) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(scheme.background) + .testTag(TvHomeContentViewportTag), + contentPadding = contentPadding + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + } + if (hasContinueWatching) { + item { + TvContinueWatchingSection( + items = continueWatching, + onFocusedItem = onMediaFocused, + onMovieSelected = onMovieSelected, + onEpisodeSelected = onEpisodeSelected, + firstItemFocusRequester = initialFocusRequester, + firstItemTestTag = TvHomeInitialFocusTag, + rowTestTag = TvHomeContinueWatchingRowTag + ) + } + } + + if (hasContinueWatching && (hasNextUp || hasLibraries)) { + item { + Spacer(modifier = Modifier.height(20.dp)) + } + } + + if (hasNextUp) { + item { + TvNextUpSection( + items = nextUp, + onFocusedItem = onMediaFocused, + onEpisodeSelected = onEpisodeSelected, + firstItemFocusRequester = initialFocusRequester.takeIf { !hasContinueWatching }, + firstItemTestTag = TvHomeInitialFocusTag.takeIf { !hasContinueWatching }, + rowTestTag = TvHomeNextUpRowTag + ) + } + } + + if (hasLibraries && (hasContinueWatching || hasNextUp)) { + item { + Spacer(modifier = Modifier.height(20.dp)) + } + } + + itemsIndexed( + items = visibleLibraries, + key = { _, library -> library.id } + ) { index, library -> + TvLibraryPosterSection( + title = library.name, + items = libraryContent[library.id].orEmpty(), + onFocusedItem = onMediaFocused, + firstItemFocusRequester = initialFocusRequester.takeIf { + !hasContinueWatching && + !hasNextUp && + library.id == firstVisibleLibraryId + }, + firstItemTestTag = TvHomeInitialFocusTag.takeIf { + !hasContinueWatching && + !hasNextUp && + library.id == firstVisibleLibraryId + }, + rowTestTag = tvHomeLibraryRowTag(library.id), + onMovieSelected = onMovieSelected, + onSeriesSelected = onSeriesSelected, + onEpisodeSelected = onEpisodeSelected + ) + + if (index < visibleLibraries.lastIndex) { + Spacer(modifier = Modifier.height(20.dp)) + } + } + + if (hasVisibleContent) { + item { + Spacer(modifier = Modifier.height(TvHomeBringIntoViewTrailingSpace)) + } + } } } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeSections.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeSections.kt index 6a236add..2836b8c0 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeSections.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeSections.kt @@ -1,9 +1,11 @@ package hu.bbara.purefin.tv.home.ui import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.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.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -57,6 +61,12 @@ private val TvHomeSectionsHorizontalPadding = 32.dp private val TvHomeSectionsRowSpacing = 18.dp private val TvHomeLandscapeCardWidth = 248.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 fun TvContinueWatchingSection( @@ -66,16 +76,16 @@ fun TvContinueWatchingSection( onEpisodeSelected: (UUID, UUID, UUID) -> Unit, firstItemFocusRequester: FocusRequester? = null, firstItemTestTag: String? = null, + rowTestTag: String? = null, modifier: Modifier = Modifier ) { if (items.isEmpty()) return TvSectionHeader( title = "Continue Watching", ) - LazyRow( - modifier = modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding), - horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing) + TvHomeSectionRow( + modifier = modifier, + rowTestTag = rowTestTag ) { itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f) @@ -135,16 +145,16 @@ fun TvNextUpSection( onEpisodeSelected: (UUID, UUID, UUID) -> Unit, firstItemFocusRequester: FocusRequester? = null, firstItemTestTag: String? = null, + rowTestTag: String? = null, modifier: Modifier = Modifier ) { if (items.isEmpty()) return TvSectionHeader( title = "Next Up", ) - LazyRow( - modifier = modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding), - horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing) + TvHomeSectionRow( + modifier = modifier, + rowTestTag = rowTestTag ) { itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> TvHomeLandscapeCard( @@ -183,6 +193,7 @@ fun TvLibraryPosterSection( onFocusedItem: (FocusableItem) -> Unit = {}, firstItemFocusRequester: FocusRequester? = null, firstItemTestTag: String? = null, + rowTestTag: String? = null, modifier: Modifier = Modifier, onMovieSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit, @@ -191,10 +202,9 @@ fun TvLibraryPosterSection( TvSectionHeader( title = title, ) - LazyRow( - modifier = modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding), - horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing) + TvHomeSectionRow( + modifier = modifier, + rowTestTag = rowTestTag ) { itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> 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 fun TvSectionHeader( title: String,