diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 1e97acc2c..43c378475 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -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, + nextUpItemsBySeries: Map>, +): List { + 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 { 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt index de96857ee..3a7b23a13 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt index 49403c028..773c68a8b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt @@ -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) } } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt new file mode 100644 index 000000000..37b701ef4 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -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, + ) +} \ No newline at end of file