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.loadSearchResult
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers 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.backup
import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback 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.Event
import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog 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.changeStatusBarState
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute 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) this.setKey(getString(R.string.jsdelivr_proxy_key), false)
} else { } else {
this.setKey(getString(R.string.jsdelivr_proxy_key), true) this.setKey(getString(R.string.jsdelivr_proxy_key), true)
val parentView: View = findViewById(android.R.id.content) showSnackbar(
Snackbar.make(parentView, R.string.jsdelivr_enabled, Snackbar.LENGTH_LONG) this@MainActivity,
.let { snackbar -> R.string.jsdelivr_enabled,
snackbar.setAction(R.string.revert) { Snackbar.LENGTH_LONG,
setKey(getString(R.string.jsdelivr_proxy_key), false) 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()
}
} }
} }
} }
@ -1603,7 +1601,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
if (isLayout(TV or EMULATOR)) { if (isLayout(TV or EMULATOR)) {
if (navDestination.matchDestination(R.id.navigation_home)) { if (navDestination.matchDestination(R.id.navigation_home)) {
attachBackPressedCallback() attachBackPressedCallback {
showConfirmExitDialog()
window?.navigationBarColor =
colorFromAttribute(R.attr.primaryGrayBackground)
updateLocale()
}
} else detachBackPressedCallback() } else detachBackPressedCallback()
} }
} }
@ -1848,28 +1851,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
finish() 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 { suspend fun checkGithubConnectivity(): Boolean {
return try { return try {
app.get( app.get(
@ -1880,4 +1861,4 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
false false
} }
} }
} }

View file

@ -1,9 +1,9 @@
package com.lagradost.cloudstream3.ui.download package com.lagradost.cloudstream3.ui.download
import android.annotation.SuppressLint
import android.text.format.Formatter.formatShortFileSize import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.CheckBox
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil 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_GO_TO_CHILD = 0
const val DOWNLOAD_ACTION_LOAD_RESULT = 1 const val DOWNLOAD_ACTION_LOAD_RESULT = 1
abstract class VisualDownloadCached( sealed class VisualDownloadCached {
open val currentBytes: Long, abstract val currentBytes: Long
open val totalBytes: Long, abstract val totalBytes: Long
open val data: VideoDownloadHelper.DownloadCached abstract val data: VideoDownloadHelper.DownloadCached
) { abstract var isSelected: Boolean
// Just to be extra-safe with areContentsTheSame data class Child(
override fun equals(other: Any?): Boolean { override val currentBytes: Long,
if (this === other) return true override val totalBytes: Long,
if (other !is VisualDownloadCached) return false override val data: VideoDownloadHelper.DownloadEpisodeCached,
override var isSelected: Boolean,
) : VisualDownloadCached()
if (currentBytes != other.currentBytes) return false data class Header(
if (totalBytes != other.totalBytes) return false override val currentBytes: Long,
if (data != other.data) return false override val totalBytes: Long,
override val data: VideoDownloadHelper.DownloadHeaderCached,
return true override var isSelected: Boolean,
} val child: VideoDownloadHelper.DownloadEpisodeCached?,
val currentOngoingDownloads: Int,
override fun hashCode(): Int { val totalDownloads: Int,
var result = currentBytes.hashCode() ) : VisualDownloadCached()
result = 31 * result + totalBytes.hashCode()
result = 31 * result + data.hashCode()
return result
}
} }
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( data class DownloadClickEvent(
val action: Int, val action: Int,
val data: VideoDownloadHelper.DownloadEpisodeCached val data: VideoDownloadHelper.DownloadEpisodeCached
@ -83,108 +66,180 @@ data class DownloadHeaderClickEvent(
) )
class DownloadAdapter( class DownloadAdapter(
private val clickCallback: (DownloadHeaderClickEvent) -> Unit, private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit,
private val mediaClickCallback: (DownloadClickEvent) -> Unit, private val onItemClickEvent: (DownloadClickEvent) -> Unit,
private val onItemSelectionChanged: (Int, Boolean) -> Unit,
) : ListAdapter<VisualDownloadCached, DownloadAdapter.DownloadViewHolder>(DiffCallback()) { ) : ListAdapter<VisualDownloadCached, DownloadAdapter.DownloadViewHolder>(DiffCallback()) {
private var isMultiDeleteState: Boolean = false
companion object { companion object {
private const val VIEW_TYPE_HEADER = 0 private const val VIEW_TYPE_HEADER = 0
private const val VIEW_TYPE_CHILD = 1 private const val VIEW_TYPE_CHILD = 1
} }
inner class DownloadViewHolder( inner class DownloadViewHolder(
private val binding: ViewBinding, private val binding: ViewBinding
private val clickCallback: (DownloadHeaderClickEvent) -> Unit,
private val mediaClickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
fun bind(card: VisualDownloadCached?) { fun bind(card: VisualDownloadCached?) {
when (binding) { when (binding) {
is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadHeaderCached) is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadCached.Header)
is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadChildCached) is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadCached.Child)
} }
} }
@SuppressLint("SetTextI18n") private fun bindHeader(card: VisualDownloadCached.Header?) {
private fun bindHeader(card: VisualDownloadHeaderCached?) { if (binding !is DownloadHeaderEpisodeBinding || card == null) return
if (binding !is DownloadHeaderEpisodeBinding) return
card ?: return
val d = card.data
val data = card.data
binding.apply { binding.apply {
downloadHeaderPoster.apply { episodeHolder.apply {
setImage(d.poster) if (isMultiDeleteState) {
setOnClickListener { setOnClickListener {
clickCallback.invoke(DownloadHeaderClickEvent(DOWNLOAD_ACTION_LOAD_RESULT, d)) toggleIsChecked(deleteCheckbox, data.id)
}
}
setOnLongClickListener {
toggleIsChecked(deleteCheckbox, data.id)
true
} }
} }
downloadHeaderTitle.text = d.name downloadHeaderPoster.apply {
val formattedSizeString = formatShortFileSize(itemView.context, card.totalBytes) 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) { 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 (isMultiDeleteState) {
if (status == DownloadStatusTell.IsDone) { deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
// We do this here instead if we are finished downloading onItemSelectionChanged.invoke(data.id, isChecked)
// 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)
} }
} else deleteCheckbox.setOnCheckedChangeListener(null)
downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, mediaClickCallback) deleteCheckbox.apply {
downloadButton.isVisible = true isVisible = isMultiDeleteState
isChecked = card.isSelected
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))
}
} }
} }
} }
private fun bindChild(card: VisualDownloadChildCached?) { private fun DownloadHeaderEpisodeBinding.handleChildDownload(
if (binding !is DownloadChildEpisodeBinding) return card: VisualDownloadCached.Header,
card ?: return formattedSize: String
val d = card.data ) {
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 { binding.apply {
val posDur = getViewPos(d.id) val posDur = getViewPos(data.id)
downloadChildEpisodeProgress.apply { downloadChildEpisodeProgress.apply {
isVisible = posDur != null isVisible = posDur != null
posDur?.let { 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) { if (status == DownloadStatusTell.IsDone) {
// We do this here instead if we are finished downloading // We do this here instead if we are finished downloading
// so that we can use the value from the view model // so that we can use the value from the view model
// rather than extra unneeded disk operations and to prevent a // rather than extra unneeded disk operations and to prevent a
// delay in updating download icon state. // delay in updating download icon state.
downloadButton.setProgress(card.currentBytes, card.totalBytes) 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 // We will let the view model handle this
downloadButton.doSetProgress = false downloadButton.doSetProgress = false
downloadButton.progressBar.progressDrawable = downloadButton.progressBar.progressDrawable =
downloadButton.getDrawableFromStatus(status) downloadButton.getDrawableFromStatus(status)
?.let { ContextCompat.getDrawable(downloadButton.context, it) } ?.let { ContextCompat.getDrawable(downloadButton.context, it) }
downloadChildEpisodeTextExtra.text = formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes) downloadChildEpisodeTextExtra.text =
formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes)
} else { } 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 = downloadButton.progressBar.progressDrawable =
ContextCompat.getDrawable(downloadButton.context, downloadButton.progressDrawable) ContextCompat.getDrawable(
downloadButton.context,
downloadButton.progressDrawable
)
} }
downloadButton.setDefaultClickListener(d, downloadChildEpisodeTextExtra, mediaClickCallback) downloadButton.setDefaultClickListener(
downloadButton.isVisible = true data,
downloadChildEpisodeTextExtra,
onItemClickEvent
)
downloadButton.isVisible = !isMultiDeleteState
downloadChildEpisodeText.apply { 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 isSelected = true // Needed for text repeating
} }
downloadChildEpisodeHolder.setOnClickListener { 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) VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false)
else -> throw IllegalArgumentException("Invalid view type") else -> throw IllegalArgumentException("Invalid view type")
} }
return DownloadViewHolder(binding, clickCallback, mediaClickCallback) return DownloadViewHolder(binding)
} }
override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) { override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) {
@ -245,18 +351,52 @@ class DownloadAdapter(
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int): Int {
return when (getItem(position)) { return when (getItem(position)) {
is VisualDownloadChildCached -> VIEW_TYPE_CHILD is VisualDownloadCached.Child -> VIEW_TYPE_CHILD
is VisualDownloadHeaderCached -> VIEW_TYPE_HEADER is VisualDownloadCached.Header -> VIEW_TYPE_HEADER
else -> throw IllegalArgumentException("Invalid data type at position $position") 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>() { 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 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 return oldItem == newItem
} }
} }

View file

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

View file

@ -1,29 +1,33 @@
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.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
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.observe
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
import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.DataStore.getKeys
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV 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() { class DownloadChildFragment : Fragment() {
private lateinit var downloadsViewModel: DownloadViewModel
private var binding: FragmentChildDownloadsBinding? = null
companion object { companion object {
fun newInstance(headerName: String, folder: String): Bundle { fun newInstance(headerName: String, folder: String): Bundle {
return Bundle().apply { return Bundle().apply {
@ -34,61 +38,54 @@ class DownloadChildFragment : Fragment() {
} }
override fun onDestroyView() { override fun onDestroyView() {
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it } detachBackPressedCallback()
downloadDeleteEventListener = null
binding = null binding = null
super.onDestroyView() super.onDestroyView()
} }
private var binding: FragmentChildDownloadsBinding? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false) val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false)
binding = localBinding binding = localBinding
return localBinding.root 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) 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 folder = arguments?.getString("folder")
val name = arguments?.getString("name") val name = arguments?.getString("name")
if (folder == null) { if (folder == null) {
activity?.onBackPressedDispatcher?.onBackPressed() // TODO FIX activity?.onBackPressedDispatcher?.onBackPressed()
return return
} }
fixPaddingStatusbar(binding?.downloadChildRoot)
binding?.downloadChildToolbar?.apply { binding?.downloadChildToolbar?.apply {
title = name title = name
@ -101,13 +98,55 @@ class DownloadChildFragment : Fragment() {
setAppBarNoScrollFlagsOnTV() 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( val adapter = DownloadAdapter(
{}, {},
{ downloadClickEvent -> { click ->
handleDownloadClick(downloadClickEvent) if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) { context?.let { ctx ->
setUpDownloadDeleteListener(folder) 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) { private fun handleSelectedChange(selected: MutableSet<Int>) {
downloadDeleteEventListener = { id: Int -> if (selected.isNotEmpty()) {
val list = (binding?.downloadChildList?.adapter as? DownloadAdapter)?.currentList binding?.downloadDeleteAppbar?.isVisible = true
if (list != null) { binding?.downloadChildToolbar?.isVisible = false
if (list.any { it.data.id == id }) { activity?.attachBackPressedCallback {
updateList(folder) 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.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.os.Build import android.os.Build
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.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -17,7 +19,6 @@ import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged import androidx.core.widget.doOnTextChanged
@ -27,7 +28,7 @@ import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
import com.lagradost.cloudstream3.databinding.StreamInputBinding 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.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick 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.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult 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.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.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import java.net.URI import java.net.URI
const val DOWNLOAD_NAVIGATE_TO = "downloadpage" const val DOWNLOAD_NAVIGATE_TO = "downloadpage"
class DownloadFragment : Fragment() { class DownloadFragment : Fragment() {
private lateinit var downloadsViewModel: DownloadViewModel private lateinit var downloadsViewModel: DownloadViewModel
private var binding: FragmentDownloadsBinding? = null
private fun View.setLayoutWidth(weight: Long) { private fun View.setLayoutWidth(weight: Long) {
val param = LinearLayout.LayoutParams( val param = LinearLayout.LayoutParams(
@ -65,14 +68,11 @@ class DownloadFragment : Fragment() {
} }
override fun onDestroyView() { override fun onDestroyView() {
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it } detachBackPressedCallback()
downloadDeleteEventListener = null
binding = null binding = null
super.onDestroyView() super.onDestroyView()
} }
private var binding: FragmentDownloadsBinding? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -84,12 +84,34 @@ class DownloadFragment : Fragment() {
return localBinding.root return localBinding.root
} }
private var downloadDeleteEventListener: ((Int) -> Unit)? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
hideKeyboard() hideKeyboard()
binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV() 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) { observe(downloadsViewModel.headerCards) {
(binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it) (binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it)
@ -97,25 +119,82 @@ class DownloadFragment : Fragment() {
binding?.textNoDownloads?.isVisible = it.isEmpty() binding?.textNoDownloads?.isVisible = it.isEmpty()
} }
observe(downloadsViewModel.availableBytes) { 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) { observe(downloadsViewModel.usedBytes) {
updateStorageInfo(view.context, it, R.string.used_storage, binding?.downloadUsedTxt, binding?.downloadUsed) updateStorageInfo(
binding?.downloadStorageAppbar?.isVisible = it > 0 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) { 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( val adapter = DownloadAdapter(
{ click -> handleItemClick(click) },
{ click -> { click ->
handleItemClick(click) if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
context?.let { ctx ->
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
}
} else handleDownloadClick(click)
}, },
{ downloadClickEvent -> { itemId, isChecked ->
handleDownloadClick(downloadClickEvent) if (isChecked) {
if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) { downloadsViewModel.addSelected(itemId)
setUpDownloadDeleteListener() } else downloadsViewModel.removeSelected(itemId)
}
} }
) )
@ -126,7 +205,6 @@ class DownloadFragment : Fragment() {
setLinearListLayout( setLinearListLayout(
isHorizontal = false, isHorizontal = false,
nextRight = FOCUS_SELF, nextRight = FOCUS_SELF,
nextUp = FOCUS_SELF,
nextDown = FOCUS_SELF, nextDown = FOCUS_SELF,
) )
} }
@ -147,35 +225,68 @@ class DownloadFragment : Fragment() {
handleScroll(scrollY - oldScrollY) handleScroll(scrollY - oldScrollY)
} }
} }
downloadsViewModel.updateList(requireContext())
context?.let { downloadsViewModel.updateHeaderList(it) }
fixPaddingStatusbar(binding?.downloadRoot) fixPaddingStatusbar(binding?.downloadRoot)
} }
private fun handleItemClick(click: DownloadHeaderClickEvent) { private fun handleItemClick(click: DownloadHeaderClickEvent) {
when (click.action) { when (click.action) {
DOWNLOAD_ACTION_GO_TO_CHILD -> { DOWNLOAD_ACTION_GO_TO_CHILD -> {
if (!click.data.type.isMovieType()) { if (click.data.type.isEpisodeBased()) {
val folder = DataStore.getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString()) val folder =
getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString())
activity?.navigate( activity?.navigate(
R.id.action_navigation_downloads_to_navigation_download_child, R.id.action_navigation_downloads_to_navigation_download_child,
DownloadChildFragment.newInstance(click.data.name, folder) DownloadChildFragment.newInstance(click.data.name, folder)
) )
} }
} }
DOWNLOAD_ACTION_LOAD_RESULT -> { 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() { private fun handleSelectedChange(selected: MutableSet<Int>) {
downloadDeleteEventListener = { id -> if (selected.isNotEmpty()) {
val list = (binding?.downloadList?.adapter as? DownloadAdapter)?.currentList binding?.downloadDeleteAppbar?.isVisible = true
if (list?.any { it.data.id == id } == true) { binding?.downloadStorageAppbar?.isVisible = false
context?.let { downloadsViewModel.updateList(it) } 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( private fun updateStorageInfo(
@ -185,7 +296,10 @@ class DownloadFragment : Fragment() {
textView: TextView?, textView: TextView?,
view: View? 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) view?.setLayoutWidth(bytes)
} }
@ -218,7 +332,9 @@ class DownloadFragment : Fragment() {
if (!preventAutoSwitching) activateSwitchOnHls(text?.toString(), binding) 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() val fixedText = copy.trim()
binding.streamUrl.setText(fixedText) binding.streamUrl.setText(fixedText)
activateSwitchOnHls(fixedText, binding) activateSwitchOnHls(fixedText, binding)

View file

@ -1,122 +1,439 @@
package com.lagradost.cloudstream3.ui.download package com.lagradost.cloudstream3.ui.download
import android.content.Context import android.content.Context
import android.content.DialogInterface
import android.os.Environment import android.os.Environment
import android.os.StatFs import android.os.StatFs
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.launchSafe
import com.lagradost.cloudstream3.mvvm.logError 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_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getFolderName
import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys import com.lagradost.cloudstream3.utils.DataStore.getKeys
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class DownloadViewModel : ViewModel() { class DownloadViewModel : ViewModel() {
private val _headerCards =
MutableLiveData<List<VisualDownloadHeaderCached>>().apply { listOf<VisualDownloadHeaderCached>() } private val _headerCards = MutableLiveData<List<VisualDownloadCached.Header>>()
val headerCards: LiveData<List<VisualDownloadHeaderCached>> = _headerCards 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 _usedBytes = MutableLiveData<Long>()
private val _availableBytes = MutableLiveData<Long>()
private val _downloadBytes = MutableLiveData<Long>()
val usedBytes: LiveData<Long> = _usedBytes val usedBytes: LiveData<Long> = _usedBytes
private val _availableBytes = MutableLiveData<Long>()
val availableBytes: LiveData<Long> = _availableBytes val availableBytes: LiveData<Long> = _availableBytes
private val _downloadBytes = MutableLiveData<Long>()
val downloadBytes: LiveData<Long> = _downloadBytes 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 { private val _isMultiDeleteState = MutableLiveData(false)
val children = withContext(Dispatchers.IO) { val isMultiDeleteState: LiveData<Boolean> = _isMultiDeleteState
context.getKeys(DOWNLOAD_EPISODE_CACHE)
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) } .mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) }
.distinctBy { it.id } // Remove duplicates .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) { if (visual != previousVisual) {
previousVisual = visual previousVisual = visual
updateStorageStats(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)
}
_headerCards.postValue(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?) 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 // some extra padding for just in case
return VideoDownloadManager.downloadStatus[id] return VideoDownloadManager.downloadStatus[id]
?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) { ?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) {
@ -101,7 +101,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
} else DownloadStatusTell.IsPaused } 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) val status = getStatus(id, downloadedBytes, totalBytes)
currentMetaData.apply { currentMetaData.apply {
@ -140,7 +140,8 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
} else { } else {
if (doSetProgress) { if (doSetProgress) {
progressText?.apply { progressText?.apply {
val currentFormattedSizeString = formatShortFileSize(context, downloadedBytes) val currentFormattedSizeString =
formatShortFileSize(context, downloadedBytes)
val totalFormattedSizeString = formatShortFileSize(context, totalBytes) val totalFormattedSizeString = formatShortFileSize(context, totalBytes)
text = text =
// if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else // 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 progressBarBackground: View
private var statusView: ImageView var statusView: ImageView
open fun onInflate() {} open fun onInflate() {}
@ -248,7 +248,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
} */ } */
@MainThread @MainThread
private fun setStatusInternal(status : DownloadStatusTell?) { private fun setStatusInternal(status: DownloadStatusTell?) {
val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading
if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) { if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) {
val animation = AnimationUtils.loadAnimation(context, waitingAnimation) val animation = AnimationUtils.loadAnimation(context, waitingAnimation)
@ -286,7 +286,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
if (Looper.myLooper() == Looper.getMainLooper()) { if (Looper.myLooper() == Looper.getMainLooper()) {
try { try {
setStatusInternal(status) setStatusInternal(status)
} catch (t : Throwable) { } catch (t: Throwable) {
logError(t) // Just in case setStatusInternal throws because thread logError(t) // Just in case setStatusInternal throws because thread
progressBarBackground.post { progressBarBackground.post {
setStatusInternal(status) setStatusInternal(status)

View file

@ -4,7 +4,9 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType
import com.lagradost.cloudstream3.utils.ExtractorLink 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.max
import kotlin.math.min import kotlin.math.min
@ -49,10 +51,6 @@ class DownloadFileGenerator(
return null return null
} }
private fun cleanDisplayName(name: String): String {
return name.substringBeforeLast('.').trim()
}
override suspend fun generateLinks( override suspend fun generateLinks(
clearCache: Boolean, clearCache: Boolean,
type: LoadType, type: LoadType,
@ -69,28 +67,9 @@ class DownloadFileGenerator(
val cleanDisplay = cleanDisplayName(display) val cleanDisplay = cleanDisplayName(display)
VideoDownloadManager.getFolder(ctx, relative, meta.basePath) getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) ->
?.forEach { (name, uri) -> if (isMatchingSubtitle(name, display, cleanDisplay)) {
// 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
val cleanName = cleanDisplayName(name) 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) val realName = cleanName.removePrefix(cleanDisplay)
subtitleCallback( subtitleCallback(
@ -104,6 +83,7 @@ class DownloadFileGenerator(
) )
) )
} }
}
return true return true
} }

View file

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

View file

@ -7,7 +7,6 @@ import android.os.Bundle
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.activity.OnBackPressedCallback
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.lagradost.cloudstream3.CommonActivity.screenHeight 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.CSPlayerEvent
import com.lagradost.cloudstream3.ui.player.PlayerEventSource import com.lagradost.cloudstream3.ui.player.PlayerEventSource
import com.lagradost.cloudstream3.ui.player.SubtitleData 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() { open class ResultTrailerPlayer : ResultFragmentPhone() {
@ -156,7 +157,9 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
uiReset() uiReset()
if (isFullScreenPlayer) { if (isFullScreenPlayer) {
attachBackPressedCallback() activity?.attachBackPressedCallback {
updateFullscreen(false)
}
} else detachBackPressedCallback() } else detachBackPressedCallback()
} }
@ -175,27 +178,4 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
fixPlayerSize() 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 { fun Context.isNetworkAvailable(): Boolean {
val manager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetworkInfo = manager.activeNetworkInfo return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return activeNetworkInfo != null && activeNetworkInfo.isConnected || manager.allNetworkInfo?.any { it.isConnected } ?: false 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> { fun splitQuery(url: URL): Map<String, String> {
@ -1018,4 +1024,4 @@ object AppContextUtils {
} }
return currentAudioFocusRequest 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.Glide
import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.load.model.GlideUrl
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.api.Log
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey 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.R
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.services.VideoDownloadService import com.lagradost.cloudstream3.services.VideoDownloadService
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.removeKey import com.lagradost.cloudstream3.utils.DataStore.removeKey
import com.lagradost.cloudstream3.utils.SubtitleUtils.deleteMatchingSubtitles
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.safefile.MediaFileContentType import com.lagradost.safefile.MediaFileContentType
import com.lagradost.safefile.SafeFile import com.lagradost.safefile.SafeFile
@ -42,6 +45,8 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive 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) val success = deleteFile(context, id)
if (success) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) if (success) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
return success return success
@ -1759,11 +1794,17 @@ object VideoDownloadManager {
private fun deleteFile(context: Context, id: Int): Boolean { private fun deleteFile(context: Context, id: Int): Boolean {
val info = val info =
context.getKey<DownloadedFileInfo>(KEY_DOWNLOAD_INFO, id.toString()) ?: return false context.getKey<DownloadedFileInfo>(KEY_DOWNLOAD_INFO, id.toString()) ?: return false
val file = info.toFile(context)
downloadEvent.invoke(id to DownloadActionType.Stop) downloadEvent.invoke(id to DownloadActionType.Stop)
downloadProgressEvent.invoke(Triple(id, 0, 0)) downloadProgressEvent.invoke(Triple(id, 0, 0))
downloadStatusEvent.invoke(id to DownloadType.IsStopped) downloadStatusEvent.invoke(id to DownloadType.IsStopped)
downloadDeleteEvent.invoke(id) 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? { 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" <androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/download_child_episode_holder" android:id="@+id/download_child_episode_holder"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="50dp" android:layout_height="50dp"
android:layout_marginBottom="5dp" android:layout_marginBottom="5dp"
android:foreground="@drawable/outline_drawable" android:foreground="@drawable/outline_drawable"
@ -18,7 +16,6 @@
<androidx.core.widget.ContentLoadingProgressBar <androidx.core.widget.ContentLoadingProgressBar
android:id="@+id/download_child_episode_progress" android:id="@+id/download_child_episode_progress"
style="@android:style/Widget.Material.ProgressBar.Horizontal" style="@android:style/Widget.Material.ProgressBar.Horizontal"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="5dp" android:layout_height="5dp"
@ -56,12 +53,10 @@
android:layout_gravity="center_vertical" android:layout_gravity="center_vertical"
android:layout_marginStart="10dp" android:layout_marginStart="10dp"
android:layout_marginEnd="10dp" android:layout_marginEnd="10dp"
android:ellipsize="marquee" android:ellipsize="marquee"
android:gravity="center_vertical" android:gravity="center_vertical"
android:marqueeRepeatLimit="marquee_forever" android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true" android:scrollHorizontally="true"
android:singleLine="true" android:singleLine="true"
android:textColor="?attr/textColor" android:textColor="?attr/textColor"
tools:text="Episode 1 Episode 1 Episode 1 Episode 1 Episode 1 Episode 1 Episode 1" /> tools:text="Episode 1 Episode 1 Episode 1 Episode 1 Episode 1 Episode 1 Episode 1" />
@ -78,7 +73,6 @@
tools:text="128MB / 237MB" /> tools:text="128MB / 237MB" />
</LinearLayout> </LinearLayout>
<com.lagradost.cloudstream3.ui.download.button.PieFetchButton <com.lagradost.cloudstream3.ui.download.button.PieFetchButton
android:id="@+id/download_button" android:id="@+id/download_button"
android:layout_width="@dimen/download_size" android:layout_width="@dimen/download_size"
@ -89,5 +83,16 @@
android:focusable="true" android:focusable="true"
android:nextFocusLeft="@id/download_child_episode_holder" android:nextFocusLeft="@id/download_child_episode_holder"
android:padding="10dp" /> 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> </GridLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>

View file

@ -77,5 +77,16 @@
android:focusable="true" android:focusable="true"
android:nextFocusLeft="@id/episode_holder" android:nextFocusLeft="@id/episode_holder"
android:padding="10dp" /> 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> </LinearLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>

View file

@ -7,13 +7,69 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/primaryGrayBackground" android:background="?attr/primaryGrayBackground"
android:orientation="vertical" android:orientation="vertical"
tools:context=".ui.download.DownloadFragment"> tools:context=".ui.download.DownloadChildFragment">
<com.google.android.material.appbar.AppBarLayout <com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@android:color/transparent"> 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 <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/download_child_toolbar" android:id="@+id/download_child_toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -28,7 +84,6 @@
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/download_child_list" android:id="@+id/download_child_list"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/primaryBlackBackground" android:background="?attr/primaryBlackBackground"

View file

@ -17,6 +17,62 @@
For Scroll add to LinearLayout For Scroll add to LinearLayout
app:layout_scrollFlags="scroll|enterAlways" 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 <LinearLayout
android:id="@+id/download_storage_appbar" android:id="@+id/download_storage_appbar"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -137,7 +193,7 @@
android:background="?attr/primaryBlackBackground" android:background="?attr/primaryBlackBackground"
android:descendantFocusability="afterDescendants" android:descendantFocusability="afterDescendants"
android:nextFocusLeft="@id/nav_rail_view" 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" app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:listitem="@layout/download_header_episode" /> tools:listitem="@layout/download_header_episode" />
@ -154,11 +210,11 @@
<!-- <!--
<ProgressBar <ProgressBar
android:visibility="visible" android:visibility="visible"
tools:visibility="gone" tools:visibility="gone"
android:id="@+id/download_loading" android:id="@+id/download_loading"
android:layout_gravity="center" android:layout_gravity="center"
android:layout_width="50dp" android:layout_height="50dp"> android:layout_width="50dp" android:layout_height="50dp">
</ProgressBar>--> </ProgressBar>-->
<com.facebook.shimmer.ShimmerFrameLayout <com.facebook.shimmer.ShimmerFrameLayout
@ -182,15 +238,10 @@
android:orientation="vertical"> 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" />
<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> </LinearLayout>
</com.facebook.shimmer.ShimmerFrameLayout> </com.facebook.shimmer.ShimmerFrameLayout>
@ -201,24 +252,24 @@
android:orientation="vertical" android:orientation="vertical"
android:layout_gravity="bottom|end"> android:layout_gravity="bottom|end">
<com.google.android.material.floatingactionbutton.FloatingActionButton <com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/open_local_video_button" android:id="@+id/open_local_video_button"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
style="?attr/floatingActionButtonSmallStyle" style="?attr/floatingActionButtonSmallStyle"
android:backgroundTint="?attr/primaryGrayBackground" android:backgroundTint="?attr/primaryGrayBackground"
android:src="@drawable/netflix_play" android:src="@drawable/netflix_play"
android:layout_marginEnd="16dp" android:layout_marginEnd="16dp"
android:tooltipText="@string/open_local_video" android:tooltipText="@string/open_local_video"
android:layout_gravity="bottom|end" android:layout_gravity="bottom|end"
android:contentDescription="@string/open_local_video" /> android:contentDescription="@string/open_local_video" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/download_stream_button" android:id="@+id/download_stream_button"
style="@style/ExtendedFloatingActionButton" style="@style/ExtendedFloatingActionButton"
android:text="@string/stream" android:text="@string/stream"
android:textColor="?attr/textColor" android:textColor="?attr/textColor"
app:icon="@drawable/ic_network_stream" app:icon="@drawable/ic_network_stream"
android:contentDescription="@string/stream" /> android:contentDescription="@string/stream" />
</LinearLayout> </LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -150,7 +150,11 @@
<string name="download_canceled">Download Canceled</string> <string name="download_canceled">Download Canceled</string>
<string name="download_done">Download Done</string> <string name="download_done">Download Done</string>
<string name="download_format" translatable="false">%s - %s</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="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="update_started">Update Started</string>
<string name="stream">Network stream</string> <string name="stream">Network stream</string>
<string name="open_local_video">Open local video</string> <string name="open_local_video">Open local video</string>
@ -300,8 +304,10 @@
<string name="season_short">S</string> <string name="season_short">S</string>
<string name="episode_short">E</string> <string name="episode_short">E</string>
<string name="no_episodes_found">No Episodes found</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">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="cancel">Cancel</string>
<string name="pause">Pause</string> <string name="pause">Pause</string>
<string name="start">Start</string> <string name="start">Start</string>
@ -312,6 +318,10 @@
<string name="go_back_30">-30</string> <string name="go_back_30">-30</string>
<string name="go_forward_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" 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_time_left" formatted="true">%dm\nremaining</string>
<string name="resume_remaining" formatted="true">%s\nremaining</string> <string name="resume_remaining" formatted="true">%s\nremaining</string>
<string name="status_ongoing">Ongoing</string> <string name="status_ongoing">Ongoing</string>