mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
feat: refactor media retrieval to use combined flows for episodes and continue watching
This commit is contained in:
@@ -7,7 +7,10 @@ import hu.bbara.purefin.data.InMemoryMediaRepository
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -18,8 +21,14 @@ class EpisodeScreenViewModel @Inject constructor(
|
||||
private val navigationManager: NavigationManager,
|
||||
): ViewModel() {
|
||||
|
||||
private val _episode = MutableStateFlow<Episode?>(null)
|
||||
val episode = _episode.asStateFlow()
|
||||
private val _episodeId = MutableStateFlow<UUID?>(null)
|
||||
|
||||
val episode: StateFlow<Episode?> = combine(
|
||||
_episodeId,
|
||||
mediaRepository.episodes
|
||||
) { id, episodesMap ->
|
||||
id?.let { episodesMap[it] }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch { mediaRepository.ensureReady() }
|
||||
@@ -30,13 +39,7 @@ class EpisodeScreenViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun selectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
|
||||
viewModelScope.launch {
|
||||
_episode.value = mediaRepository.getEpisode(
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
episodeId = episodeId,
|
||||
)
|
||||
}
|
||||
_episodeId.value = episodeId
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -40,9 +40,10 @@ fun SeriesScreen(
|
||||
|
||||
val series = viewModel.series.collectAsState()
|
||||
|
||||
if (series.value != null) {
|
||||
val seriesData = series.value
|
||||
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
|
||||
SeriesScreenInternal(
|
||||
series = series.value!!,
|
||||
series = seriesData,
|
||||
onBack = viewModel::onBack,
|
||||
modifier = modifier
|
||||
)
|
||||
|
||||
@@ -60,9 +60,5 @@ class SeriesViewModel @Inject constructor(
|
||||
|
||||
fun selectSeries(seriesId: UUID) {
|
||||
_seriesId.value = seriesId
|
||||
// Ensure content is loaded from API if not cached
|
||||
viewModelScope.launch {
|
||||
mediaRepository.getSeriesWithContent(seriesId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,16 +19,12 @@ import hu.bbara.purefin.navigation.NavigationManager
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import hu.bbara.purefin.navigation.SeriesDto
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
@@ -59,97 +55,58 @@ class HomePageViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
val continueWatching = mediaRepository.continueWatching
|
||||
.mapLatest { list ->
|
||||
withContext(Dispatchers.IO) {
|
||||
list.map { media ->
|
||||
when (media) {
|
||||
is Media.MovieMedia -> {
|
||||
val movie = mediaRepository.getMovie(media.movieId)
|
||||
ContinueWatchingItem(
|
||||
type = BaseItemKind.MOVIE,
|
||||
movie = movie
|
||||
)
|
||||
}
|
||||
|
||||
is Media.EpisodeMedia -> {
|
||||
val episode = mediaRepository.getEpisode(
|
||||
seriesId = media.seriesId,
|
||||
episodeId = media.episodeId
|
||||
)
|
||||
ContinueWatchingItem(
|
||||
type = BaseItemKind.EPISODE,
|
||||
episode = episode
|
||||
)
|
||||
}
|
||||
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: $media")
|
||||
}
|
||||
}.distinctBy { it.id }
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val latestLibraryContent = mediaRepository.latestLibraryContent
|
||||
.mapLatest { libraryMap ->
|
||||
withContext(Dispatchers.IO) {
|
||||
libraryMap.mapValues { (_, items) ->
|
||||
items.map { media ->
|
||||
when (media) {
|
||||
is Media.MovieMedia -> {
|
||||
val movie = mediaRepository.getMovie(media.movieId)
|
||||
PosterItem(
|
||||
type = BaseItemKind.MOVIE,
|
||||
movie = movie
|
||||
)
|
||||
}
|
||||
|
||||
is Media.EpisodeMedia -> {
|
||||
val episode = mediaRepository.getEpisode(
|
||||
seriesId = media.seriesId,
|
||||
episodeId = media.episodeId
|
||||
)
|
||||
PosterItem(
|
||||
type = BaseItemKind.EPISODE,
|
||||
episode = episode
|
||||
)
|
||||
}
|
||||
|
||||
is Media.SeriesMedia -> {
|
||||
val series = mediaRepository.getSeries(media.id)
|
||||
PosterItem(
|
||||
type = BaseItemKind.SERIES,
|
||||
series = series
|
||||
)
|
||||
}
|
||||
|
||||
is Media.SeasonMedia -> {
|
||||
val series = mediaRepository.getSeries(media.seriesId)
|
||||
PosterItem(
|
||||
type = BaseItemKind.SERIES,
|
||||
series = series
|
||||
)
|
||||
}
|
||||
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: $media")
|
||||
}
|
||||
}.distinctBy { it.id }
|
||||
val continueWatching = combine(
|
||||
mediaRepository.continueWatching,
|
||||
mediaRepository.movies,
|
||||
mediaRepository.episodes
|
||||
) { list, moviesMap, episodesMap ->
|
||||
list.mapNotNull { media ->
|
||||
when (media) {
|
||||
is Media.MovieMedia -> moviesMap[media.movieId]?.let {
|
||||
ContinueWatchingItem(type = BaseItemKind.MOVIE, movie = it)
|
||||
}
|
||||
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
|
||||
ContinueWatchingItem(type = BaseItemKind.EPISODE, episode = it)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}.distinctBy { it.id }
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val latestLibraryContent = combine(
|
||||
mediaRepository.latestLibraryContent,
|
||||
mediaRepository.movies,
|
||||
mediaRepository.series,
|
||||
mediaRepository.episodes
|
||||
) { libraryMap, moviesMap, seriesMap, episodesMap ->
|
||||
libraryMap.mapValues { (_, items) ->
|
||||
items.mapNotNull { media ->
|
||||
when (media) {
|
||||
is Media.MovieMedia -> moviesMap[media.movieId]?.let {
|
||||
PosterItem(type = BaseItemKind.MOVIE, movie = it)
|
||||
}
|
||||
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
|
||||
PosterItem(type = BaseItemKind.EPISODE, episode = it)
|
||||
}
|
||||
is Media.SeriesMedia -> seriesMap[media.seriesId]?.let {
|
||||
PosterItem(type = BaseItemKind.SERIES, series = it)
|
||||
}
|
||||
is Media.SeasonMedia -> seriesMap[media.seriesId]?.let {
|
||||
PosterItem(type = BaseItemKind.SERIES, series = it)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}.distinctBy { it.id }
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyMap()
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyMap()
|
||||
)
|
||||
|
||||
init {
|
||||
viewModelScope.launch { mediaRepository.ensureReady() }
|
||||
|
||||
@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.app.home.ui.PosterItem
|
||||
import hu.bbara.purefin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.data.InMemoryMediaRepository
|
||||
import hu.bbara.purefin.data.model.Media
|
||||
import hu.bbara.purefin.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.navigation.MovieDto
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
@@ -14,7 +15,8 @@ import hu.bbara.purefin.navigation.SeriesDto
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
@@ -35,8 +37,26 @@ class LibraryViewModel @Inject constructor(
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = ""
|
||||
)
|
||||
private val _contents = MutableStateFlow<List<PosterItem>>(emptyList())
|
||||
val contents = _contents.asStateFlow()
|
||||
|
||||
private val _libraryItems = MutableStateFlow<List<Media>>(emptyList())
|
||||
|
||||
val contents: StateFlow<List<PosterItem>> = combine(
|
||||
_libraryItems,
|
||||
mediaRepository.movies,
|
||||
mediaRepository.series
|
||||
) { items, moviesMap, seriesMap ->
|
||||
items.mapNotNull { media ->
|
||||
when (media) {
|
||||
is Media.MovieMedia -> moviesMap[media.movieId]?.let {
|
||||
PosterItem(type = BaseItemKind.MOVIE, movie = it)
|
||||
}
|
||||
is Media.SeriesMedia -> seriesMap[media.seriesId]?.let {
|
||||
PosterItem(type = BaseItemKind.SERIES, series = it)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
init {
|
||||
viewModelScope.launch { mediaRepository.ensureReady() }
|
||||
@@ -67,22 +87,10 @@ class LibraryViewModel @Inject constructor(
|
||||
fun selectLibrary(libraryId: UUID) {
|
||||
viewModelScope.launch {
|
||||
val libraryItems = jellyfinApiClient.getLibraryContent(libraryId)
|
||||
_contents.value = libraryItems.map {
|
||||
_libraryItems.value = libraryItems.map {
|
||||
when (it.type) {
|
||||
BaseItemKind.MOVIE -> {
|
||||
val movie = mediaRepository.getMovie(it.id)
|
||||
PosterItem(
|
||||
type = BaseItemKind.MOVIE,
|
||||
movie = movie
|
||||
)
|
||||
}
|
||||
BaseItemKind.SERIES -> {
|
||||
val series = mediaRepository.getSeries(it.id)
|
||||
PosterItem(
|
||||
type = BaseItemKind.SERIES,
|
||||
series = series
|
||||
)
|
||||
}
|
||||
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = it.id)
|
||||
BaseItemKind.SERIES -> Media.SeriesMedia(seriesId = it.id)
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,16 @@ class InMemoryMediaRepository @Inject constructor(
|
||||
override val series: StateFlow<Map<UUID, Series>> = localDataSource.seriesFlow
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
|
||||
localDataSource.observeSeriesWithContent(seriesId)
|
||||
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
|
||||
scope.launch {
|
||||
awaitReady()
|
||||
ensureSeriesContentLoaded(seriesId)
|
||||
}
|
||||
return localDataSource.observeSeriesWithContent(seriesId)
|
||||
}
|
||||
|
||||
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
|
||||
val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
|
||||
@@ -203,23 +211,11 @@ class InMemoryMediaRepository @Inject constructor(
|
||||
//TODO Load seasons and episodes, other types are already loaded at this point.
|
||||
}
|
||||
|
||||
override suspend fun getMovie(movieId: UUID): Movie {
|
||||
private suspend fun ensureSeriesContentLoaded(seriesId: UUID) {
|
||||
awaitReady()
|
||||
return localDataSource.getMovie(movieId)
|
||||
?: throw RuntimeException("Movie not found")
|
||||
}
|
||||
|
||||
override suspend fun getSeries(seriesId: UUID): Series {
|
||||
awaitReady()
|
||||
return localDataSource.getSeriesBasic(seriesId)
|
||||
?: throw RuntimeException("Series not found")
|
||||
}
|
||||
|
||||
override suspend fun getSeriesWithContent(seriesId: UUID): Series {
|
||||
awaitReady()
|
||||
// Use cached content if available
|
||||
// Skip if content is already cached in Room
|
||||
localDataSource.getSeriesWithContent(seriesId)?.takeIf { it.seasons.isNotEmpty() }?.let {
|
||||
return it
|
||||
return
|
||||
}
|
||||
|
||||
val series = this.series.value[seriesId] ?: throw RuntimeException("Series not found")
|
||||
@@ -234,68 +230,6 @@ class InMemoryMediaRepository @Inject constructor(
|
||||
val updatedSeries = series.copy(seasons = filledSeasons)
|
||||
localDataSource.saveSeries(listOf(updatedSeries))
|
||||
localDataSource.saveSeriesContent(updatedSeries)
|
||||
return updatedSeries
|
||||
}
|
||||
|
||||
override suspend fun getSeason(
|
||||
seriesId: UUID,
|
||||
seasonId: UUID,
|
||||
): Season {
|
||||
awaitReady()
|
||||
localDataSource.getSeason(seriesId, seasonId)?.let { return it }
|
||||
// Fallback: ensure series content is loaded, then retry
|
||||
val series = getSeriesWithContent(seriesId)
|
||||
return series.seasons.find { it.id == seasonId }?: throw RuntimeException("Season not found")
|
||||
}
|
||||
|
||||
override suspend fun getSeasons(
|
||||
seriesId: UUID,
|
||||
): List<Season> {
|
||||
awaitReady()
|
||||
val seasons = localDataSource.getSeasons(seriesId)
|
||||
if (seasons.isNotEmpty()) return seasons
|
||||
val series = getSeriesWithContent(seriesId)
|
||||
return series.seasons
|
||||
}
|
||||
|
||||
override suspend fun getEpisode(
|
||||
seriesId: UUID,
|
||||
episodeId: UUID
|
||||
) : Episode {
|
||||
awaitReady()
|
||||
localDataSource.getEpisodeById(episodeId)?.let { return it }
|
||||
val series = getSeriesWithContent(seriesId)
|
||||
return series.seasons.flatMap { it.episodes }.find { it.id == episodeId }?: throw RuntimeException("Episode not found")
|
||||
}
|
||||
|
||||
override suspend fun getEpisode(
|
||||
seriesId: UUID,
|
||||
seasonId: UUID,
|
||||
episodeId: UUID
|
||||
): Episode {
|
||||
awaitReady()
|
||||
localDataSource.getEpisode(seriesId, seasonId, episodeId)?.let { return it }
|
||||
val series = getSeriesWithContent(seriesId)
|
||||
return series.seasons.find { it.id == seasonId }?.episodes?.find { it.id == episodeId } ?: throw RuntimeException("Episode not found")
|
||||
}
|
||||
|
||||
override suspend fun getEpisodes(
|
||||
seriesId: UUID,
|
||||
seasonId: UUID
|
||||
): List<Episode> {
|
||||
awaitReady()
|
||||
val episodes = localDataSource.getSeason(seriesId, seasonId)?.episodes
|
||||
if (episodes != null && episodes.isNotEmpty()) return episodes
|
||||
val series = getSeriesWithContent(seriesId)
|
||||
return series.seasons.find { it.id == seasonId }?.episodes ?: throw RuntimeException("Season not found")
|
||||
}
|
||||
|
||||
override suspend fun getEpisodes(seriesId: UUID): List<Episode> {
|
||||
awaitReady()
|
||||
val episodes = localDataSource.getEpisodesBySeries(seriesId)
|
||||
if (episodes.isNotEmpty()) return episodes
|
||||
val series = getSeriesWithContent(seriesId)
|
||||
return series.seasons.flatMap { it.episodes }
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package hu.bbara.purefin.data
|
||||
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.data.model.Season
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -12,22 +11,13 @@ interface MediaRepository {
|
||||
|
||||
val movies: StateFlow<Map<UUID, Movie>>
|
||||
val series: StateFlow<Map<UUID, Series>>
|
||||
val episodes: StateFlow<Map<UUID, Episode>>
|
||||
val state: StateFlow<MediaRepositoryState>
|
||||
|
||||
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?>
|
||||
|
||||
suspend fun ensureReady()
|
||||
|
||||
suspend fun getMovie(movieId: UUID) : Movie
|
||||
suspend fun getSeries(seriesId: UUID) : Series
|
||||
suspend fun getSeriesWithContent(seriesId: UUID) : Series
|
||||
suspend fun getSeasons(seriesId: UUID) : List<Season>
|
||||
suspend fun getSeason(seriesId: UUID, seasonId: UUID) : Season
|
||||
suspend fun getEpisodes(seriesId: UUID) : List<Episode>
|
||||
suspend fun getEpisodes(seriesId: UUID, seasonId: UUID) : List<Episode>
|
||||
suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) : Episode
|
||||
suspend fun getEpisode(seriesId: UUID, episodeId: UUID) : Episode
|
||||
|
||||
suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long)
|
||||
suspend fun refreshHomeData()
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ class RoomMediaLocalDataSource @Inject constructor(
|
||||
entities.associate { it.id to it.toDomain(seasons = emptyList(), cast = emptyList()) }
|
||||
}
|
||||
|
||||
val episodesFlow: Flow<Map<UUID, Episode>> = episodeDao.observeAll()
|
||||
.map { entities -> entities.associate { it.id to it.toDomain(cast = emptyList()) } }
|
||||
|
||||
// Full content Flow for series detail screen (scoped to one series)
|
||||
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
|
||||
seriesDao.observeWithContent(seriesId).map { relation ->
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import hu.bbara.purefin.data.local.room.EpisodeEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
@@ -20,6 +21,9 @@ interface EpisodeDao {
|
||||
@Query("SELECT * FROM episodes WHERE seasonId = :seasonId")
|
||||
suspend fun getBySeasonId(seasonId: UUID): List<EpisodeEntity>
|
||||
|
||||
@Query("SELECT * FROM episodes")
|
||||
fun observeAll(): Flow<List<EpisodeEntity>>
|
||||
|
||||
@Query("SELECT * FROM episodes WHERE id = :id")
|
||||
suspend fun getById(id: UUID): EpisodeEntity?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user