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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
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.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.layout.size
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
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.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Close
|
||||||
import androidx.compose.material.icons.outlined.Download
|
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.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import hu.bbara.purefin.common.ui.PosterCard
|
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
|
import hu.bbara.purefin.feature.shared.download.DownloadsViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -29,8 +47,11 @@ fun DownloadsContent(
|
|||||||
viewModel: DownloadsViewModel = hiltViewModel(),
|
viewModel: DownloadsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val downloads = viewModel.downloads.collectAsState(emptyList())
|
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(
|
Column(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
@@ -46,9 +67,9 @@ fun DownloadsContent(
|
|||||||
text = "No downloads yet",
|
text = "No downloads yet",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
@@ -58,6 +79,37 @@ fun DownloadsContent(
|
|||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
modifier = modifier.background(MaterialTheme.colorScheme.background)
|
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 ->
|
items(downloads.value) { item ->
|
||||||
PosterCard(
|
PosterCard(
|
||||||
item = item,
|
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.Season
|
||||||
import hu.bbara.purefin.core.model.Series
|
import hu.bbara.purefin.core.model.Series
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
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> {
|
fun observeDownloadState(contentId: String): StateFlow<DownloadState> {
|
||||||
val flow = getOrCreateStateFlow(contentId)
|
val flow = getOrCreateStateFlow(contentId)
|
||||||
// Initialize from current download index
|
// 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.core.data.navigation.SeriesDto
|
||||||
import hu.bbara.purefin.feature.download.MediaDownloadManager
|
import hu.bbara.purefin.feature.download.MediaDownloadManager
|
||||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
@@ -25,36 +27,79 @@ class DownloadsViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun onMovieSelected(movieId: UUID) {
|
fun onMovieSelected(movieId: UUID) {
|
||||||
navigationManager.navigate(Route.MovieRoute(
|
navigationManager.navigate(Route.MovieRoute(
|
||||||
MovieDto(
|
MovieDto(id = movieId)
|
||||||
id = movieId,
|
|
||||||
)
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSeriesSelected(seriesId: UUID) {
|
fun onSeriesSelected(seriesId: UUID) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
navigationManager.navigate(Route.SeriesRoute(
|
navigationManager.navigate(Route.SeriesRoute(
|
||||||
SeriesDto(
|
SeriesDto(id = seriesId)
|
||||||
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(
|
val downloads = combine(
|
||||||
offlineMediaRepository.movies,
|
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
|
offlineMediaRepository.series
|
||||||
) { movies, series ->
|
) { inProgress, movies, episodes, seriesMap ->
|
||||||
movies.values.map {
|
inProgress.mapNotNull { (contentId, progress) ->
|
||||||
PosterItem(
|
val id = try { UUID.fromString(contentId) } catch (e: Exception) { return@mapNotNull null }
|
||||||
type = BaseItemKind.MOVIE,
|
val movie = movies[id]
|
||||||
movie = it
|
if (movie != null) {
|
||||||
)
|
ActiveDownloadItem(
|
||||||
} + series.values.map {
|
contentId = contentId,
|
||||||
PosterItem(
|
title = movie.title,
|
||||||
type = BaseItemKind.SERIES,
|
subtitle = "",
|
||||||
series = it
|
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