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

@@ -20,9 +20,14 @@ import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
@@ -71,6 +76,37 @@ class MediaDownloadManager @Inject constructor(
})
}
/**
* Polls the download index every 500 ms and emits a map of contentId → progress (0100)
* for every download that is currently queued or in progress.
* Uses the download index directly so it always reflects the true download state,
* regardless of listener callback timing.
*/
fun observeActiveDownloads(): Flow<Map<String, Float>> = flow {
while (true) {
try {
val result = buildMap<String, Float> {
val cursor = downloadManager.downloadIndex.getDownloads(
Download.STATE_QUEUED,
Download.STATE_DOWNLOADING,
Download.STATE_RESTARTING
)
cursor.use {
while (it.moveToNext()) {
val d = it.download
put(d.request.id, d.percentDownloaded)
}
}
}
emit(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to poll active downloads", e)
emit(emptyMap())
}
delay(500)
}
}.flowOn(Dispatchers.IO).distinctUntilChanged()
fun observeDownloadState(contentId: String): StateFlow<DownloadState> {
val flow = getOrCreateStateFlow(contentId)
// Initialize from current download index

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)
}
}
}
}
}