Compare commits

...

1 commit

Author SHA1 Message Date
Osten
eef078d07c
Download selection fix + sub del fix + Del dialog fix 2025-12-08 16:22:30 +01:00
5 changed files with 105 additions and 155 deletions

View file

@ -1,16 +1,16 @@
package com.lagradost.cloudstream3.ui.download
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.format.Formatter.formatShortFileSize
import android.view.View
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
@ -55,22 +55,6 @@ class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
}
override fun onBindingCreated(binding: FragmentChildDownloadsBinding) {
/**
* We never want to retain multi-delete state
* when navigating to downloads. Setting this state
* immediately can sometimes result in the observer
* not being notified in time to update the UI.
*
* By posting to the main looper, we ensure that this
* operation is executed after the view has been fully created
* and all initializations are completed, allowing the
* observer to properly receive and handle the state change.
*/
Handler(Looper.getMainLooper()).post {
downloadViewModel.setIsMultiDeleteState(false)
}
val folder = arguments?.getString("folder")
val name = arguments?.getString("name")
if (folder == null) {
@ -101,30 +85,56 @@ class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
}
(binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value)
}
else -> {
(binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(null)
}
}
}
observe(downloadViewModel.isMultiDeleteState) { isMultiDeleteState ->
val adapter = binding.downloadChildList.adapter as? DownloadAdapter
adapter?.setIsMultiDeleteState(isMultiDeleteState)
binding.downloadDeleteAppbar.isVisible = isMultiDeleteState
if (!isMultiDeleteState) {
activity?.detachBackPressedCallback("Downloads")
downloadViewModel.clearSelectedItems()
binding.downloadChildToolbar.isVisible = true
}
}
observe(downloadViewModel.selectedBytes) {
updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it)
}
observe(downloadViewModel.selectedItemIds) {
handleSelectedChange(it)
updateDeleteButton(it.count(), downloadViewModel.selectedBytes.value ?: 0L)
binding.btnDelete.isVisible = it.isNotEmpty()
binding.selectItemsText.isVisible = it.isEmpty()
binding.apply {
btnDelete.setOnClickListener { view ->
downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener)
}
btnCancel.setOnClickListener {
downloadViewModel.cancelSelection()
}
btnToggleAll.setOnClickListener {
val allSelected = downloadViewModel.isAllChildrenSelected()
if (allSelected) {
downloadViewModel.clearSelectedItems()
} else {
downloadViewModel.selectAllChildren()
}
}
}
observeNullable(downloadViewModel.selectedItemIds) { selection ->
val isMultiDeleteState = selection != null
val adapter = binding.downloadChildList.adapter as? DownloadAdapter
adapter?.setIsMultiDeleteState(isMultiDeleteState)
binding.downloadDeleteAppbar.isVisible = isMultiDeleteState
binding.downloadChildToolbar.isGone = isMultiDeleteState
if (selection == null) {
activity?.detachBackPressedCallback("Downloads")
return@observeNullable
}
activity?.attachBackPressedCallback("Downloads") {
downloadViewModel.cancelSelection()
}
updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L)
binding.btnDelete.isVisible = selection.isNotEmpty()
binding.selectItemsText.isVisible = selection.isEmpty()
val allSelected = downloadViewModel.isAllChildrenSelected()
if (allSelected) {
@ -160,37 +170,6 @@ class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
}
}
private fun handleSelectedChange(selected: Set<Int>) {
if (selected.isNotEmpty()) {
binding?.downloadDeleteAppbar?.isVisible = true
binding?.downloadChildToolbar?.isVisible = false
activity?.attachBackPressedCallback("Downloads") {
downloadViewModel.setIsMultiDeleteState(false)
}
binding?.btnDelete?.setOnClickListener {
context?.let { ctx ->
downloadViewModel.handleMultiDelete(ctx)
}
}
binding?.btnCancel?.setOnClickListener {
downloadViewModel.setIsMultiDeleteState(false)
}
binding?.btnToggleAll?.setOnClickListener {
val allSelected = downloadViewModel.isAllChildrenSelected()
if (allSelected) {
downloadViewModel.clearSelectedItems()
} else {
downloadViewModel.selectAllChildren()
}
}
downloadViewModel.setIsMultiDeleteState(true)
}
}
private fun updateDeleteButton(count: Int, selectedBytes: Long) {
val formattedSize = formatShortFileSize(context, selectedBytes)
binding?.btnDelete?.text =

View file

@ -7,8 +7,6 @@ import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.text.format.Formatter.formatShortFileSize
import android.view.View
import android.widget.LinearLayout
@ -28,6 +26,7 @@ import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.player.BasicLink
@ -87,21 +86,6 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
binding.downloadAppbar.setAppBarNoScrollFlagsOnTV()
binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV()
/**
* We never want to retain multi-delete state
* when navigating to downloads. Setting this state
* immediately can sometimes result in the observer
* not being notified in time to update the UI.
*
* By posting to the main looper, we ensure that this
* operation is executed after the view has been fully created
* and all initializations are completed, allowing the
* observer to properly receive and handle the state change.
*/
Handler(Looper.getMainLooper()).post {
downloadViewModel.setIsMultiDeleteState(false)
}
observe(downloadViewModel.headerCards) { cards ->
when (cards) {
is Resource.Success -> {
@ -161,26 +145,44 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
observe(downloadViewModel.selectedBytes) {
updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it)
}
observe(downloadViewModel.isMultiDeleteState) { isMultiDeleteState ->
binding.apply {
btnDelete.setOnClickListener { view ->
downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener)
}
btnCancel.setOnClickListener {
downloadViewModel.cancelSelection()
}
btnToggleAll.setOnClickListener {
val allSelected = downloadViewModel.isAllHeadersSelected()
if (allSelected) {
downloadViewModel.clearSelectedItems()
} else {
downloadViewModel.selectAllHeaders()
}
}
}
observeNullable(downloadViewModel.selectedItemIds) { selection ->
val isMultiDeleteState = selection != null
val adapter = binding.downloadList.adapter as? DownloadAdapter
adapter?.setIsMultiDeleteState(isMultiDeleteState)
binding.downloadDeleteAppbar.isVisible = isMultiDeleteState
if (!isMultiDeleteState) {
activity?.detachBackPressedCallback("Downloads")
downloadViewModel.clearSelectedItems()
// Prevent race condition and make sure
// we don't display it early
if (downloadViewModel.usedBytes.value?.let { it > 0 } == true) {
binding.downloadAppbar.isVisible = true
}
}
}
observe(downloadViewModel.selectedItemIds) {
handleSelectedChange(it)
updateDeleteButton(it.count(), downloadViewModel.selectedBytes.value ?: 0L)
binding.downloadAppbar.isGone = isMultiDeleteState
binding.btnDelete.isVisible = it.isNotEmpty()
binding.selectItemsText.isVisible = it.isEmpty()
if (selection == null) {
activity?.detachBackPressedCallback("Downloads")
return@observeNullable
}
activity?.attachBackPressedCallback("Downloads") {
downloadViewModel.cancelSelection()
}
updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L)
binding.btnDelete.isVisible = selection.isNotEmpty()
binding.selectItemsText.isVisible = selection.isEmpty()
val allSelected = downloadViewModel.isAllHeadersSelected()
if (allSelected) {
@ -260,37 +262,6 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
}
}
private fun handleSelectedChange(selected: Set<Int>) {
if (selected.isNotEmpty()) {
binding?.downloadDeleteAppbar?.isVisible = true
binding?.downloadAppbar?.isVisible = false
activity?.attachBackPressedCallback("Downloads") {
downloadViewModel.setIsMultiDeleteState(false)
}
binding?.btnDelete?.setOnClickListener {
context?.let { ctx ->
downloadViewModel.handleMultiDelete(ctx)
}
}
binding?.btnCancel?.setOnClickListener {
downloadViewModel.setIsMultiDeleteState(false)
}
binding?.btnToggleAll?.setOnClickListener {
val allSelected = downloadViewModel.isAllHeadersSelected()
if (allSelected) {
downloadViewModel.clearSelectedItems()
} else {
downloadViewModel.selectAllHeaders()
}
}
downloadViewModel.setIsMultiDeleteState(true)
}
}
private fun updateDeleteButton(count: Int, selectedBytes: Long) {
val formattedSize = formatShortFileSize(context, selectedBytes)
binding?.btnDelete?.text =

View file

@ -29,7 +29,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class DownloadViewModel : ViewModel() {
private val _headerCards = ResourceLiveData<List<VisualDownloadCached.Header>>(Resource.Loading())
private val _headerCards =
ResourceLiveData<List<VisualDownloadCached.Header>>(Resource.Loading())
val headerCards: LiveData<Resource<List<VisualDownloadCached.Header>>> = _headerCards
private val _childCards = ResourceLiveData<List<VisualDownloadCached.Child>>(Resource.Loading())
@ -47,22 +48,20 @@ class DownloadViewModel : ViewModel() {
private val _selectedBytes = ConsistentLiveData<Long>(0)
val selectedBytes: LiveData<Long> = _selectedBytes
private val _isMultiDeleteState = ConsistentLiveData(false)
val isMultiDeleteState: LiveData<Boolean> = _isMultiDeleteState
private val _selectedItemIds = ConsistentLiveData<Set<Int>?>(null)
val selectedItemIds: LiveData<Set<Int>?> = _selectedItemIds
private val _selectedItemIds = ConsistentLiveData<Set<Int>>(mutableSetOf())
val selectedItemIds: LiveData<Set<Int>> = _selectedItemIds
fun setIsMultiDeleteState(value: Boolean) {
_isMultiDeleteState.postValue(value)
fun cancelSelection() {
updateSelectedItems { null }
}
fun addSelected(itemId: Int) {
updateSelectedItems { it + itemId }
updateSelectedItems { it?.plus(itemId) ?: setOf(itemId) }
}
fun removeSelected(itemId: Int) {
updateSelectedItems { it - itemId }
updateSelectedItems { it?.minus(itemId) ?: emptySet() }
}
fun selectAllHeaders() {
@ -97,8 +96,8 @@ class DownloadViewModel : ViewModel() {
return currentSelected.size == headers.size && headers.all { it.data.id in currentSelected }
}
private fun updateSelectedItems(action: (Set<Int>) -> Set<Int>) {
val currentSelected = action(selectedItemIds.value ?: mutableSetOf())
private fun updateSelectedItems(action: (Set<Int>?) -> Set<Int>?) {
val currentSelected = action(selectedItemIds.value)
_selectedItemIds.postValue(currentSelected)
postHeaders()
postChildren()
@ -115,7 +114,6 @@ class DownloadViewModel : ViewModel() {
fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
// Do not push loading as it interrupts the UI
//_headerCards.postValue(Resource.Loading())
clearSelectedItems()
val visual = withContext(Dispatchers.IO) {
val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
@ -232,7 +230,6 @@ class DownloadViewModel : ViewModel() {
fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe {
_childCards.postValue(Resource.Loading()) // always push loading
clearSelectedItems()
val visual = withContext(Dispatchers.IO) {
context.getKeys(folder).mapNotNull { key ->
@ -260,6 +257,7 @@ class DownloadViewModel : ViewModel() {
}
private fun removeItems(idsToRemove: Set<Int>) = viewModelScope.launchSafe {
_selectedItemIds.postValue(null)
postHeaders(_headerCards.success?.filter { it.data.id !in idsToRemove })
postChildren(_childCards.success?.filter { it.data.id !in idsToRemove })
}
@ -368,16 +366,16 @@ class DownloadViewModel : ViewModel() {
.joinToString(separator = "\n") { "$it" }
return when {
data.seriesNames.isNotEmpty() && data.names.isEmpty() -> {
context.getString(R.string.delete_message_series_only).format(formattedSeriesNames)
}
data.ids.count() == 1 -> {
context.getString(R.string.delete_message).format(
data.names.firstOrNull()
)
}
data.seriesNames.isNotEmpty() && data.names.isEmpty() -> {
context.getString(R.string.delete_message_series_only).format(formattedSeriesNames)
}
data.parentName != null && data.names.isNotEmpty() -> {
context.getString(R.string.delete_message_series_episodes)
.format(data.parentName, formattedNames)
@ -406,7 +404,6 @@ class DownloadViewModel : ViewModel() {
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
viewModelScope.launchSafe {
setIsMultiDeleteState(false)
deleteFilesAndUpdateSettings(context, ids, this) { successfulIds ->
// We always remove parent because if we are deleting from here
// and we have it as non-empty, it was triggered on

View file

@ -2,8 +2,7 @@ package com.lagradost.cloudstream3.utils
import android.content.Context
import com.lagradost.api.Log
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder
import com.lagradost.safefile.SafeFile
import com.lagradost.cloudstream3.utils.VideoDownloadManager.basePathToFile
object SubtitleUtils {
@ -14,16 +13,20 @@ object SubtitleUtils {
)
fun deleteMatchingSubtitles(context: Context, info: VideoDownloadManager.DownloadedFileInfo) {
val relative = info.relativePath
val display = info.displayName
val cleanDisplay = cleanDisplayName(display)
val cleanDisplay = cleanDisplayName(info.displayName)
getFolder(context, relative, info.basePath)?.forEach { (name, uri) ->
if (isMatchingSubtitle(name, display, cleanDisplay)) {
val subtitleFile = SafeFile.fromUri(context, uri)
if (subtitleFile == null || subtitleFile.delete() != true) {
Log.e("SubtitleDeletion", "Failed to delete subtitle file: ${subtitleFile?.name()}")
val base = basePathToFile(context, info.basePath)
val folder =
base?.gotoDirectory(info.relativePath, createMissingDirectories = false) ?: return
val folderFiles = folder.listFiles() ?: return
for (file in folderFiles) {
val name = file.name() ?: continue
if (!isMatchingSubtitle(name, info.displayName, cleanDisplay)) {
continue
}
if (file.delete() != true) {
Log.e("SubtitleDeletion", "Failed to delete subtitle file: $name")
}
}
}

View file

@ -1628,7 +1628,7 @@ object VideoDownloadManager {
* Turns a string to an UniFile. Used for stored string paths such as settings.
* Should only be used to get a download path.
* */
private fun basePathToFile(context: Context, path: String?): SafeFile? {
fun basePathToFile(context: Context, path: String?): SafeFile? {
return when {
path.isNullOrBlank() -> getDefaultDir(context)
path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri())