mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Use BringIntoViewSpec for TV home focus alignment
This commit is contained in:
@@ -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<ContinueWatchingItem> {
|
||||
return listOf(
|
||||
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> {
|
||||
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<UUID, List<PosterItem>> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user