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.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user