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 package com.lagradost.cloudstream3.ui.download
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.format.Formatter.formatShortFileSize import android.text.format.Formatter.formatShortFileSize
import android.view.View import android.view.View
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
@ -55,22 +55,6 @@ class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
} }
override fun onBindingCreated(binding: 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 folder = arguments?.getString("folder")
val name = arguments?.getString("name") val name = arguments?.getString("name")
if (folder == null) { if (folder == null) {
@ -101,30 +85,56 @@ class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
} }
(binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value) (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value)
} }
else -> { else -> {
(binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(null) (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) { observe(downloadViewModel.selectedBytes) {
updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) 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() val allSelected = downloadViewModel.isAllChildrenSelected()
if (allSelected) { 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) { private fun updateDeleteButton(count: Int, selectedBytes: Long) {
val formattedSize = formatShortFileSize(context, selectedBytes) val formattedSize = formatShortFileSize(context, selectedBytes)
binding?.btnDelete?.text = binding?.btnDelete?.text =

View file

@ -7,8 +7,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.Looper
import android.text.format.Formatter.formatShortFileSize import android.text.format.Formatter.formatShortFileSize
import android.view.View import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
@ -28,6 +26,7 @@ import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.player.BasicLink import com.lagradost.cloudstream3.ui.player.BasicLink
@ -87,21 +86,6 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
binding.downloadAppbar.setAppBarNoScrollFlagsOnTV() binding.downloadAppbar.setAppBarNoScrollFlagsOnTV()
binding.downloadDeleteAppbar.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 -> observe(downloadViewModel.headerCards) { cards ->
when (cards) { when (cards) {
is Resource.Success -> { is Resource.Success -> {
@ -161,26 +145,44 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
observe(downloadViewModel.selectedBytes) { observe(downloadViewModel.selectedBytes) {
updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) 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 val adapter = binding.downloadList.adapter as? DownloadAdapter
adapter?.setIsMultiDeleteState(isMultiDeleteState) adapter?.setIsMultiDeleteState(isMultiDeleteState)
binding.downloadDeleteAppbar.isVisible = isMultiDeleteState binding.downloadDeleteAppbar.isVisible = isMultiDeleteState
if (!isMultiDeleteState) { binding.downloadAppbar.isGone = 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.btnDelete.isVisible = it.isNotEmpty() if (selection == null) {
binding.selectItemsText.isVisible = it.isEmpty() 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() val allSelected = downloadViewModel.isAllHeadersSelected()
if (allSelected) { 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) { private fun updateDeleteButton(count: Int, selectedBytes: Long) {
val formattedSize = formatShortFileSize(context, selectedBytes) val formattedSize = formatShortFileSize(context, selectedBytes)
binding?.btnDelete?.text = binding?.btnDelete?.text =

View file

@ -29,7 +29,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class DownloadViewModel : ViewModel() { 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 val headerCards: LiveData<Resource<List<VisualDownloadCached.Header>>> = _headerCards
private val _childCards = ResourceLiveData<List<VisualDownloadCached.Child>>(Resource.Loading()) private val _childCards = ResourceLiveData<List<VisualDownloadCached.Child>>(Resource.Loading())
@ -47,22 +48,20 @@ class DownloadViewModel : ViewModel() {
private val _selectedBytes = ConsistentLiveData<Long>(0) private val _selectedBytes = ConsistentLiveData<Long>(0)
val selectedBytes: LiveData<Long> = _selectedBytes val selectedBytes: LiveData<Long> = _selectedBytes
private val _isMultiDeleteState = ConsistentLiveData(false) private val _selectedItemIds = ConsistentLiveData<Set<Int>?>(null)
val isMultiDeleteState: LiveData<Boolean> = _isMultiDeleteState val selectedItemIds: LiveData<Set<Int>?> = _selectedItemIds
private val _selectedItemIds = ConsistentLiveData<Set<Int>>(mutableSetOf())
val selectedItemIds: LiveData<Set<Int>> = _selectedItemIds
fun setIsMultiDeleteState(value: Boolean) { fun cancelSelection() {
_isMultiDeleteState.postValue(value) updateSelectedItems { null }
} }
fun addSelected(itemId: Int) { fun addSelected(itemId: Int) {
updateSelectedItems { it + itemId } updateSelectedItems { it?.plus(itemId) ?: setOf(itemId) }
} }
fun removeSelected(itemId: Int) { fun removeSelected(itemId: Int) {
updateSelectedItems { it - itemId } updateSelectedItems { it?.minus(itemId) ?: emptySet() }
} }
fun selectAllHeaders() { fun selectAllHeaders() {
@ -97,8 +96,8 @@ class DownloadViewModel : ViewModel() {
return currentSelected.size == headers.size && headers.all { it.data.id in currentSelected } return currentSelected.size == headers.size && headers.all { it.data.id in currentSelected }
} }
private fun updateSelectedItems(action: (Set<Int>) -> Set<Int>) { private fun updateSelectedItems(action: (Set<Int>?) -> Set<Int>?) {
val currentSelected = action(selectedItemIds.value ?: mutableSetOf()) val currentSelected = action(selectedItemIds.value)
_selectedItemIds.postValue(currentSelected) _selectedItemIds.postValue(currentSelected)
postHeaders() postHeaders()
postChildren() postChildren()
@ -115,7 +114,6 @@ class DownloadViewModel : ViewModel() {
fun updateHeaderList(context: Context) = viewModelScope.launchSafe { fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
// Do not push loading as it interrupts the UI // Do not push loading as it interrupts the UI
//_headerCards.postValue(Resource.Loading()) //_headerCards.postValue(Resource.Loading())
clearSelectedItems()
val visual = withContext(Dispatchers.IO) { val visual = withContext(Dispatchers.IO) {
val children = context.getKeys(DOWNLOAD_EPISODE_CACHE) val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
@ -232,7 +230,6 @@ class DownloadViewModel : ViewModel() {
fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe { fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe {
_childCards.postValue(Resource.Loading()) // always push loading _childCards.postValue(Resource.Loading()) // always push loading
clearSelectedItems()
val visual = withContext(Dispatchers.IO) { val visual = withContext(Dispatchers.IO) {
context.getKeys(folder).mapNotNull { key -> context.getKeys(folder).mapNotNull { key ->
@ -260,6 +257,7 @@ class DownloadViewModel : ViewModel() {
} }
private fun removeItems(idsToRemove: Set<Int>) = viewModelScope.launchSafe { private fun removeItems(idsToRemove: Set<Int>) = viewModelScope.launchSafe {
_selectedItemIds.postValue(null)
postHeaders(_headerCards.success?.filter { it.data.id !in idsToRemove }) postHeaders(_headerCards.success?.filter { it.data.id !in idsToRemove })
postChildren(_childCards.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" } .joinToString(separator = "\n") { "$it" }
return when { return when {
data.seriesNames.isNotEmpty() && data.names.isEmpty() -> {
context.getString(R.string.delete_message_series_only).format(formattedSeriesNames)
}
data.ids.count() == 1 -> { data.ids.count() == 1 -> {
context.getString(R.string.delete_message).format( context.getString(R.string.delete_message).format(
data.names.firstOrNull() 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() -> { data.parentName != null && data.names.isNotEmpty() -> {
context.getString(R.string.delete_message_series_episodes) context.getString(R.string.delete_message_series_episodes)
.format(data.parentName, formattedNames) .format(data.parentName, formattedNames)
@ -406,7 +404,6 @@ class DownloadViewModel : ViewModel() {
when (which) { when (which) {
DialogInterface.BUTTON_POSITIVE -> { DialogInterface.BUTTON_POSITIVE -> {
viewModelScope.launchSafe { viewModelScope.launchSafe {
setIsMultiDeleteState(false)
deleteFilesAndUpdateSettings(context, ids, this) { successfulIds -> deleteFilesAndUpdateSettings(context, ids, this) { successfulIds ->
// We always remove parent because if we are deleting from here // We always remove parent because if we are deleting from here
// and we have it as non-empty, it was triggered on // 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 android.content.Context
import com.lagradost.api.Log import com.lagradost.api.Log
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder import com.lagradost.cloudstream3.utils.VideoDownloadManager.basePathToFile
import com.lagradost.safefile.SafeFile
object SubtitleUtils { object SubtitleUtils {
@ -14,16 +13,20 @@ object SubtitleUtils {
) )
fun deleteMatchingSubtitles(context: Context, info: VideoDownloadManager.DownloadedFileInfo) { fun deleteMatchingSubtitles(context: Context, info: VideoDownloadManager.DownloadedFileInfo) {
val relative = info.relativePath val cleanDisplay = cleanDisplayName(info.displayName)
val display = info.displayName
val cleanDisplay = cleanDisplayName(display)
getFolder(context, relative, info.basePath)?.forEach { (name, uri) -> val base = basePathToFile(context, info.basePath)
if (isMatchingSubtitle(name, display, cleanDisplay)) { val folder =
val subtitleFile = SafeFile.fromUri(context, uri) base?.gotoDirectory(info.relativePath, createMissingDirectories = false) ?: return
if (subtitleFile == null || subtitleFile.delete() != true) { val folderFiles = folder.listFiles() ?: return
Log.e("SubtitleDeletion", "Failed to delete subtitle file: ${subtitleFile?.name()}")
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. * Turns a string to an UniFile. Used for stored string paths such as settings.
* Should only be used to get a download path. * 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 { return when {
path.isNullOrBlank() -> getDefaultDir(context) path.isNullOrBlank() -> getDefaultDir(context)
path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri())