feat: refactor HomeScreen to use buildHomeContinueWatchingItems function and add unit tests for continue watching logic

This commit is contained in:
tapframe 2026-03-31 00:26:36 +05:30
parent 97f0eeb5da
commit 8c99523aa2
4 changed files with 280 additions and 73 deletions

View file

@ -87,16 +87,10 @@ fun HomeScreen(
visibleContinueWatchingEntries,
nextUpItemsBySeries,
) {
buildList {
addAll(
visibleContinueWatchingEntries.map { entry ->
entry.lastUpdatedEpochMs to entry.toContinueWatchingItem()
},
)
addAll(nextUpItemsBySeries.values)
}
.sortedByDescending { it.first }
.map { it.second }
buildHomeContinueWatchingItems(
visibleEntries = visibleContinueWatchingEntries,
nextUpItemsBySeries = nextUpItemsBySeries,
)
}
val allManifestsSettled = addonsUiState.addons.isNotEmpty() &&
addonsUiState.addons.none { it.isRefreshing }
@ -284,6 +278,38 @@ fun HomeScreen(
private const val HOME_CATALOG_PREVIEW_LIMIT = 18
internal fun buildHomeContinueWatchingItems(
visibleEntries: List<WatchProgressEntry>,
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
): List<ContinueWatchingItem> {
return buildList {
addAll(
visibleEntries.map { entry ->
HomeContinueWatchingCandidate(
lastUpdatedEpochMs = entry.lastUpdatedEpochMs,
item = entry.toContinueWatchingItem(),
isProgressEntry = true,
)
},
)
addAll(
nextUpItemsBySeries.values.map { (lastUpdatedEpochMs, item) ->
HomeContinueWatchingCandidate(
lastUpdatedEpochMs = lastUpdatedEpochMs,
item = item,
isProgressEntry = false,
)
},
)
}
.sortedWith(
compareByDescending<HomeContinueWatchingCandidate> { it.lastUpdatedEpochMs }
.thenByDescending { it.isProgressEntry },
)
.distinctBy { it.item.videoId }
.map(HomeContinueWatchingCandidate::item)
}
private data class CompletedSeriesCandidate(
val content: WatchingContentRef,
val seasonNumber: Int,
@ -291,6 +317,12 @@ private data class CompletedSeriesCandidate(
val markedAtEpochMs: Long,
)
private data class HomeContinueWatchingCandidate(
val lastUpdatedEpochMs: Long,
val item: ContinueWatchingItem,
val isProgressEntry: Boolean,
)
private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app.features.details.MetaDetails) =
WatchProgressEntry(
contentType = content.type,

View file

@ -1,7 +1,5 @@
package com.nuvio.app.features.home.components
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -18,6 +16,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@ -28,10 +27,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -40,6 +40,11 @@ import coil3.compose.AsyncImage
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.features.home.MetaPreview
import kotlinx.coroutines.launch
import kotlin.math.abs
private const val HERO_BACKGROUND_PARALLAX = 0.055f
private const val HERO_BACKGROUND_SCALE = 1.14f
private const val HERO_CONTENT_PARALLAX = 0.18f
@Composable
fun HomeHeroSection(
@ -58,7 +63,32 @@ fun HomeHeroSection(
.clip(RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)),
) {
val heroHeight = (maxWidth.value * 1.22f).dp.coerceIn(440.dp, 800.dp)
val currentItem = items[pagerState.currentPage.coerceIn(items.indices)]
val heroWidthPx = with(LocalDensity.current) { maxWidth.toPx() }
val currentPage = pagerState.currentPage.coerceIn(items.indices)
val visiblePages = listOf(
currentPage,
(currentPage - 1).coerceIn(items.indices),
(currentPage + 1).coerceIn(items.indices),
).distinct()
.mapNotNull { index ->
val pageOffset = heroPageOffset(pagerState, index)
val visibility = (1f - abs(pageOffset)).coerceIn(0f, 1f)
if (visibility <= 0f) {
null
} else {
HeroPageLayer(
page = index,
visibility = visibility,
offset = pageOffset,
)
}
}
.sortedBy(HeroPageLayer::visibility)
val currentItem = visiblePages
.lastOrNull()
?.page
?.let(items::get)
?: items[currentPage]
Box(
modifier = Modifier
@ -69,7 +99,7 @@ fun HomeHeroSection(
state = pagerState,
modifier = Modifier
.fillMaxSize()
.alpha(0.01f),
.graphicsLayer { alpha = 0.01f },
) {
Box(modifier = Modifier.fillMaxSize())
}
@ -77,15 +107,18 @@ fun HomeHeroSection(
Box(
modifier = Modifier.fillMaxSize(),
) {
Crossfade(
targetState = currentItem,
animationSpec = tween(durationMillis = 320),
label = "home-hero-background",
) { item ->
visiblePages.forEach { layer ->
AsyncImage(
model = item.banner ?: item.poster,
contentDescription = item.name,
modifier = Modifier.fillMaxSize(),
model = items[layer.page].banner ?: items[layer.page].poster,
contentDescription = items[layer.page].name,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
alpha = layer.visibility
translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX
scaleX = HERO_BACKGROUND_SCALE
scaleY = HERO_BACKGROUND_SCALE
},
contentScale = ContentScale.Crop,
)
}
@ -127,15 +160,23 @@ fun HomeHeroSection(
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Crossfade(
targetState = currentItem,
animationSpec = tween(durationMillis = 240),
label = "home-hero-content",
) { item ->
HeroContentBlock(
item = item,
onItemClick = onItemClick,
)
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
visiblePages.forEach { layer ->
Box(
modifier = Modifier.graphicsLayer {
alpha = layer.visibility
translationX = -layer.offset * heroWidthPx * HERO_CONTENT_PARALLAX
},
) {
HeroContentBlock(
item = items[layer.page],
onItemClick = onItemClick,
)
}
}
}
Spacer(modifier = Modifier.height(14.dp))
@ -163,6 +204,7 @@ fun HomeHeroSection(
verticalAlignment = Alignment.CenterVertically,
) {
items.forEachIndexed { index, _ ->
val activeFraction = heroPageVisibility(pagerState, index)
Box(
modifier = Modifier
.clickable {
@ -172,14 +214,10 @@ fun HomeHeroSection(
}
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground)
.alpha(if (pagerState.currentPage == index) 0.92f else 0.35f)
.then(
if (pagerState.currentPage == index) {
Modifier.width(32.dp)
} else {
Modifier.width(8.dp)
},
)
.graphicsLayer {
alpha = 0.35f + (0.57f * activeFraction)
}
.width(8.dp + (24.dp * activeFraction))
.height(8.dp),
)
}
@ -191,6 +229,24 @@ fun HomeHeroSection(
}
}
private data class HeroPageLayer(
val page: Int,
val visibility: Float,
val offset: Float,
)
private fun heroPageOffset(
pagerState: PagerState,
page: Int,
): Float = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
private fun heroPageVisibility(
pagerState: PagerState,
page: Int,
): Float {
return (1f - abs(heroPageOffset(pagerState, page))).coerceIn(0f, 1f)
}
@Composable
fun HomeHeroReservedSpace(modifier: Modifier = Modifier) {
BoxWithConstraints(

View file

@ -24,6 +24,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
@ -65,6 +66,7 @@ fun HomeSkeletonHero(modifier: Modifier = Modifier) {
.clip(RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)),
) {
val heroHeight = (maxWidth.value * 1.22f).dp.coerceIn(440.dp, 800.dp)
val containerWidth = maxWidth
Box(
modifier = Modifier
@ -78,10 +80,25 @@ fun HomeSkeletonHero(modifier: Modifier = Modifier) {
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.background.copy(alpha = 0.04f),
MaterialTheme.colorScheme.background.copy(alpha = 0.18f),
MaterialTheme.colorScheme.background.copy(alpha = 0.42f),
MaterialTheme.colorScheme.background.copy(alpha = 0.92f),
MaterialTheme.colorScheme.background.copy(alpha = 0.02f),
MaterialTheme.colorScheme.background.copy(alpha = 0.12f),
MaterialTheme.colorScheme.background.copy(alpha = 0.34f),
MaterialTheme.colorScheme.background.copy(alpha = 0.78f),
),
),
),
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.align(Alignment.BottomCenter)
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.background.copy(alpha = 0f),
MaterialTheme.colorScheme.background,
),
),
),
@ -89,48 +106,48 @@ fun HomeSkeletonHero(modifier: Modifier = Modifier) {
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 24.dp),
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.weight(1f))
// Logo placeholder — matches fillMaxWidth(0.62f) + aspectRatio(2.6f)
SkeletonBlock(
brush = brush,
width = 220.dp,
height = 64.dp,
cornerRadius = 20.dp,
width = (containerWidth.value * 0.62f).dp,
height = ((containerWidth.value * 0.62f) / 2.6f).dp,
cornerRadius = 12.dp,
)
Spacer(modifier = Modifier.height(14.dp))
Row {
Spacer(modifier = Modifier.height(12.dp))
// Meta info row: type · genre · year
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SkeletonBlock(brush = brush, width = 52.dp, height = 14.dp, cornerRadius = 999.dp)
SkeletonDot(brush = brush)
SkeletonBlock(brush = brush, width = 72.dp, height = 14.dp, cornerRadius = 999.dp)
Spacer(modifier = Modifier.width(8.dp))
SkeletonDot(brush = brush)
Spacer(modifier = Modifier.width(8.dp))
SkeletonBlock(brush = brush, width = 88.dp, height = 14.dp, cornerRadius = 999.dp)
Spacer(modifier = Modifier.width(8.dp))
SkeletonDot(brush = brush)
Spacer(modifier = Modifier.width(8.dp))
SkeletonBlock(brush = brush, width = 54.dp, height = 14.dp, cornerRadius = 999.dp)
SkeletonBlock(brush = brush, width = 40.dp, height = 14.dp, cornerRadius = 999.dp)
}
Spacer(modifier = Modifier.height(18.dp))
Spacer(modifier = Modifier.height(14.dp))
// "View Details" button placeholder
SkeletonBlock(
brush = brush,
width = 148.dp,
width = 160.dp,
height = 48.dp,
cornerRadius = 40.dp,
)
Spacer(modifier = Modifier.height(16.dp))
Row {
repeat(4) { index ->
if (index > 0) {
Spacer(modifier = Modifier.width(8.dp))
}
SkeletonBlock(
brush = brush,
width = if (index == 0) 32.dp else 8.dp,
height = 8.dp,
cornerRadius = 999.dp,
)
}
Spacer(modifier = Modifier.height(12.dp))
// Pager dots
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SkeletonBlock(brush = brush, width = 32.dp, height = 8.dp, cornerRadius = 999.dp)
SkeletonBlock(brush = brush, width = 8.dp, height = 8.dp, cornerRadius = 999.dp)
SkeletonBlock(brush = brush, width = 8.dp, height = 8.dp, cornerRadius = 999.dp)
SkeletonBlock(brush = brush, width = 8.dp, height = 8.dp, cornerRadius = 999.dp)
}
}
}

View file

@ -0,0 +1,102 @@
package com.nuvio.app.features.home
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import kotlin.test.Test
import kotlin.test.assertEquals
class HomeScreenTest {
@Test
fun `build home continue watching items removes duplicate video ids`() {
val inProgress = progressEntry(
videoId = "tt0944947:1:4",
title = "Game of Thrones",
episodeTitle = "Cripples, Bastards, and Broken Things",
lastUpdatedEpochMs = 250L,
)
val nextUp = continueWatchingItem(
videoId = "tt0944947:1:4",
subtitle = "Up Next • S1E4 • Cripples, Bastards, and Broken Things",
)
val movie = progressEntry(
videoId = "movie-1",
title = "Movie",
lastUpdatedEpochMs = 100L,
seasonNumber = null,
episodeNumber = null,
episodeTitle = null,
)
val result = buildHomeContinueWatchingItems(
visibleEntries = listOf(inProgress, movie),
nextUpItemsBySeries = mapOf("tt0944947" to (200L to nextUp)),
)
assertEquals(listOf("tt0944947:1:4", "movie-1"), result.map(ContinueWatchingItem::videoId))
assertEquals("S1E4 • Cripples, Bastards, and Broken Things", result.first().subtitle)
}
@Test
fun `build home continue watching items prefers progress entry on timestamp tie`() {
val inProgress = progressEntry(
videoId = "show:1:5",
title = "Show",
episodeTitle = "The Wolf and the Lion",
lastUpdatedEpochMs = 500L,
)
val nextUp = continueWatchingItem(
videoId = "show:1:5",
subtitle = "Up Next • S1E5 • The Wolf and the Lion",
)
val result = buildHomeContinueWatchingItems(
visibleEntries = listOf(inProgress),
nextUpItemsBySeries = mapOf("show" to (500L to nextUp)),
)
assertEquals(1, result.size)
assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle)
}
private fun progressEntry(
videoId: String,
title: String,
lastUpdatedEpochMs: Long,
seasonNumber: Int? = 1,
episodeNumber: Int? = 4,
episodeTitle: String? = "Episode",
): WatchProgressEntry =
WatchProgressEntry(
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
parentMetaId = videoId.substringBefore(':'),
parentMetaType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
videoId = videoId,
title = title,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
lastPositionMs = if (seasonNumber != null && episodeNumber != null) 120_000L else 60_000L,
durationMs = 1_000_000L,
lastUpdatedEpochMs = lastUpdatedEpochMs,
)
private fun continueWatchingItem(
videoId: String,
subtitle: String,
): ContinueWatchingItem =
ContinueWatchingItem(
parentMetaId = videoId.substringBefore(':'),
parentMetaType = "series",
videoId = videoId,
title = "Show",
subtitle = subtitle,
imageUrl = null,
seasonNumber = 1,
episodeNumber = 4,
episodeTitle = subtitle.substringAfterLast("", "Episode"),
resumePositionMs = 0L,
durationMs = 0L,
progressFraction = 0f,
)
}