mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-06 03:21:22 +00:00
feat: refactor HomeScreen to use buildHomeContinueWatchingItems function and add unit tests for continue watching logic
This commit is contained in:
parent
97f0eeb5da
commit
8c99523aa2
4 changed files with 280 additions and 73 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue