From eef078d07cd2183b76ea90889f89339a65d1bcf9 Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:22:30 +0100 Subject: [PATCH] Download selection fix + sub del fix + Del dialog fix --- .../ui/download/DownloadChildFragment.kt | 103 +++++++----------- .../ui/download/DownloadFragment.kt | 95 ++++++---------- .../ui/download/DownloadViewModel.kt | 33 +++--- .../cloudstream3/utils/SubtitleUtils.kt | 27 +++-- .../utils/VideoDownloadManager.kt | 2 +- 5 files changed, 105 insertions(+), 155 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt index 08194fd31cb..d44ea0020b5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt @@ -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( } 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( } (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value) } + else -> { (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(null) } } } - observe(downloadViewModel.isMultiDeleteState) { isMultiDeleteState -> + + observe(downloadViewModel.selectedBytes) { + updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) + } + + + 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 - if (!isMultiDeleteState) { + binding.downloadChildToolbar.isGone = isMultiDeleteState + + if (selection == null) { activity?.detachBackPressedCallback("Downloads") - downloadViewModel.clearSelectedItems() - binding.downloadChildToolbar.isVisible = true + return@observeNullable } - } - observe(downloadViewModel.selectedBytes) { - updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) - } - observe(downloadViewModel.selectedItemIds) { - handleSelectedChange(it) - updateDeleteButton(it.count(), downloadViewModel.selectedBytes.value ?: 0L) + activity?.attachBackPressedCallback("Downloads") { + downloadViewModel.cancelSelection() + } + + updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) - binding.btnDelete.isVisible = it.isNotEmpty() - binding.selectItemsText.isVisible = it.isEmpty() + binding.btnDelete.isVisible = selection.isNotEmpty() + binding.selectItemsText.isVisible = selection.isEmpty() val allSelected = downloadViewModel.isAllChildrenSelected() if (allSelected) { @@ -160,37 +170,6 @@ class DownloadChildFragment : BaseFragment( } } - private fun handleSelectedChange(selected: Set) { - 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 = diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt index e3d77abac9c..3bd424640dd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt @@ -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( 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( 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) { + binding.downloadAppbar.isGone = isMultiDeleteState + + if (selection == null) { 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 - } + return@observeNullable } - } - observe(downloadViewModel.selectedItemIds) { - handleSelectedChange(it) - updateDeleteButton(it.count(), downloadViewModel.selectedBytes.value ?: 0L) + activity?.attachBackPressedCallback("Downloads") { + downloadViewModel.cancelSelection() + } + updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) - binding.btnDelete.isVisible = it.isNotEmpty() - binding.selectItemsText.isVisible = it.isEmpty() + binding.btnDelete.isVisible = selection.isNotEmpty() + binding.selectItemsText.isVisible = selection.isEmpty() val allSelected = downloadViewModel.isAllHeadersSelected() if (allSelected) { @@ -260,37 +262,6 @@ class DownloadFragment : BaseFragment( } } - private fun handleSelectedChange(selected: Set) { - 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 = diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt index bf81e606987..ee69390ff2b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt @@ -29,7 +29,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class DownloadViewModel : ViewModel() { - private val _headerCards = ResourceLiveData>(Resource.Loading()) + private val _headerCards = + ResourceLiveData>(Resource.Loading()) val headerCards: LiveData>> = _headerCards private val _childCards = ResourceLiveData>(Resource.Loading()) @@ -47,22 +48,20 @@ class DownloadViewModel : ViewModel() { private val _selectedBytes = ConsistentLiveData(0) val selectedBytes: LiveData = _selectedBytes - private val _isMultiDeleteState = ConsistentLiveData(false) - val isMultiDeleteState: LiveData = _isMultiDeleteState + private val _selectedItemIds = ConsistentLiveData?>(null) + val selectedItemIds: LiveData?> = _selectedItemIds - private val _selectedItemIds = ConsistentLiveData>(mutableSetOf()) - val selectedItemIds: LiveData> = _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) -> Set) { - val currentSelected = action(selectedItemIds.value ?: mutableSetOf()) + private fun updateSelectedItems(action: (Set?) -> Set?) { + 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) = 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 diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt index 66a6e156c76..97be98aea87 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt @@ -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) - - 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 cleanDisplay = cleanDisplayName(info.displayName) + + 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") } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index 9748bd29673..cdda1186818 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -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())