From 920b0e0e4a201400ecf76bd5c7d2c0eb79ee88a0 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 19 Apr 2026 20:35:20 +0200 Subject: [PATCH] Refactor TV home media cards smaller --- .../home/components/TvHomeContentTest.kt | 103 ++++++++++++------ .../purefin/ui/common/card/PosterCard.kt | 2 + .../screen/home/components/TvHomeContent.kt | 22 +++- .../screen/home/components/TvHomeSections.kt | 48 ++++++-- .../ui/common/card/PosterCardContent.kt | 26 ++++- 5 files changed, 147 insertions(+), 54 deletions(-) diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/home/components/TvHomeContentTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/home/components/TvHomeContentTest.kt index e2b04c3a..2553a2e4 100644 --- a/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/home/components/TvHomeContentTest.kt +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/home/components/TvHomeContentTest.kt @@ -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 { + @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 { 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 { + private fun sampleContinueWatchingRow(): List { 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 { + private fun sampleNextUp(): List { 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 { + private fun sampleNextUpRow(): List { 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> { + private fun sampleLibraryContent(): Map> { 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 + } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt index d73f70a7..9084f4f4 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt @@ -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, diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvHomeContent.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvHomeContent.kt index d6995869..3bc31015 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvHomeContent.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvHomeContent.kt @@ -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 diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvHomeSections.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvHomeSections.kt index 615be76a..e825ab9e 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvHomeSections.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/home/components/TvHomeSections.kt @@ -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, @@ -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 diff --git a/core/ui/src/main/java/hu/bbara/purefin/ui/common/card/PosterCardContent.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/card/PosterCardContent.kt index 289447a3..be86bf10 100644 --- a/core/ui/src/main/java/hu/bbara/purefin/ui/common/card/PosterCardContent.kt +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/card/PosterCardContent.kt @@ -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 )