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.text.font.FontWeight
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.ui.common.card.PosterCard
import hu.bbara.purefin.core.feature.downloads.DownloadsViewModel import hu.bbara.purefin.core.feature.downloads.DownloadsViewModel
import hu.bbara.purefin.ui.common.card.PosterCard
@Composable @Composable
fun DownloadsContent( fun DownloadsContent(
@@ -101,14 +101,17 @@ fun DownloadsContent(
} }
} }
} }
items(downloads.value, key = { item -> item.id }) { item -> items(downloads.value, key = { item -> item.media.id }) { item ->
PosterCard( Column(
item = item, modifier = Modifier.testTag("$DownloadsItemTagPrefix${item.media.id}")
onMovieSelected = viewModel::onMovieSelected, ) {
onSeriesSelected = viewModel::onSeriesSelected, PosterCard(
onEpisodeSelected = { _, _, _ -> }, item = item.media,
modifier = Modifier.testTag("$DownloadsItemTagPrefix${item.id}") onMovieSelected = viewModel::onMovieSelected,
) onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = { _, _, _ -> },
)
}
} }
} }
} }

View File

@@ -1,5 +1,9 @@
package hu.bbara.purefin.ui.screen.series 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.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -50,30 +54,94 @@ fun SeriesScreen(
} }
val seriesState = viewModel.series.collectAsStateWithLifecycle() val seriesState = viewModel.series.collectAsStateWithLifecycle()
var pendingDownloadOption by remember { mutableStateOf<Pair<SeriesDownloadOption, Season>?>(null) }
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
}
if (!canPerformOption) return@rememberLauncherForActivityResult
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)
}
}
val seriesData = seriesState.value val seriesData = seriesState.value
if (seriesData != null && seriesData.seasons.isNotEmpty()) { if (seriesData != null && seriesData.seasons.isNotEmpty()) {
LaunchedEffect(seriesData) { LaunchedEffect(seriesData) {
viewModel.observeSeriesDownloadState(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( SeriesScreenInternal(
series = seriesData, series = seriesData,
seriesDownloadState = viewModel.seriesDownloadState.collectAsStateWithLifecycle().value, seriesDownloadState = seriesDownloadState,
seasonDownloadState = viewModel.seasonDownloadState.collectAsStateWithLifecycle().value, seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = viewModel.isSmartDownloadEnabled.collectAsStateWithLifecycle().value, isSmartDownloadEnabled = isSmartDownloadEnabled,
onDownloadOptionSelected = { option, selectedSeason -> onDownloadOptionSelected = { option, selectedSeason ->
when (option) { if (canPerformDownloadOption(option)) {
SeriesDownloadOption.SEASON -> if (shouldRequestNotificationPermission(option)) {
viewModel.downloadSeason(seriesData.id, selectedSeason.id) pendingDownloadOption = option to selectedSeason
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
SeriesDownloadOption.SERIES -> } else {
viewModel.downloadSeries(seriesData) performDownloadOption(option, selectedSeason)
}
SeriesDownloadOption.SMART ->
viewModel.enableSmartDownload(seriesData.id)
SeriesDownloadOption.DELETE_SMART ->
viewModel.deleteSmartDownloads(seriesData.id)
} }
}, },
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes, onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
@@ -132,8 +200,8 @@ private fun SeriesScreenInternal(
) { ) {
SeriesActionButtons( SeriesActionButtons(
nextUpEpisode = nextUpEpisode, nextUpEpisode = nextUpEpisode,
seriesDownloadState = seriesDownloadState,
selectedSeason = selectedSeason, selectedSeason = selectedSeason,
seriesDownloadState = seriesDownloadState,
seasonDownloadState = seasonDownloadState, seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled, isSmartDownloadEnabled = isSmartDownloadEnabled,
offline = offline, offline = offline,
@@ -192,7 +260,7 @@ private fun SeriesScreenPreview() {
AppTheme { AppTheme {
SeriesScreenInternal( SeriesScreenInternal(
series = previewSeries(), series = previewSeries(),
seriesDownloadState = DownloadState.Downloading(progressPercent = 0.58f), seriesDownloadState = DownloadState.NotDownloaded,
seasonDownloadState = DownloadState.NotDownloaded, seasonDownloadState = DownloadState.NotDownloaded,
isSmartDownloadEnabled = true, isSmartDownloadEnabled = true,
onDownloadOptionSelected = { _, _ -> }, onDownloadOptionSelected = { _, _ -> },

View File

@@ -26,18 +26,19 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.ArrowBack 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.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.Download
import androidx.compose.material.icons.outlined.DownloadDone
import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -48,6 +49,7 @@ 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.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
@@ -111,8 +113,8 @@ internal fun SeriesMetaChips(series: Series) {
@Composable @Composable
internal fun SeriesActionButtons( internal fun SeriesActionButtons(
nextUpEpisode: Episode?, nextUpEpisode: Episode?,
seriesDownloadState: DownloadState,
selectedSeason: Season, selectedSeason: Season,
seriesDownloadState: DownloadState,
seasonDownloadState: DownloadState, seasonDownloadState: DownloadState,
isSmartDownloadEnabled: Boolean, isSmartDownloadEnabled: Boolean,
offline: Boolean, offline: Boolean,
@@ -121,7 +123,6 @@ internal fun SeriesActionButtons(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val navigationManager = LocalNavigationManager.current val navigationManager = LocalNavigationManager.current
val scheme = MaterialTheme.colorScheme
var showDownloadDialog by remember { mutableStateOf(false) } var showDownloadDialog by remember { mutableStateOf(false) }
val playAction = remember(nextUpEpisode, offline) { val playAction = remember(nextUpEpisode, offline) {
nextUpEpisode?.let { episode -> nextUpEpisode?.let { episode ->
@@ -163,11 +164,7 @@ internal fun SeriesActionButtons(
MediaActionButton( MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface, backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface, iconColor = MaterialTheme.colorScheme.onSurface,
icon = when { icon = Icons.Outlined.Download,
seriesDownloadState is DownloadState.Downloading -> Icons.Outlined.Close
seriesDownloadState is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
else -> Icons.Outlined.Download
},
height = 48.dp, height = 48.dp,
modifier = Modifier.testTag(SeriesDownloadButtonTag), modifier = Modifier.testTag(SeriesDownloadButtonTag),
onClick = { showDownloadDialog = true } onClick = { showDownloadDialog = true }
@@ -175,8 +172,9 @@ internal fun SeriesActionButtons(
} }
if (showDownloadDialog) { if (showDownloadDialog) {
DownloadOptionsDialog( DownloadOptionsBottomSheet(
selectedSeasonName = selectedSeason.name, selectedSeasonName = selectedSeason.name,
seriesDownloadState = seriesDownloadState,
seasonDownloadState = seasonDownloadState, seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled, isSmartDownloadEnabled = isSmartDownloadEnabled,
onDownloadOptionSelected = { onDownloadOptionSelected = {
@@ -195,68 +193,136 @@ internal enum class SeriesDownloadOption {
DELETE_SMART DELETE_SMART
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun DownloadOptionsDialog( private fun DownloadOptionsBottomSheet(
selectedSeasonName: String, selectedSeasonName: String,
seriesDownloadState: DownloadState,
seasonDownloadState: DownloadState, seasonDownloadState: DownloadState,
isSmartDownloadEnabled: Boolean, isSmartDownloadEnabled: Boolean,
onDownloadOptionSelected: (SeriesDownloadOption) -> Unit, onDownloadOptionSelected: (SeriesDownloadOption) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
AlertDialog( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
modifier = Modifier.testTag(SeriesDownloadDialogTag), modifier = Modifier.testTag(SeriesDownloadDialogTag),
title = { Text("Download") }, ) {
text = { Column(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { 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(
text = "Choose how to download this series.", text = "Choose how to download this series.",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
},
confirmButton = { DownloadOptionRow(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { title = "Download selected season",
TextButton( supportingText = when (seasonDownloadState) {
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SEASON) }, is DownloadState.Downloaded -> "$selectedSeasonName is already downloaded."
modifier = Modifier.testTag(SeriesDownloadSeasonButtonTag) is DownloadState.Downloading -> "$selectedSeasonName is downloading."
) { else -> "Save episodes from $selectedSeasonName for offline viewing."
Text( },
when (seasonDownloadState) { icon = Icons.Outlined.Download,
is DownloadState.Downloaded -> "$selectedSeasonName Downloaded" onClick = { onDownloadOptionSelected(SeriesDownloadOption.SEASON) },
is DownloadState.Downloading -> "Downloading $selectedSeasonName" enabled = seasonDownloadState is DownloadState.NotDownloaded,
else -> "Download $selectedSeasonName" modifier = Modifier.testTag(SeriesDownloadSeasonButtonTag)
} )
) DownloadOptionRow(
} title = "Download all episodes",
TextButton( supportingText = when (seriesDownloadState) {
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SERIES) }, is DownloadState.Downloaded -> "All episodes in this series are already downloaded."
modifier = Modifier.testTag(SeriesDownloadAllButtonTag) is DownloadState.Downloading -> "All episodes in this series are downloading."
) { else -> "Save every available episode in this series."
Text("Download All") },
} icon = Icons.Outlined.Download,
} onClick = { onDownloadOptionSelected(SeriesDownloadOption.SERIES) },
}, enabled = seriesDownloadState is DownloadState.NotDownloaded,
dismissButton = { modifier = Modifier.testTag(SeriesDownloadAllButtonTag)
)
if (isSmartDownloadEnabled) { 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) }, onClick = { onDownloadOptionSelected(SeriesDownloadOption.DELETE_SMART) },
modifier = Modifier.testTag(SeriesSmartDownloadButtonTag), modifier = Modifier.testTag(SeriesSmartDownloadButtonTag),
colors = ButtonDefaults.textButtonColors( destructive = true
contentColor = MaterialTheme.colorScheme.error )
)
) {
Text("Delete Smart Downloads")
}
} else { } else {
TextButton( DownloadOptionRow(
title = "Smart Downloads",
supportingText = "Automatically keep the next unwatched episodes available offline.",
icon = Icons.Outlined.AutoAwesome,
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SMART) }, onClick = { onDownloadOptionSelected(SeriesDownloadOption.SMART) },
modifier = Modifier.testTag(SeriesSmartDownloadButtonTag) 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() private val activeDownloadsMap = downloadManager.observeActiveDownloads()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) .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( val downloads = combine(
offlineCatalogReader.movies, offlineCatalogReader.movies,
offlineCatalogReader.series, offlineCatalogReader.series,
activeDownloadsMap offlineCatalogReader.episodes,
) { movies, series, inProgress -> activeDownloadsMap,
) { movies, series, episodes, inProgress ->
movies.values movies.values
.filter { it.id.toString() !in inProgress } .filter { it.id.toString() !in inProgress }
.map { MovieUiModel(it) } + .map { DownloadedItem(media = MovieUiModel(it)) } +
series.values.map { SeriesUiModel(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. */ /** Items currently being downloaded with their progress. */

View File

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