feat: update media selection logic to handle offline state in view models and DTOs

This commit is contained in:
2026-05-14 12:56:48 +02:00
parent 83730188eb
commit f60aba5a72
19 changed files with 111 additions and 72 deletions

View File

@@ -40,7 +40,7 @@ fun EntryProviderScope<Route>.tvSeriesSection() {
fun EntryProviderScope<Route>.tvEpisodeSection() {
entry<Route.EpisodeRoute> { route ->
TvSeriesScreen(
series = SeriesDto(id = route.item.seriesId),
series = SeriesDto(id = route.item.seriesId, offline = route.item.offline),
focusedSeasonId = route.item.seasonId,
focusedEpisodeId = route.item.id
)

View File

@@ -37,11 +37,7 @@ fun TvEpisodeScreen(
val navigationManager = LocalNavigationManager.current
LaunchedEffect(episode) {
viewModel.selectEpisode(
seriesId = episode.seriesId,
seasonId = episode.seasonId,
episodeId = episode.id
)
viewModel.selectEpisode(episode)
}
val episode = viewModel.episode.collectAsState()

View File

@@ -28,8 +28,8 @@ fun TvMovieScreen(
viewModel: MovieScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
LaunchedEffect(movie.id) {
viewModel.selectMovie(movie.id)
LaunchedEffect(movie) {
viewModel.selectMovie(movie)
}
val movieItem = viewModel.movie.collectAsState()

View File

@@ -42,8 +42,8 @@ fun TvSeriesScreen(
modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel()
) {
LaunchedEffect(series.id) {
viewModel.selectSeries(series.id)
LaunchedEffect(series) {
viewModel.selectSeries(series)
}
val series = viewModel.series.collectAsState()

View File

@@ -43,11 +43,7 @@ fun EpisodeScreen(
val previousRoute = remember(backStack) { backStack.getOrNull(backStack.lastIndex - 1) }
LaunchedEffect(episode) {
viewModel.selectEpisode(
seriesId = episode.seriesId,
seasonId = episode.seasonId,
episodeId = episode.id
)
viewModel.selectEpisode(episode)
}
val episode = viewModel.episode.collectAsState()

View File

@@ -35,8 +35,8 @@ fun MovieScreen(
viewModel: MovieScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
LaunchedEffect(movie.id) {
viewModel.selectMovie(movie.id)
LaunchedEffect(movie) {
viewModel.selectMovie(movie)
}
val movieItem = viewModel.movie.collectAsState()

View File

@@ -45,13 +45,13 @@ fun SeriesScreen(
modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel()
) {
LaunchedEffect(series.id) {
viewModel.selectSeries(series.id)
LaunchedEffect(series) {
viewModel.selectSeries(series)
}
val series = viewModel.series.collectAsState()
val seriesState = viewModel.series.collectAsState()
val seriesData = series.value
val seriesData = seriesState.value
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
LaunchedEffect(seriesData) {
viewModel.observeSeriesDownloadState(seriesData)
@@ -75,6 +75,7 @@ fun SeriesScreen(
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState,
onBack = viewModel::onGoHome,
offline = series.offline,
modifier = modifier
)
} else {
@@ -91,6 +92,7 @@ private fun SeriesScreenInternal(
onLoadSeasonEpisodes: (UUID, UUID) -> Unit,
onObserveSeasonDownloadState: (List<Episode>) -> Unit,
onBack: () -> Unit,
offline: Boolean,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
@@ -128,6 +130,7 @@ private fun SeriesScreenInternal(
seriesDownloadState = seriesDownloadState,
selectedSeason = selectedSeason,
seasonDownloadState = seasonDownloadState,
offline = offline,
onDownloadOptionSelected = { option ->
onDownloadOptionSelected(option, selectedSeason)
}
@@ -188,7 +191,8 @@ private fun SeriesScreenPreview() {
onDownloadOptionSelected = { _, _ -> },
onLoadSeasonEpisodes = { _, _ -> },
onObserveSeasonDownloadState = {},
onBack = {}
onBack = {},
offline = false,
)
}
}

View File

@@ -112,6 +112,7 @@ internal fun SeriesActionButtons(
seriesDownloadState: DownloadState,
selectedSeason: Season,
seasonDownloadState: DownloadState,
offline: Boolean,
onDownloadOptionSelected: (SeriesDownloadOption) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -119,8 +120,7 @@ internal fun SeriesActionButtons(
val navigationManager = LocalNavigationManager.current
val scheme = MaterialTheme.colorScheme
var showDownloadDialog by remember { mutableStateOf(false) }
val episodeId = nextUpEpisode?.id
val playAction = remember(nextUpEpisode) {
val playAction = remember(nextUpEpisode, offline) {
nextUpEpisode?.let { episode ->
{
navigationManager.navigate(
@@ -128,7 +128,8 @@ internal fun SeriesActionButtons(
EpisodeDto(
id = episode.id,
seasonId = episode.seasonId,
seriesId = episode.seriesId
seriesId = episode.seriesId,
offline = offline,
)
)
)

View File

@@ -3,14 +3,15 @@ package hu.bbara.purefin.core.feature.content.episode
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.model.Episode
import java.util.UUID
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -25,28 +26,32 @@ import javax.inject.Inject
@HiltViewModel
class EpisodeScreenViewModel @Inject constructor(
private val mediaCatalogReader: LocalMediaRepository,
private val defaultMediaCatalogReader: LocalMediaRepository,
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController,
): ViewModel() {
private val _episodeId = MutableStateFlow<UUID?>(null)
private val _seriesId = MutableStateFlow<UUID?>(null)
private val _episode = MutableStateFlow<EpisodeDto?>(null)
@OptIn(ExperimentalCoroutinesApi::class)
val episode: StateFlow<Episode?> = _episodeId
.flatMapLatest { episodeId ->
if (episodeId == null) flowOf(null) else mediaCatalogReader.getEpisode(episodeId)
val episode: StateFlow<Episode?> = _episode
.flatMapLatest { episode ->
if (episode == null) {
flowOf(null)
} else {
mediaCatalogReader(episode.offline).getEpisode(episode.id)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@OptIn(ExperimentalCoroutinesApi::class)
val seriesTitle: StateFlow<String?> = _seriesId
.flatMapLatest { seriesId ->
if (seriesId == null) {
val seriesTitle: StateFlow<String?> = _episode
.flatMapLatest { episode ->
if (episode == null) {
flowOf(null)
} else {
mediaCatalogReader.getSeries(seriesId).map { it?.name }
mediaCatalogReader(episode.offline).getSeries(episode.seriesId).map { it?.name }
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@@ -59,22 +64,25 @@ class EpisodeScreenViewModel @Inject constructor(
}
fun onSeriesClick() {
val seriesId = _seriesId.value ?: return
navigationManager.navigate(Route.SeriesRoute(SeriesDto(id = seriesId)))
val episode = _episode.value ?: return
navigationManager.navigate(
Route.SeriesRoute(
SeriesDto(id = episode.seriesId, offline = episode.offline)
)
)
}
fun selectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
_episodeId.value = episodeId
_seriesId.value = seriesId
fun selectEpisode(episode: EpisodeDto) {
_episode.value = episode
viewModelScope.launch {
mediaDownloadManager.observeDownloadState(episodeId.toString()).collect {
mediaDownloadManager.observeDownloadState(episode.id.toString()).collect {
_downloadState.value = it
}
}
}
fun onDownloadClick() {
val episodeId = _episodeId.value ?: return
val episodeId = _episode.value?.id ?: return
viewModelScope.launch {
when (_downloadState.value) {
is DownloadState.NotDownloaded, is DownloadState.Failed -> {
@@ -87,4 +95,7 @@ class EpisodeScreenViewModel @Inject constructor(
}
}
private fun mediaCatalogReader(offline: Boolean): LocalMediaRepository {
return if (offline) offlineMediaCatalogReader else defaultMediaCatalogReader
}
}

View File

@@ -3,13 +3,14 @@ package hu.bbara.purefin.core.feature.content.movie
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.model.Movie
import java.util.UUID
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -23,17 +24,22 @@ import javax.inject.Inject
@HiltViewModel
class MovieScreenViewModel @Inject constructor(
private val mediaCatalogReader: LocalMediaRepository,
private val defaultMediaCatalogReader: LocalMediaRepository,
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController,
): ViewModel() {
private val _movieId = MutableStateFlow<UUID?>(null)
private val _movie = MutableStateFlow<MovieDto?>(null)
@OptIn(ExperimentalCoroutinesApi::class)
val movie: StateFlow<Movie?> = _movieId
.flatMapLatest { movieId ->
if (movieId == null) flowOf(null) else mediaCatalogReader.getMovie(movieId)
val movie: StateFlow<Movie?> = _movie
.flatMapLatest { movie ->
if (movie == null) {
flowOf(null)
} else {
mediaCatalogReader(movie.offline).getMovie(movie.id)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@@ -53,10 +59,10 @@ class MovieScreenViewModel @Inject constructor(
navigationManager.replaceAll(Route.Home)
}
fun selectMovie(movieId: UUID) {
_movieId.value = movieId
fun selectMovie(movie: MovieDto) {
_movie.value = movie
viewModelScope.launch {
mediaDownloadManager.observeDownloadState(movieId.toString()).collect {
mediaDownloadManager.observeDownloadState(movie.id.toString()).collect {
_downloadState.value = it
}
}
@@ -79,4 +85,7 @@ class MovieScreenViewModel @Inject constructor(
}
}
private fun mediaCatalogReader(offline: Boolean): LocalMediaRepository {
return if (offline) offlineMediaCatalogReader else defaultMediaCatalogReader
}
}

View File

@@ -3,12 +3,14 @@ package hu.bbara.purefin.core.feature.content.series
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Series
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -27,17 +29,22 @@ import javax.inject.Inject
@HiltViewModel
class SeriesViewModel @Inject constructor(
private val mediaCatalogReader: LocalMediaRepository,
private val defaultMediaCatalogReader: LocalMediaRepository,
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController,
) : ViewModel() {
private val _seriesId = MutableStateFlow<UUID?>(null)
private val _series = MutableStateFlow<SeriesDto?>(null)
@OptIn(ExperimentalCoroutinesApi::class)
val series: StateFlow<Series?> = _seriesId
.flatMapLatest { seriesId ->
if (seriesId == null) flowOf(null) else mediaCatalogReader.getSeries(seriesId)
val series: StateFlow<Series?> = _series
.flatMapLatest { series ->
if (series == null) {
flowOf(null)
} else {
mediaCatalogReader(series.offline).getSeries(series.id)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@@ -80,12 +87,13 @@ class SeriesViewModel @Inject constructor(
fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
viewModelScope.launch {
mediaCatalogReader.loadSeasonEpisodes(seriesId, seasonId)
selectedMediaCatalogReader().loadSeasonEpisodes(seriesId, seasonId)
}
}
fun downloadSeason(seriesId: UUID, seasonId: UUID) {
viewModelScope.launch {
val mediaCatalogReader = selectedMediaCatalogReader()
mediaCatalogReader.loadSeasonEpisodes(seriesId, seasonId)
val episodes = mediaCatalogReader.getSeries(seriesId)
.first()
@@ -105,6 +113,7 @@ class SeriesViewModel @Inject constructor(
fun downloadSeries(seriesData: Series) {
viewModelScope.launch {
val mediaCatalogReader = selectedMediaCatalogReader()
seriesData.seasons.forEach { season ->
mediaCatalogReader.loadSeasonEpisodes(seriesData.id, season.id)
}
@@ -136,7 +145,8 @@ class SeriesViewModel @Inject constructor(
EpisodeDto(
id = episodeId,
seasonId = seasonId,
seriesId = seriesId
seriesId = seriesId,
offline = _series.value?.offline == true,
)
))
}
@@ -153,10 +163,18 @@ class SeriesViewModel @Inject constructor(
navigationManager.replaceAll(Route.Home)
}
fun selectSeries(seriesId: UUID) {
_seriesId.value = seriesId
fun selectSeries(series: SeriesDto) {
_series.value = series
viewModelScope.launch {
mediaCatalogReader.loadSeasons(seriesId)
mediaCatalogReader(series.offline).loadSeasons(series.id)
}
}
private fun selectedMediaCatalogReader(): LocalMediaRepository {
return mediaCatalogReader(_series.value?.offline == true)
}
private fun mediaCatalogReader(offline: Boolean): LocalMediaRepository {
return if (offline) offlineMediaCatalogReader else defaultMediaCatalogReader
}
}

View File

@@ -23,7 +23,7 @@ import javax.inject.Inject
@HiltViewModel
class DownloadsViewModel @Inject constructor(
@Offline private val offlineCatalogReader: LocalMediaRepository,
@param:Offline private val offlineCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager,
private val downloadManager: MediaDownloadController,
) : ViewModel() {
@@ -31,7 +31,7 @@ class DownloadsViewModel @Inject constructor(
fun onMovieSelected(movieId: UUID) {
navigationManager.navigate(
Route.MovieRoute(
MovieDto(id = movieId)
MovieDto(id = movieId, offline = true)
))
}
@@ -39,7 +39,7 @@ class DownloadsViewModel @Inject constructor(
viewModelScope.launch {
navigationManager.navigate(
Route.SeriesRoute(
SeriesDto(id = seriesId)
SeriesDto(id = seriesId, offline = true)
))
}
}

View File

@@ -11,4 +11,5 @@ data class EpisodeDto(
val seasonId: UUID,
@Serializable(with = UuidSerializer::class)
val seriesId: UUID,
val offline: Boolean = false,
)

View File

@@ -7,4 +7,5 @@ import kotlinx.serialization.Serializable
data class MovieDto(
@Serializable(with = UuidSerializer::class)
val id: UUID,
val offline: Boolean = false,
)

View File

@@ -7,4 +7,5 @@ import kotlinx.serialization.Serializable
data class SeriesDto(
@Serializable(with = UuidSerializer::class)
val id: UUID,
val offline: Boolean = false,
)

View File

@@ -37,7 +37,7 @@ class OfflineLocalMediaRepository @Inject constructor(
}
override suspend fun getSeries(id: UUID): Flow<Series?> {
return series.map { it[id] }
return localDataSource.observeSeriesWithContent(id)
}
override suspend fun getEpisode(id: UUID): Flow<Episode?> {

View File

@@ -15,10 +15,10 @@ interface EpisodeDao {
@Upsert
suspend fun upsertAll(episodes: List<EpisodeEntity>)
@Query("SELECT * FROM episodes WHERE seriesId = :seriesId")
@Query("SELECT * FROM episodes WHERE seriesId = :seriesId ORDER BY seasonIndex ASC, `index` ASC")
suspend fun getBySeriesId(seriesId: UUID): List<EpisodeEntity>
@Query("SELECT * FROM episodes WHERE seasonId = :seasonId")
@Query("SELECT * FROM episodes WHERE seasonId = :seasonId ORDER BY `index` ASC")
suspend fun getBySeasonId(seasonId: UUID): List<EpisodeEntity>
@Query("SELECT * FROM episodes")

View File

@@ -14,7 +14,7 @@ interface SeasonDao {
@Upsert
suspend fun upsertAll(seasons: List<SeasonEntity>)
@Query("SELECT * FROM seasons WHERE seriesId = :seriesId")
@Query("SELECT * FROM seasons WHERE seriesId = :seriesId ORDER BY `index` ASC")
suspend fun getBySeriesId(seriesId: UUID): List<SeasonEntity>
@Query("SELECT * FROM seasons WHERE id = :id")

View File

@@ -43,11 +43,12 @@ class OfflineRoomMediaLocalDataSource(
seriesDao.observeWithContent(seriesId).map { relation ->
relation?.let {
it.series.toDomain(
seasons = it.seasons.map { swe ->
seasons = it.seasons.sortedBy { swe -> swe.season.index }.map { swe ->
swe.season.toDomain(
episodes = swe.episodes.map { ep -> ep.toDomain() }
episodes = swe.episodes.sortedBy { ep -> ep.index }.map { ep -> ep.toDomain() }
)
}
)
} )
}
}