Add multi-select deletion to downloads

This commit is contained in:
Niels van Velzen authored and Niels van Velzen committed 2026-06-27 14:42:15 +02:00
1 parent 2ace128505
commit a98457b0a0
4 files changed
+218 -44

No files matched your search

@@ -15,6 +15,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.jellyfin.mobile.R
@@ -22,7 +23,7 @@ import org.jellyfin.mobile.data.entity.DownloadEntity
@Composable
fun DownloadRemoveDialog(
download: DownloadEntity,
downloads: List<DownloadEntity>,
onConfirm: (Boolean) -> Unit,
onDismiss: () -> Unit,
) {
@@ -31,13 +32,25 @@ fun DownloadRemoveDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.download_remove)) },
title = { Text(text = pluralStringResource(R.plurals.download_remove_title, downloads.size)) },
text = {
Column {
val name = remember(download, context) {
download.getDisplayName(context).orEmpty()
val name = remember(downloads, context) {
downloads
.firstOrNull()
.takeIf { downloads.size == 1 }
?.getDisplayName(context)
.orEmpty()
}
Text(text = stringResource(R.string.download_remove_description, name))
Text(
text = pluralStringResource(
R.plurals.download_remove_description,
downloads.size,
downloads.size,
name,
)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 8.dp),
@@ -1,11 +1,19 @@
package org.jellyfin.mobile.ui.screens.downloads
import android.text.format.Formatter
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Checkbox
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.ListItem
@@ -14,6 +22,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
@@ -38,10 +47,13 @@ fun DownloadsList(
downloads: List<DownloadFiles>,
onOpen: (DownloadEntity) -> Unit,
onDownload: (DownloadEntity) -> Unit,
onRemove: (DownloadEntity) -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues.Zero,
selection: Set<Long> = emptySet(),
onToggleSelection: (DownloadEntity) -> Unit = {},
) {
val selectionMode = selection.isNotEmpty()
LazyColumn(
modifier = modifier,
contentPadding = contentPadding,
@@ -54,7 +66,9 @@ fun DownloadsList(
downloadFiles = downloadFiles,
onOpen = { onOpen(downloadFiles.download) },
onDownload = { onDownload(downloadFiles.download) },
onRemove = { onRemove(downloadFiles.download) },
onToggleSelection = { onToggleSelection(downloadFiles.download) },
isSelected = selection.contains(downloadFiles.download.id),
selectionMode = selectionMode,
)
}
}
@@ -66,8 +80,10 @@ fun DownloadItem(
downloadFiles: DownloadFiles,
onOpen: () -> Unit,
onDownload: () -> Unit,
onRemove: () -> Unit,
onToggleSelection: () -> Unit,
modifier: Modifier = Modifier,
isSelected: Boolean = false,
selectionMode: Boolean = false,
) {
val (download, files) = downloadFiles
val context = LocalContext.current
@@ -83,13 +99,13 @@ fun DownloadItem(
modifier = modifier
.combinedClickable(
onClick = {
if (!isVerified) {
onDownload()
} else {
onOpen()
when {
selectionMode -> onToggleSelection()
!isVerified -> onDownload()
else -> onOpen()
}
},
onLongClick = { onRemove() },
onLongClick = { onToggleSelection() },
),
text = {
val name = remember(download, context) { download.getDisplayName(context).orEmpty() }
@@ -100,21 +116,35 @@ fun DownloadItem(
)
},
icon = {
val uri = remember(files) {
files.find { it.type == DownloadFileType.IMAGE_PRIMARY }?.uri
}
Row(verticalAlignment = Alignment.CenterVertically) {
AnimatedVisibility(
visible = selectionMode,
enter = fadeIn() + expandHorizontally(),
exit = fadeOut() + shrinkHorizontally(),
) {
Checkbox(
checked = isSelected,
onCheckedChange = null,
modifier = Modifier.padding(end = 8.dp)
)
}
AsyncImage(
model = uri,
placeholder = painterResource(R.drawable.ic_local_movies_white_64),
fallback = painterResource(R.drawable.ic_local_movies_white_64),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(
width = 64.dp,
height = 64.dp,
val uri = remember(files) {
files.find { it.type == DownloadFileType.IMAGE_PRIMARY }?.uri
}
AsyncImage(
model = uri,
placeholder = painterResource(R.drawable.ic_local_movies_white_64),
fallback = painterResource(R.drawable.ic_local_movies_white_64),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(
width = 64.dp,
height = 64.dp,
)
)
)
}
},
secondaryText = {
if (download.status == DownloadStatus.DOWNLOADING || download.status == DownloadStatus.QUEUED) {
@@ -1,13 +1,25 @@
package org.jellyfin.mobile.ui.screens.downloads
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
@@ -16,10 +28,14 @@ import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -30,7 +46,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import org.jellyfin.mobile.R
import org.jellyfin.mobile.data.entity.DownloadEntity
import org.jellyfin.mobile.downloads.DownloadsViewModel
@Composable
@@ -39,16 +54,32 @@ fun DownloadsScreen(
onBackPressed: () -> Unit = {},
) {
val downloads by viewModel.downloads.collectAsState()
var downloadToRemove by remember { mutableStateOf<DownloadEntity?>(null) }
val selection = remember { mutableStateSetOf<Long>() }
var showDeleteConfirm by remember { mutableStateOf(false) }
var showMenu by remember { mutableStateOf(false) }
if (downloadToRemove != null) {
val selectionMode = selection.isNotEmpty()
BackHandler(enabled = selectionMode) {
selection.clear()
}
if (showDeleteConfirm) {
val downloadsToRemove = remember(selection, downloads) {
downloads
.map { it.download }
.filter { it.id in selection }
}
DownloadRemoveDialog(
download = downloadToRemove!!,
downloads = downloadsToRemove,
onConfirm = { keepLocalFiles ->
viewModel.removeDownload(downloadToRemove!!, deleteFiles = !keepLocalFiles)
downloadToRemove = null
downloadsToRemove.forEach { download ->
viewModel.removeDownload(download, deleteFiles = !keepLocalFiles)
}
selection.clear()
showDeleteConfirm = false
},
onDismiss = { downloadToRemove = null },
onDismiss = { showDeleteConfirm = false },
)
}
@@ -59,16 +90,103 @@ fun DownloadsScreen(
topBar = {
TopAppBar(
title = {
Text(text = stringResource(R.string.downloads))
AnimatedContent(
targetState = selectionMode,
transitionSpec = {
if (targetState) {
(slideInVertically { height -> height } + fadeIn()) togetherWith
(slideOutVertically { height -> -height } + fadeOut())
} else {
(slideInVertically { height -> -height } + fadeIn()) togetherWith
(slideOutVertically { height -> height } + fadeOut())
}
},
label = "TitleAnimation",
) { isSelectionMode ->
if (isSelectionMode) {
Text(text = stringResource(R.string.selected_count, selection.size))
} else {
Text(text = stringResource(R.string.downloads))
}
}
},
navigationIcon = {
IconButton(
onClick = onBackPressed,
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = null,
)
Crossfade(
targetState = selectionMode,
label = "NavIconAnimation",
) { isSelectionMode ->
if (isSelectionMode) {
IconButton(
onClick = { selection.clear() },
) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = null,
)
}
} else {
IconButton(
onClick = { onBackPressed() },
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = null,
)
}
}
}
},
actions = {
Row {
AnimatedVisibility(
visible = selectionMode,
enter = fadeIn(),
exit = fadeOut(),
) {
Row {
IconButton(onClick = { showDeleteConfirm = true }) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = null,
)
}
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = null,
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
if (selection.size < downloads.size) {
DropdownMenuItem(
onClick = {
selection.addAll(downloads.map { it.download.id })
showMenu = false
},
) {
Text(text = stringResource(R.string.select_all))
}
}
if (selection.isNotEmpty()) {
DropdownMenuItem(
onClick = {
selection.clear()
showMenu = false
},
) {
Text(text = stringResource(R.string.deselect_all))
}
}
}
}
}
}
}
},
backgroundColor = Color.Transparent,
@@ -84,7 +202,11 @@ fun DownloadsScreen(
downloads = downloads,
onOpen = { viewModel.openDownload(it) },
onDownload = { viewModel.download(it) },
onRemove = { downloadToRemove = it },
selection = selection,
onToggleSelection = { download ->
if (selection.contains(download.id)) selection.remove(download.id)
else selection.add(download.id)
},
contentPadding = innerPadding,
)
}
+11 -2
View File
@@ -160,11 +160,20 @@
<string name="download_cancel">Cancel</string>
<string name="download_completed">Download completed</string>
<string name="download_progress">%1$d%%</string>
<string name="download_remove">Remove download</string>
<plurals name="download_remove_title">
<item quantity="one">Remove download</item>
<item quantity="other">Remove downloads</item>
</plurals>
<string name="download_remove_keep_local">Keep local file(s)</string>
<string name="download_remove_description">Do you want to remove "%1$s" from your downloads?</string>
<plurals name="download_remove_description">
<item quantity="one">Do you want to remove \"%2$s\" from your downloads?</item>
<item quantity="other">Do you want to remove these %1$d downloads?</item>
</plurals>
<string name="download_incomplete">Download incomplete</string>
<string name="download_settings_dialog_title">Choose download location</string>
<string name="download_settings_dialog_message">Please choose where downloaded media should be saved. Downloads use WiFi only by default.</string>
<string name="select_folder">Select folder</string>
<string name="select_all">Select all</string>
<string name="deselect_all">Deselect all</string>
<string name="selected_count">%1$d selected</string>
</resources>