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.
This commit is contained in:
2026-02-19 20:42:10 +01:00
parent a86d496ff9
commit 969f6dc5fd
13 changed files with 210 additions and 10 deletions

View File

@@ -50,6 +50,7 @@ fun HomePage(
) )
} }
val continueWatching = viewModel.continueWatching.collectAsState() val continueWatching = viewModel.continueWatching.collectAsState()
val nextUp = viewModel.nextUp.collectAsState()
val latestLibraryContent = viewModel.latestLibraryContent.collectAsState() val latestLibraryContent = viewModel.latestLibraryContent.collectAsState()
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
@@ -95,6 +96,7 @@ fun HomePage(
libraries = libraries, libraries = libraries,
libraryContent = latestLibraryContent.value, libraryContent = latestLibraryContent.value,
continueWatching = continueWatching.value, continueWatching = continueWatching.value,
nextUp = nextUp.value,
onMovieSelected = viewModel::onMovieSelected, onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected, onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = viewModel::onEpisodeSelected, onEpisodeSelected = viewModel::onEpisodeSelected,

View File

@@ -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.ContinueWatchingItem
import hu.bbara.purefin.app.home.ui.HomeNavItem import hu.bbara.purefin.app.home.ui.HomeNavItem
import hu.bbara.purefin.app.home.ui.LibraryItem 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.app.home.ui.PosterItem
import hu.bbara.purefin.data.MediaRepository import hu.bbara.purefin.data.MediaRepository
import hu.bbara.purefin.data.model.Media import hu.bbara.purefin.data.model.Media
@@ -86,6 +87,24 @@ class HomePageViewModel @Inject constructor(
initialValue = emptyList() 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( val latestLibraryContent = combine(
mediaRepository.latestLibraryContent, mediaRepository.latestLibraryContent,
mediaRepository.movies, mediaRepository.movies,

View File

@@ -17,6 +17,7 @@ fun HomeContent(
libraries: List<LibraryItem>, libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>, libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>, continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
@@ -40,6 +41,15 @@ fun HomeContent(
item { item {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
item {
NextUpSection(
items = nextUp,
onEpisodeSelected = onEpisodeSelected
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
}
items( items(
items = libraries.filter { libraryContent[it.id]?.isEmpty() != true }, items = libraries.filter { libraryContent[it.id]?.isEmpty() != true },
key = { it.id } key = { it.id }

View File

@@ -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( data class LibraryItem(
val id: UUID, val id: UUID,
val name: String, val name: String,

View File

@@ -1,5 +1,6 @@
package hu.bbara.purefin.app.home.ui package hu.bbara.purefin.app.home.ui
import android.content.Intent
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.PosterCard
import hu.bbara.purefin.common.ui.components.MediaProgressBar import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage 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.UUID
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import kotlin.math.nextUp import kotlin.math.nextUp
@@ -154,6 +163,126 @@ fun ContinueWatchingCard(
} }
} }
@Composable
fun NextUpSection(
items: List<NextUpItem>,
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 @Composable
fun LibraryPosterSection( fun LibraryPosterSection(
title: String, title: String,

View File

@@ -158,7 +158,7 @@ class JellyfinApiClient @Inject constructor(
val getNextUpRequest = GetNextUpRequest( val getNextUpRequest = GetNextUpRequest(
userId = getUserId(), userId = getUserId(),
fields = itemFields, fields = itemFields,
enableResumable = true, enableResumable = false,
) )
val result = api.tvShowsApi.getNextUp(getNextUpRequest) val result = api.tvShowsApi.getNextUp(getNextUpRequest)
Log.d("getNextUpEpisodes", result.content.toString()) Log.d("getNextUpEpisodes", result.content.toString())

View File

@@ -69,6 +69,10 @@ class ActiveMediaRepository @Inject constructor(
activeRepository.flatMapLatest { it.continueWatching } activeRepository.flatMapLatest { it.continueWatching }
.stateIn(scope, SharingStarted.Eagerly, emptyList()) .stateIn(scope, SharingStarted.Eagerly, emptyList())
override val nextUp: StateFlow<List<Media>> =
activeRepository.flatMapLatest { it.nextUp }
.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> =
activeRepository.flatMapLatest { it.latestLibraryContent } activeRepository.flatMapLatest { it.latestLibraryContent }
.stateIn(scope, SharingStarted.Eagerly, emptyMap()) .stateIn(scope, SharingStarted.Eagerly, emptyMap())

View File

@@ -79,6 +79,9 @@ class InMemoryMediaRepository @Inject constructor(
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList()) private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
override val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow() override val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
private val _nextUp: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
override val nextUp: StateFlow<List<Media>> = _nextUp.asStateFlow()
private val _latestLibraryContent: MutableStateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap()) private val _latestLibraryContent: MutableStateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap())
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = _latestLibraryContent.asStateFlow() override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = _latestLibraryContent.asStateFlow()
@@ -94,6 +97,9 @@ class InMemoryMediaRepository @Inject constructor(
if (cache.continueWatching.isNotEmpty()) { if (cache.continueWatching.isNotEmpty()) {
_continueWatching.value = cache.continueWatching.mapNotNull { it.toMedia() } _continueWatching.value = cache.continueWatching.mapNotNull { it.toMedia() }
} }
if (cache.nextUp.isNotEmpty()) {
_nextUp.value = cache.nextUp.mapNotNull { it.toMedia() }
}
if (cache.latestLibraryContent.isNotEmpty()) { if (cache.latestLibraryContent.isNotEmpty()) {
_latestLibraryContent.value = cache.latestLibraryContent.mapNotNull { (key, items) -> _latestLibraryContent.value = cache.latestLibraryContent.mapNotNull { (key, items) ->
val uuid = runCatching { UUID.fromString(key) }.getOrNull() ?: return@mapNotNull null val uuid = runCatching { UUID.fromString(key) }.getOrNull() ?: return@mapNotNull null
@@ -105,6 +111,7 @@ class InMemoryMediaRepository @Inject constructor(
private suspend fun persistHomeCache() { private suspend fun persistHomeCache() {
val cache = HomeCache( val cache = HomeCache(
continueWatching = _continueWatching.value.map { it.toCachedItem() }, continueWatching = _continueWatching.value.map { it.toCachedItem() },
nextUp = _nextUp.value.map { it.toCachedItem() },
latestLibraryContent = _latestLibraryContent.value.map { (uuid, items) -> latestLibraryContent = _latestLibraryContent.value.map { (uuid, items) ->
uuid.toString() to items.map { it.toCachedItem() } uuid.toString() to items.map { it.toCachedItem() }
}.toMap() }.toMap()
@@ -115,13 +122,13 @@ class InMemoryMediaRepository @Inject constructor(
private fun Media.toCachedItem(): CachedMediaItem = when (this) { private fun Media.toCachedItem(): CachedMediaItem = when (this) {
is Media.MovieMedia -> CachedMediaItem(type = "MOVIE", id = movieId.toString()) is Media.MovieMedia -> CachedMediaItem(type = "MOVIE", id = movieId.toString())
is Media.SeriesMedia -> CachedMediaItem(type = "SERIES", id = seriesId.toString()) is Media.SeriesMedia -> CachedMediaItem(type = "SERIES", id = seriesId.toString())
is Media.SeasonMedia -> CachedMediaItem(type = "SEASON", id = seasonId.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(), mediaId = seriesId.toString()) is Media.EpisodeMedia -> CachedMediaItem(type = "EPISODE", id = episodeId.toString(), seriesId = seriesId.toString())
} }
private fun CachedMediaItem.toMedia(): Media? { private fun CachedMediaItem.toMedia(): Media? {
val uuid = runCatching { UUID.fromString(id) }.getOrNull() ?: return null 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) { return when (type) {
"MOVIE" -> Media.MovieMedia(movieId = uuid) "MOVIE" -> Media.MovieMedia(movieId = uuid)
"SERIES" -> Media.SeriesMedia(seriesId = uuid) "SERIES" -> Media.SeriesMedia(seriesId = uuid)
@@ -146,6 +153,7 @@ class InMemoryMediaRepository @Inject constructor(
} }
loadLibraries() loadLibraries()
loadContinueWatching() loadContinueWatching()
loadNextUp()
loadLatestLibraryContent() loadLatestLibraryContent()
persistHomeCache() persistHomeCache()
_state.value = MediaRepositoryState.Ready _state.value = MediaRepositoryState.Ready
@@ -220,7 +228,7 @@ class InMemoryMediaRepository @Inject constructor(
} }
suspend fun loadContinueWatching() { suspend fun loadContinueWatching() {
val continueWatchingItems = jellyfinApiClient.getNextUpEpisodes() val continueWatchingItems = jellyfinApiClient.getContinueWatching()
val items = continueWatchingItems.mapNotNull { item -> val items = continueWatchingItems.mapNotNull { item ->
when (item.type) { when (item.type) {
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id) 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() { suspend fun loadLatestLibraryContent() {
// TODO Make libraries accessible in a field or something that is not this ugly. // TODO Make libraries accessible in a field or something that is not this ugly.
val librariesItem = jellyfinApiClient.getLibraries() val librariesItem = jellyfinApiClient.getLibraries()
@@ -326,6 +351,7 @@ class InMemoryMediaRepository @Inject constructor(
loadLibraries() loadLibraries()
loadContinueWatching() loadContinueWatching()
loadNextUp()
loadLatestLibraryContent() loadLatestLibraryContent()
persistHomeCache() persistHomeCache()
initialLoadTimestamp = System.currentTimeMillis() initialLoadTimestamp = System.currentTimeMillis()

View File

@@ -18,6 +18,7 @@ interface MediaRepository {
val state: StateFlow<MediaRepositoryState> val state: StateFlow<MediaRepositoryState>
val continueWatching: StateFlow<List<Media>> val continueWatching: StateFlow<List<Media>>
val nextUp: StateFlow<List<Media>>
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> fun observeSeriesWithContent(seriesId: UUID): Flow<Series?>

View File

@@ -49,6 +49,7 @@ class OfflineMediaRepository @Inject constructor(
// Offline mode doesn't support these server-side features // Offline mode doesn't support these server-side features
override val continueWatching: StateFlow<List<Media>> = MutableStateFlow(emptyList()) override val continueWatching: StateFlow<List<Media>> = MutableStateFlow(emptyList())
override val nextUp: StateFlow<List<Media>> = MutableStateFlow(emptyList())
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap()) override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap())
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> { override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {

View File

@@ -6,11 +6,12 @@ import kotlinx.serialization.Serializable
data class CachedMediaItem( data class CachedMediaItem(
val type: String, val type: String,
val id: String, val id: String,
val mediaId: String? = null val seriesId: String? = null
) )
@Serializable @Serializable
data class HomeCache( data class HomeCache(
val continueWatching: List<CachedMediaItem> = emptyList(), val continueWatching: List<CachedMediaItem> = emptyList(),
val nextUp: List<CachedMediaItem> = emptyList(),
val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap() val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap()
) )

View File

@@ -5,10 +5,10 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import hu.bbara.purefin.data.local.room.dao.CastMemberDao 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.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.MovieDao
import hu.bbara.purefin.data.local.room.dao.SeasonDao 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.SeriesDao
import hu.bbara.purefin.data.local.room.dao.LibraryDao
@Database( @Database(
entities = [ entities = [
@@ -19,7 +19,7 @@ import hu.bbara.purefin.data.local.room.dao.SeriesDao
LibraryEntity::class, LibraryEntity::class,
CastMemberEntity::class CastMemberEntity::class
], ],
version = 4, version = 3,
exportSchema = false exportSchema = false
) )
@TypeConverters(UuidConverters::class) @TypeConverters(UuidConverters::class)

View File

@@ -12,8 +12,7 @@ import java.util.UUID
ForeignKey( ForeignKey(
entity = LibraryEntity::class, entity = LibraryEntity::class,
parentColumns = ["id"], parentColumns = ["id"],
childColumns = ["libraryId"], childColumns = ["libraryId"]
onDelete = ForeignKey.CASCADE
), ),
], ],
indices = [Index("libraryId")] indices = [Index("libraryId")]