mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
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:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (0–100)
|
||||
* 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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 (0–100f). 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user