Refactor TV home media cards smaller

This commit is contained in:
2026-04-19 20:35:20 +02:00
parent 2c5585417a
commit 920b0e0e4a
5 changed files with 147 additions and 54 deletions

View File

@@ -18,12 +18,11 @@ 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.LibraryKind
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.core.ui.model.EpisodeUiModel
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.core.ui.model.MovieUiModel
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.ui.screen.home.TvHomeScreen
import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Assert.assertEquals
@@ -68,7 +67,6 @@ class TvHomeContentTest {
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
@@ -80,7 +78,6 @@ class TvHomeContentTest {
composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049")
composeRule.onNodeWithTag(TvHomeHeroProgressLabelTag).assertTextEquals("42%")
}
@Test
@@ -92,7 +89,6 @@ class TvHomeContentTest {
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatchingRow(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
@@ -126,7 +122,6 @@ class TvHomeContentTest {
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
@@ -156,7 +151,6 @@ class TvHomeContentTest {
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = sampleNextUp(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
@@ -196,7 +190,6 @@ class TvHomeContentTest {
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
@@ -228,7 +221,6 @@ class TvHomeContentTest {
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = sampleNextUp(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
@@ -266,7 +258,6 @@ class TvHomeContentTest {
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = sampleNextUpRow(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
@@ -295,11 +286,49 @@ class TvHomeContentTest {
assertEquals(alignedTopBefore, alignedTopAfter, 2f)
}
private fun sampleContinueWatching(): List<ContinueWatchingItem> {
@Test
fun tvHomeScreen_usesReducedMediaCardWidths() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
val expectedLandscapeWidth = with(composeRule.density) { 223.2.dp.toPx() }
assertEquals(expectedLandscapeWidth, nodeWidth(TvHomeInitialFocusTag), 2f)
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
val expectedPosterWidth = with(composeRule.density) { 122.4.dp.toPx() }
composeRule.onNodeWithTag(tvHomeLibraryFirstItemTag(sampleLibraryId())).assertIsDisplayed()
assertEquals(
expectedPosterWidth,
nodeWidth(tvHomeLibraryFirstItemTag(sampleLibraryId())),
2f
)
}
private fun sampleContinueWatching(): List<MediaUiModel> {
return listOf(
ContinueWatchingItem(
type = MediaKind.MOVIE,
movie = sampleMovie(
MovieUiModel(
sampleMovie(
id = "11111111-1111-1111-1111-111111111111",
title = "Blade Runner 2049",
progress = 42.0
@@ -308,19 +337,17 @@ class TvHomeContentTest {
)
}
private fun sampleContinueWatchingRow(): List<ContinueWatchingItem> {
private fun sampleContinueWatchingRow(): List<MediaUiModel> {
return listOf(
ContinueWatchingItem(
type = MediaKind.MOVIE,
movie = sampleMovie(
MovieUiModel(
sampleMovie(
id = "11111111-1111-1111-1111-111111111111",
title = "Blade Runner 2049",
progress = 42.0
)
),
ContinueWatchingItem(
type = MediaKind.MOVIE,
movie = sampleMovie(
MovieUiModel(
sampleMovie(
id = "55555555-5555-5555-5555-555555555555",
title = "Mad Max: Fury Road",
progress = 8.0
@@ -329,10 +356,10 @@ class TvHomeContentTest {
)
}
private fun sampleNextUp(): List<NextUpItem> {
private fun sampleNextUp(): List<MediaUiModel> {
return listOf(
NextUpItem(
episode = sampleEpisode(
EpisodeUiModel(
sampleEpisode(
id = "66666666-6666-6666-6666-666666666666",
title = "The Train Job"
)
@@ -340,16 +367,16 @@ class TvHomeContentTest {
)
}
private fun sampleNextUpRow(): List<NextUpItem> {
private fun sampleNextUpRow(): List<MediaUiModel> {
return listOf(
NextUpItem(
episode = sampleEpisode(
EpisodeUiModel(
sampleEpisode(
id = "66666666-6666-6666-6666-666666666666",
title = "The Train Job"
)
),
NextUpItem(
episode = sampleEpisode(
EpisodeUiModel(
sampleEpisode(
id = "77777777-7777-7777-7777-777777777777",
title = "Safe",
releaseDate = "2024-02-09"
@@ -370,14 +397,13 @@ class TvHomeContentTest {
)
}
private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> {
private fun sampleLibraryContent(): Map<UUID, List<MediaUiModel>> {
val libraryId = sampleLibraryId()
return mapOf(
libraryId to listOf(
PosterItem(
type = MediaKind.MOVIE,
movie = sampleMovie(
MovieUiModel(
sampleMovie(
id = "44444444-4444-4444-4444-444444444444",
title = "Arrival",
progress = null,
@@ -462,4 +488,11 @@ class TvHomeContentTest {
.boundsInRoot
.top
}
private fun nodeWidth(tag: String): Float {
return composeRule.onNodeWithTag(tag, useUnmergedTree = true)
.fetchSemanticsNode()
.boundsInRoot
.width
}
}

View File

@@ -18,6 +18,7 @@ fun PosterCard(
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp,
contentScale: Float = 1f,
showSecondaryText: Boolean = false,
indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp,
@@ -39,6 +40,7 @@ fun PosterCard(
modifier = modifier,
imageModifier = imageModifier,
posterWidth = posterWidth,
contentScale = contentScale,
showSecondaryText = showSecondaryText,
indicatorSize = indicatorSize,
indicatorPadding = indicatorPadding,

View File

@@ -13,7 +13,11 @@ 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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalDensity
@@ -43,8 +47,21 @@ fun TvHomeContent(
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
val hasContinueWatching = continueWatching.isNotEmpty()
val hasNextUp = nextUp.isNotEmpty()
val hasLibraryItems = libraries.any { libraryContent[it.id].orEmpty().isNotEmpty() }
val firstLibraryWithItemsId = libraries.firstOrNull {
libraryContent[it.id].orEmpty().isNotEmpty()
}?.id
val hasVisibleContent = hasContinueWatching || hasNextUp
val hasInitialFocusableItem = hasContinueWatching || hasNextUp || hasLibraryItems
val initialFocusRequester = remember { FocusRequester() }
var initialFocusApplied by remember { mutableStateOf(false) }
LaunchedEffect(hasInitialFocusableItem, initialFocusApplied) {
if (!initialFocusApplied && hasInitialFocusableItem) {
initialFocusRequester.requestFocus()
initialFocusApplied = true
}
}
CompositionLocalProvider(
LocalBringIntoViewSpec provides tvHomeColumnBringIntoViewSpec(topOffsetPx)
@@ -94,7 +111,10 @@ fun TvHomeContent(
title = library.name,
items = libraryContent[library.id].orEmpty(),
onFocusedItem = onMediaFocused,
firstItemFocusRequester = initialFocusRequester,
firstItemFocusRequester = initialFocusRequester.takeIf {
!hasContinueWatching && !hasNextUp && library.id == firstLibraryWithItemsId
},
firstItemTestTag = tvHomeLibraryFirstItemTag(library.id),
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected

View File

@@ -50,12 +50,25 @@ import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import java.util.UUID
import kotlin.math.roundToInt
private val TvHomeSectionsThumbShape = RoundedCornerShape(20.dp)
private const val TvHomeMediaCardScale = 0.9f
private val TvHomeSectionsThumbShape = RoundedCornerShape((20f * TvHomeMediaCardScale).dp)
private val TvHomeSectionsHorizontalPadding = 32.dp
private val TvHomeSectionsRowSpacing = 18.dp
private val TvHomeLandscapeCardWidth = 248.dp
private val TvHomePosterCardWidth = 136.dp
private val TvHomeLandscapeCardWidth = (248f * TvHomeMediaCardScale).dp
private val TvHomePosterCardWidth = (136f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeFocusedBorderWidth = (2f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeUnfocusedBorderWidth = (1f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeProgressBarHeight = (6f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeProgressBarPadding = (8f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeMetadataSpacing = (4f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeMetadataTopPadding = (12f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeMetadataHorizontalPadding = (4f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeTitleFontSize = (15f * TvHomeMediaCardScale).sp
private val TvHomeLandscapeSupportingFontSize = (11f * TvHomeMediaCardScale).sp
private val TvHomePosterIndicatorSize = (24f * TvHomeMediaCardScale).roundToInt()
private val TvHomePosterIndicatorPadding = (6f * TvHomeMediaCardScale).dp
internal const val TvHomeSectionRowTagPrefix = "tv-home-section-row-"
internal const val TvHomeContinueWatchingRowTag = "${TvHomeSectionRowTagPrefix}continue-watching"
internal const val TvHomeNextUpRowTag = "${TvHomeSectionRowTagPrefix}next-up"
@@ -63,6 +76,9 @@ internal const val TvHomeNextUpRowTag = "${TvHomeSectionRowTagPrefix}next-up"
internal fun tvHomeLibraryRowTag(libraryId: UUID): String =
"${TvHomeSectionRowTagPrefix}library-$libraryId"
internal fun tvHomeLibraryFirstItemTag(libraryId: UUID): String =
"${TvHomeSectionRowTagPrefix}library-item-$libraryId"
@Composable
fun TvContinueWatchingSection(
items: List<MediaUiModel>,
@@ -190,9 +206,10 @@ fun TvLibraryPosterSection(
PosterCard(
item = item,
posterWidth = TvHomePosterCardWidth,
contentScale = TvHomeMediaCardScale,
showSecondaryText = true,
indicatorSize = 24,
indicatorPadding = 6.dp,
indicatorSize = TvHomePosterIndicatorSize,
indicatorPadding = TvHomePosterIndicatorPadding,
imageModifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
@@ -296,7 +313,7 @@ private fun TvHomeLandscapeCard(
.aspectRatio(16f / 9f)
.clip(TvHomeSectionsThumbShape)
.border(
width = if (isFocused) 2.dp else 1.dp,
width = if (isFocused) TvHomeLandscapeFocusedBorderWidth else TvHomeLandscapeUnfocusedBorderWidth,
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.6f),
shape = TvHomeSectionsThumbShape
)
@@ -335,21 +352,28 @@ private fun TvHomeLandscapeCard(
foregroundColor = scheme.primary,
backgroundColor = scheme.surfaceVariant,
contentPadding = PaddingValues(0.dp),
barHeight = 6.dp,
barHeight = TvHomeLandscapeProgressBarHeight,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 8.dp, vertical = 8.dp)
.padding(
horizontal = TvHomeLandscapeProgressBarPadding,
vertical = TvHomeLandscapeProgressBarPadding
)
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(top = 12.dp, start = 4.dp, end = 4.dp)
verticalArrangement = Arrangement.spacedBy(TvHomeLandscapeMetadataSpacing),
modifier = Modifier.padding(
top = TvHomeLandscapeMetadataTopPadding,
start = TvHomeLandscapeMetadataHorizontalPadding,
end = TvHomeLandscapeMetadataHorizontalPadding
)
) {
Text(
text = title,
color = scheme.onBackground,
fontSize = 15.sp,
fontSize = TvHomeLandscapeTitleFontSize,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -358,7 +382,7 @@ private fun TvHomeLandscapeCard(
Text(
text = supporting,
color = scheme.onSurfaceVariant,
fontSize = 11.sp,
fontSize = TvHomeLandscapeSupportingFontSize,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis

View File

@@ -64,6 +64,7 @@ fun PosterCardContent(
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp,
contentScale: Float = 1f,
showSecondaryText: Boolean = false,
indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp,
@@ -74,6 +75,14 @@ fun PosterCardContent(
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape((14f * contentScale).dp)
val unfocusedBorderWidth = (1f * contentScale).dp
val scaledFocusedBorderWidth = focusedBorderWidth * contentScale
val contentTopPadding = (8f * contentScale).dp
val contentHorizontalPadding = (4f * contentScale).dp
val contentBottomPadding = (8f * contentScale).dp
val titleFontSize = (13f * contentScale).sp
val secondaryFontSize = (11f * contentScale).sp
val scale by animateFloatAsState(
targetValue = if (isFocused) focusedScale else 1f,
label = "scale"
@@ -94,11 +103,11 @@ fun PosterCardContent(
contentDescription = null,
modifier = imageModifier
.aspectRatio(2f / 3f)
.clip(RoundedCornerShape(14.dp))
.clip(shape)
.border(
width = if (isFocused) focusedBorderWidth else 1.dp,
width = if (isFocused) scaledFocusedBorderWidth else unfocusedBorderWidth,
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f),
shape = RoundedCornerShape(14.dp)
shape = shape
)
.background(scheme.surfaceVariant)
.onFocusChanged {
@@ -125,12 +134,17 @@ fun PosterCardContent(
}
}
Column(
modifier = Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 8.dp)
modifier = Modifier.padding(
top = contentTopPadding,
start = contentHorizontalPadding,
end = contentHorizontalPadding,
bottom = contentBottomPadding
)
) {
Text(
text = model.primaryText,
color = scheme.onBackground,
fontSize = 13.sp,
fontSize = titleFontSize,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -142,7 +156,7 @@ fun PosterCardContent(
Text(
text = text,
color = scheme.onSurfaceVariant,
fontSize = 11.sp,
fontSize = secondaryFontSize,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)