diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/DownloadsContent.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/DownloadsContent.kt index d6d70be..1096084 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/DownloadsContent.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/DownloadsContent.kt @@ -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 + ) + } + } + } } diff --git a/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt b/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt index 2ef1acb..e7a03a5 100644 --- a/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt +++ b/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt @@ -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> = flow { + while (true) { + try { + val result = buildMap { + 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 { val flow = getOrCreateStateFlow(contentId) // Initialize from current download index diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/download/ActiveDownloadItem.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/download/ActiveDownloadItem.kt new file mode 100644 index 0000000..322574d --- /dev/null +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/download/ActiveDownloadItem.kt @@ -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, +) diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/download/DownloadsViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/download/DownloadsViewModel.kt index 6bc820b..f09375e 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/download/DownloadsViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/download/DownloadsViewModel.kt @@ -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) + } } } -} \ No newline at end of file +}