From 51198fe90e0facfa7eca98400e451eaf5a8128fd Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 15 Feb 2026 15:09:04 +0100 Subject: [PATCH] feature: Add next up section to homepage --- .../hu/bbara/purefin/app/home/HomePage.kt | 2 + .../purefin/app/home/HomePageViewModel.kt | 19 +++ .../bbara/purefin/app/home/ui/HomeContent.kt | 7 + .../bbara/purefin/app/home/ui/HomeModels.kt | 8 ++ .../bbara/purefin/app/home/ui/HomeSections.kt | 120 ++++++++++++++++++ .../purefin/data/InMemoryMediaRepository.kt | 22 ++++ 6 files changed, 178 insertions(+) diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt index 72f4559..1118939 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt @@ -49,6 +49,7 @@ fun HomePage( ) } val continueWatching = viewModel.continueWatching.collectAsState() + val nextUp = viewModel.nextUp.collectAsState() val latestLibraryContent = viewModel.latestLibraryContent.collectAsState() LifecycleResumeEffect(Unit) { @@ -92,6 +93,7 @@ fun HomePage( libraries = libraries, libraryContent = latestLibraryContent.value, continueWatching = continueWatching.value, + nextUp = nextUp.value, onMovieSelected = viewModel::onMovieSelected, onSeriesSelected = viewModel::onSeriesSelected, onEpisodeSelected = viewModel::onEpisodeSelected, diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt index 29de039..2e9eba1 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt @@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.app.home.ui.ContinueWatchingItem import hu.bbara.purefin.app.home.ui.HomeNavItem import hu.bbara.purefin.app.home.ui.LibraryItem +import hu.bbara.purefin.app.home.ui.NextUpItem import hu.bbara.purefin.app.home.ui.PosterItem import hu.bbara.purefin.client.JellyfinApiClient import hu.bbara.purefin.data.InMemoryMediaRepository @@ -77,6 +78,24 @@ class HomePageViewModel @Inject constructor( initialValue = emptyList() ) + val nextUp = combine( + mediaRepository.nextUp, + mediaRepository.episodes + ) { list, episodesMap -> + list.mapNotNull { media -> + when (media) { + is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let { + NextUpItem(episode = it) + } + else -> null + } + }.distinctBy { it.id } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList() + ) + val latestLibraryContent = combine( mediaRepository.latestLibraryContent, mediaRepository.movies, diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeContent.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeContent.kt index 2d59b00..0fd3353 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeContent.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeContent.kt @@ -17,6 +17,7 @@ fun HomeContent( libraries: List, libraryContent: Map>, continueWatching: List, + nextUp: List, onMovieSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit, @@ -37,6 +38,12 @@ fun HomeContent( onEpisodeSelected = onEpisodeSelected ) } + item { + NextUpSection( + items = nextUp, + onEpisodeSelected = onEpisodeSelected + ) + } items( items = libraries.filter { libraryContent[it.id]?.isEmpty() != true }, key = { it.id } diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt index e83ba8c..293ef58 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt @@ -35,6 +35,14 @@ data class ContinueWatchingItem( } } +data class NextUpItem( + val episode: Episode +) { + val id: UUID = episode.id + val primaryText: String = episode.title + val secondaryText: String = episode.releaseDate +} + data class LibraryItem( val id: UUID, val name: String, diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt index 4236698..d686976 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt @@ -188,6 +188,126 @@ fun ContinueWatchingCard( } } +@Composable +fun NextUpSection( + items: List, + onEpisodeSelected: (UUID, UUID, UUID) -> Unit, + modifier: Modifier = Modifier +) { + SectionHeader( + title = "Next Up", + action = null + ) + LazyRow( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items( + items = items, key = { it.id }) { item -> + NextUpCard( + item = item, + onEpisodeSelected = onEpisodeSelected + ) + } + } +} + +@Composable +fun NextUpCard( + item: NextUpItem, + modifier: Modifier = Modifier, + onEpisodeSelected: (UUID, UUID, UUID) -> Unit, +) { + val scheme = MaterialTheme.colorScheme + + val context = LocalContext.current + val density = LocalDensity.current + + val imageUrl = item.episode.heroImageUrl + + val cardWidth = 280.dp + val cardHeight = cardWidth * 9 / 16 + + fun openItem(item: NextUpItem) { + val episode = item.episode + onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id) + } + + val imageRequest = ImageRequest.Builder(context) + .data(imageUrl) + .size(with(density) { cardWidth.roundToPx() }, with(density) { cardHeight.roundToPx() }) + .build() + + Column( + modifier = modifier + .width(cardWidth) + .wrapContentHeight() + ) { + Box( + modifier = Modifier + .width(cardWidth) + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(16.dp)) + .border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) + .background(scheme.surfaceVariant) + ) { + PurefinAsyncImage( + model = imageRequest, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clickable { + openItem(item) + }, + contentScale = ContentScale.Crop, + ) + IconButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 8.dp, bottom = 16.dp) + .clip(CircleShape) + .background(scheme.secondary) + .size(36.dp), + onClick = { + val intent = Intent(context, PlayerActivity::class.java) + intent.putExtra("MEDIA_ID", item.id.toString()) + context.startActivity(intent) + }, + colors = IconButtonColors( + containerColor = scheme.secondary, + contentColor = scheme.onSecondary, + disabledContainerColor = scheme.secondary, + disabledContentColor = scheme.onSecondary + ) + ) { + Icon( + imageVector = Icons.Outlined.PlayArrow, + contentDescription = "Play", + modifier = Modifier.size(28.dp), + ) + } + } + Column(modifier = Modifier.padding(top = 12.dp)) { + Text( + text = item.primaryText, + color = scheme.onBackground, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = item.secondaryText, + color = scheme.onSurfaceVariant, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + @Composable fun LibraryPosterSection( title: String, diff --git a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt index 95821ee..ec934c0 100644 --- a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt @@ -67,6 +67,9 @@ class InMemoryMediaRepository @Inject constructor( private val _continueWatching: MutableStateFlow> = MutableStateFlow(emptyList()) val continueWatching: StateFlow> = _continueWatching.asStateFlow() + private val _nextUp: MutableStateFlow> = MutableStateFlow(emptyList()) + val nextUp: StateFlow> = _nextUp.asStateFlow() + private val _latestLibraryContent: MutableStateFlow>> = MutableStateFlow(emptyMap()) val latestLibraryContent: StateFlow>> = _latestLibraryContent.asStateFlow() @@ -82,6 +85,7 @@ class InMemoryMediaRepository @Inject constructor( try { loadLibraries() loadContinueWatching() + loadNextUp() loadLatestLibraryContent() _state.value = MediaRepositoryState.Ready ready.complete(Unit) @@ -171,6 +175,23 @@ class InMemoryMediaRepository @Inject constructor( } } + suspend fun loadNextUp() { + val nextUpItems = jellyfinApiClient.getNextUpEpisodes() + val items = nextUpItems.map { item -> + Media.EpisodeMedia( + episodeId = item.id, + seriesId = item.seriesId!! + ) + } + _nextUp.value = items + + // Load episodes + nextUpItems.forEach { item -> + val episode = item.toEpisode(serverUrl()) + localDataSource.saveEpisode(episode) + } + } + suspend fun loadLatestLibraryContent() { // TODO Make libraries accessible in a field or something that is not this ugly. val librariesItem = jellyfinApiClient.getLibraries() @@ -243,6 +264,7 @@ class InMemoryMediaRepository @Inject constructor( override suspend fun refreshHomeData() { loadLibraries() loadContinueWatching() + loadNextUp() loadLatestLibraryContent() }