Compare commits

...

5 Commits

16 changed files with 289 additions and 261 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

@@ -1,25 +1,23 @@
package hu.bbara.purefin.ui.screen.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.ui.screen.home.components.TvFocusedItemHero
import hu.bbara.purefin.ui.screen.home.components.TvHomeContent
import hu.bbara.purefin.ui.screen.home.components.rememberTvHomeHeroState
import java.util.UUID
private const val TvHomeHeroHeightFraction = 0.32f
private val TvHomeMinHeroHeight = 160.dp
private val TvHomeMaxHeroHeight = 200.dp
@Composable
fun TvHomeScreen(
libraries: List<LibraryItem>,
@@ -32,39 +30,29 @@ fun TvHomeScreen(
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val heroState = rememberTvHomeHeroState(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp
)
val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) }
BoxWithConstraints(
Surface(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
) {
val heroHeight = heroState.focusedHero?.let {
(maxHeight * TvHomeHeroHeightFraction)
.coerceIn(TvHomeMinHeroHeight, TvHomeMaxHeroHeight)
}
Column(
modifier = Modifier.fillMaxSize()
) {
heroState.focusedHero?.let { hero ->
TvFocusedItemHero(
item = hero,
height = heroHeight ?: TvHomeMinHeroHeight
)
}
TvFocusedItemHero(
item = focusedMediaUiModel.value,
height = 220.dp
)
TvHomeContent(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
//TODO implement it by throwing out complex TvHomeHeroState shit ass code. Fuck ai btw
onMediaFocused = {},
onMediaFocused = {
focusedMediaUiModel.value = it
},
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected,

View File

@@ -45,7 +45,7 @@ internal fun TvFocusedItemHero(
.background(scheme.background)
) {
Crossfade(
targetState = item.imageUrl,
targetState = item.backdropImageUrl,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-background"
) { imageUrl ->

View File

@@ -6,7 +6,7 @@ import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.ui.unit.dp
import kotlin.math.abs
internal val TvHomeFocusedItemTopOffset = 56.dp
internal val TvHomeFocusedItemTopOffset = 42.dp
internal val TvHomeBringIntoViewTrailingSpace = 120.dp
private const val TvHomeRowPivotParentFraction = 0.3f

View File

@@ -18,7 +18,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.platform.LocalDensity
@@ -45,35 +44,28 @@ fun TvHomeContent(
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val itemRegistry = rememberTvHomeItemRegistry(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp
)
val visibleLibraries = itemRegistry.visibleLibraries
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
val hasContinueWatching = continueWatching.isNotEmpty()
val hasNextUp = nextUp.isNotEmpty()
val hasLibraries = visibleLibraries.isNotEmpty()
val hasVisibleContent = hasContinueWatching || hasNextUp || hasLibraries
val firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id
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() }
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) {
withFrameNanos { }
LaunchedEffect(hasInitialFocusableItem, initialFocusApplied) {
if (!initialFocusApplied && hasInitialFocusableItem) {
initialFocusRequester.requestFocus()
initialFocusApplied = true
}
}
CompositionLocalProvider(LocalBringIntoViewSpec provides columnBringIntoViewSpec) {
CompositionLocalProvider(
LocalBringIntoViewSpec provides tvHomeColumnBringIntoViewSpec(topOffsetPx)
) {
LazyColumn(
modifier = modifier
.fillMaxSize()
@@ -98,12 +90,6 @@ fun TvHomeContent(
}
}
if (hasContinueWatching && (hasNextUp || hasLibraries)) {
item(key = "tv-home-post-continue-gap") {
Spacer(modifier = Modifier.height(20.dp))
}
}
if (hasNextUp) {
item(key = "tv-home-next-up") {
TvNextUpSection(
@@ -117,39 +103,22 @@ fun TvHomeContent(
}
}
if (hasLibraries && (hasContinueWatching || hasNextUp)) {
item(key = "tv-home-post-next-up-gap") {
Spacer(modifier = Modifier.height(20.dp))
}
}
itemsIndexed(
items = visibleLibraries,
items = libraries,
key = { _, library -> library.id }
) { index, library ->
) { _, library ->
TvLibraryPosterSection(
title = library.name,
items = libraryContent[library.id].orEmpty(),
onFocusedItem = onMediaFocused,
firstItemFocusRequester = initialFocusRequester.takeIf {
!hasContinueWatching &&
!hasNextUp &&
library.id == firstVisibleLibraryId
!hasContinueWatching && !hasNextUp && library.id == firstLibraryWithItemsId
},
firstItemTestTag = TvHomeInitialFocusTag.takeIf {
!hasContinueWatching &&
!hasNextUp &&
library.id == firstVisibleLibraryId
},
rowTestTag = tvHomeLibraryRowTag(library.id),
firstItemTestTag = tvHomeLibraryFirstItemTag(library.id),
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected
)
if (index < visibleLibraries.lastIndex) {
Spacer(modifier = Modifier.height(20.dp))
}
}
if (hasVisibleContent) {

View File

@@ -1,111 +0,0 @@
package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.feature.browse.home.LibraryItem
import java.util.UUID
//TODO throw this out and simplify it.
internal data class TvHomeItemRegistry(
val visibleLibraries: List<LibraryItem>,
private val libraryContent: Map<UUID, List<MediaUiModel>>,
private val continueWatching: List<MediaUiModel>,
private val nextUp: List<MediaUiModel>,
val firstAvailableItemId: UUID?,
) {
val firstAvailableItem: MediaUiModel?
get() = firstAvailableItemId?.let(::itemById)
fun itemById(id: UUID): MediaUiModel? {
return continueWatching.firstOrNull { it.id == id }
?: nextUp.firstOrNull { it.id == id }
?: visibleLibraries.asSequence()
.mapNotNull { library -> libraryContent[library.id] }
.flatten()
.firstOrNull { it.id == id }
}
}
internal class TvHomeHeroState(
val focusedHero: MediaUiModel?,
val onMediaFocused: (FocusableItem) -> Unit,
)
@Composable
internal fun rememberTvHomeItemRegistry(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<MediaUiModel>>,
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,
): TvHomeItemRegistry {
return remember(libraries, libraryContent, continueWatching, nextUp) {
createTvHomeItemRegistry(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp
)
}
}
internal fun createTvHomeItemRegistry(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<MediaUiModel>>,
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,
): TvHomeItemRegistry {
val visibleLibraries = libraries.filter { libraryContent[it.id]?.isEmpty() != true }
val firstAvailableItemId = continueWatching.firstOrNull()?.id
?: nextUp.firstOrNull()?.id
?: visibleLibraries.firstOrNull()?.id?.let { libraryId ->
libraryContent[libraryId]?.firstOrNull()?.id
}
return TvHomeItemRegistry(
visibleLibraries = visibleLibraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
firstAvailableItemId = firstAvailableItemId
)
}
@Composable
internal fun rememberTvHomeHeroState(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<MediaUiModel>>,
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,
): TvHomeHeroState {
val itemRegistry = rememberTvHomeItemRegistry(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp
)
var focusedItemId by remember { mutableStateOf<UUID?>(null) }
val focusedItem = remember(focusedItemId, itemRegistry) {
focusedItemId?.let(itemRegistry::itemById)
}
val focusedHero = remember(focusedItem, itemRegistry) {
(focusedItem ?: itemRegistry.firstAvailableItem)
}
val onMediaFocused: (FocusableItem) -> Unit = remember {
{ item ->
focusedItemId = item.id
}
}
return remember(focusedHero, onMediaFocused) {
TvHomeHeroState(
focusedHero = focusedHero,
onMediaFocused = onMediaFocused
)
}
}

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>,
@@ -86,7 +102,7 @@ fun TvContinueWatchingSection(
TvHomeLandscapeCard(
title = item.primaryText,
supporting = item.secondaryText,
imageUrl = item.imageUrl,
imageUrl = item.primaryImageUrl,
progress = item.progress ?: 0f,
imageModifier = Modifier
.then(
@@ -138,7 +154,7 @@ fun TvNextUpSection(
TvHomeLandscapeCard(
title = item.primaryText,
supporting = item.secondaryText,
imageUrl = item.imageUrl,
imageUrl = item.primaryImageUrl,
imageModifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
@@ -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

@@ -0,0 +1,49 @@
package hu.bbara.purefin.ui.screen.home.components
import org.junit.Assert.assertEquals
import org.junit.Test
class TvHomeBringIntoViewSpecTest {
private val topOffsetPx = 56f
private val spec = tvHomeColumnBringIntoViewSpec(topOffsetPx)
@Test
fun returnsZero_whenFocusedItemAlreadyAtTopOffset() {
assertEquals(
0f,
spec.calculateScrollDistance(
offset = topOffsetPx,
size = 52f,
containerSize = 240f
),
0.0001f
)
}
@Test
fun scrollsDownToTopOffset_whenFocusedItemIsBelowTarget() {
assertEquals(
64f,
spec.calculateScrollDistance(
offset = 120f,
size = 52f,
containerSize = 240f
),
0.0001f
)
}
@Test
fun scrollsUpToTopOffset_whenFocusedItemIsAboveTarget() {
assertEquals(
-32f,
spec.calculateScrollDistance(
offset = 24f,
size = 52f,
containerSize = 240f
),
0.0001f
)
}
}

View File

@@ -63,7 +63,7 @@ internal fun ContinueWatchingCard(
.background(scheme.surfaceContainer)
) {
PurefinAsyncImage(
model = item.imageUrl,
model = item.primaryImageUrl,
contentDescription = item.primaryText,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop

View File

@@ -66,7 +66,7 @@ internal fun HomeBrowseCard(
.background(scheme.surface)
) {
PurefinAsyncImage(
model = uiModel.imageUrl,
model = uiModel.primaryImageUrl,
contentDescription = uiModel.primaryText,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop

View File

@@ -58,7 +58,7 @@ internal fun NextUpCard(
.background(scheme.surface)
) {
PurefinAsyncImage(
model = uiModel.imageUrl,
model = uiModel.primaryImageUrl,
contentDescription = uiModel.primaryText,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop

View File

@@ -12,7 +12,8 @@ sealed interface MediaUiModel {
val primaryText: String
val secondaryText: String
val description: String
val imageUrl: String
val primaryImageUrl: String
val backdropImageUrl: String
val progress: Float?
get() = null
val watched: Boolean
@@ -24,7 +25,21 @@ class MovieUiModel: MediaUiModel {
override val primaryText: String
override val secondaryText: String
override val description: String
override val imageUrl: String
private val prefixImageUrl: String
override val primaryImageUrl: String
get() {
return ImageUrlBuilder.finishImageUrl(
prefixImageUrl = prefixImageUrl,
artworkKind = ArtworkKind.PRIMARY
)
}
override val backdropImageUrl: String
get() {
return ImageUrlBuilder.finishImageUrl(
prefixImageUrl = prefixImageUrl,
artworkKind = ArtworkKind.BACKDROP
)
}
override val progress: Float?
constructor(movie: Movie) {
@@ -32,12 +47,32 @@ class MovieUiModel: MediaUiModel {
primaryText = movie.title
secondaryText = movie.year
description = movie.synopsis
imageUrl = ImageUrlBuilder.finishImageUrl(
prefixImageUrl = movie.imageUrlPrefix,
artworkKind = ArtworkKind.PRIMARY
)
prefixImageUrl = movie.imageUrlPrefix
progress = (movie.progress?.toFloat() ?: 0f) / 100f
}
companion object {
fun createPlaceholder() : MovieUiModel {
return MovieUiModel(
Movie(
id = UUID.randomUUID(),
libraryId = UUID.randomUUID(),
title = "Loading...",
progress = 0.0,
watched = false,
year = "",
rating = "",
runtime = "",
format = "",
synopsis = "",
imageUrlPrefix = "",
audioTrack = "",
subtitles = "",
cast = emptyList()
)
)
}
}
}
class SeriesUiModel : MediaUiModel {
@@ -45,17 +80,28 @@ class SeriesUiModel : MediaUiModel {
override val primaryText: String
override val secondaryText: String
override val description: String
override val imageUrl: String
private val prefixImageUrl: String
override val primaryImageUrl: String
get() {
return ImageUrlBuilder.finishImageUrl(
prefixImageUrl = prefixImageUrl,
artworkKind = ArtworkKind.PRIMARY
)
}
override val backdropImageUrl: String
get() {
return ImageUrlBuilder.finishImageUrl(
prefixImageUrl = prefixImageUrl,
artworkKind = ArtworkKind.BACKDROP
)
}
constructor(series: Series) {
id = series.id
primaryText = series.name
secondaryText = "${series.seasonCount} seasons"
description = series.synopsis
imageUrl = ImageUrlBuilder.finishImageUrl(
prefixImageUrl = series.imageUrlPrefix,
artworkKind = ArtworkKind.PRIMARY
)
prefixImageUrl = series.imageUrlPrefix
}
}
@@ -64,7 +110,21 @@ class EpisodeUiModel : MediaUiModel {
override val primaryText: String
override val secondaryText: String
override val description: String
override val imageUrl: String
private val prefixImageUrl: String
override val primaryImageUrl: String
get() {
return ImageUrlBuilder.finishImageUrl(
prefixImageUrl = prefixImageUrl,
artworkKind = ArtworkKind.PRIMARY
)
}
override val backdropImageUrl: String
get() {
return ImageUrlBuilder.finishImageUrl(
prefixImageUrl = prefixImageUrl,
artworkKind = ArtworkKind.PRIMARY
)
}
override val progress: Float?
val seriesId: UUID
val seasonId: UUID
@@ -74,10 +134,7 @@ class EpisodeUiModel : MediaUiModel {
primaryText = episode.title
secondaryText = episode.releaseDate
description = episode.synopsis
imageUrl = ImageUrlBuilder.finishImageUrl(
prefixImageUrl = episode.imageUrlPrefix,
artworkKind = ArtworkKind.PRIMARY
)
prefixImageUrl = episode.imageUrlPrefix
progress = (episode.progress?.toFloat() ?: 0f) / 100f
seriesId = episode.seriesId
seasonId = episode.seasonId

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"
@@ -90,15 +99,15 @@ fun PosterCardContent(
) {
Box {
PurefinAsyncImage(
model = model.imageUrl,
model = model.primaryImageUrl,
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
)

View File

@@ -36,6 +36,7 @@ dependencies {
implementation(libs.jellyfin.core)
implementation(libs.media3.common)
implementation(libs.kotlinx.serialization.json)
implementation(libs.slf4j.api)
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.datastore)

View File

@@ -8,7 +8,7 @@ lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
kotlin = "2.2.10"
composeBom = "2025.02.00"
jellyfin-core = "1.8.5"
jellyfin-core = "1.8.8"
hilt = "2.57.2"
hiltNavigationCompose = "1.2.0"
ksp = "2.3.2"
@@ -23,6 +23,7 @@ media3 = "1.9.0"
media3FfmpegDecoder = "1.9.0+1"
nav3Core = "1.0.0"
room = "2.7.0"
slf4j = "2.0.17"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -67,6 +68,7 @@ androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runt
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
[plugins]