From 969f6dc5fd5783a6770a847e85d3740c2853ce3f Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Thu, 19 Feb 2026 20:42:10 +0100 Subject: [PATCH] revert: restore separate ContinueWatching and NextUp sections Manually reverts c5b613e which combined the two sections. Restores the NextUp UI section, data flow, and dedicated API call. --- .../hu/bbara/purefin/app/home/HomePage.kt | 2 + .../purefin/app/home/HomePageViewModel.kt | 19 +++ .../bbara/purefin/app/home/ui/HomeContent.kt | 10 ++ .../bbara/purefin/app/home/ui/HomeModels.kt | 8 ++ .../bbara/purefin/app/home/ui/HomeSections.kt | 129 ++++++++++++++++++ .../bbara/purefin/client/JellyfinApiClient.kt | 2 +- .../purefin/data/ActiveMediaRepository.kt | 4 + .../purefin/data/InMemoryMediaRepository.kt | 34 ++++- .../hu/bbara/purefin/data/MediaRepository.kt | 1 + .../purefin/data/OfflineMediaRepository.kt | 1 + .../hu/bbara/purefin/data/cache/HomeCache.kt | 3 +- .../data/local/room/OfflineMediaDatabase.kt | 4 +- .../purefin/data/local/room/SeriesEntity.kt | 3 +- 13 files changed, 210 insertions(+), 10 deletions(-) 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 7f39a8a..b54cd8f 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 @@ -50,6 +50,7 @@ fun HomePage( ) } val continueWatching = viewModel.continueWatching.collectAsState() + val nextUp = viewModel.nextUp.collectAsState() val latestLibraryContent = viewModel.latestLibraryContent.collectAsState() LifecycleResumeEffect(Unit) { @@ -95,6 +96,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 ae859fb..9ec07ac 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.data.MediaRepository import hu.bbara.purefin.data.model.Media @@ -86,6 +87,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 3547791..80a2a1f 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, @@ -40,6 +41,15 @@ fun HomeContent( item { Spacer(modifier = Modifier.height(16.dp)) } + item { + NextUpSection( + items = nextUp, + onEpisodeSelected = onEpisodeSelected + ) + } + item { + Spacer(modifier = Modifier.height(16.dp)) + } 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 045dc26..7627782 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 @@ -1,5 +1,6 @@ package hu.bbara.purefin.app.home.ui +import android.content.Intent import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -12,11 +13,18 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -34,6 +42,7 @@ import coil3.request.ImageRequest import hu.bbara.purefin.common.ui.PosterCard import hu.bbara.purefin.common.ui.components.MediaProgressBar import hu.bbara.purefin.common.ui.components.PurefinAsyncImage +import hu.bbara.purefin.player.PlayerActivity import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemKind import kotlin.math.nextUp @@ -154,6 +163,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/client/JellyfinApiClient.kt b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt index ec2dae4..c27c5f9 100644 --- a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt +++ b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt @@ -158,7 +158,7 @@ class JellyfinApiClient @Inject constructor( val getNextUpRequest = GetNextUpRequest( userId = getUserId(), fields = itemFields, - enableResumable = true, + enableResumable = false, ) val result = api.tvShowsApi.getNextUp(getNextUpRequest) Log.d("getNextUpEpisodes", result.content.toString()) diff --git a/app/src/main/java/hu/bbara/purefin/data/ActiveMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/ActiveMediaRepository.kt index b46e2a7..45ca20b 100644 --- a/app/src/main/java/hu/bbara/purefin/data/ActiveMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/ActiveMediaRepository.kt @@ -69,6 +69,10 @@ class ActiveMediaRepository @Inject constructor( activeRepository.flatMapLatest { it.continueWatching } .stateIn(scope, SharingStarted.Eagerly, emptyList()) + override val nextUp: StateFlow> = + activeRepository.flatMapLatest { it.nextUp } + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + override val latestLibraryContent: StateFlow>> = activeRepository.flatMapLatest { it.latestLibraryContent } .stateIn(scope, SharingStarted.Eagerly, emptyMap()) 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 6065cd4..2f1fe7a 100644 --- a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt @@ -79,6 +79,9 @@ class InMemoryMediaRepository @Inject constructor( private val _continueWatching: MutableStateFlow> = MutableStateFlow(emptyList()) override val continueWatching: StateFlow> = _continueWatching.asStateFlow() + private val _nextUp: MutableStateFlow> = MutableStateFlow(emptyList()) + override val nextUp: StateFlow> = _nextUp.asStateFlow() + private val _latestLibraryContent: MutableStateFlow>> = MutableStateFlow(emptyMap()) override val latestLibraryContent: StateFlow>> = _latestLibraryContent.asStateFlow() @@ -94,6 +97,9 @@ class InMemoryMediaRepository @Inject constructor( if (cache.continueWatching.isNotEmpty()) { _continueWatching.value = cache.continueWatching.mapNotNull { it.toMedia() } } + if (cache.nextUp.isNotEmpty()) { + _nextUp.value = cache.nextUp.mapNotNull { it.toMedia() } + } if (cache.latestLibraryContent.isNotEmpty()) { _latestLibraryContent.value = cache.latestLibraryContent.mapNotNull { (key, items) -> val uuid = runCatching { UUID.fromString(key) }.getOrNull() ?: return@mapNotNull null @@ -105,6 +111,7 @@ class InMemoryMediaRepository @Inject constructor( private suspend fun persistHomeCache() { val cache = HomeCache( continueWatching = _continueWatching.value.map { it.toCachedItem() }, + nextUp = _nextUp.value.map { it.toCachedItem() }, latestLibraryContent = _latestLibraryContent.value.map { (uuid, items) -> uuid.toString() to items.map { it.toCachedItem() } }.toMap() @@ -115,13 +122,13 @@ class InMemoryMediaRepository @Inject constructor( private fun Media.toCachedItem(): CachedMediaItem = when (this) { is Media.MovieMedia -> CachedMediaItem(type = "MOVIE", id = movieId.toString()) is Media.SeriesMedia -> CachedMediaItem(type = "SERIES", id = seriesId.toString()) - is Media.SeasonMedia -> CachedMediaItem(type = "SEASON", id = seasonId.toString(), mediaId = seriesId.toString()) - is Media.EpisodeMedia -> CachedMediaItem(type = "EPISODE", id = episodeId.toString(), mediaId = seriesId.toString()) + is Media.SeasonMedia -> CachedMediaItem(type = "SEASON", id = seasonId.toString(), seriesId = seriesId.toString()) + is Media.EpisodeMedia -> CachedMediaItem(type = "EPISODE", id = episodeId.toString(), seriesId = seriesId.toString()) } private fun CachedMediaItem.toMedia(): Media? { val uuid = runCatching { UUID.fromString(id) }.getOrNull() ?: return null - val seriesUuid = mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() } + val seriesUuid = seriesId?.let { runCatching { UUID.fromString(it) }.getOrNull() } return when (type) { "MOVIE" -> Media.MovieMedia(movieId = uuid) "SERIES" -> Media.SeriesMedia(seriesId = uuid) @@ -146,6 +153,7 @@ class InMemoryMediaRepository @Inject constructor( } loadLibraries() loadContinueWatching() + loadNextUp() loadLatestLibraryContent() persistHomeCache() _state.value = MediaRepositoryState.Ready @@ -220,7 +228,7 @@ class InMemoryMediaRepository @Inject constructor( } suspend fun loadContinueWatching() { - val continueWatchingItems = jellyfinApiClient.getNextUpEpisodes() + val continueWatchingItems = jellyfinApiClient.getContinueWatching() val items = continueWatchingItems.mapNotNull { item -> when (item.type) { BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id) @@ -245,6 +253,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() @@ -326,6 +351,7 @@ class InMemoryMediaRepository @Inject constructor( loadLibraries() loadContinueWatching() + loadNextUp() loadLatestLibraryContent() persistHomeCache() initialLoadTimestamp = System.currentTimeMillis() diff --git a/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt index 30bce5c..489820d 100644 --- a/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt @@ -18,6 +18,7 @@ interface MediaRepository { val state: StateFlow val continueWatching: StateFlow> + val nextUp: StateFlow> val latestLibraryContent: StateFlow>> fun observeSeriesWithContent(seriesId: UUID): Flow diff --git a/app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt index 3630962..b6a8e61 100644 --- a/app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt @@ -49,6 +49,7 @@ class OfflineMediaRepository @Inject constructor( // Offline mode doesn't support these server-side features override val continueWatching: StateFlow> = MutableStateFlow(emptyList()) + override val nextUp: StateFlow> = MutableStateFlow(emptyList()) override val latestLibraryContent: StateFlow>> = MutableStateFlow(emptyMap()) override fun observeSeriesWithContent(seriesId: UUID): Flow { diff --git a/app/src/main/java/hu/bbara/purefin/data/cache/HomeCache.kt b/app/src/main/java/hu/bbara/purefin/data/cache/HomeCache.kt index 3982f32..dc08861 100644 --- a/app/src/main/java/hu/bbara/purefin/data/cache/HomeCache.kt +++ b/app/src/main/java/hu/bbara/purefin/data/cache/HomeCache.kt @@ -6,11 +6,12 @@ import kotlinx.serialization.Serializable data class CachedMediaItem( val type: String, val id: String, - val mediaId: String? = null + val seriesId: String? = null ) @Serializable data class HomeCache( val continueWatching: List = emptyList(), + val nextUp: List = emptyList(), val latestLibraryContent: Map> = emptyMap() ) diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/OfflineMediaDatabase.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/OfflineMediaDatabase.kt index ae29c47..c6c8e8e 100644 --- a/app/src/main/java/hu/bbara/purefin/data/local/room/OfflineMediaDatabase.kt +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/OfflineMediaDatabase.kt @@ -5,10 +5,10 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import hu.bbara.purefin.data.local.room.dao.CastMemberDao import hu.bbara.purefin.data.local.room.dao.EpisodeDao -import hu.bbara.purefin.data.local.room.dao.LibraryDao import hu.bbara.purefin.data.local.room.dao.MovieDao import hu.bbara.purefin.data.local.room.dao.SeasonDao import hu.bbara.purefin.data.local.room.dao.SeriesDao +import hu.bbara.purefin.data.local.room.dao.LibraryDao @Database( entities = [ @@ -19,7 +19,7 @@ import hu.bbara.purefin.data.local.room.dao.SeriesDao LibraryEntity::class, CastMemberEntity::class ], - version = 4, + version = 3, exportSchema = false ) @TypeConverters(UuidConverters::class) diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/SeriesEntity.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/SeriesEntity.kt index c204f10..fe28673 100644 --- a/app/src/main/java/hu/bbara/purefin/data/local/room/SeriesEntity.kt +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/SeriesEntity.kt @@ -12,8 +12,7 @@ import java.util.UUID ForeignKey( entity = LibraryEntity::class, parentColumns = ["id"], - childColumns = ["libraryId"], - onDelete = ForeignKey.CASCADE + childColumns = ["libraryId"] ), ], indices = [Index("libraryId")]