feat(downloads): update downloaded item handling

This commit is contained in:
2026-05-23 18:45:27 +00:00
parent ef56db5d16
commit b226eaeba2
6 changed files with 239 additions and 86 deletions

View File

@@ -24,8 +24,8 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.core.feature.downloads.DownloadsViewModel
import hu.bbara.purefin.ui.common.card.PosterCard
@Composable
fun DownloadsContent(
@@ -101,16 +101,19 @@ fun DownloadsContent(
}
}
}
items(downloads.value, key = { item -> item.id }) { item ->
items(downloads.value, key = { item -> item.media.id }) { item ->
Column(
modifier = Modifier.testTag("$DownloadsItemTagPrefix${item.media.id}")
) {
PosterCard(
item = item,
item = item.media,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = { _, _, _ -> },
modifier = Modifier.testTag("$DownloadsItemTagPrefix${item.id}")
)
}
}
}
}
internal const val DownloadsEmptyStateTag = "downloads-empty-state"

View File

@@ -1,5 +1,9 @@
package hu.bbara.purefin.ui.screen.series
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
@@ -50,18 +54,23 @@ fun SeriesScreen(
}
val seriesState = viewModel.series.collectAsStateWithLifecycle()
var pendingDownloadOption by remember { mutableStateOf<Pair<SeriesDownloadOption, Season>?>(null) }
val seriesData = seriesState.value
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
LaunchedEffect(seriesData) {
viewModel.observeSeriesDownloadState(seriesData)
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { _ ->
// Proceed with download regardless — notification is nice-to-have
val pendingOption = pendingDownloadOption
pendingDownloadOption = null
val (option, selectedSeason) = pendingOption ?: return@rememberLauncherForActivityResult
val seriesData = seriesState.value ?: return@rememberLauncherForActivityResult
val canPerformOption = when (option) {
SeriesDownloadOption.SEASON -> viewModel.seasonDownloadState.value is DownloadState.NotDownloaded
SeriesDownloadOption.SERIES -> viewModel.seriesDownloadState.value is DownloadState.NotDownloaded
SeriesDownloadOption.SMART -> !viewModel.isSmartDownloadEnabled.value
SeriesDownloadOption.DELETE_SMART -> true
}
SeriesScreenInternal(
series = seriesData,
seriesDownloadState = viewModel.seriesDownloadState.collectAsStateWithLifecycle().value,
seasonDownloadState = viewModel.seasonDownloadState.collectAsStateWithLifecycle().value,
isSmartDownloadEnabled = viewModel.isSmartDownloadEnabled.collectAsStateWithLifecycle().value,
onDownloadOptionSelected = { option, selectedSeason ->
if (!canPerformOption) return@rememberLauncherForActivityResult
when (option) {
SeriesDownloadOption.SEASON ->
viewModel.downloadSeason(seriesData.id, selectedSeason.id)
@@ -75,6 +84,65 @@ fun SeriesScreen(
SeriesDownloadOption.DELETE_SMART ->
viewModel.deleteSmartDownloads(seriesData.id)
}
}
val seriesData = seriesState.value
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
LaunchedEffect(seriesData) {
viewModel.observeSeriesDownloadState(seriesData)
}
val seriesDownloadState = viewModel.seriesDownloadState.collectAsStateWithLifecycle().value
val seasonDownloadState = viewModel.seasonDownloadState.collectAsStateWithLifecycle().value
val isSmartDownloadEnabled = viewModel.isSmartDownloadEnabled.collectAsStateWithLifecycle().value
fun canPerformDownloadOption(option: SeriesDownloadOption): Boolean = when (option) {
SeriesDownloadOption.SEASON -> seasonDownloadState is DownloadState.NotDownloaded
SeriesDownloadOption.SERIES -> seriesDownloadState is DownloadState.NotDownloaded
SeriesDownloadOption.SMART -> !isSmartDownloadEnabled
SeriesDownloadOption.DELETE_SMART -> true
}
fun performDownloadOption(option: SeriesDownloadOption, selectedSeason: Season) {
if (!canPerformDownloadOption(option)) return
when (option) {
SeriesDownloadOption.SEASON ->
viewModel.downloadSeason(seriesData.id, selectedSeason.id)
SeriesDownloadOption.SERIES ->
viewModel.downloadSeries(seriesData)
SeriesDownloadOption.SMART ->
viewModel.enableSmartDownload(seriesData.id)
SeriesDownloadOption.DELETE_SMART ->
viewModel.deleteSmartDownloads(seriesData.id)
}
}
fun shouldRequestNotificationPermission(option: SeriesDownloadOption): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false
return when (option) {
SeriesDownloadOption.SEASON,
SeriesDownloadOption.SERIES,
SeriesDownloadOption.SMART -> canPerformDownloadOption(option)
SeriesDownloadOption.DELETE_SMART -> false
}
}
SeriesScreenInternal(
series = seriesData,
seriesDownloadState = seriesDownloadState,
seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled,
onDownloadOptionSelected = { option, selectedSeason ->
if (canPerformDownloadOption(option)) {
if (shouldRequestNotificationPermission(option)) {
pendingDownloadOption = option to selectedSeason
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
performDownloadOption(option, selectedSeason)
}
}
},
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState,
@@ -132,8 +200,8 @@ private fun SeriesScreenInternal(
) {
SeriesActionButtons(
nextUpEpisode = nextUpEpisode,
seriesDownloadState = seriesDownloadState,
selectedSeason = selectedSeason,
seriesDownloadState = seriesDownloadState,
seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled,
offline = offline,
@@ -192,7 +260,7 @@ private fun SeriesScreenPreview() {
AppTheme {
SeriesScreenInternal(
series = previewSeries(),
seriesDownloadState = DownloadState.Downloading(progressPercent = 0.58f),
seriesDownloadState = DownloadState.NotDownloaded,
seasonDownloadState = DownloadState.NotDownloaded,
isSmartDownloadEnabled = true,
onDownloadOptionSelected = { _, _ -> },

View File

@@ -26,18 +26,19 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.Cast
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.DownloadDone
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -48,6 +49,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
@@ -111,8 +113,8 @@ internal fun SeriesMetaChips(series: Series) {
@Composable
internal fun SeriesActionButtons(
nextUpEpisode: Episode?,
seriesDownloadState: DownloadState,
selectedSeason: Season,
seriesDownloadState: DownloadState,
seasonDownloadState: DownloadState,
isSmartDownloadEnabled: Boolean,
offline: Boolean,
@@ -121,7 +123,6 @@ internal fun SeriesActionButtons(
) {
val context = LocalContext.current
val navigationManager = LocalNavigationManager.current
val scheme = MaterialTheme.colorScheme
var showDownloadDialog by remember { mutableStateOf(false) }
val playAction = remember(nextUpEpisode, offline) {
nextUpEpisode?.let { episode ->
@@ -163,11 +164,7 @@ internal fun SeriesActionButtons(
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface,
icon = when {
seriesDownloadState is DownloadState.Downloading -> Icons.Outlined.Close
seriesDownloadState is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
else -> Icons.Outlined.Download
},
icon = Icons.Outlined.Download,
height = 48.dp,
modifier = Modifier.testTag(SeriesDownloadButtonTag),
onClick = { showDownloadDialog = true }
@@ -175,8 +172,9 @@ internal fun SeriesActionButtons(
}
if (showDownloadDialog) {
DownloadOptionsDialog(
DownloadOptionsBottomSheet(
selectedSeasonName = selectedSeason.name,
seriesDownloadState = seriesDownloadState,
seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled,
onDownloadOptionSelected = {
@@ -195,68 +193,136 @@ internal enum class SeriesDownloadOption {
DELETE_SMART
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DownloadOptionsDialog(
private fun DownloadOptionsBottomSheet(
selectedSeasonName: String,
seriesDownloadState: DownloadState,
seasonDownloadState: DownloadState,
isSmartDownloadEnabled: Boolean,
onDownloadOptionSelected: (SeriesDownloadOption) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
ModalBottomSheet(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(SeriesDownloadDialogTag),
title = { Text("Download") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "Download options",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Choose how to download this series.",
style = MaterialTheme.typography.bodyMedium
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
DownloadOptionRow(
title = "Download selected season",
supportingText = when (seasonDownloadState) {
is DownloadState.Downloaded -> "$selectedSeasonName is already downloaded."
is DownloadState.Downloading -> "$selectedSeasonName is downloading."
else -> "Save episodes from $selectedSeasonName for offline viewing."
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
icon = Icons.Outlined.Download,
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SEASON) },
enabled = seasonDownloadState is DownloadState.NotDownloaded,
modifier = Modifier.testTag(SeriesDownloadSeasonButtonTag)
) {
Text(
when (seasonDownloadState) {
is DownloadState.Downloaded -> "$selectedSeasonName Downloaded"
is DownloadState.Downloading -> "Downloading $selectedSeasonName"
else -> "Download $selectedSeasonName"
}
)
}
TextButton(
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SERIES) },
modifier = Modifier.testTag(SeriesDownloadAllButtonTag)
) {
Text("Download All")
}
}
DownloadOptionRow(
title = "Download all episodes",
supportingText = when (seriesDownloadState) {
is DownloadState.Downloaded -> "All episodes in this series are already downloaded."
is DownloadState.Downloading -> "All episodes in this series are downloading."
else -> "Save every available episode in this series."
},
dismissButton = {
icon = Icons.Outlined.Download,
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SERIES) },
enabled = seriesDownloadState is DownloadState.NotDownloaded,
modifier = Modifier.testTag(SeriesDownloadAllButtonTag)
)
if (isSmartDownloadEnabled) {
TextButton(
DownloadOptionRow(
title = "Delete series downloads",
supportingText = "Turns off Smart Downloads and removes offline episodes for this series.",
icon = Icons.Outlined.Delete,
onClick = { onDownloadOptionSelected(SeriesDownloadOption.DELETE_SMART) },
modifier = Modifier.testTag(SeriesSmartDownloadButtonTag),
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
destructive = true
)
) {
Text("Delete Smart Downloads")
}
} else {
TextButton(
DownloadOptionRow(
title = "Smart Downloads",
supportingText = "Automatically keep the next unwatched episodes available offline.",
icon = Icons.Outlined.AutoAwesome,
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SMART) },
modifier = Modifier.testTag(SeriesSmartDownloadButtonTag)
) {
Text("Smart Download")
)
}
}
}
}
@Composable
private fun DownloadOptionRow(
title: String,
supportingText: String,
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
destructive: Boolean = false,
enabled: Boolean = true,
) {
val contentColor = if (!enabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else if (destructive) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
}
val supportingColor = if (!enabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else if (destructive) {
MaterialTheme.colorScheme.error.copy(alpha = 0.8f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
ListItem(
headlineContent = {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = contentColor
)
},
supportingContent = {
Text(
text = supportingText,
style = MaterialTheme.typography.bodyMedium,
color = supportingColor
)
},
leadingContent = {
Icon(
imageVector = icon,
contentDescription = null,
tint = contentColor
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = modifier.clickable(enabled = enabled, onClick = onClick)
)
}

View File

@@ -0,0 +1,7 @@
package hu.bbara.purefin.core.feature.downloads
import hu.bbara.purefin.core.model.MediaUiModel
data class DownloadedItem(
val media: MediaUiModel,
)

View File

@@ -48,16 +48,25 @@ class DownloadsViewModel @Inject constructor(
private val activeDownloadsMap = downloadManager.observeActiveDownloads()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
/** Items that are fully downloaded and not currently in progress. */
/** Offline items with saved content available, excluding items that are only currently in progress. */
val downloads = combine(
offlineCatalogReader.movies,
offlineCatalogReader.series,
activeDownloadsMap
) { movies, series, inProgress ->
offlineCatalogReader.episodes,
activeDownloadsMap,
) { movies, series, episodes, inProgress ->
movies.values
.filter { it.id.toString() !in inProgress }
.map { MovieUiModel(it) } +
series.values.map { SeriesUiModel(it) }
.map { DownloadedItem(media = MovieUiModel(it)) } +
series.values.filter { series ->
episodes.values.any { episode ->
episode.seriesId == series.id && episode.id.toString() !in inProgress
}
}.map {
DownloadedItem(
media = SeriesUiModel(it),
)
}
}
/** Items currently being downloaded with their progress. */

View File

@@ -29,5 +29,5 @@ android.r8.strictFullModeForKeepRules=false
android.builtInKotlin=false
android.newDsl=false
purefinReleaseVersionCode=1000018
purefinDebugVersionCode=1000000
purefinDebugVersionCode=1000001
purefinTvVersionCode=2000009