feat: show in-progress downloads with progress bar and cancel in DownloadsContent

- Add ActiveDownloadItem data class to represent a download in progress
- Add observeActiveDownloads() to MediaDownloadManager, polling the Media3
  download index every 500ms on Dispatchers.IO for reliable real-time progress
  (listener callbacks alone do not fire on every progress update)
- DownloadsViewModel exposes activeDownloads (StateFlow) and cancelDownload();
  the completed downloads flow filters out items currently in progress
- DownloadsContent shows a "Downloading" section with thumbnail, title,
  progress bar + percentage, and a cancel button above the completed grid
This commit is contained in:
2026-03-03 14:08:19 +01:00
parent fce5a981a2
commit 2a7874806d
4 changed files with 235 additions and 21 deletions

View File

@@ -0,0 +1,9 @@
package hu.bbara.purefin.feature.shared.download
data class ActiveDownloadItem(
val contentId: String,
val title: String,
val subtitle: String,
val imageUrl: String,
val progress: Float,
)

View File

@@ -10,7 +10,9 @@ import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.data.navigation.SeriesDto
import hu.bbara.purefin.feature.download.MediaDownloadManager
import hu.bbara.purefin.feature.shared.home.PosterItem
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
@@ -25,36 +27,79 @@ class DownloadsViewModel @Inject constructor(
fun onMovieSelected(movieId: UUID) {
navigationManager.navigate(Route.MovieRoute(
MovieDto(
id = movieId,
)
MovieDto(id = movieId)
))
}
fun onSeriesSelected(seriesId: UUID) {
viewModelScope.launch {
navigationManager.navigate(Route.SeriesRoute(
SeriesDto(
id = seriesId,
)
SeriesDto(id = seriesId)
))
}
}
// Shared polling source: contentId → progress (0100f). Starts when UI is subscribed.
private val activeDownloadsMap = downloadManager.observeActiveDownloads()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
/** Items that are fully downloaded and not currently in progress. */
val downloads = combine(
offlineMediaRepository.movies,
offlineMediaRepository.series,
activeDownloadsMap
) { movies, series, inProgress ->
movies.values
.filter { it.id.toString() !in inProgress }
.map { PosterItem(type = BaseItemKind.MOVIE, movie = it) } +
series.values.map { PosterItem(type = BaseItemKind.SERIES, series = it) }
}
/** Items currently being downloaded with their progress. */
val activeDownloads = combine(
activeDownloadsMap,
offlineMediaRepository.movies,
offlineMediaRepository.episodes,
offlineMediaRepository.series
) { movies, series ->
movies.values.map {
PosterItem(
type = BaseItemKind.MOVIE,
movie = it
)
} + series.values.map {
PosterItem(
type = BaseItemKind.SERIES,
series = it
)
) { inProgress, movies, episodes, seriesMap ->
inProgress.mapNotNull { (contentId, progress) ->
val id = try { UUID.fromString(contentId) } catch (e: Exception) { return@mapNotNull null }
val movie = movies[id]
if (movie != null) {
ActiveDownloadItem(
contentId = contentId,
title = movie.title,
subtitle = "",
imageUrl = movie.heroImageUrl,
progress = progress
)
} else {
val episode = episodes[id]
episode?.let {
ActiveDownloadItem(
contentId = contentId,
title = it.title,
subtitle = seriesMap[it.seriesId]?.name ?: "",
imageUrl = it.heroImageUrl,
progress = progress
)
}
}
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun cancelDownload(contentId: String) {
viewModelScope.launch {
val id = try {
UUID.fromString(contentId)
} catch (e: Exception) {
return@launch
}
if (offlineMediaRepository.episodes.value.containsKey(id)) {
downloadManager.cancelEpisodeDownload(id)
} else {
downloadManager.cancelDownload(id)
}
}
}
}
}