Support for multi deleting downloads and other major improvements/fixes (#1177)

This commit is contained in:
Luna712 2024-07-30 12:54:54 -06:00 committed by GitHub
parent 8fcb3e3121
commit ab379ab31c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1384 additions and 446 deletions

View file

@ -133,6 +133,8 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackupUtils.backup
import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback
@ -151,6 +153,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching
import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
@ -1254,17 +1257,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
this.setKey(getString(R.string.jsdelivr_proxy_key), false)
} else {
this.setKey(getString(R.string.jsdelivr_proxy_key), true)
val parentView: View = findViewById(android.R.id.content)
Snackbar.make(parentView, R.string.jsdelivr_enabled, Snackbar.LENGTH_LONG)
.let { snackbar ->
snackbar.setAction(R.string.revert) {
setKey(getString(R.string.jsdelivr_proxy_key), false)
}
snackbar.setBackgroundTint(colorFromAttribute(R.attr.primaryGrayBackground))
snackbar.setTextColor(colorFromAttribute(R.attr.textColor))
snackbar.setActionTextColor(colorFromAttribute(R.attr.colorPrimary))
snackbar.show()
}
showSnackbar(
this@MainActivity,
R.string.jsdelivr_enabled,
Snackbar.LENGTH_LONG,
R.string.revert
) { setKey(getString(R.string.jsdelivr_proxy_key), false) }
}
}
}
@ -1603,7 +1601,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
if (isLayout(TV or EMULATOR)) {
if (navDestination.matchDestination(R.id.navigation_home)) {
attachBackPressedCallback()
attachBackPressedCallback {
showConfirmExitDialog()
window?.navigationBarColor =
colorFromAttribute(R.attr.primaryGrayBackground)
updateLocale()
}
} else detachBackPressedCallback()
}
}
@ -1848,28 +1851,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
finish()
}
private var backPressedCallback: OnBackPressedCallback? = null
private fun attachBackPressedCallback() {
if (backPressedCallback == null) {
backPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
showConfirmExitDialog()
window?.navigationBarColor =
colorFromAttribute(R.attr.primaryGrayBackground)
updateLocale()
}
}
}
backPressedCallback?.isEnabled = true
onBackPressedDispatcher.addCallback(this, backPressedCallback ?: return)
}
private fun detachBackPressedCallback() {
backPressedCallback?.isEnabled = false
}
suspend fun checkGithubConnectivity(): Boolean {
return try {
app.get(
@ -1880,4 +1861,4 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
false
}
}
}
}

View file

@ -1,9 +1,9 @@
package com.lagradost.cloudstream3.ui.download
import android.annotation.SuppressLint
import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.CheckBox
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
@ -31,47 +31,30 @@ const val DOWNLOAD_ACTION_LONG_CLICK = 5
const val DOWNLOAD_ACTION_GO_TO_CHILD = 0
const val DOWNLOAD_ACTION_LOAD_RESULT = 1
abstract class VisualDownloadCached(
open val currentBytes: Long,
open val totalBytes: Long,
open val data: VideoDownloadHelper.DownloadCached
) {
sealed class VisualDownloadCached {
abstract val currentBytes: Long
abstract val totalBytes: Long
abstract val data: VideoDownloadHelper.DownloadCached
abstract var isSelected: Boolean
// Just to be extra-safe with areContentsTheSame
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is VisualDownloadCached) return false
data class Child(
override val currentBytes: Long,
override val totalBytes: Long,
override val data: VideoDownloadHelper.DownloadEpisodeCached,
override var isSelected: Boolean,
) : VisualDownloadCached()
if (currentBytes != other.currentBytes) return false
if (totalBytes != other.totalBytes) return false
if (data != other.data) return false
return true
}
override fun hashCode(): Int {
var result = currentBytes.hashCode()
result = 31 * result + totalBytes.hashCode()
result = 31 * result + data.hashCode()
return result
}
data class Header(
override val currentBytes: Long,
override val totalBytes: Long,
override val data: VideoDownloadHelper.DownloadHeaderCached,
override var isSelected: Boolean,
val child: VideoDownloadHelper.DownloadEpisodeCached?,
val currentOngoingDownloads: Int,
val totalDownloads: Int,
) : VisualDownloadCached()
}
data class VisualDownloadChildCached(
override val currentBytes: Long,
override val totalBytes: Long,
override val data: VideoDownloadHelper.DownloadEpisodeCached,
): VisualDownloadCached(currentBytes, totalBytes, data)
data class VisualDownloadHeaderCached(
override val currentBytes: Long,
override val totalBytes: Long,
override val data: VideoDownloadHelper.DownloadHeaderCached,
val child: VideoDownloadHelper.DownloadEpisodeCached?,
val currentOngoingDownloads: Int,
val totalDownloads: Int,
): VisualDownloadCached(currentBytes, totalBytes, data)
data class DownloadClickEvent(
val action: Int,
val data: VideoDownloadHelper.DownloadEpisodeCached
@ -83,108 +66,180 @@ data class DownloadHeaderClickEvent(
)
class DownloadAdapter(
private val clickCallback: (DownloadHeaderClickEvent) -> Unit,
private val mediaClickCallback: (DownloadClickEvent) -> Unit,
private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit,
private val onItemClickEvent: (DownloadClickEvent) -> Unit,
private val onItemSelectionChanged: (Int, Boolean) -> Unit,
) : ListAdapter<VisualDownloadCached, DownloadAdapter.DownloadViewHolder>(DiffCallback()) {
private var isMultiDeleteState: Boolean = false
companion object {
private const val VIEW_TYPE_HEADER = 0
private const val VIEW_TYPE_CHILD = 1
}
inner class DownloadViewHolder(
private val binding: ViewBinding,
private val clickCallback: (DownloadHeaderClickEvent) -> Unit,
private val mediaClickCallback: (DownloadClickEvent) -> Unit,
private val binding: ViewBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(card: VisualDownloadCached?) {
when (binding) {
is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadHeaderCached)
is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadChildCached)
is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadCached.Header)
is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadCached.Child)
}
}
@SuppressLint("SetTextI18n")
private fun bindHeader(card: VisualDownloadHeaderCached?) {
if (binding !is DownloadHeaderEpisodeBinding) return
card ?: return
val d = card.data
private fun bindHeader(card: VisualDownloadCached.Header?) {
if (binding !is DownloadHeaderEpisodeBinding || card == null) return
val data = card.data
binding.apply {
downloadHeaderPoster.apply {
setImage(d.poster)
setOnClickListener {
clickCallback.invoke(DownloadHeaderClickEvent(DOWNLOAD_ACTION_LOAD_RESULT, d))
episodeHolder.apply {
if (isMultiDeleteState) {
setOnClickListener {
toggleIsChecked(deleteCheckbox, data.id)
}
}
setOnLongClickListener {
toggleIsChecked(deleteCheckbox, data.id)
true
}
}
downloadHeaderTitle.text = d.name
val formattedSizeString = formatShortFileSize(itemView.context, card.totalBytes)
downloadHeaderPoster.apply {
setImage(data.poster)
if (isMultiDeleteState) {
setOnClickListener {
toggleIsChecked(deleteCheckbox, data.id)
}
} else {
setOnClickListener {
onHeaderClickEvent.invoke(
DownloadHeaderClickEvent(
DOWNLOAD_ACTION_LOAD_RESULT,
data
)
)
}
}
setOnLongClickListener {
toggleIsChecked(deleteCheckbox, data.id)
true
}
}
downloadHeaderTitle.text = data.name
val formattedSize = formatShortFileSize(itemView.context, card.totalBytes)
if (card.child != null) {
downloadHeaderGotoChild.isVisible = false
handleChildDownload(card, formattedSize)
} else handleParentDownload(card, formattedSize)
val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes)
if (status == DownloadStatusTell.IsDone) {
// We do this here instead if we are finished downloading
// so that we can use the value from the view model
// rather than extra unneeded disk operations and to prevent a
// delay in updating download icon state.
downloadButton.setProgress(card.currentBytes, card.totalBytes)
downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes)
// We will let the view model handle this
downloadButton.doSetProgress = false
downloadButton.progressBar.progressDrawable =
downloadButton.getDrawableFromStatus(status)
?.let { ContextCompat.getDrawable(downloadButton.context, it) }
downloadHeaderInfo.text = formattedSizeString
} else {
downloadButton.doSetProgress = true
downloadButton.progressBar.progressDrawable =
ContextCompat.getDrawable(downloadButton.context, downloadButton.progressDrawable)
if (isMultiDeleteState) {
deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
onItemSelectionChanged.invoke(data.id, isChecked)
}
} else deleteCheckbox.setOnCheckedChangeListener(null)
downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, mediaClickCallback)
downloadButton.isVisible = true
episodeHolder.setOnClickListener {
mediaClickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, card.child))
}
} else {
downloadButton.isVisible = false
downloadHeaderGotoChild.isVisible = true
try {
downloadHeaderInfo.text = downloadHeaderInfo.context.getString(R.string.extra_info_format)
.format(
card.totalDownloads,
downloadHeaderInfo.context.resources.getQuantityString(
R.plurals.episodes,
card.totalDownloads
),
formattedSizeString
)
} catch (e: Exception) {
// You probably formatted incorrectly
downloadHeaderInfo.text = "Error"
logError(e)
}
episodeHolder.setOnClickListener {
clickCallback.invoke(DownloadHeaderClickEvent(DOWNLOAD_ACTION_GO_TO_CHILD, d))
}
deleteCheckbox.apply {
isVisible = isMultiDeleteState
isChecked = card.isSelected
}
}
}
private fun bindChild(card: VisualDownloadChildCached?) {
if (binding !is DownloadChildEpisodeBinding) return
card ?: return
val d = card.data
private fun DownloadHeaderEpisodeBinding.handleChildDownload(
card: VisualDownloadCached.Header,
formattedSize: String
) {
card.child ?: return
downloadHeaderGotoChild.isVisible = false
val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes)
if (status == DownloadStatusTell.IsDone) {
// We do this here instead if we are finished downloading
// so that we can use the value from the view model
// rather than extra unneeded disk operations and to prevent a
// delay in updating download icon state.
downloadButton.setProgress(card.currentBytes, card.totalBytes)
downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes)
// We will let the view model handle this
downloadButton.doSetProgress = false
downloadButton.progressBar.progressDrawable =
downloadButton.getDrawableFromStatus(status)
?.let { ContextCompat.getDrawable(downloadButton.context, it) }
downloadHeaderInfo.text = formattedSize
} else {
// We need to make sure we restore the correct progress
// when we refresh data in the adapter.
downloadButton.resetView()
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
ContextCompat.getDrawable(downloadButton.context, it)
}
downloadButton.statusView.setImageDrawable(drawable)
downloadButton.progressBar.progressDrawable =
ContextCompat.getDrawable(
downloadButton.context,
downloadButton.progressDrawable
)
}
downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent)
downloadButton.isVisible = !isMultiDeleteState
if (!isMultiDeleteState) {
episodeHolder.setOnClickListener {
onItemClickEvent.invoke(
DownloadClickEvent(
DOWNLOAD_ACTION_PLAY_FILE,
card.child
)
)
}
}
}
private fun DownloadHeaderEpisodeBinding.handleParentDownload(
card: VisualDownloadCached.Header,
formattedSize: String
) {
downloadButton.isVisible = false
downloadHeaderGotoChild.isVisible = !isMultiDeleteState
try {
downloadHeaderInfo.text =
downloadHeaderInfo.context.getString(R.string.extra_info_format).format(
card.totalDownloads,
downloadHeaderInfo.context.resources.getQuantityString(
R.plurals.episodes,
card.totalDownloads
),
formattedSize
)
} catch (e: Exception) {
downloadHeaderInfo.text = ""
logError(e)
}
if (!isMultiDeleteState) {
episodeHolder.setOnClickListener {
onHeaderClickEvent.invoke(
DownloadHeaderClickEvent(
DOWNLOAD_ACTION_GO_TO_CHILD,
card.data
)
)
}
}
}
private fun bindChild(card: VisualDownloadCached.Child?) {
if (binding !is DownloadChildEpisodeBinding || card == null) return
val data = card.data
binding.apply {
val posDur = getViewPos(d.id)
val posDur = getViewPos(data.id)
downloadChildEpisodeProgress.apply {
isVisible = posDur != null
posDur?.let {
@ -194,36 +249,87 @@ class DownloadAdapter(
}
}
val status = downloadButton.getStatus(d.id, card.currentBytes, card.totalBytes)
val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes)
if (status == DownloadStatusTell.IsDone) {
// We do this here instead if we are finished downloading
// so that we can use the value from the view model
// rather than extra unneeded disk operations and to prevent a
// delay in updating download icon state.
downloadButton.setProgress(card.currentBytes, card.totalBytes)
downloadButton.applyMetaData(d.id, card.currentBytes, card.totalBytes)
downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes)
// We will let the view model handle this
downloadButton.doSetProgress = false
downloadButton.progressBar.progressDrawable =
downloadButton.getDrawableFromStatus(status)
?.let { ContextCompat.getDrawable(downloadButton.context, it) }
downloadChildEpisodeTextExtra.text = formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes)
downloadChildEpisodeTextExtra.text =
formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes)
} else {
downloadButton.doSetProgress = true
// We need to make sure we restore the correct progress
// when we refresh data in the adapter.
downloadButton.resetView()
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
ContextCompat.getDrawable(downloadButton.context, it)
}
downloadButton.statusView.setImageDrawable(drawable)
downloadButton.progressBar.progressDrawable =
ContextCompat.getDrawable(downloadButton.context, downloadButton.progressDrawable)
ContextCompat.getDrawable(
downloadButton.context,
downloadButton.progressDrawable
)
}
downloadButton.setDefaultClickListener(d, downloadChildEpisodeTextExtra, mediaClickCallback)
downloadButton.isVisible = true
downloadButton.setDefaultClickListener(
data,
downloadChildEpisodeTextExtra,
onItemClickEvent
)
downloadButton.isVisible = !isMultiDeleteState
downloadChildEpisodeText.apply {
text = context.getNameFull(d.name, d.episode, d.season)
text = context.getNameFull(data.name, data.episode, data.season)
isSelected = true // Needed for text repeating
}
downloadChildEpisodeHolder.setOnClickListener {
mediaClickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, d))
onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data))
}
downloadChildEpisodeHolder.apply {
when {
isMultiDeleteState -> {
setOnClickListener {
toggleIsChecked(deleteCheckbox, data.id)
}
}
else -> {
setOnClickListener {
onItemClickEvent.invoke(
DownloadClickEvent(
DOWNLOAD_ACTION_PLAY_FILE,
data
)
)
}
}
}
setOnLongClickListener {
toggleIsChecked(deleteCheckbox, data.id)
true
}
}
if (isMultiDeleteState) {
deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
onItemSelectionChanged.invoke(data.id, isChecked)
}
} else deleteCheckbox.setOnCheckedChangeListener(null)
deleteCheckbox.apply {
isVisible = isMultiDeleteState
isChecked = card.isSelected
}
}
}
@ -236,7 +342,7 @@ class DownloadAdapter(
VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false)
else -> throw IllegalArgumentException("Invalid view type")
}
return DownloadViewHolder(binding, clickCallback, mediaClickCallback)
return DownloadViewHolder(binding)
}
override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) {
@ -245,18 +351,52 @@ class DownloadAdapter(
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is VisualDownloadChildCached -> VIEW_TYPE_CHILD
is VisualDownloadHeaderCached -> VIEW_TYPE_HEADER
is VisualDownloadCached.Child -> VIEW_TYPE_CHILD
is VisualDownloadCached.Header -> VIEW_TYPE_HEADER
else -> throw IllegalArgumentException("Invalid data type at position $position")
}
}
fun setIsMultiDeleteState(value: Boolean) {
if (isMultiDeleteState == value) return
isMultiDeleteState = value
notifyItemRangeChanged(0, itemCount)
}
fun notifyAllSelected() {
currentList.indices.forEach { index ->
if (!currentList[index].isSelected) {
notifyItemChanged(index)
}
}
}
fun notifySelectionStates() {
currentList.indices.forEach { index ->
if (currentList[index].isSelected) {
notifyItemChanged(index)
}
}
}
private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) {
val isChecked = !checkbox.isChecked
checkbox.isChecked = isChecked
onItemSelectionChanged.invoke(itemId, isChecked)
}
class DiffCallback : DiffUtil.ItemCallback<VisualDownloadCached>() {
override fun areItemsTheSame(oldItem: VisualDownloadCached, newItem: VisualDownloadCached): Boolean {
override fun areItemsTheSame(
oldItem: VisualDownloadCached,
newItem: VisualDownloadCached
): Boolean {
return oldItem.data.id == newItem.data.id
}
override fun areContentsTheSame(oldItem: VisualDownloadCached, newItem: VisualDownloadCached): Boolean {
override fun areContentsTheSame(
oldItem: VisualDownloadCached,
newItem: VisualDownloadCached
): Boolean {
return oldItem == newItem
}
}

View file

@ -1,11 +1,10 @@
package com.lagradost.cloudstream3.ui.download
import android.content.DialogInterface
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import com.google.android.material.snackbar.Snackbar
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.player.DownloadFileGenerator
@ -14,9 +13,11 @@ import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import kotlinx.coroutines.MainScope
object DownloadButtonSetup {
fun handleDownloadClick(click: DownloadClickEvent) {
@ -29,9 +30,15 @@ object DownloadButtonSetup {
DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
VideoDownloadManager.deleteFileAndUpdateSettings(ctx, id)
VideoDownloadManager.deleteFilesAndUpdateSettings(
ctx,
setOf(id),
MainScope()
)
}
DialogInterface.BUTTON_NEGATIVE -> {
// Do nothing on cancel
}
}
}
@ -56,11 +63,13 @@ object DownloadButtonSetup {
}
}
}
DOWNLOAD_ACTION_PAUSE_DOWNLOAD -> {
VideoDownloadManager.downloadEvent.invoke(
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Pause)
)
}
DOWNLOAD_ACTION_RESUME_DOWNLOAD -> {
activity?.let { ctx ->
if (VideoDownloadManager.downloadStatus.containsKey(id) && VideoDownloadManager.downloadStatus[id] == VideoDownloadManager.DownloadType.IsPaused) {
@ -79,6 +88,7 @@ object DownloadButtonSetup {
}
}
}
DOWNLOAD_ACTION_LONG_CLICK -> {
activity?.let { act ->
val length =
@ -88,12 +98,15 @@ object DownloadButtonSetup {
)?.fileLength
?: 0
if (length > 0) {
showToast(R.string.delete, Toast.LENGTH_LONG)
} else {
showToast(R.string.download, Toast.LENGTH_LONG)
showSnackbar(
act,
R.string.offline_file,
Snackbar.LENGTH_LONG
)
}
}
}
DOWNLOAD_ACTION_PLAY_FILE -> {
activity?.let { act ->
val info =
@ -119,7 +132,7 @@ object DownloadButtonSetup {
id = click.data.id,
parentId = click.data.parentId,
name = act.getString(R.string.downloaded_file), //click.data.name ?: keyInfo.displayName
name = act.getString(R.string.downloaded_file), // click.data.name ?: keyInfo.displayName
season = click.data.season,
episode = click.data.episode,
headerName = parent.name,
@ -132,7 +145,7 @@ object DownloadButtonSetup {
)
)
)
//R.id.global_to_navigation_player, PlayerFragment.newInstance(
// R.id.global_to_navigation_player, PlayerFragment.newInstance(
// UriData(
// info.path.toString(),
// keyInfo.basePath,
@ -145,7 +158,7 @@ object DownloadButtonSetup {
// click.data.season
// ),
// getViewPos(click.data.id)?.position ?: 0
//)
// )
)
}
}

View file

@ -1,29 +1,33 @@
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.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class DownloadChildFragment : Fragment() {
private lateinit var downloadsViewModel: DownloadViewModel
private var binding: FragmentChildDownloadsBinding? = null
companion object {
fun newInstance(headerName: String, folder: String): Bundle {
return Bundle().apply {
@ -34,61 +38,54 @@ class DownloadChildFragment : Fragment() {
}
override fun onDestroyView() {
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it }
downloadDeleteEventListener = null
detachBackPressedCallback()
binding = null
super.onDestroyView()
}
private var binding: FragmentChildDownloadsBinding? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false)
binding = localBinding
return localBinding.root
}
private fun updateList(folder: String) = main {
context?.let { ctx ->
val data = withContext(Dispatchers.IO) { ctx.getKeys(folder) }
val eps = withContext(Dispatchers.IO) {
data.mapNotNull { key ->
context?.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key)
}.mapNotNull {
val info = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(ctx, it.id)
?: return@mapNotNull null
VisualDownloadChildCached(
currentBytes = info.fileLength,
totalBytes = info.totalBytes,
data = it,
)
}
}.sortedBy { it.data.episode + (it.data.season ?: 0) * 100000 }
if (eps.isEmpty()) {
activity?.onBackPressedDispatcher?.onBackPressed()
return@main
}
(binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(eps)
}
}
private var downloadDeleteEventListener: ((Int) -> Unit)? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
/**
* 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 {
downloadsViewModel.setIsMultiDeleteState(false)
}
/**
* We have to make sure selected items are
* cleared here as well so we don't run in an
* inconsistent state where selected items do
* not match the multi delete state we are in.
*/
downloadsViewModel.clearSelectedItems()
val folder = arguments?.getString("folder")
val name = arguments?.getString("name")
if (folder == null) {
activity?.onBackPressedDispatcher?.onBackPressed() // TODO FIX
activity?.onBackPressedDispatcher?.onBackPressed()
return
}
fixPaddingStatusbar(binding?.downloadChildRoot)
binding?.downloadChildToolbar?.apply {
title = name
@ -101,13 +98,55 @@ class DownloadChildFragment : Fragment() {
setAppBarNoScrollFlagsOnTV()
}
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
observe(downloadsViewModel.childCards) {
if (it.isEmpty()) {
activity?.onBackPressedDispatcher?.onBackPressed()
return@observe
}
(binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it)
}
observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
adapter?.setIsMultiDeleteState(isMultiDeleteState)
binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
if (!isMultiDeleteState) {
detachBackPressedCallback()
downloadsViewModel.clearSelectedItems()
binding?.downloadChildToolbar?.isVisible = true
}
}
observe(downloadsViewModel.selectedBytes) {
updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
}
observe(downloadsViewModel.selectedItemIds) {
handleSelectedChange(it)
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
binding?.btnDelete?.isVisible = it.isNotEmpty()
binding?.selectItemsText?.isVisible = it.isEmpty()
val allSelected = downloadsViewModel.isAllSelected()
if (allSelected) {
binding?.btnToggleAll?.setText(R.string.deselect_all)
} else binding?.btnToggleAll?.setText(R.string.select_all)
}
val adapter = DownloadAdapter(
{},
{ downloadClickEvent ->
handleDownloadClick(downloadClickEvent)
if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) {
setUpDownloadDeleteListener(folder)
}
{ click ->
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
context?.let { ctx ->
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
}
} else handleDownloadClick(click)
},
{ itemId, isChecked ->
if (isChecked) {
downloadsViewModel.addSelected(itemId)
} else downloadsViewModel.removeSelected(itemId)
}
)
@ -122,18 +161,47 @@ class DownloadChildFragment : Fragment() {
)
}
updateList(folder)
context?.let { downloadsViewModel.updateChildList(it, folder) }
fixPaddingStatusbar(binding?.downloadChildRoot)
}
private fun setUpDownloadDeleteListener(folder: String) {
downloadDeleteEventListener = { id: Int ->
val list = (binding?.downloadChildList?.adapter as? DownloadAdapter)?.currentList
if (list != null) {
if (list.any { it.data.id == id }) {
updateList(folder)
private fun handleSelectedChange(selected: MutableSet<Int>) {
if (selected.isNotEmpty()) {
binding?.downloadDeleteAppbar?.isVisible = true
binding?.downloadChildToolbar?.isVisible = false
activity?.attachBackPressedCallback {
downloadsViewModel.setIsMultiDeleteState(false)
}
binding?.btnDelete?.setOnClickListener {
context?.let { ctx ->
downloadsViewModel.handleMultiDelete(ctx)
}
}
binding?.btnCancel?.setOnClickListener {
downloadsViewModel.setIsMultiDeleteState(false)
}
binding?.btnToggleAll?.setOnClickListener {
val allSelected = downloadsViewModel.isAllSelected()
val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
if (allSelected) {
adapter?.notifySelectionStates()
downloadsViewModel.clearSelectedItems()
} else {
adapter?.notifyAllSelected()
downloadsViewModel.selectAllItems()
}
}
downloadsViewModel.setIsMultiDeleteState(true)
}
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
}
private fun updateDeleteButton(count: Int, selectedBytes: Long) {
val formattedSize = formatShortFileSize(context, selectedBytes)
binding?.btnDelete?.text =
getString(R.string.delete_format).format(count, formattedSize)
}
}

View file

@ -8,6 +8,8 @@ import android.content.Intent
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater
import android.view.View
@ -17,7 +19,6 @@ import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged
@ -27,7 +28,7 @@ import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
import com.lagradost.cloudstream3.databinding.StreamInputBinding
import com.lagradost.cloudstream3.isMovieType
import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
@ -40,20 +41,22 @@ import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DataStore
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import java.net.URI
const val DOWNLOAD_NAVIGATE_TO = "downloadpage"
class DownloadFragment : Fragment() {
private lateinit var downloadsViewModel: DownloadViewModel
private var binding: FragmentDownloadsBinding? = null
private fun View.setLayoutWidth(weight: Long) {
val param = LinearLayout.LayoutParams(
@ -65,14 +68,11 @@ class DownloadFragment : Fragment() {
}
override fun onDestroyView() {
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it }
downloadDeleteEventListener = null
detachBackPressedCallback()
binding = null
super.onDestroyView()
}
private var binding: FragmentDownloadsBinding? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -84,12 +84,34 @@ class DownloadFragment : Fragment() {
return localBinding.root
}
private var downloadDeleteEventListener: ((Int) -> Unit)? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
hideKeyboard()
binding?.downloadStorageAppbar?.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 {
downloadsViewModel.setIsMultiDeleteState(false)
}
/**
* We have to make sure selected items are
* cleared here as well so we don't run in an
* inconsistent state where selected items do
* not match the multi delete state we are in.
*/
downloadsViewModel.clearSelectedItems()
observe(downloadsViewModel.headerCards) {
(binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it)
@ -97,25 +119,82 @@ class DownloadFragment : Fragment() {
binding?.textNoDownloads?.isVisible = it.isEmpty()
}
observe(downloadsViewModel.availableBytes) {
updateStorageInfo(view.context, it, R.string.free_storage, binding?.downloadFreeTxt, binding?.downloadFree)
updateStorageInfo(
view.context,
it,
R.string.free_storage,
binding?.downloadFreeTxt,
binding?.downloadFree
)
}
observe(downloadsViewModel.usedBytes) {
updateStorageInfo(view.context, it, R.string.used_storage, binding?.downloadUsedTxt, binding?.downloadUsed)
binding?.downloadStorageAppbar?.isVisible = it > 0
updateStorageInfo(
view.context,
it,
R.string.used_storage,
binding?.downloadUsedTxt,
binding?.downloadUsed
)
// Prevent race condition and make sure
// we don't display it early
if (
downloadsViewModel.isMultiDeleteState.value == null ||
downloadsViewModel.isMultiDeleteState.value == false
) binding?.downloadStorageAppbar?.isVisible = it > 0
}
observe(downloadsViewModel.downloadBytes) {
updateStorageInfo(view.context, it, R.string.app_storage, binding?.downloadAppTxt, binding?.downloadApp)
updateStorageInfo(
view.context,
it,
R.string.app_storage,
binding?.downloadAppTxt,
binding?.downloadApp
)
}
observe(downloadsViewModel.selectedBytes) {
updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
}
observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
val adapter = binding?.downloadList?.adapter as? DownloadAdapter
adapter?.setIsMultiDeleteState(isMultiDeleteState)
binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
if (!isMultiDeleteState) {
detachBackPressedCallback()
downloadsViewModel.clearSelectedItems()
// Prevent race condition and make sure
// we don't display it early
if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) {
binding?.downloadStorageAppbar?.isVisible = true
}
}
}
observe(downloadsViewModel.selectedItemIds) {
handleSelectedChange(it)
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
binding?.btnDelete?.isVisible = it.isNotEmpty()
binding?.selectItemsText?.isVisible = it.isEmpty()
val allSelected = downloadsViewModel.isAllSelected()
if (allSelected) {
binding?.btnToggleAll?.setText(R.string.deselect_all)
} else binding?.btnToggleAll?.setText(R.string.select_all)
}
val adapter = DownloadAdapter(
{ click -> handleItemClick(click) },
{ click ->
handleItemClick(click)
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
context?.let { ctx ->
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
}
} else handleDownloadClick(click)
},
{ downloadClickEvent ->
handleDownloadClick(downloadClickEvent)
if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) {
setUpDownloadDeleteListener()
}
{ itemId, isChecked ->
if (isChecked) {
downloadsViewModel.addSelected(itemId)
} else downloadsViewModel.removeSelected(itemId)
}
)
@ -126,7 +205,6 @@ class DownloadFragment : Fragment() {
setLinearListLayout(
isHorizontal = false,
nextRight = FOCUS_SELF,
nextUp = FOCUS_SELF,
nextDown = FOCUS_SELF,
)
}
@ -147,35 +225,68 @@ class DownloadFragment : Fragment() {
handleScroll(scrollY - oldScrollY)
}
}
downloadsViewModel.updateList(requireContext())
context?.let { downloadsViewModel.updateHeaderList(it) }
fixPaddingStatusbar(binding?.downloadRoot)
}
private fun handleItemClick(click: DownloadHeaderClickEvent) {
when (click.action) {
DOWNLOAD_ACTION_GO_TO_CHILD -> {
if (!click.data.type.isMovieType()) {
val folder = DataStore.getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString())
if (click.data.type.isEpisodeBased()) {
val folder =
getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString())
activity?.navigate(
R.id.action_navigation_downloads_to_navigation_download_child,
DownloadChildFragment.newInstance(click.data.name, folder)
)
}
}
DOWNLOAD_ACTION_LOAD_RESULT -> {
(activity as AppCompatActivity?)?.loadResult(click.data.url, click.data.apiName)
activity?.loadResult(click.data.url, click.data.apiName)
}
}
}
private fun setUpDownloadDeleteListener() {
downloadDeleteEventListener = { id ->
val list = (binding?.downloadList?.adapter as? DownloadAdapter)?.currentList
if (list?.any { it.data.id == id } == true) {
context?.let { downloadsViewModel.updateList(it) }
private fun handleSelectedChange(selected: MutableSet<Int>) {
if (selected.isNotEmpty()) {
binding?.downloadDeleteAppbar?.isVisible = true
binding?.downloadStorageAppbar?.isVisible = false
activity?.attachBackPressedCallback {
downloadsViewModel.setIsMultiDeleteState(false)
}
binding?.btnDelete?.setOnClickListener {
context?.let { ctx ->
downloadsViewModel.handleMultiDelete(ctx)
}
}
binding?.btnCancel?.setOnClickListener {
downloadsViewModel.setIsMultiDeleteState(false)
}
binding?.btnToggleAll?.setOnClickListener {
val allSelected = downloadsViewModel.isAllSelected()
val adapter = binding?.downloadList?.adapter as? DownloadAdapter
if (allSelected) {
adapter?.notifySelectionStates()
downloadsViewModel.clearSelectedItems()
} else {
adapter?.notifyAllSelected()
downloadsViewModel.selectAllItems()
}
}
downloadsViewModel.setIsMultiDeleteState(true)
}
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
}
private fun updateDeleteButton(count: Int, selectedBytes: Long) {
val formattedSize = formatShortFileSize(context, selectedBytes)
binding?.btnDelete?.text =
getString(R.string.delete_format).format(count, formattedSize)
}
private fun updateStorageInfo(
@ -185,7 +296,10 @@ class DownloadFragment : Fragment() {
textView: TextView?,
view: View?
) {
textView?.text = getString(R.string.storage_size_format).format(getString(stringRes), formatShortFileSize(context, bytes))
textView?.text = getString(R.string.storage_size_format).format(
getString(stringRes),
formatShortFileSize(context, bytes)
)
view?.setLayoutWidth(bytes)
}
@ -218,7 +332,9 @@ class DownloadFragment : Fragment() {
if (!preventAutoSwitching) activateSwitchOnHls(text?.toString(), binding)
}
(activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt(0)?.text?.toString()?.let { copy ->
(activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt(
0
)?.text?.toString()?.let { copy ->
val fixedText = copy.trim()
binding.streamUrl.setText(fixedText)
activateSwitchOnHls(fixedText, binding)

View file

@ -1,122 +1,439 @@
package com.lagradost.cloudstream3.ui.download
import android.content.Context
import android.content.DialogInterface
import android.os.Environment
import android.os.StatFs
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.lagradost.cloudstream3.isMovieType
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class DownloadViewModel : ViewModel() {
private val _headerCards =
MutableLiveData<List<VisualDownloadHeaderCached>>().apply { listOf<VisualDownloadHeaderCached>() }
val headerCards: LiveData<List<VisualDownloadHeaderCached>> = _headerCards
private val _headerCards = MutableLiveData<List<VisualDownloadCached.Header>>()
val headerCards: LiveData<List<VisualDownloadCached.Header>> = _headerCards
private val _childCards = MutableLiveData<List<VisualDownloadCached.Child>>()
val childCards: LiveData<List<VisualDownloadCached.Child>> = _childCards
private val _usedBytes = MutableLiveData<Long>()
private val _availableBytes = MutableLiveData<Long>()
private val _downloadBytes = MutableLiveData<Long>()
val usedBytes: LiveData<Long> = _usedBytes
private val _availableBytes = MutableLiveData<Long>()
val availableBytes: LiveData<Long> = _availableBytes
private val _downloadBytes = MutableLiveData<Long>()
val downloadBytes: LiveData<Long> = _downloadBytes
private var previousVisual: List<VisualDownloadHeaderCached>? = null
private val _selectedBytes = MutableLiveData<Long>(0)
val selectedBytes: LiveData<Long> = _selectedBytes
fun updateList(context: Context) = viewModelScope.launchSafe {
val children = withContext(Dispatchers.IO) {
context.getKeys(DOWNLOAD_EPISODE_CACHE)
private val _isMultiDeleteState = MutableLiveData(false)
val isMultiDeleteState: LiveData<Boolean> = _isMultiDeleteState
private val _selectedItemIds = MutableLiveData<MutableSet<Int>>(mutableSetOf())
val selectedItemIds: LiveData<MutableSet<Int>> = _selectedItemIds
private var previousVisual: List<VisualDownloadCached>? = null
fun setIsMultiDeleteState(value: Boolean) {
_isMultiDeleteState.postValue(value)
}
fun addSelected(itemId: Int) {
updateSelectedItems { it.add(itemId) }
}
fun removeSelected(itemId: Int) {
updateSelectedItems { it.remove(itemId) }
}
fun selectAllItems() {
val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
updateSelectedItems { it.addAll(items.map { item -> item.data.id }) }
}
fun clearSelectedItems() {
// We need this to be done immediately
// so we can't use postValue
_selectedItemIds.value = mutableSetOf()
updateSelectedItems { it.clear() }
}
fun isAllSelected(): Boolean {
val currentSelected = selectedItemIds.value ?: return false
val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
return items.count() == currentSelected.count() && items.all { it.data.id in currentSelected }
}
private fun updateSelectedItems(action: (MutableSet<Int>) -> Unit) {
val currentSelected = selectedItemIds.value ?: mutableSetOf()
action(currentSelected)
_selectedItemIds.postValue(currentSelected)
updateSelectedBytes()
updateSelectedCards()
}
private fun updateSelectedBytes() = viewModelScope.launchSafe {
val selectedItemsList = getSelectedItemsData() ?: return@launchSafe
val totalSelectedBytes = selectedItemsList.sumOf { it.totalBytes }
_selectedBytes.postValue(totalSelectedBytes)
}
private fun updateSelectedCards() = viewModelScope.launchSafe {
val currentSelected = selectedItemIds.value ?: return@launchSafe
headerCards.value?.let { headers ->
headers.forEach { header ->
header.isSelected = header.data.id in currentSelected
}
_headerCards.postValue(headers)
}
childCards.value?.let { children ->
children.forEach { child ->
child.isSelected = child.data.id in currentSelected
}
_childCards.postValue(children)
}
}
fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
val visual = withContext(Dispatchers.IO) {
val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) }
.distinctBy { it.id } // Remove duplicates
val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) =
calculateDownloadStats(context, children)
val cached = context.getKeys(DOWNLOAD_HEADER_CACHE)
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadHeaderCached>(it) }
createVisualDownloadList(
context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads
)
}
// parentId : bytes
val totalBytesUsedByChild = HashMap<Int, Long>()
// parentId : bytes
val currentBytesUsedByChild = HashMap<Int, Long>()
// parentId : downloadsCount
val totalDownloads = HashMap<Int, Int>()
// Gets all children downloads
withContext(Dispatchers.IO) {
children.forEach { c ->
val childFile = getDownloadFileInfoAndUpdateSettings(context, c.id) ?: return@forEach
if (childFile.fileLength <= 1) return@forEach
val len = childFile.totalBytes
val flen = childFile.fileLength
totalBytesUsedByChild[c.parentId] = totalBytesUsedByChild[c.parentId]?.plus(len) ?: len
currentBytesUsedByChild[c.parentId] = currentBytesUsedByChild[c.parentId]?.plus(flen) ?: flen
totalDownloads[c.parentId] = totalDownloads[c.parentId]?.plus(1) ?: 1
}
}
val cached = withContext(Dispatchers.IO) { // Won't fetch useless keys
totalDownloads.entries.filter { it.value > 0 }.mapNotNull {
context.getKey<VideoDownloadHelper.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE,
it.key.toString()
)
}
}
val visual = withContext(Dispatchers.IO) {
cached.mapNotNull {
val downloads = totalDownloads[it.id] ?: 0
val bytes = totalBytesUsedByChild[it.id] ?: 0
val currentBytes = currentBytesUsedByChild[it.id] ?: 0
if (bytes <= 0 || downloads <= 0) return@mapNotNull null
val movieEpisode =
if (!it.type.isMovieType()) null
else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
DOWNLOAD_EPISODE_CACHE,
getFolderName(it.id.toString(), it.id.toString())
)
VisualDownloadHeaderCached(
currentBytes = currentBytes,
totalBytes = bytes,
data = it,
child = movieEpisode,
currentOngoingDownloads = 0,
totalDownloads = downloads,
)
}.sortedBy {
(it.child?.episode ?: 0) + (it.child?.season?.times(10000) ?: 0)
} // Episode sorting by episode, lowest to highest
}
// Only update list if different from the previous one to prevent duplicate initialization
if (visual != previousVisual) {
previousVisual = visual
try {
val stat = StatFs(Environment.getExternalStorageDirectory().path)
val localBytesAvailable = stat.availableBytes
val localTotalBytes = stat.blockSizeLong * stat.blockCountLong
val localDownloadedBytes = visual.sumOf { it.totalBytes }
_usedBytes.postValue(localTotalBytes - localBytesAvailable - localDownloadedBytes)
_availableBytes.postValue(localBytesAvailable)
_downloadBytes.postValue(localDownloadedBytes)
} catch (t: Throwable) {
_downloadBytes.postValue(0)
logError(t)
}
updateStorageStats(visual)
_headerCards.postValue(visual)
}
}
private fun calculateDownloadStats(
context: Context,
children: List<VideoDownloadHelper.DownloadEpisodeCached>
): Triple<Map<Int, Long>, Map<Int, Long>, Map<Int, Int>> {
// parentId : bytes
val totalBytesUsedByChild = mutableMapOf<Int, Long>()
// parentId : bytes
val currentBytesUsedByChild = mutableMapOf<Int, Long>()
// parentId : downloadsCount
val totalDownloads = mutableMapOf<Int, Int>()
children.forEach { child ->
val childFile = getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach
if (childFile.fileLength <= 1) return@forEach
val len = childFile.totalBytes
val flen = childFile.fileLength
totalBytesUsedByChild.merge(child.parentId, len, Long::plus)
currentBytesUsedByChild.merge(child.parentId, flen, Long::plus)
totalDownloads.merge(child.parentId, 1, Int::plus)
}
return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads)
}
private fun createVisualDownloadList(
context: Context,
cached: List<VideoDownloadHelper.DownloadHeaderCached>,
totalBytesUsedByChild: Map<Int, Long>,
currentBytesUsedByChild: Map<Int, Long>,
totalDownloads: Map<Int, Int>
): List<VisualDownloadCached.Header> {
return cached.mapNotNull {
val downloads = totalDownloads[it.id] ?: 0
val bytes = totalBytesUsedByChild[it.id] ?: 0
val currentBytes = currentBytesUsedByChild[it.id] ?: 0
if (bytes <= 0 || downloads <= 0) return@mapNotNull null
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
DOWNLOAD_EPISODE_CACHE,
getFolderName(it.id.toString(), it.id.toString())
)
VisualDownloadCached.Header(
currentBytes = currentBytes,
totalBytes = bytes,
data = it,
child = movieEpisode,
currentOngoingDownloads = 0,
totalDownloads = downloads,
isSelected = isSelected,
)
// Prevent order being almost completely random,
// making things difficult to find.
}.sortedWith(compareBy<VisualDownloadCached.Header> {
// Sort by isEpisodeBased() ascending. We put those that
// are episode based at the bottom for UI purposes and to
// make it easier to find by grouping them together.
it.data.type.isEpisodeBased()
}.thenBy {
// Then we sort alphabetically by name (case-insensitive).
// Again, we do this to make things easier to find.
it.data.name.lowercase()
})
}
fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe {
val visual = withContext(Dispatchers.IO) {
context.getKeys(folder).mapNotNull { key ->
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key)
}.mapNotNull {
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
val info = getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null
VisualDownloadCached.Child(
currentBytes = info.fileLength,
totalBytes = info.totalBytes,
isSelected = isSelected,
data = it,
)
}
}.sortedWith(compareBy(
// Sort by season first, and then by episode number,
// to ensure sorting is consistent.
{ it.data.season ?: 0 },
{ it.data.episode }
))
if (previousVisual != visual) {
previousVisual = visual
_childCards.postValue(visual)
}
}
private fun removeItems(idsToRemove: Set<Int>) = viewModelScope.launchSafe {
val updatedHeaders = headerCards.value.orEmpty().filter { it.data.id !in idsToRemove }
val updatedChildren = childCards.value.orEmpty().filter { it.data.id !in idsToRemove }
_headerCards.postValue(updatedHeaders)
_childCards.postValue(updatedChildren)
}
private fun updateStorageStats(visual: List<VisualDownloadCached.Header>) {
try {
val stat = StatFs(Environment.getExternalStorageDirectory().path)
val localBytesAvailable = stat.availableBytes
val localTotalBytes = stat.blockSizeLong * stat.blockCountLong
val localDownloadedBytes = visual.sumOf { it.totalBytes }
val localUsedBytes = localTotalBytes - localBytesAvailable
_usedBytes.postValue(localUsedBytes)
_availableBytes.postValue(localBytesAvailable)
_downloadBytes.postValue(localDownloadedBytes)
} catch (t: Throwable) {
_downloadBytes.postValue(0)
logError(t)
}
}
fun handleMultiDelete(context: Context) = viewModelScope.launchSafe {
val selectedItemsList = getSelectedItemsData().orEmpty()
val deleteData = processSelectedItems(context, selectedItemsList)
val message = buildDeleteMessage(context, deleteData)
showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds)
}
fun handleSingleDelete(
context: Context,
itemId: Int
) = viewModelScope.launchSafe {
val itemData = getItemDataFromId(itemId)
val deleteData = processSelectedItems(context, itemData)
val message = buildDeleteMessage(context, deleteData)
showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds)
}
private fun processSelectedItems(
context: Context,
selectedItemsList: List<VisualDownloadCached>
): DeleteData {
val names = mutableListOf<String>()
val seriesNames = mutableListOf<String>()
val ids = mutableSetOf<Int>()
val parentIds = mutableSetOf<Int>()
var parentName: String? = null
selectedItemsList.forEach { item ->
when (item) {
is VisualDownloadCached.Header -> {
if (item.data.type.isEpisodeBased()) {
val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE)
.mapNotNull {
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
it
)
}
.filter { it.parentId == item.data.id }
.map { it.id }
ids.addAll(episodes)
parentIds.add(item.data.id)
val episodeInfo = "${item.data.name} (${item.totalDownloads} ${
context.resources.getQuantityString(
R.plurals.episodes,
item.totalDownloads
).lowercase()
})"
seriesNames.add(episodeInfo)
} else {
ids.add(item.data.id)
names.add(item.data.name)
}
}
is VisualDownloadCached.Child -> {
ids.add(item.data.id)
val parent = context.getKey<VideoDownloadHelper.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE,
item.data.parentId.toString()
)
parentName = parent?.name
names.add(
context.getNameFull(
item.data.name,
item.data.episode,
item.data.season
)
)
}
}
}
return DeleteData(ids, parentIds, seriesNames, names, parentName)
}
private fun buildDeleteMessage(
context: Context,
data: DeleteData
): String {
val formattedNames = data.names.sortedBy { it.lowercase() }
.joinToString(separator = "\n") { "$it" }
val formattedSeriesNames = data.seriesNames.sortedBy { it.lowercase() }
.joinToString(separator = "\n") { "$it" }
return when {
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)
}
data.seriesNames.isNotEmpty() -> {
val seriesSection = context.getString(R.string.delete_message_series_section)
.format(formattedSeriesNames)
context.getString(R.string.delete_message_multiple)
.format(formattedNames) + "\n\n" + seriesSection
}
else -> context.getString(R.string.delete_message_multiple).format(formattedNames)
}
}
private fun showDeleteConfirmationDialog(
context: Context,
message: String,
ids: Set<Int>,
parentIds: Set<Int>
) {
val builder = AlertDialog.Builder(context)
val dialogClickListener =
DialogInterface.OnClickListener { _, which ->
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
// parent header card
removeItems(successfulIds + parentIds)
}
}
}
DialogInterface.BUTTON_NEGATIVE -> {
// Do nothing on cancel
}
}
}
try {
val title = if (ids.count() == 1) {
R.string.delete_file
} else R.string.delete_files
builder.setTitle(title)
.setMessage(message)
.setPositiveButton(R.string.delete, dialogClickListener)
.setNegativeButton(R.string.cancel, dialogClickListener)
.show().setDefaultFocus()
} catch (e: Exception) {
logError(e)
}
}
private fun getSelectedItemsData(): List<VisualDownloadCached>? {
val headers = headerCards.value.orEmpty()
val children = childCards.value.orEmpty()
return selectedItemIds.value?.mapNotNull { id ->
headers.find { it.data.id == id } ?: children.find { it.data.id == id }
}
}
private fun getItemDataFromId(itemId: Int): List<VisualDownloadCached> {
val headers = headerCards.value.orEmpty()
val children = childCards.value.orEmpty()
return (headers + children).filter { it.data.id == itemId }
}
private data class DeleteData(
val ids: Set<Int>,
val parentIds: Set<Int>,
val seriesNames: List<String>,
val names: List<String>,
val parentName: String?
)
}

View file

@ -93,7 +93,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
abstract fun setStatus(status: VideoDownloadManager.DownloadType?)
fun getStatus(id:Int, downloadedBytes: Long, totalBytes: Long): DownloadStatusTell {
fun getStatus(id: Int, downloadedBytes: Long, totalBytes: Long): DownloadStatusTell {
// some extra padding for just in case
return VideoDownloadManager.downloadStatus[id]
?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) {
@ -101,7 +101,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
} else DownloadStatusTell.IsPaused
}
fun applyMetaData(id:Int, downloadedBytes: Long, totalBytes: Long) {
fun applyMetaData(id: Int, downloadedBytes: Long, totalBytes: Long) {
val status = getStatus(id, downloadedBytes, totalBytes)
currentMetaData.apply {
@ -140,7 +140,8 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
} else {
if (doSetProgress) {
progressText?.apply {
val currentFormattedSizeString = formatShortFileSize(context, downloadedBytes)
val currentFormattedSizeString =
formatShortFileSize(context, downloadedBytes)
val totalFormattedSizeString = formatShortFileSize(context, totalBytes)
text =
// if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else

View file

@ -58,7 +58,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
}
private var progressBarBackground: View
private var statusView: ImageView
var statusView: ImageView
open fun onInflate() {}
@ -248,7 +248,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
} */
@MainThread
private fun setStatusInternal(status : DownloadStatusTell?) {
private fun setStatusInternal(status: DownloadStatusTell?) {
val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading
if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) {
val animation = AnimationUtils.loadAnimation(context, waitingAnimation)
@ -286,7 +286,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
if (Looper.myLooper() == Looper.getMainLooper()) {
try {
setStatusInternal(status)
} catch (t : Throwable) {
} catch (t: Throwable) {
logError(t) // Just in case setStatusInternal throws because thread
progressBarBackground.post {
setStatusInternal(status)

View file

@ -4,7 +4,9 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName
import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder
import kotlin.math.max
import kotlin.math.min
@ -49,10 +51,6 @@ class DownloadFileGenerator(
return null
}
private fun cleanDisplayName(name: String): String {
return name.substringBeforeLast('.').trim()
}
override suspend fun generateLinks(
clearCache: Boolean,
type: LoadType,
@ -69,28 +67,9 @@ class DownloadFileGenerator(
val cleanDisplay = cleanDisplayName(display)
VideoDownloadManager.getFolder(ctx, relative, meta.basePath)
?.forEach { (name, uri) ->
// only these files are allowed, so no videos as subtitles
if (listOf(
".vtt",
".srt",
".txt",
".ass",
".ttml",
".sbv",
".dfxp"
).none { name.contains(it, true) }
) return@forEach
// cant have the exact same file as a subtitle
if (name.equals(display, true)) return@forEach
getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) ->
if (isMatchingSubtitle(name, display, cleanDisplay)) {
val cleanName = cleanDisplayName(name)
// we only want files with the approx same name
if (!cleanName.startsWith(cleanDisplay, true)) return@forEach
val realName = cleanName.removePrefix(cleanDisplay)
subtitleCallback(
@ -104,6 +83,7 @@ class DownloadFileGenerator(
)
)
}
}
return true
}

View file

@ -4,13 +4,13 @@ import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import com.lagradost.cloudstream3.CommonActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playLink
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
class DownloadedPlayerActivity : AppCompatActivity() {
private val dTAG = "DownloadedPlayerAct"
@ -70,14 +70,7 @@ class DownloadedPlayerActivity : AppCompatActivity() {
return
}
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
finish()
}
}
)
attachBackPressedCallback { finish() }
}
override fun onResume() {

View file

@ -7,7 +7,6 @@ import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.activity.OnBackPressedCallback
import androidx.core.view.isGone
import androidx.core.view.isVisible
import com.lagradost.cloudstream3.CommonActivity.screenHeight
@ -17,6 +16,8 @@ import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.ui.player.CSPlayerEvent
import com.lagradost.cloudstream3.ui.player.PlayerEventSource
import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
open class ResultTrailerPlayer : ResultFragmentPhone() {
@ -156,7 +157,9 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
uiReset()
if (isFullScreenPlayer) {
attachBackPressedCallback()
activity?.attachBackPressedCallback {
updateFullscreen(false)
}
} else detachBackPressedCallback()
}
@ -175,27 +178,4 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
fixPlayerSize()
}
}
private var backPressedCallback: OnBackPressedCallback? = null
private fun attachBackPressedCallback() {
if (backPressedCallback == null) {
backPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
updateFullscreen(false)
}
}
}
backPressedCallback?.isEnabled = true
activity?.onBackPressedDispatcher?.addCallback(
activity ?: return,
backPressedCallback ?: return
)
}
private fun detachBackPressedCallback() {
backPressedCallback?.isEnabled = false
}
}

View file

@ -677,9 +677,15 @@ object AppContextUtils {
}
fun Context.isNetworkAvailable(): Boolean {
val manager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetworkInfo = manager.activeNetworkInfo
return activeNetworkInfo != null && activeNetworkInfo.isConnected || manager.allNetworkInfo?.any { it.isConnected } ?: false
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val network = connectivityManager.activeNetwork ?: return false
val networkCapabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} else {
@Suppress("DEPRECATION")
connectivityManager.activeNetworkInfo?.isConnected == true
}
}
fun splitQuery(url: URL): Map<String, String> {
@ -1018,4 +1024,4 @@ object AppContextUtils {
}
return currentAudioFocusRequest
}
}
}

View file

@ -0,0 +1,30 @@
package com.lagradost.cloudstream3.utils
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
object BackPressedCallbackHelper {
private var backPressedCallback: OnBackPressedCallback? = null
fun ComponentActivity.attachBackPressedCallback(callback: () -> Unit) {
if (backPressedCallback == null) {
backPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
callback.invoke()
}
}
}
backPressedCallback?.isEnabled = true
onBackPressedDispatcher.addCallback(
this@attachBackPressedCallback,
backPressedCallback ?: return
)
}
fun detachBackPressedCallback() {
backPressedCallback?.isEnabled = false
backPressedCallback = null
}
}

View file

@ -0,0 +1,84 @@
package com.lagradost.cloudstream3.utils
import android.app.Activity
import android.view.View
import androidx.annotation.MainThread
import androidx.annotation.StringRes
import com.google.android.material.snackbar.Snackbar
import com.lagradost.api.Log
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.result.UiText
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
object SnackbarHelper {
private const val TAG = "COMPACT"
private var currentSnackbar: Snackbar? = null
@MainThread
fun showSnackbar(
act: Activity?,
message: UiText,
duration: Int = Snackbar.LENGTH_SHORT,
actionText: UiText? = null,
actionCallback: (() -> Unit)? = null
) {
if (act == null) return
showSnackbar(act, message.asString(act), duration,
actionText?.asString(act), actionCallback)
}
@MainThread
fun showSnackbar(
act: Activity?,
@StringRes message: Int,
duration: Int = Snackbar.LENGTH_SHORT,
@StringRes actionText: Int? = null,
actionCallback: (() -> Unit)? = null
) {
if (act == null) return
showSnackbar(act, act.getString(message), duration,
actionText?.let { act.getString(it) }, actionCallback)
}
@MainThread
fun showSnackbar(
act: Activity?,
message: String?,
duration: Int = Snackbar.LENGTH_SHORT,
actionText: String? = null,
actionCallback: (() -> Unit)? = null
) {
if (act == null || message == null) {
Log.w(TAG, "Invalid showSnackbar: act = $act, message = $message")
return
}
Log.i(TAG, "showSnackbar: $message")
try {
currentSnackbar?.dismiss()
} catch (e: Exception) {
logError(e)
}
try {
val parentView = act.findViewById<View>(android.R.id.content)
val snackbar = Snackbar.make(parentView, message, duration)
actionCallback?.let {
snackbar.setAction(actionText) { actionCallback.invoke() }
}
snackbar.show()
currentSnackbar = snackbar
snackbar.setBackgroundTint(act.colorFromAttribute(R.attr.primaryBlackBackground))
snackbar.setTextColor(act.colorFromAttribute(R.attr.textColor))
snackbar.setActionTextColor(act.colorFromAttribute(R.attr.colorPrimary))
} catch (e: Exception) {
logError(e)
}
}
}

View file

@ -0,0 +1,56 @@
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
object SubtitleUtils {
// Only these files are allowed, so no videos as subtitles
private val allowedExtensions = listOf(
".vtt", ".srt", ".txt", ".ass",
".ttml", ".sbv", ".dfxp"
)
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()) {
Log.e("SubtitleDeletion", "Failed to delete subtitle file: ${subtitleFile?.name()}")
}
}
}
}
/**
* @param name the file name of the subtitle
* @param display the file name of the video
* @param cleanDisplay the cleanDisplayName of the video file name
*/
fun isMatchingSubtitle(
name: String,
display: String,
cleanDisplay: String
): Boolean {
// Check if the file has a valid subtitle extension
val hasValidExtension = allowedExtensions.any { name.contains(it, ignoreCase = true) }
// We can't have the exact same file as a subtitle
val isNotDisplayName = !name.equals(display, ignoreCase = true)
// Check if the file name starts with a cleaned version of the display name
val startsWithCleanDisplay = cleanDisplayName(name).startsWith(cleanDisplay, ignoreCase = true)
return hasValidExtension && isNotDisplayName && startsWithCleanDisplay
}
fun cleanDisplayName(name: String): String {
return name.substringBeforeLast('.').trim()
}
}

View file

@ -20,6 +20,7 @@ import androidx.work.WorkManager
import com.bumptech.glide.Glide
import com.bumptech.glide.load.model.GlideUrl
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.api.Log
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
@ -29,12 +30,14 @@ import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.services.VideoDownloadService
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.removeKey
import com.lagradost.cloudstream3.utils.SubtitleUtils.deleteMatchingSubtitles
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.safefile.MediaFileContentType
import com.lagradost.safefile.SafeFile
@ -42,6 +45,8 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@ -1733,7 +1738,37 @@ object VideoDownloadManager {
}
}
fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean {
fun deleteFilesAndUpdateSettings(
context: Context,
ids: Set<Int>,
scope: CoroutineScope,
onComplete: (Set<Int>) -> Unit = {}
) {
scope.launchSafe(Dispatchers.IO) {
val deleteJobs = ids.map { id ->
async {
id to deleteFileAndUpdateSettings(context, id)
}
}
val results = deleteJobs.awaitAll()
val (successfulResults, failedResults) = results.partition { it.second }
val successfulIds = successfulResults.map { it.first }.toSet()
if (failedResults.isNotEmpty()) {
failedResults.forEach { (id, _) ->
// TODO show a toast if some failed?
Log.e("FileDeletion", "Failed to delete file with ID: $id")
}
} else {
Log.i("FileDeletion", "All files deleted successfully")
}
onComplete.invoke(successfulIds)
}
}
private fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean {
val success = deleteFile(context, id)
if (success) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
return success
@ -1759,11 +1794,17 @@ object VideoDownloadManager {
private fun deleteFile(context: Context, id: Int): Boolean {
val info =
context.getKey<DownloadedFileInfo>(KEY_DOWNLOAD_INFO, id.toString()) ?: return false
val file = info.toFile(context)
downloadEvent.invoke(id to DownloadActionType.Stop)
downloadProgressEvent.invoke(Triple(id, 0, 0))
downloadStatusEvent.invoke(id to DownloadType.IsStopped)
downloadDeleteEvent.invoke(id)
return info.toFile(context)?.delete() ?: false
val isFileDeleted = file?.delete() == true || file?.exists() == false
if (isFileDeleted) deleteMatchingSubtitles(context, info)
return isFileDeleted
}
fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? {

View file

@ -2,10 +2,8 @@
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/download_child_episode_holder"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginBottom="5dp"
android:foreground="@drawable/outline_drawable"
@ -18,7 +16,6 @@
<androidx.core.widget.ContentLoadingProgressBar
android:id="@+id/download_child_episode_progress"
style="@android:style/Widget.Material.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="5dp"
@ -56,12 +53,10 @@
android:layout_gravity="center_vertical"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:ellipsize="marquee"
android:gravity="center_vertical"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:singleLine="true"
android:textColor="?attr/textColor"
tools:text="Episode 1 Episode 1 Episode 1 Episode 1 Episode 1 Episode 1 Episode 1" />
@ -78,7 +73,6 @@
tools:text="128MB / 237MB" />
</LinearLayout>
<com.lagradost.cloudstream3.ui.download.button.PieFetchButton
android:id="@+id/download_button"
android:layout_width="@dimen/download_size"
@ -89,5 +83,16 @@
android:focusable="true"
android:nextFocusLeft="@id/download_child_episode_holder"
android:padding="10dp" />
<CheckBox
android:id="@+id/delete_checkbox"
android:layout_width="@dimen/download_size"
android:layout_height="@dimen/download_size"
android:layout_gravity="center_vertical|end"
android:layout_marginStart="-50dp"
android:focusable="true"
android:nextFocusLeft="@id/download_child_episode_holder"
android:padding="10dp"
android:visibility="gone" />
</GridLayout>
</androidx.cardview.widget.CardView>

View file

@ -77,5 +77,16 @@
android:focusable="true"
android:nextFocusLeft="@id/episode_holder"
android:padding="10dp" />
<CheckBox
android:id="@+id/delete_checkbox"
android:layout_width="@dimen/download_size"
android:layout_height="@dimen/download_size"
android:layout_gravity="center_vertical|end"
android:layout_marginStart="-50dp"
android:focusable="true"
android:nextFocusLeft="@id/episode_holder"
android:padding="10dp"
android:visibility="gone" />
</LinearLayout>
</androidx.cardview.widget.CardView>

View file

@ -7,13 +7,69 @@
android:layout_height="match_parent"
android:background="?attr/primaryGrayBackground"
android:orientation="vertical"
tools:context=".ui.download.DownloadFragment">
tools:context=".ui.download.DownloadChildFragment">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent">
<LinearLayout
android:id="@+id/download_delete_appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="?attr/colorPrimary"
android:padding="8dp"
android:visibility="gone">
<ImageButton
android:id="@+id/btnCancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:src="@drawable/ic_baseline_close_24"
android:contentDescription="@string/cancel"
android:padding="8dp"
android:layout_gravity="center_vertical"
android:nextFocusLeft="@id/nav_rail_view"
app:tint="@android:color/white" />
<Button
android:id="@+id/btnDelete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:text="@string/delete"
android:textColor="@android:color/white"
android:layout_gravity="center_vertical" />
<TextView
android:id="@+id/selectItemsText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:text="@string/downloads_delete_select"
android:textColor="@android:color/white"
android:layout_gravity="center_vertical"
android:visibility="gone" />
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<Button
android:id="@+id/btnToggleAll"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:text="@string/select_all"
android:textColor="@android:color/white"
android:layout_marginEnd="8dp"
android:nextFocusDown="@id/download_child_list" />
</LinearLayout>
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/download_child_toolbar"
android:layout_width="match_parent"
@ -28,7 +84,6 @@
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/download_child_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/primaryBlackBackground"

View file

@ -17,6 +17,62 @@
For Scroll add to LinearLayout
app:layout_scrollFlags="scroll|enterAlways"
-->
<LinearLayout
android:id="@+id/download_delete_appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="?attr/colorPrimary"
android:padding="8dp"
android:visibility="gone">
<ImageButton
android:id="@+id/btnCancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:src="@drawable/ic_baseline_close_24"
android:contentDescription="@string/cancel"
android:padding="8dp"
android:layout_gravity="center_vertical"
android:nextFocusLeft="@id/nav_rail_view"
app:tint="@android:color/white" />
<Button
android:id="@+id/btnDelete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:text="@string/delete"
android:textColor="@android:color/white"
android:layout_gravity="center_vertical" />
<TextView
android:id="@+id/selectItemsText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:text="@string/downloads_delete_select"
android:textColor="@android:color/white"
android:layout_gravity="center_vertical"
android:visibility="gone" />
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<Button
android:id="@+id/btnToggleAll"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:text="@string/select_all"
android:textColor="@android:color/white"
android:layout_marginEnd="8dp"
android:nextFocusDown="@id/download_list" />
</LinearLayout>
<LinearLayout
android:id="@+id/download_storage_appbar"
android:layout_width="match_parent"
@ -137,7 +193,7 @@
android:background="?attr/primaryBlackBackground"
android:descendantFocusability="afterDescendants"
android:nextFocusLeft="@id/nav_rail_view"
android:tag = "@string/tv_no_focus_tag"
android:tag="@string/tv_no_focus_tag"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:listitem="@layout/download_header_episode" />
@ -154,11 +210,11 @@
<!--
<ProgressBar
android:visibility="visible"
tools:visibility="gone"
android:id="@+id/download_loading"
android:layout_gravity="center"
android:layout_width="50dp" android:layout_height="50dp">
android:visibility="visible"
tools:visibility="gone"
android:id="@+id/download_loading"
android:layout_gravity="center"
android:layout_width="50dp" android:layout_height="50dp">
</ProgressBar>-->
<com.facebook.shimmer.ShimmerFrameLayout
@ -182,15 +238,10 @@
android:orientation="vertical">
<include layout="@layout/loading_downloads" />
<include layout="@layout/loading_downloads" />
<include layout="@layout/loading_downloads" />
<include layout="@layout/loading_downloads" />
<include layout="@layout/loading_downloads" />
<include layout="@layout/loading_downloads" />
</LinearLayout>
</com.facebook.shimmer.ShimmerFrameLayout>
@ -201,24 +252,24 @@
android:orientation="vertical"
android:layout_gravity="bottom|end">
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/open_local_video_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="?attr/floatingActionButtonSmallStyle"
android:backgroundTint="?attr/primaryGrayBackground"
android:src="@drawable/netflix_play"
android:layout_marginEnd="16dp"
android:tooltipText="@string/open_local_video"
android:layout_gravity="bottom|end"
android:contentDescription="@string/open_local_video" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/open_local_video_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="?attr/floatingActionButtonSmallStyle"
android:backgroundTint="?attr/primaryGrayBackground"
android:src="@drawable/netflix_play"
android:layout_marginEnd="16dp"
android:tooltipText="@string/open_local_video"
android:layout_gravity="bottom|end"
android:contentDescription="@string/open_local_video" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/download_stream_button"
style="@style/ExtendedFloatingActionButton"
android:text="@string/stream"
android:textColor="?attr/textColor"
app:icon="@drawable/ic_network_stream"
android:contentDescription="@string/stream" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/download_stream_button"
style="@style/ExtendedFloatingActionButton"
android:text="@string/stream"
android:textColor="?attr/textColor"
app:icon="@drawable/ic_network_stream"
android:contentDescription="@string/stream" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -150,7 +150,11 @@
<string name="download_canceled">Download Canceled</string>
<string name="download_done">Download Done</string>
<string name="download_format" translatable="false">%s - %s</string>
<string name="downloads_delete_select">Select Items to Delete</string>
<string name="downloads_empty">There are currently no downloads.</string>
<string name="offline_file">Available for watching offline</string>
<string name="select_all">Select All</string>
<string name="deselect_all">Deselect All</string>
<string name="update_started">Update Started</string>
<string name="stream">Network stream</string>
<string name="open_local_video">Open local video</string>
@ -300,8 +304,10 @@
<string name="season_short">S</string>
<string name="episode_short">E</string>
<string name="no_episodes_found">No Episodes found</string>
<string name="delete_file">Delete File</string>
<string name="delete">Delete</string>
<string name="delete_file">Delete File</string>
<string name="delete_files">Delete Files</string>
<string name="delete_format" formatted="true">Delete (%1$d | %2$s)</string>
<string name="cancel">Cancel</string>
<string name="pause">Pause</string>
<string name="start">Start</string>
@ -312,6 +318,10 @@
<string name="go_back_30">-30</string>
<string name="go_forward_30">+30</string>
<string name="delete_message" formatted="true">This will permanently delete %s\nAre you sure?</string>
<string name="delete_message_multiple" formatted="true">Are you sure you want to permanently delete the following items?\n\n%s</string>
<string name="delete_message_series_episodes" formatted="true">Are you sure you want to permanently delete the following episodes in %1$s?\n\n%2$s</string>
<string name="delete_message_series_section" formatted="true">You will also permanently delete all episodes in the following series:\n\n%s</string>
<string name="delete_message_series_only" formatted="true">Are you sure you want to permanently delete all episodes in the following series?\n\n%s</string>
<string name="resume_time_left" formatted="true">%dm\nremaining</string>
<string name="resume_remaining" formatted="true">%s\nremaining</string>
<string name="status_ongoing">Ongoing</string>