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

@@ -4,23 +4,41 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PosterCard
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.feature.shared.download.ActiveDownloadItem
import hu.bbara.purefin.feature.shared.download.DownloadsViewModel
@Composable
@@ -29,8 +47,11 @@ fun DownloadsContent(
viewModel: DownloadsViewModel = hiltViewModel(),
) {
val downloads = viewModel.downloads.collectAsState(emptyList())
val activeDownloads = viewModel.activeDownloads.collectAsState()
if (downloads.value.isEmpty()) {
val isEmpty = downloads.value.isEmpty() && activeDownloads.value.isEmpty()
if (isEmpty) {
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
@@ -46,9 +67,9 @@ fun DownloadsContent(
text = "No downloads yet",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
)
}
return
}
LazyVerticalGrid(
@@ -58,6 +79,37 @@ fun DownloadsContent(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.background(MaterialTheme.colorScheme.background)
) {
if (activeDownloads.value.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
text = "Downloading",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onBackground,
)
}
items(
items = activeDownloads.value,
key = { it.contentId },
span = { GridItemSpan(maxLineSpan) }
) { item ->
DownloadingItemRow(
item = item,
onCancel = { viewModel.cancelDownload(it) }
)
}
if (downloads.value.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
text = "Downloaded",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
items(downloads.value) { item ->
PosterCard(
item = item,
@@ -67,5 +119,77 @@ fun DownloadsContent(
)
}
}
}
@Composable
private fun DownloadingItemRow(
item: ActiveDownloadItem,
onCancel: (String) -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
PurefinAsyncImage(
model = item.imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 44.dp, height = 66.dp)
.clip(RoundedCornerShape(8.dp))
)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (item.subtitle.isNotEmpty()) {
Text(
text = item.subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(6.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
LinearProgressIndicator(
progress = { item.progress / 100f },
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
Text(
text = "${item.progress.toInt()}%",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(onClick = { onCancel(item.contentId) }) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "Cancel download",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

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
) { 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
)
} + series.values.map {
PosterItem(
type = BaseItemKind.SERIES,
series = it
} 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)
}
}
}
}