mirror of
https://github.com/recloudstream/cloudstream.git
synced 2024-08-15 01:53:11 +00:00
Support for multi deleting downloads and other major improvements/fixes (#1177)
This commit is contained in:
parent
8fcb3e3121
commit
ab379ab31c
21 changed files with 1384 additions and 446 deletions
|
@ -133,6 +133,8 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
|
|||
import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers
|
||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
||||
import com.lagradost.cloudstream3.utils.BackupUtils.backup
|
||||
import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup
|
||||
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback
|
||||
|
@ -151,6 +153,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching
|
|||
import com.lagradost.cloudstream3.utils.Event
|
||||
import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||
|
@ -1254,17 +1257,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
this.setKey(getString(R.string.jsdelivr_proxy_key), false)
|
||||
} else {
|
||||
this.setKey(getString(R.string.jsdelivr_proxy_key), true)
|
||||
val parentView: View = findViewById(android.R.id.content)
|
||||
Snackbar.make(parentView, R.string.jsdelivr_enabled, Snackbar.LENGTH_LONG)
|
||||
.let { snackbar ->
|
||||
snackbar.setAction(R.string.revert) {
|
||||
setKey(getString(R.string.jsdelivr_proxy_key), false)
|
||||
}
|
||||
snackbar.setBackgroundTint(colorFromAttribute(R.attr.primaryGrayBackground))
|
||||
snackbar.setTextColor(colorFromAttribute(R.attr.textColor))
|
||||
snackbar.setActionTextColor(colorFromAttribute(R.attr.colorPrimary))
|
||||
snackbar.show()
|
||||
}
|
||||
showSnackbar(
|
||||
this@MainActivity,
|
||||
R.string.jsdelivr_enabled,
|
||||
Snackbar.LENGTH_LONG,
|
||||
R.string.revert
|
||||
) { setKey(getString(R.string.jsdelivr_proxy_key), false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1603,7 +1601,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
|
||||
if (isLayout(TV or EMULATOR)) {
|
||||
if (navDestination.matchDestination(R.id.navigation_home)) {
|
||||
attachBackPressedCallback()
|
||||
attachBackPressedCallback {
|
||||
showConfirmExitDialog()
|
||||
window?.navigationBarColor =
|
||||
colorFromAttribute(R.attr.primaryGrayBackground)
|
||||
updateLocale()
|
||||
}
|
||||
} else detachBackPressedCallback()
|
||||
}
|
||||
}
|
||||
|
@ -1848,28 +1851,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
finish()
|
||||
}
|
||||
|
||||
private var backPressedCallback: OnBackPressedCallback? = null
|
||||
|
||||
private fun attachBackPressedCallback() {
|
||||
if (backPressedCallback == null) {
|
||||
backPressedCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
showConfirmExitDialog()
|
||||
window?.navigationBarColor =
|
||||
colorFromAttribute(R.attr.primaryGrayBackground)
|
||||
updateLocale()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backPressedCallback?.isEnabled = true
|
||||
onBackPressedDispatcher.addCallback(this, backPressedCallback ?: return)
|
||||
}
|
||||
|
||||
private fun detachBackPressedCallback() {
|
||||
backPressedCallback?.isEnabled = false
|
||||
}
|
||||
|
||||
suspend fun checkGithubConnectivity(): Boolean {
|
||||
return try {
|
||||
app.get(
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
package com.lagradost.cloudstream3.ui.download
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.text.format.Formatter.formatShortFileSize
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CheckBox
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
|
@ -31,47 +31,30 @@ const val DOWNLOAD_ACTION_LONG_CLICK = 5
|
|||
const val DOWNLOAD_ACTION_GO_TO_CHILD = 0
|
||||
const val DOWNLOAD_ACTION_LOAD_RESULT = 1
|
||||
|
||||
abstract class VisualDownloadCached(
|
||||
open val currentBytes: Long,
|
||||
open val totalBytes: Long,
|
||||
open val data: VideoDownloadHelper.DownloadCached
|
||||
) {
|
||||
sealed class VisualDownloadCached {
|
||||
abstract val currentBytes: Long
|
||||
abstract val totalBytes: Long
|
||||
abstract val data: VideoDownloadHelper.DownloadCached
|
||||
abstract var isSelected: Boolean
|
||||
|
||||
// Just to be extra-safe with areContentsTheSame
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is VisualDownloadCached) return false
|
||||
data class Child(
|
||||
override val currentBytes: Long,
|
||||
override val totalBytes: Long,
|
||||
override val data: VideoDownloadHelper.DownloadEpisodeCached,
|
||||
override var isSelected: Boolean,
|
||||
) : VisualDownloadCached()
|
||||
|
||||
if (currentBytes != other.currentBytes) return false
|
||||
if (totalBytes != other.totalBytes) return false
|
||||
if (data != other.data) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = currentBytes.hashCode()
|
||||
result = 31 * result + totalBytes.hashCode()
|
||||
result = 31 * result + data.hashCode()
|
||||
return result
|
||||
}
|
||||
data class Header(
|
||||
override val currentBytes: Long,
|
||||
override val totalBytes: Long,
|
||||
override val data: VideoDownloadHelper.DownloadHeaderCached,
|
||||
override var isSelected: Boolean,
|
||||
val child: VideoDownloadHelper.DownloadEpisodeCached?,
|
||||
val currentOngoingDownloads: Int,
|
||||
val totalDownloads: Int,
|
||||
) : VisualDownloadCached()
|
||||
}
|
||||
|
||||
data class VisualDownloadChildCached(
|
||||
override val currentBytes: Long,
|
||||
override val totalBytes: Long,
|
||||
override val data: VideoDownloadHelper.DownloadEpisodeCached,
|
||||
): VisualDownloadCached(currentBytes, totalBytes, data)
|
||||
|
||||
data class VisualDownloadHeaderCached(
|
||||
override val currentBytes: Long,
|
||||
override val totalBytes: Long,
|
||||
override val data: VideoDownloadHelper.DownloadHeaderCached,
|
||||
val child: VideoDownloadHelper.DownloadEpisodeCached?,
|
||||
val currentOngoingDownloads: Int,
|
||||
val totalDownloads: Int,
|
||||
): VisualDownloadCached(currentBytes, totalBytes, data)
|
||||
|
||||
data class DownloadClickEvent(
|
||||
val action: Int,
|
||||
val data: VideoDownloadHelper.DownloadEpisodeCached
|
||||
|
@ -83,108 +66,180 @@ data class DownloadHeaderClickEvent(
|
|||
)
|
||||
|
||||
class DownloadAdapter(
|
||||
private val clickCallback: (DownloadHeaderClickEvent) -> Unit,
|
||||
private val mediaClickCallback: (DownloadClickEvent) -> Unit,
|
||||
private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit,
|
||||
private val onItemClickEvent: (DownloadClickEvent) -> Unit,
|
||||
private val onItemSelectionChanged: (Int, Boolean) -> Unit,
|
||||
) : ListAdapter<VisualDownloadCached, DownloadAdapter.DownloadViewHolder>(DiffCallback()) {
|
||||
|
||||
private var isMultiDeleteState: Boolean = false
|
||||
|
||||
companion object {
|
||||
private const val VIEW_TYPE_HEADER = 0
|
||||
private const val VIEW_TYPE_CHILD = 1
|
||||
}
|
||||
|
||||
inner class DownloadViewHolder(
|
||||
private val binding: ViewBinding,
|
||||
private val clickCallback: (DownloadHeaderClickEvent) -> Unit,
|
||||
private val mediaClickCallback: (DownloadClickEvent) -> Unit,
|
||||
private val binding: ViewBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(card: VisualDownloadCached?) {
|
||||
when (binding) {
|
||||
is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadHeaderCached)
|
||||
is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadChildCached)
|
||||
is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadCached.Header)
|
||||
is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadCached.Child)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private fun bindHeader(card: VisualDownloadHeaderCached?) {
|
||||
if (binding !is DownloadHeaderEpisodeBinding) return
|
||||
card ?: return
|
||||
val d = card.data
|
||||
private fun bindHeader(card: VisualDownloadCached.Header?) {
|
||||
if (binding !is DownloadHeaderEpisodeBinding || card == null) return
|
||||
|
||||
val data = card.data
|
||||
binding.apply {
|
||||
downloadHeaderPoster.apply {
|
||||
setImage(d.poster)
|
||||
setOnClickListener {
|
||||
clickCallback.invoke(DownloadHeaderClickEvent(DOWNLOAD_ACTION_LOAD_RESULT, d))
|
||||
episodeHolder.apply {
|
||||
if (isMultiDeleteState) {
|
||||
setOnClickListener {
|
||||
toggleIsChecked(deleteCheckbox, data.id)
|
||||
}
|
||||
}
|
||||
|
||||
setOnLongClickListener {
|
||||
toggleIsChecked(deleteCheckbox, data.id)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
downloadHeaderTitle.text = d.name
|
||||
val formattedSizeString = formatShortFileSize(itemView.context, card.totalBytes)
|
||||
downloadHeaderPoster.apply {
|
||||
setImage(data.poster)
|
||||
if (isMultiDeleteState) {
|
||||
setOnClickListener {
|
||||
toggleIsChecked(deleteCheckbox, data.id)
|
||||
}
|
||||
} else {
|
||||
setOnClickListener {
|
||||
onHeaderClickEvent.invoke(
|
||||
DownloadHeaderClickEvent(
|
||||
DOWNLOAD_ACTION_LOAD_RESULT,
|
||||
data
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
setOnLongClickListener {
|
||||
toggleIsChecked(deleteCheckbox, data.id)
|
||||
true
|
||||
}
|
||||
}
|
||||
downloadHeaderTitle.text = data.name
|
||||
val formattedSize = formatShortFileSize(itemView.context, card.totalBytes)
|
||||
|
||||
if (card.child != null) {
|
||||
downloadHeaderGotoChild.isVisible = false
|
||||
handleChildDownload(card, formattedSize)
|
||||
} else handleParentDownload(card, formattedSize)
|
||||
|
||||
val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes)
|
||||
if (status == DownloadStatusTell.IsDone) {
|
||||
// We do this here instead if we are finished downloading
|
||||
// so that we can use the value from the view model
|
||||
// rather than extra unneeded disk operations and to prevent a
|
||||
// delay in updating download icon state.
|
||||
downloadButton.setProgress(card.currentBytes, card.totalBytes)
|
||||
downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes)
|
||||
// We will let the view model handle this
|
||||
downloadButton.doSetProgress = false
|
||||
downloadButton.progressBar.progressDrawable =
|
||||
downloadButton.getDrawableFromStatus(status)
|
||||
?.let { ContextCompat.getDrawable(downloadButton.context, it) }
|
||||
downloadHeaderInfo.text = formattedSizeString
|
||||
} else {
|
||||
downloadButton.doSetProgress = true
|
||||
downloadButton.progressBar.progressDrawable =
|
||||
ContextCompat.getDrawable(downloadButton.context, downloadButton.progressDrawable)
|
||||
if (isMultiDeleteState) {
|
||||
deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
||||
onItemSelectionChanged.invoke(data.id, isChecked)
|
||||
}
|
||||
} else deleteCheckbox.setOnCheckedChangeListener(null)
|
||||
|
||||
downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, mediaClickCallback)
|
||||
downloadButton.isVisible = true
|
||||
|
||||
episodeHolder.setOnClickListener {
|
||||
mediaClickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, card.child))
|
||||
}
|
||||
} else {
|
||||
downloadButton.isVisible = false
|
||||
downloadHeaderGotoChild.isVisible = true
|
||||
|
||||
try {
|
||||
downloadHeaderInfo.text = downloadHeaderInfo.context.getString(R.string.extra_info_format)
|
||||
.format(
|
||||
card.totalDownloads,
|
||||
downloadHeaderInfo.context.resources.getQuantityString(
|
||||
R.plurals.episodes,
|
||||
card.totalDownloads
|
||||
),
|
||||
formattedSizeString
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// You probably formatted incorrectly
|
||||
downloadHeaderInfo.text = "Error"
|
||||
logError(e)
|
||||
}
|
||||
|
||||
episodeHolder.setOnClickListener {
|
||||
clickCallback.invoke(DownloadHeaderClickEvent(DOWNLOAD_ACTION_GO_TO_CHILD, d))
|
||||
}
|
||||
deleteCheckbox.apply {
|
||||
isVisible = isMultiDeleteState
|
||||
isChecked = card.isSelected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindChild(card: VisualDownloadChildCached?) {
|
||||
if (binding !is DownloadChildEpisodeBinding) return
|
||||
card ?: return
|
||||
val d = card.data
|
||||
private fun DownloadHeaderEpisodeBinding.handleChildDownload(
|
||||
card: VisualDownloadCached.Header,
|
||||
formattedSize: String
|
||||
) {
|
||||
card.child ?: return
|
||||
downloadHeaderGotoChild.isVisible = false
|
||||
|
||||
val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes)
|
||||
if (status == DownloadStatusTell.IsDone) {
|
||||
// We do this here instead if we are finished downloading
|
||||
// so that we can use the value from the view model
|
||||
// rather than extra unneeded disk operations and to prevent a
|
||||
// delay in updating download icon state.
|
||||
downloadButton.setProgress(card.currentBytes, card.totalBytes)
|
||||
downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes)
|
||||
// We will let the view model handle this
|
||||
downloadButton.doSetProgress = false
|
||||
downloadButton.progressBar.progressDrawable =
|
||||
downloadButton.getDrawableFromStatus(status)
|
||||
?.let { ContextCompat.getDrawable(downloadButton.context, it) }
|
||||
downloadHeaderInfo.text = formattedSize
|
||||
} else {
|
||||
// We need to make sure we restore the correct progress
|
||||
// when we refresh data in the adapter.
|
||||
downloadButton.resetView()
|
||||
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
|
||||
ContextCompat.getDrawable(downloadButton.context, it)
|
||||
}
|
||||
downloadButton.statusView.setImageDrawable(drawable)
|
||||
downloadButton.progressBar.progressDrawable =
|
||||
ContextCompat.getDrawable(
|
||||
downloadButton.context,
|
||||
downloadButton.progressDrawable
|
||||
)
|
||||
}
|
||||
|
||||
downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent)
|
||||
downloadButton.isVisible = !isMultiDeleteState
|
||||
|
||||
if (!isMultiDeleteState) {
|
||||
episodeHolder.setOnClickListener {
|
||||
onItemClickEvent.invoke(
|
||||
DownloadClickEvent(
|
||||
DOWNLOAD_ACTION_PLAY_FILE,
|
||||
card.child
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DownloadHeaderEpisodeBinding.handleParentDownload(
|
||||
card: VisualDownloadCached.Header,
|
||||
formattedSize: String
|
||||
) {
|
||||
downloadButton.isVisible = false
|
||||
downloadHeaderGotoChild.isVisible = !isMultiDeleteState
|
||||
|
||||
try {
|
||||
downloadHeaderInfo.text =
|
||||
downloadHeaderInfo.context.getString(R.string.extra_info_format).format(
|
||||
card.totalDownloads,
|
||||
downloadHeaderInfo.context.resources.getQuantityString(
|
||||
R.plurals.episodes,
|
||||
card.totalDownloads
|
||||
),
|
||||
formattedSize
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
downloadHeaderInfo.text = ""
|
||||
logError(e)
|
||||
}
|
||||
|
||||
if (!isMultiDeleteState) {
|
||||
episodeHolder.setOnClickListener {
|
||||
onHeaderClickEvent.invoke(
|
||||
DownloadHeaderClickEvent(
|
||||
DOWNLOAD_ACTION_GO_TO_CHILD,
|
||||
card.data
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindChild(card: VisualDownloadCached.Child?) {
|
||||
if (binding !is DownloadChildEpisodeBinding || card == null) return
|
||||
|
||||
val data = card.data
|
||||
binding.apply {
|
||||
val posDur = getViewPos(d.id)
|
||||
val posDur = getViewPos(data.id)
|
||||
downloadChildEpisodeProgress.apply {
|
||||
isVisible = posDur != null
|
||||
posDur?.let {
|
||||
|
@ -194,36 +249,87 @@ class DownloadAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
val status = downloadButton.getStatus(d.id, card.currentBytes, card.totalBytes)
|
||||
val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes)
|
||||
if (status == DownloadStatusTell.IsDone) {
|
||||
// We do this here instead if we are finished downloading
|
||||
// so that we can use the value from the view model
|
||||
// rather than extra unneeded disk operations and to prevent a
|
||||
// delay in updating download icon state.
|
||||
downloadButton.setProgress(card.currentBytes, card.totalBytes)
|
||||
downloadButton.applyMetaData(d.id, card.currentBytes, card.totalBytes)
|
||||
downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes)
|
||||
// We will let the view model handle this
|
||||
downloadButton.doSetProgress = false
|
||||
downloadButton.progressBar.progressDrawable =
|
||||
downloadButton.getDrawableFromStatus(status)
|
||||
?.let { ContextCompat.getDrawable(downloadButton.context, it) }
|
||||
downloadChildEpisodeTextExtra.text = formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes)
|
||||
downloadChildEpisodeTextExtra.text =
|
||||
formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes)
|
||||
} else {
|
||||
downloadButton.doSetProgress = true
|
||||
// We need to make sure we restore the correct progress
|
||||
// when we refresh data in the adapter.
|
||||
downloadButton.resetView()
|
||||
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
|
||||
ContextCompat.getDrawable(downloadButton.context, it)
|
||||
}
|
||||
downloadButton.statusView.setImageDrawable(drawable)
|
||||
downloadButton.progressBar.progressDrawable =
|
||||
ContextCompat.getDrawable(downloadButton.context, downloadButton.progressDrawable)
|
||||
ContextCompat.getDrawable(
|
||||
downloadButton.context,
|
||||
downloadButton.progressDrawable
|
||||
)
|
||||
}
|
||||
|
||||
downloadButton.setDefaultClickListener(d, downloadChildEpisodeTextExtra, mediaClickCallback)
|
||||
downloadButton.isVisible = true
|
||||
downloadButton.setDefaultClickListener(
|
||||
data,
|
||||
downloadChildEpisodeTextExtra,
|
||||
onItemClickEvent
|
||||
)
|
||||
downloadButton.isVisible = !isMultiDeleteState
|
||||
|
||||
downloadChildEpisodeText.apply {
|
||||
text = context.getNameFull(d.name, d.episode, d.season)
|
||||
text = context.getNameFull(data.name, data.episode, data.season)
|
||||
isSelected = true // Needed for text repeating
|
||||
}
|
||||
|
||||
downloadChildEpisodeHolder.setOnClickListener {
|
||||
mediaClickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, d))
|
||||
onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data))
|
||||
}
|
||||
|
||||
downloadChildEpisodeHolder.apply {
|
||||
when {
|
||||
isMultiDeleteState -> {
|
||||
setOnClickListener {
|
||||
toggleIsChecked(deleteCheckbox, data.id)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
setOnClickListener {
|
||||
onItemClickEvent.invoke(
|
||||
DownloadClickEvent(
|
||||
DOWNLOAD_ACTION_PLAY_FILE,
|
||||
data
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOnLongClickListener {
|
||||
toggleIsChecked(deleteCheckbox, data.id)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
if (isMultiDeleteState) {
|
||||
deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
||||
onItemSelectionChanged.invoke(data.id, isChecked)
|
||||
}
|
||||
} else deleteCheckbox.setOnCheckedChangeListener(null)
|
||||
|
||||
deleteCheckbox.apply {
|
||||
isVisible = isMultiDeleteState
|
||||
isChecked = card.isSelected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -236,7 +342,7 @@ class DownloadAdapter(
|
|||
VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false)
|
||||
else -> throw IllegalArgumentException("Invalid view type")
|
||||
}
|
||||
return DownloadViewHolder(binding, clickCallback, mediaClickCallback)
|
||||
return DownloadViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) {
|
||||
|
@ -245,18 +351,52 @@ class DownloadAdapter(
|
|||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return when (getItem(position)) {
|
||||
is VisualDownloadChildCached -> VIEW_TYPE_CHILD
|
||||
is VisualDownloadHeaderCached -> VIEW_TYPE_HEADER
|
||||
is VisualDownloadCached.Child -> VIEW_TYPE_CHILD
|
||||
is VisualDownloadCached.Header -> VIEW_TYPE_HEADER
|
||||
else -> throw IllegalArgumentException("Invalid data type at position $position")
|
||||
}
|
||||
}
|
||||
|
||||
fun setIsMultiDeleteState(value: Boolean) {
|
||||
if (isMultiDeleteState == value) return
|
||||
isMultiDeleteState = value
|
||||
notifyItemRangeChanged(0, itemCount)
|
||||
}
|
||||
|
||||
fun notifyAllSelected() {
|
||||
currentList.indices.forEach { index ->
|
||||
if (!currentList[index].isSelected) {
|
||||
notifyItemChanged(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun notifySelectionStates() {
|
||||
currentList.indices.forEach { index ->
|
||||
if (currentList[index].isSelected) {
|
||||
notifyItemChanged(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) {
|
||||
val isChecked = !checkbox.isChecked
|
||||
checkbox.isChecked = isChecked
|
||||
onItemSelectionChanged.invoke(itemId, isChecked)
|
||||
}
|
||||
|
||||
class DiffCallback : DiffUtil.ItemCallback<VisualDownloadCached>() {
|
||||
override fun areItemsTheSame(oldItem: VisualDownloadCached, newItem: VisualDownloadCached): Boolean {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: VisualDownloadCached,
|
||||
newItem: VisualDownloadCached
|
||||
): Boolean {
|
||||
return oldItem.data.id == newItem.data.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: VisualDownloadCached, newItem: VisualDownloadCached): Boolean {
|
||||
override fun areContentsTheSame(
|
||||
oldItem: VisualDownloadCached,
|
||||
newItem: VisualDownloadCached
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
package com.lagradost.cloudstream3.ui.download
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CommonActivity.activity
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.ui.player.DownloadFileGenerator
|
||||
|
@ -14,9 +13,11 @@ import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
|||
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
||||
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||
import kotlinx.coroutines.MainScope
|
||||
|
||||
object DownloadButtonSetup {
|
||||
fun handleDownloadClick(click: DownloadClickEvent) {
|
||||
|
@ -29,9 +30,15 @@ object DownloadButtonSetup {
|
|||
DialogInterface.OnClickListener { _, which ->
|
||||
when (which) {
|
||||
DialogInterface.BUTTON_POSITIVE -> {
|
||||
VideoDownloadManager.deleteFileAndUpdateSettings(ctx, id)
|
||||
VideoDownloadManager.deleteFilesAndUpdateSettings(
|
||||
ctx,
|
||||
setOf(id),
|
||||
MainScope()
|
||||
)
|
||||
}
|
||||
|
||||
DialogInterface.BUTTON_NEGATIVE -> {
|
||||
// Do nothing on cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -56,11 +63,13 @@ object DownloadButtonSetup {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
DOWNLOAD_ACTION_PAUSE_DOWNLOAD -> {
|
||||
VideoDownloadManager.downloadEvent.invoke(
|
||||
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Pause)
|
||||
)
|
||||
}
|
||||
|
||||
DOWNLOAD_ACTION_RESUME_DOWNLOAD -> {
|
||||
activity?.let { ctx ->
|
||||
if (VideoDownloadManager.downloadStatus.containsKey(id) && VideoDownloadManager.downloadStatus[id] == VideoDownloadManager.DownloadType.IsPaused) {
|
||||
|
@ -79,6 +88,7 @@ object DownloadButtonSetup {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
DOWNLOAD_ACTION_LONG_CLICK -> {
|
||||
activity?.let { act ->
|
||||
val length =
|
||||
|
@ -88,12 +98,15 @@ object DownloadButtonSetup {
|
|||
)?.fileLength
|
||||
?: 0
|
||||
if (length > 0) {
|
||||
showToast(R.string.delete, Toast.LENGTH_LONG)
|
||||
} else {
|
||||
showToast(R.string.download, Toast.LENGTH_LONG)
|
||||
showSnackbar(
|
||||
act,
|
||||
R.string.offline_file,
|
||||
Snackbar.LENGTH_LONG
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DOWNLOAD_ACTION_PLAY_FILE -> {
|
||||
activity?.let { act ->
|
||||
val info =
|
||||
|
@ -119,7 +132,7 @@ object DownloadButtonSetup {
|
|||
|
||||
id = click.data.id,
|
||||
parentId = click.data.parentId,
|
||||
name = act.getString(R.string.downloaded_file), //click.data.name ?: keyInfo.displayName
|
||||
name = act.getString(R.string.downloaded_file), // click.data.name ?: keyInfo.displayName
|
||||
season = click.data.season,
|
||||
episode = click.data.episode,
|
||||
headerName = parent.name,
|
||||
|
@ -132,7 +145,7 @@ object DownloadButtonSetup {
|
|||
)
|
||||
)
|
||||
)
|
||||
//R.id.global_to_navigation_player, PlayerFragment.newInstance(
|
||||
// R.id.global_to_navigation_player, PlayerFragment.newInstance(
|
||||
// UriData(
|
||||
// info.path.toString(),
|
||||
// keyInfo.basePath,
|
||||
|
@ -145,7 +158,7 @@ object DownloadButtonSetup {
|
|||
// click.data.season
|
||||
// ),
|
||||
// getViewPos(click.data.id)?.position ?: 0
|
||||
//)
|
||||
// )
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,29 +1,33 @@
|
|||
package com.lagradost.cloudstream3.ui.download
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.format.Formatter.formatShortFileSize
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
|
||||
import com.lagradost.cloudstream3.mvvm.observe
|
||||
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
|
||||
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
||||
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class DownloadChildFragment : Fragment() {
|
||||
private lateinit var downloadsViewModel: DownloadViewModel
|
||||
private var binding: FragmentChildDownloadsBinding? = null
|
||||
|
||||
companion object {
|
||||
fun newInstance(headerName: String, folder: String): Bundle {
|
||||
return Bundle().apply {
|
||||
|
@ -34,61 +38,54 @@ class DownloadChildFragment : Fragment() {
|
|||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it }
|
||||
downloadDeleteEventListener = null
|
||||
detachBackPressedCallback()
|
||||
binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private var binding: FragmentChildDownloadsBinding? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
|
||||
val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false)
|
||||
binding = localBinding
|
||||
return localBinding.root
|
||||
}
|
||||
|
||||
private fun updateList(folder: String) = main {
|
||||
context?.let { ctx ->
|
||||
val data = withContext(Dispatchers.IO) { ctx.getKeys(folder) }
|
||||
val eps = withContext(Dispatchers.IO) {
|
||||
data.mapNotNull { key ->
|
||||
context?.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key)
|
||||
}.mapNotNull {
|
||||
val info = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(ctx, it.id)
|
||||
?: return@mapNotNull null
|
||||
VisualDownloadChildCached(
|
||||
currentBytes = info.fileLength,
|
||||
totalBytes = info.totalBytes,
|
||||
data = it,
|
||||
)
|
||||
}
|
||||
}.sortedBy { it.data.episode + (it.data.season ?: 0) * 100000 }
|
||||
if (eps.isEmpty()) {
|
||||
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||
return@main
|
||||
}
|
||||
|
||||
(binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(eps)
|
||||
}
|
||||
}
|
||||
|
||||
private var downloadDeleteEventListener: ((Int) -> Unit)? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
/**
|
||||
* We never want to retain multi-delete state
|
||||
* when navigating to downloads. Setting this state
|
||||
* immediately can sometimes result in the observer
|
||||
* not being notified in time to update the UI.
|
||||
*
|
||||
* By posting to the main looper, we ensure that this
|
||||
* operation is executed after the view has been fully created
|
||||
* and all initializations are completed, allowing the
|
||||
* observer to properly receive and handle the state change.
|
||||
*/
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
downloadsViewModel.setIsMultiDeleteState(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* We have to make sure selected items are
|
||||
* cleared here as well so we don't run in an
|
||||
* inconsistent state where selected items do
|
||||
* not match the multi delete state we are in.
|
||||
*/
|
||||
downloadsViewModel.clearSelectedItems()
|
||||
|
||||
val folder = arguments?.getString("folder")
|
||||
val name = arguments?.getString("name")
|
||||
if (folder == null) {
|
||||
activity?.onBackPressedDispatcher?.onBackPressed() // TODO FIX
|
||||
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||
return
|
||||
}
|
||||
fixPaddingStatusbar(binding?.downloadChildRoot)
|
||||
|
||||
binding?.downloadChildToolbar?.apply {
|
||||
title = name
|
||||
|
@ -101,13 +98,55 @@ class DownloadChildFragment : Fragment() {
|
|||
setAppBarNoScrollFlagsOnTV()
|
||||
}
|
||||
|
||||
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
|
||||
|
||||
observe(downloadsViewModel.childCards) {
|
||||
if (it.isEmpty()) {
|
||||
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||
return@observe
|
||||
}
|
||||
|
||||
(binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it)
|
||||
}
|
||||
observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
|
||||
val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
|
||||
adapter?.setIsMultiDeleteState(isMultiDeleteState)
|
||||
binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
|
||||
if (!isMultiDeleteState) {
|
||||
detachBackPressedCallback()
|
||||
downloadsViewModel.clearSelectedItems()
|
||||
binding?.downloadChildToolbar?.isVisible = true
|
||||
}
|
||||
}
|
||||
observe(downloadsViewModel.selectedBytes) {
|
||||
updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
|
||||
}
|
||||
observe(downloadsViewModel.selectedItemIds) {
|
||||
handleSelectedChange(it)
|
||||
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
|
||||
|
||||
binding?.btnDelete?.isVisible = it.isNotEmpty()
|
||||
binding?.selectItemsText?.isVisible = it.isEmpty()
|
||||
|
||||
val allSelected = downloadsViewModel.isAllSelected()
|
||||
if (allSelected) {
|
||||
binding?.btnToggleAll?.setText(R.string.deselect_all)
|
||||
} else binding?.btnToggleAll?.setText(R.string.select_all)
|
||||
}
|
||||
|
||||
val adapter = DownloadAdapter(
|
||||
{},
|
||||
{ downloadClickEvent ->
|
||||
handleDownloadClick(downloadClickEvent)
|
||||
if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) {
|
||||
setUpDownloadDeleteListener(folder)
|
||||
}
|
||||
{ click ->
|
||||
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
|
||||
context?.let { ctx ->
|
||||
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
|
||||
}
|
||||
} else handleDownloadClick(click)
|
||||
},
|
||||
{ itemId, isChecked ->
|
||||
if (isChecked) {
|
||||
downloadsViewModel.addSelected(itemId)
|
||||
} else downloadsViewModel.removeSelected(itemId)
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -122,18 +161,47 @@ class DownloadChildFragment : Fragment() {
|
|||
)
|
||||
}
|
||||
|
||||
updateList(folder)
|
||||
context?.let { downloadsViewModel.updateChildList(it, folder) }
|
||||
fixPaddingStatusbar(binding?.downloadChildRoot)
|
||||
}
|
||||
|
||||
private fun setUpDownloadDeleteListener(folder: String) {
|
||||
downloadDeleteEventListener = { id: Int ->
|
||||
val list = (binding?.downloadChildList?.adapter as? DownloadAdapter)?.currentList
|
||||
if (list != null) {
|
||||
if (list.any { it.data.id == id }) {
|
||||
updateList(folder)
|
||||
private fun handleSelectedChange(selected: MutableSet<Int>) {
|
||||
if (selected.isNotEmpty()) {
|
||||
binding?.downloadDeleteAppbar?.isVisible = true
|
||||
binding?.downloadChildToolbar?.isVisible = false
|
||||
activity?.attachBackPressedCallback {
|
||||
downloadsViewModel.setIsMultiDeleteState(false)
|
||||
}
|
||||
|
||||
binding?.btnDelete?.setOnClickListener {
|
||||
context?.let { ctx ->
|
||||
downloadsViewModel.handleMultiDelete(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
binding?.btnCancel?.setOnClickListener {
|
||||
downloadsViewModel.setIsMultiDeleteState(false)
|
||||
}
|
||||
|
||||
binding?.btnToggleAll?.setOnClickListener {
|
||||
val allSelected = downloadsViewModel.isAllSelected()
|
||||
val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
|
||||
if (allSelected) {
|
||||
adapter?.notifySelectionStates()
|
||||
downloadsViewModel.clearSelectedItems()
|
||||
} else {
|
||||
adapter?.notifyAllSelected()
|
||||
downloadsViewModel.selectAllItems()
|
||||
}
|
||||
}
|
||||
|
||||
downloadsViewModel.setIsMultiDeleteState(true)
|
||||
}
|
||||
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
|
||||
}
|
||||
|
||||
private fun updateDeleteButton(count: Int, selectedBytes: Long) {
|
||||
val formattedSize = formatShortFileSize(context, selectedBytes)
|
||||
binding?.btnDelete?.text =
|
||||
getString(R.string.delete_format).format(count, formattedSize)
|
||||
}
|
||||
}
|
|
@ -8,6 +8,8 @@ import android.content.Intent
|
|||
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.format.Formatter.formatShortFileSize
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
|
@ -17,7 +19,6 @@ import android.widget.TextView
|
|||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
|
@ -27,7 +28,7 @@ import com.lagradost.cloudstream3.CommonActivity.showToast
|
|||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
|
||||
import com.lagradost.cloudstream3.databinding.StreamInputBinding
|
||||
import com.lagradost.cloudstream3.isMovieType
|
||||
import com.lagradost.cloudstream3.isEpisodeBased
|
||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
import com.lagradost.cloudstream3.mvvm.observe
|
||||
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
|
||||
|
@ -40,20 +41,22 @@ import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
|||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
|
||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
||||
import com.lagradost.cloudstream3.utils.DataStore
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||
import java.net.URI
|
||||
|
||||
const val DOWNLOAD_NAVIGATE_TO = "downloadpage"
|
||||
|
||||
class DownloadFragment : Fragment() {
|
||||
private lateinit var downloadsViewModel: DownloadViewModel
|
||||
private var binding: FragmentDownloadsBinding? = null
|
||||
|
||||
private fun View.setLayoutWidth(weight: Long) {
|
||||
val param = LinearLayout.LayoutParams(
|
||||
|
@ -65,14 +68,11 @@ class DownloadFragment : Fragment() {
|
|||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it }
|
||||
downloadDeleteEventListener = null
|
||||
detachBackPressedCallback()
|
||||
binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private var binding: FragmentDownloadsBinding? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
|
@ -84,12 +84,34 @@ class DownloadFragment : Fragment() {
|
|||
return localBinding.root
|
||||
}
|
||||
|
||||
private var downloadDeleteEventListener: ((Int) -> Unit)? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
hideKeyboard()
|
||||
binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV()
|
||||
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
|
||||
|
||||
/**
|
||||
* We never want to retain multi-delete state
|
||||
* when navigating to downloads. Setting this state
|
||||
* immediately can sometimes result in the observer
|
||||
* not being notified in time to update the UI.
|
||||
*
|
||||
* By posting to the main looper, we ensure that this
|
||||
* operation is executed after the view has been fully created
|
||||
* and all initializations are completed, allowing the
|
||||
* observer to properly receive and handle the state change.
|
||||
*/
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
downloadsViewModel.setIsMultiDeleteState(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* We have to make sure selected items are
|
||||
* cleared here as well so we don't run in an
|
||||
* inconsistent state where selected items do
|
||||
* not match the multi delete state we are in.
|
||||
*/
|
||||
downloadsViewModel.clearSelectedItems()
|
||||
|
||||
observe(downloadsViewModel.headerCards) {
|
||||
(binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it)
|
||||
|
@ -97,25 +119,82 @@ class DownloadFragment : Fragment() {
|
|||
binding?.textNoDownloads?.isVisible = it.isEmpty()
|
||||
}
|
||||
observe(downloadsViewModel.availableBytes) {
|
||||
updateStorageInfo(view.context, it, R.string.free_storage, binding?.downloadFreeTxt, binding?.downloadFree)
|
||||
updateStorageInfo(
|
||||
view.context,
|
||||
it,
|
||||
R.string.free_storage,
|
||||
binding?.downloadFreeTxt,
|
||||
binding?.downloadFree
|
||||
)
|
||||
}
|
||||
observe(downloadsViewModel.usedBytes) {
|
||||
updateStorageInfo(view.context, it, R.string.used_storage, binding?.downloadUsedTxt, binding?.downloadUsed)
|
||||
binding?.downloadStorageAppbar?.isVisible = it > 0
|
||||
updateStorageInfo(
|
||||
view.context,
|
||||
it,
|
||||
R.string.used_storage,
|
||||
binding?.downloadUsedTxt,
|
||||
binding?.downloadUsed
|
||||
)
|
||||
|
||||
// Prevent race condition and make sure
|
||||
// we don't display it early
|
||||
if (
|
||||
downloadsViewModel.isMultiDeleteState.value == null ||
|
||||
downloadsViewModel.isMultiDeleteState.value == false
|
||||
) binding?.downloadStorageAppbar?.isVisible = it > 0
|
||||
}
|
||||
observe(downloadsViewModel.downloadBytes) {
|
||||
updateStorageInfo(view.context, it, R.string.app_storage, binding?.downloadAppTxt, binding?.downloadApp)
|
||||
updateStorageInfo(
|
||||
view.context,
|
||||
it,
|
||||
R.string.app_storage,
|
||||
binding?.downloadAppTxt,
|
||||
binding?.downloadApp
|
||||
)
|
||||
}
|
||||
observe(downloadsViewModel.selectedBytes) {
|
||||
updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
|
||||
}
|
||||
observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
|
||||
val adapter = binding?.downloadList?.adapter as? DownloadAdapter
|
||||
adapter?.setIsMultiDeleteState(isMultiDeleteState)
|
||||
binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
|
||||
if (!isMultiDeleteState) {
|
||||
detachBackPressedCallback()
|
||||
downloadsViewModel.clearSelectedItems()
|
||||
// Prevent race condition and make sure
|
||||
// we don't display it early
|
||||
if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) {
|
||||
binding?.downloadStorageAppbar?.isVisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
observe(downloadsViewModel.selectedItemIds) {
|
||||
handleSelectedChange(it)
|
||||
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
|
||||
|
||||
binding?.btnDelete?.isVisible = it.isNotEmpty()
|
||||
binding?.selectItemsText?.isVisible = it.isEmpty()
|
||||
|
||||
val allSelected = downloadsViewModel.isAllSelected()
|
||||
if (allSelected) {
|
||||
binding?.btnToggleAll?.setText(R.string.deselect_all)
|
||||
} else binding?.btnToggleAll?.setText(R.string.select_all)
|
||||
}
|
||||
|
||||
val adapter = DownloadAdapter(
|
||||
{ click -> handleItemClick(click) },
|
||||
{ click ->
|
||||
handleItemClick(click)
|
||||
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
|
||||
context?.let { ctx ->
|
||||
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
|
||||
}
|
||||
} else handleDownloadClick(click)
|
||||
},
|
||||
{ downloadClickEvent ->
|
||||
handleDownloadClick(downloadClickEvent)
|
||||
if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) {
|
||||
setUpDownloadDeleteListener()
|
||||
}
|
||||
{ itemId, isChecked ->
|
||||
if (isChecked) {
|
||||
downloadsViewModel.addSelected(itemId)
|
||||
} else downloadsViewModel.removeSelected(itemId)
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -126,7 +205,6 @@ class DownloadFragment : Fragment() {
|
|||
setLinearListLayout(
|
||||
isHorizontal = false,
|
||||
nextRight = FOCUS_SELF,
|
||||
nextUp = FOCUS_SELF,
|
||||
nextDown = FOCUS_SELF,
|
||||
)
|
||||
}
|
||||
|
@ -147,35 +225,68 @@ class DownloadFragment : Fragment() {
|
|||
handleScroll(scrollY - oldScrollY)
|
||||
}
|
||||
}
|
||||
downloadsViewModel.updateList(requireContext())
|
||||
|
||||
context?.let { downloadsViewModel.updateHeaderList(it) }
|
||||
fixPaddingStatusbar(binding?.downloadRoot)
|
||||
}
|
||||
|
||||
private fun handleItemClick(click: DownloadHeaderClickEvent) {
|
||||
when (click.action) {
|
||||
DOWNLOAD_ACTION_GO_TO_CHILD -> {
|
||||
if (!click.data.type.isMovieType()) {
|
||||
val folder = DataStore.getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString())
|
||||
if (click.data.type.isEpisodeBased()) {
|
||||
val folder =
|
||||
getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString())
|
||||
activity?.navigate(
|
||||
R.id.action_navigation_downloads_to_navigation_download_child,
|
||||
DownloadChildFragment.newInstance(click.data.name, folder)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DOWNLOAD_ACTION_LOAD_RESULT -> {
|
||||
(activity as AppCompatActivity?)?.loadResult(click.data.url, click.data.apiName)
|
||||
activity?.loadResult(click.data.url, click.data.apiName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setUpDownloadDeleteListener() {
|
||||
downloadDeleteEventListener = { id ->
|
||||
val list = (binding?.downloadList?.adapter as? DownloadAdapter)?.currentList
|
||||
if (list?.any { it.data.id == id } == true) {
|
||||
context?.let { downloadsViewModel.updateList(it) }
|
||||
private fun handleSelectedChange(selected: MutableSet<Int>) {
|
||||
if (selected.isNotEmpty()) {
|
||||
binding?.downloadDeleteAppbar?.isVisible = true
|
||||
binding?.downloadStorageAppbar?.isVisible = false
|
||||
activity?.attachBackPressedCallback {
|
||||
downloadsViewModel.setIsMultiDeleteState(false)
|
||||
}
|
||||
|
||||
binding?.btnDelete?.setOnClickListener {
|
||||
context?.let { ctx ->
|
||||
downloadsViewModel.handleMultiDelete(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
binding?.btnCancel?.setOnClickListener {
|
||||
downloadsViewModel.setIsMultiDeleteState(false)
|
||||
}
|
||||
|
||||
binding?.btnToggleAll?.setOnClickListener {
|
||||
val allSelected = downloadsViewModel.isAllSelected()
|
||||
val adapter = binding?.downloadList?.adapter as? DownloadAdapter
|
||||
if (allSelected) {
|
||||
adapter?.notifySelectionStates()
|
||||
downloadsViewModel.clearSelectedItems()
|
||||
} else {
|
||||
adapter?.notifyAllSelected()
|
||||
downloadsViewModel.selectAllItems()
|
||||
}
|
||||
}
|
||||
|
||||
downloadsViewModel.setIsMultiDeleteState(true)
|
||||
}
|
||||
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
|
||||
}
|
||||
|
||||
private fun updateDeleteButton(count: Int, selectedBytes: Long) {
|
||||
val formattedSize = formatShortFileSize(context, selectedBytes)
|
||||
binding?.btnDelete?.text =
|
||||
getString(R.string.delete_format).format(count, formattedSize)
|
||||
}
|
||||
|
||||
private fun updateStorageInfo(
|
||||
|
@ -185,7 +296,10 @@ class DownloadFragment : Fragment() {
|
|||
textView: TextView?,
|
||||
view: View?
|
||||
) {
|
||||
textView?.text = getString(R.string.storage_size_format).format(getString(stringRes), formatShortFileSize(context, bytes))
|
||||
textView?.text = getString(R.string.storage_size_format).format(
|
||||
getString(stringRes),
|
||||
formatShortFileSize(context, bytes)
|
||||
)
|
||||
view?.setLayoutWidth(bytes)
|
||||
}
|
||||
|
||||
|
@ -218,7 +332,9 @@ class DownloadFragment : Fragment() {
|
|||
if (!preventAutoSwitching) activateSwitchOnHls(text?.toString(), binding)
|
||||
}
|
||||
|
||||
(activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt(0)?.text?.toString()?.let { copy ->
|
||||
(activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt(
|
||||
0
|
||||
)?.text?.toString()?.let { copy ->
|
||||
val fixedText = copy.trim()
|
||||
binding.streamUrl.setText(fixedText)
|
||||
activateSwitchOnHls(fixedText, binding)
|
||||
|
|
|
@ -1,122 +1,439 @@
|
|||
package com.lagradost.cloudstream3.ui.download
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.lagradost.cloudstream3.isMovieType
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.isEpisodeBased
|
||||
import com.lagradost.cloudstream3.mvvm.launchSafe
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class DownloadViewModel : ViewModel() {
|
||||
private val _headerCards =
|
||||
MutableLiveData<List<VisualDownloadHeaderCached>>().apply { listOf<VisualDownloadHeaderCached>() }
|
||||
val headerCards: LiveData<List<VisualDownloadHeaderCached>> = _headerCards
|
||||
|
||||
private val _headerCards = MutableLiveData<List<VisualDownloadCached.Header>>()
|
||||
val headerCards: LiveData<List<VisualDownloadCached.Header>> = _headerCards
|
||||
|
||||
private val _childCards = MutableLiveData<List<VisualDownloadCached.Child>>()
|
||||
val childCards: LiveData<List<VisualDownloadCached.Child>> = _childCards
|
||||
|
||||
private val _usedBytes = MutableLiveData<Long>()
|
||||
private val _availableBytes = MutableLiveData<Long>()
|
||||
private val _downloadBytes = MutableLiveData<Long>()
|
||||
|
||||
val usedBytes: LiveData<Long> = _usedBytes
|
||||
|
||||
private val _availableBytes = MutableLiveData<Long>()
|
||||
val availableBytes: LiveData<Long> = _availableBytes
|
||||
|
||||
private val _downloadBytes = MutableLiveData<Long>()
|
||||
val downloadBytes: LiveData<Long> = _downloadBytes
|
||||
|
||||
private var previousVisual: List<VisualDownloadHeaderCached>? = null
|
||||
private val _selectedBytes = MutableLiveData<Long>(0)
|
||||
val selectedBytes: LiveData<Long> = _selectedBytes
|
||||
|
||||
fun updateList(context: Context) = viewModelScope.launchSafe {
|
||||
val children = withContext(Dispatchers.IO) {
|
||||
context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
||||
private val _isMultiDeleteState = MutableLiveData(false)
|
||||
val isMultiDeleteState: LiveData<Boolean> = _isMultiDeleteState
|
||||
|
||||
private val _selectedItemIds = MutableLiveData<MutableSet<Int>>(mutableSetOf())
|
||||
val selectedItemIds: LiveData<MutableSet<Int>> = _selectedItemIds
|
||||
|
||||
private var previousVisual: List<VisualDownloadCached>? = null
|
||||
|
||||
fun setIsMultiDeleteState(value: Boolean) {
|
||||
_isMultiDeleteState.postValue(value)
|
||||
}
|
||||
|
||||
fun addSelected(itemId: Int) {
|
||||
updateSelectedItems { it.add(itemId) }
|
||||
}
|
||||
|
||||
fun removeSelected(itemId: Int) {
|
||||
updateSelectedItems { it.remove(itemId) }
|
||||
}
|
||||
|
||||
fun selectAllItems() {
|
||||
val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
|
||||
updateSelectedItems { it.addAll(items.map { item -> item.data.id }) }
|
||||
}
|
||||
|
||||
fun clearSelectedItems() {
|
||||
// We need this to be done immediately
|
||||
// so we can't use postValue
|
||||
_selectedItemIds.value = mutableSetOf()
|
||||
updateSelectedItems { it.clear() }
|
||||
}
|
||||
|
||||
fun isAllSelected(): Boolean {
|
||||
val currentSelected = selectedItemIds.value ?: return false
|
||||
val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
|
||||
return items.count() == currentSelected.count() && items.all { it.data.id in currentSelected }
|
||||
}
|
||||
|
||||
private fun updateSelectedItems(action: (MutableSet<Int>) -> Unit) {
|
||||
val currentSelected = selectedItemIds.value ?: mutableSetOf()
|
||||
action(currentSelected)
|
||||
_selectedItemIds.postValue(currentSelected)
|
||||
updateSelectedBytes()
|
||||
updateSelectedCards()
|
||||
}
|
||||
|
||||
private fun updateSelectedBytes() = viewModelScope.launchSafe {
|
||||
val selectedItemsList = getSelectedItemsData() ?: return@launchSafe
|
||||
val totalSelectedBytes = selectedItemsList.sumOf { it.totalBytes }
|
||||
_selectedBytes.postValue(totalSelectedBytes)
|
||||
}
|
||||
|
||||
private fun updateSelectedCards() = viewModelScope.launchSafe {
|
||||
val currentSelected = selectedItemIds.value ?: return@launchSafe
|
||||
|
||||
headerCards.value?.let { headers ->
|
||||
headers.forEach { header ->
|
||||
header.isSelected = header.data.id in currentSelected
|
||||
}
|
||||
_headerCards.postValue(headers)
|
||||
}
|
||||
|
||||
childCards.value?.let { children ->
|
||||
children.forEach { child ->
|
||||
child.isSelected = child.data.id in currentSelected
|
||||
}
|
||||
_childCards.postValue(children)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
|
||||
val visual = withContext(Dispatchers.IO) {
|
||||
val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
||||
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) }
|
||||
.distinctBy { it.id } // Remove duplicates
|
||||
|
||||
val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) =
|
||||
calculateDownloadStats(context, children)
|
||||
|
||||
val cached = context.getKeys(DOWNLOAD_HEADER_CACHE)
|
||||
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadHeaderCached>(it) }
|
||||
|
||||
createVisualDownloadList(
|
||||
context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads
|
||||
)
|
||||
}
|
||||
|
||||
// parentId : bytes
|
||||
val totalBytesUsedByChild = HashMap<Int, Long>()
|
||||
// parentId : bytes
|
||||
val currentBytesUsedByChild = HashMap<Int, Long>()
|
||||
// parentId : downloadsCount
|
||||
val totalDownloads = HashMap<Int, Int>()
|
||||
|
||||
// Gets all children downloads
|
||||
withContext(Dispatchers.IO) {
|
||||
children.forEach { c ->
|
||||
val childFile = getDownloadFileInfoAndUpdateSettings(context, c.id) ?: return@forEach
|
||||
|
||||
if (childFile.fileLength <= 1) return@forEach
|
||||
val len = childFile.totalBytes
|
||||
val flen = childFile.fileLength
|
||||
|
||||
totalBytesUsedByChild[c.parentId] = totalBytesUsedByChild[c.parentId]?.plus(len) ?: len
|
||||
currentBytesUsedByChild[c.parentId] = currentBytesUsedByChild[c.parentId]?.plus(flen) ?: flen
|
||||
totalDownloads[c.parentId] = totalDownloads[c.parentId]?.plus(1) ?: 1
|
||||
}
|
||||
}
|
||||
|
||||
val cached = withContext(Dispatchers.IO) { // Won't fetch useless keys
|
||||
totalDownloads.entries.filter { it.value > 0 }.mapNotNull {
|
||||
context.getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
||||
DOWNLOAD_HEADER_CACHE,
|
||||
it.key.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val visual = withContext(Dispatchers.IO) {
|
||||
cached.mapNotNull {
|
||||
val downloads = totalDownloads[it.id] ?: 0
|
||||
val bytes = totalBytesUsedByChild[it.id] ?: 0
|
||||
val currentBytes = currentBytesUsedByChild[it.id] ?: 0
|
||||
if (bytes <= 0 || downloads <= 0) return@mapNotNull null
|
||||
val movieEpisode =
|
||||
if (!it.type.isMovieType()) null
|
||||
else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
|
||||
DOWNLOAD_EPISODE_CACHE,
|
||||
getFolderName(it.id.toString(), it.id.toString())
|
||||
)
|
||||
VisualDownloadHeaderCached(
|
||||
currentBytes = currentBytes,
|
||||
totalBytes = bytes,
|
||||
data = it,
|
||||
child = movieEpisode,
|
||||
currentOngoingDownloads = 0,
|
||||
totalDownloads = downloads,
|
||||
)
|
||||
}.sortedBy {
|
||||
(it.child?.episode ?: 0) + (it.child?.season?.times(10000) ?: 0)
|
||||
} // Episode sorting by episode, lowest to highest
|
||||
}
|
||||
|
||||
// Only update list if different from the previous one to prevent duplicate initialization
|
||||
if (visual != previousVisual) {
|
||||
previousVisual = visual
|
||||
|
||||
try {
|
||||
val stat = StatFs(Environment.getExternalStorageDirectory().path)
|
||||
val localBytesAvailable = stat.availableBytes
|
||||
val localTotalBytes = stat.blockSizeLong * stat.blockCountLong
|
||||
val localDownloadedBytes = visual.sumOf { it.totalBytes }
|
||||
|
||||
_usedBytes.postValue(localTotalBytes - localBytesAvailable - localDownloadedBytes)
|
||||
_availableBytes.postValue(localBytesAvailable)
|
||||
_downloadBytes.postValue(localDownloadedBytes)
|
||||
} catch (t: Throwable) {
|
||||
_downloadBytes.postValue(0)
|
||||
logError(t)
|
||||
}
|
||||
|
||||
updateStorageStats(visual)
|
||||
_headerCards.postValue(visual)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateDownloadStats(
|
||||
context: Context,
|
||||
children: List<VideoDownloadHelper.DownloadEpisodeCached>
|
||||
): Triple<Map<Int, Long>, Map<Int, Long>, Map<Int, Int>> {
|
||||
// parentId : bytes
|
||||
val totalBytesUsedByChild = mutableMapOf<Int, Long>()
|
||||
// parentId : bytes
|
||||
val currentBytesUsedByChild = mutableMapOf<Int, Long>()
|
||||
// parentId : downloadsCount
|
||||
val totalDownloads = mutableMapOf<Int, Int>()
|
||||
|
||||
children.forEach { child ->
|
||||
val childFile = getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach
|
||||
if (childFile.fileLength <= 1) return@forEach
|
||||
|
||||
val len = childFile.totalBytes
|
||||
val flen = childFile.fileLength
|
||||
|
||||
totalBytesUsedByChild.merge(child.parentId, len, Long::plus)
|
||||
currentBytesUsedByChild.merge(child.parentId, flen, Long::plus)
|
||||
totalDownloads.merge(child.parentId, 1, Int::plus)
|
||||
}
|
||||
return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads)
|
||||
}
|
||||
|
||||
private fun createVisualDownloadList(
|
||||
context: Context,
|
||||
cached: List<VideoDownloadHelper.DownloadHeaderCached>,
|
||||
totalBytesUsedByChild: Map<Int, Long>,
|
||||
currentBytesUsedByChild: Map<Int, Long>,
|
||||
totalDownloads: Map<Int, Int>
|
||||
): List<VisualDownloadCached.Header> {
|
||||
return cached.mapNotNull {
|
||||
val downloads = totalDownloads[it.id] ?: 0
|
||||
val bytes = totalBytesUsedByChild[it.id] ?: 0
|
||||
val currentBytes = currentBytesUsedByChild[it.id] ?: 0
|
||||
if (bytes <= 0 || downloads <= 0) return@mapNotNull null
|
||||
|
||||
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
|
||||
val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
|
||||
DOWNLOAD_EPISODE_CACHE,
|
||||
getFolderName(it.id.toString(), it.id.toString())
|
||||
)
|
||||
|
||||
VisualDownloadCached.Header(
|
||||
currentBytes = currentBytes,
|
||||
totalBytes = bytes,
|
||||
data = it,
|
||||
child = movieEpisode,
|
||||
currentOngoingDownloads = 0,
|
||||
totalDownloads = downloads,
|
||||
isSelected = isSelected,
|
||||
)
|
||||
// Prevent order being almost completely random,
|
||||
// making things difficult to find.
|
||||
}.sortedWith(compareBy<VisualDownloadCached.Header> {
|
||||
// Sort by isEpisodeBased() ascending. We put those that
|
||||
// are episode based at the bottom for UI purposes and to
|
||||
// make it easier to find by grouping them together.
|
||||
it.data.type.isEpisodeBased()
|
||||
}.thenBy {
|
||||
// Then we sort alphabetically by name (case-insensitive).
|
||||
// Again, we do this to make things easier to find.
|
||||
it.data.name.lowercase()
|
||||
})
|
||||
}
|
||||
|
||||
fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe {
|
||||
val visual = withContext(Dispatchers.IO) {
|
||||
context.getKeys(folder).mapNotNull { key ->
|
||||
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key)
|
||||
}.mapNotNull {
|
||||
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
|
||||
val info = getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null
|
||||
VisualDownloadCached.Child(
|
||||
currentBytes = info.fileLength,
|
||||
totalBytes = info.totalBytes,
|
||||
isSelected = isSelected,
|
||||
data = it,
|
||||
)
|
||||
}
|
||||
}.sortedWith(compareBy(
|
||||
// Sort by season first, and then by episode number,
|
||||
// to ensure sorting is consistent.
|
||||
{ it.data.season ?: 0 },
|
||||
{ it.data.episode }
|
||||
))
|
||||
|
||||
if (previousVisual != visual) {
|
||||
previousVisual = visual
|
||||
_childCards.postValue(visual)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeItems(idsToRemove: Set<Int>) = viewModelScope.launchSafe {
|
||||
val updatedHeaders = headerCards.value.orEmpty().filter { it.data.id !in idsToRemove }
|
||||
val updatedChildren = childCards.value.orEmpty().filter { it.data.id !in idsToRemove }
|
||||
_headerCards.postValue(updatedHeaders)
|
||||
_childCards.postValue(updatedChildren)
|
||||
}
|
||||
|
||||
private fun updateStorageStats(visual: List<VisualDownloadCached.Header>) {
|
||||
try {
|
||||
val stat = StatFs(Environment.getExternalStorageDirectory().path)
|
||||
val localBytesAvailable = stat.availableBytes
|
||||
val localTotalBytes = stat.blockSizeLong * stat.blockCountLong
|
||||
val localDownloadedBytes = visual.sumOf { it.totalBytes }
|
||||
val localUsedBytes = localTotalBytes - localBytesAvailable
|
||||
_usedBytes.postValue(localUsedBytes)
|
||||
_availableBytes.postValue(localBytesAvailable)
|
||||
_downloadBytes.postValue(localDownloadedBytes)
|
||||
} catch (t: Throwable) {
|
||||
_downloadBytes.postValue(0)
|
||||
logError(t)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleMultiDelete(context: Context) = viewModelScope.launchSafe {
|
||||
val selectedItemsList = getSelectedItemsData().orEmpty()
|
||||
val deleteData = processSelectedItems(context, selectedItemsList)
|
||||
val message = buildDeleteMessage(context, deleteData)
|
||||
showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds)
|
||||
}
|
||||
|
||||
fun handleSingleDelete(
|
||||
context: Context,
|
||||
itemId: Int
|
||||
) = viewModelScope.launchSafe {
|
||||
val itemData = getItemDataFromId(itemId)
|
||||
val deleteData = processSelectedItems(context, itemData)
|
||||
val message = buildDeleteMessage(context, deleteData)
|
||||
showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds)
|
||||
}
|
||||
|
||||
private fun processSelectedItems(
|
||||
context: Context,
|
||||
selectedItemsList: List<VisualDownloadCached>
|
||||
): DeleteData {
|
||||
val names = mutableListOf<String>()
|
||||
val seriesNames = mutableListOf<String>()
|
||||
|
||||
val ids = mutableSetOf<Int>()
|
||||
val parentIds = mutableSetOf<Int>()
|
||||
|
||||
var parentName: String? = null
|
||||
|
||||
selectedItemsList.forEach { item ->
|
||||
when (item) {
|
||||
is VisualDownloadCached.Header -> {
|
||||
if (item.data.type.isEpisodeBased()) {
|
||||
val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
||||
.mapNotNull {
|
||||
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
|
||||
it
|
||||
)
|
||||
}
|
||||
.filter { it.parentId == item.data.id }
|
||||
.map { it.id }
|
||||
ids.addAll(episodes)
|
||||
parentIds.add(item.data.id)
|
||||
|
||||
val episodeInfo = "${item.data.name} (${item.totalDownloads} ${
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.episodes,
|
||||
item.totalDownloads
|
||||
).lowercase()
|
||||
})"
|
||||
seriesNames.add(episodeInfo)
|
||||
} else {
|
||||
ids.add(item.data.id)
|
||||
names.add(item.data.name)
|
||||
}
|
||||
}
|
||||
|
||||
is VisualDownloadCached.Child -> {
|
||||
ids.add(item.data.id)
|
||||
val parent = context.getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
||||
DOWNLOAD_HEADER_CACHE,
|
||||
item.data.parentId.toString()
|
||||
)
|
||||
parentName = parent?.name
|
||||
names.add(
|
||||
context.getNameFull(
|
||||
item.data.name,
|
||||
item.data.episode,
|
||||
item.data.season
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DeleteData(ids, parentIds, seriesNames, names, parentName)
|
||||
}
|
||||
|
||||
private fun buildDeleteMessage(
|
||||
context: Context,
|
||||
data: DeleteData
|
||||
): String {
|
||||
val formattedNames = data.names.sortedBy { it.lowercase() }
|
||||
.joinToString(separator = "\n") { "• $it" }
|
||||
val formattedSeriesNames = data.seriesNames.sortedBy { it.lowercase() }
|
||||
.joinToString(separator = "\n") { "• $it" }
|
||||
|
||||
return when {
|
||||
data.ids.count() == 1 -> {
|
||||
context.getString(R.string.delete_message).format(
|
||||
data.names.firstOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
data.seriesNames.isNotEmpty() && data.names.isEmpty() -> {
|
||||
context.getString(R.string.delete_message_series_only).format(formattedSeriesNames)
|
||||
}
|
||||
|
||||
data.parentName != null && data.names.isNotEmpty() -> {
|
||||
context.getString(R.string.delete_message_series_episodes)
|
||||
.format(data.parentName, formattedNames)
|
||||
}
|
||||
|
||||
data.seriesNames.isNotEmpty() -> {
|
||||
val seriesSection = context.getString(R.string.delete_message_series_section)
|
||||
.format(formattedSeriesNames)
|
||||
context.getString(R.string.delete_message_multiple)
|
||||
.format(formattedNames) + "\n\n" + seriesSection
|
||||
}
|
||||
|
||||
else -> context.getString(R.string.delete_message_multiple).format(formattedNames)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDeleteConfirmationDialog(
|
||||
context: Context,
|
||||
message: String,
|
||||
ids: Set<Int>,
|
||||
parentIds: Set<Int>
|
||||
) {
|
||||
val builder = AlertDialog.Builder(context)
|
||||
val dialogClickListener =
|
||||
DialogInterface.OnClickListener { _, which ->
|
||||
when (which) {
|
||||
DialogInterface.BUTTON_POSITIVE -> {
|
||||
viewModelScope.launchSafe {
|
||||
setIsMultiDeleteState(false)
|
||||
deleteFilesAndUpdateSettings(context, ids, this) { successfulIds ->
|
||||
// We always remove parent because if we are deleting from here
|
||||
// and we have it as non-empty, it was triggered on
|
||||
// parent header card
|
||||
removeItems(successfulIds + parentIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DialogInterface.BUTTON_NEGATIVE -> {
|
||||
// Do nothing on cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val title = if (ids.count() == 1) {
|
||||
R.string.delete_file
|
||||
} else R.string.delete_files
|
||||
builder.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(R.string.delete, dialogClickListener)
|
||||
.setNegativeButton(R.string.cancel, dialogClickListener)
|
||||
.show().setDefaultFocus()
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSelectedItemsData(): List<VisualDownloadCached>? {
|
||||
val headers = headerCards.value.orEmpty()
|
||||
val children = childCards.value.orEmpty()
|
||||
|
||||
return selectedItemIds.value?.mapNotNull { id ->
|
||||
headers.find { it.data.id == id } ?: children.find { it.data.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getItemDataFromId(itemId: Int): List<VisualDownloadCached> {
|
||||
val headers = headerCards.value.orEmpty()
|
||||
val children = childCards.value.orEmpty()
|
||||
|
||||
return (headers + children).filter { it.data.id == itemId }
|
||||
}
|
||||
|
||||
private data class DeleteData(
|
||||
val ids: Set<Int>,
|
||||
val parentIds: Set<Int>,
|
||||
val seriesNames: List<String>,
|
||||
val names: List<String>,
|
||||
val parentName: String?
|
||||
)
|
||||
}
|
|
@ -93,7 +93,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
|
||||
abstract fun setStatus(status: VideoDownloadManager.DownloadType?)
|
||||
|
||||
fun getStatus(id:Int, downloadedBytes: Long, totalBytes: Long): DownloadStatusTell {
|
||||
fun getStatus(id: Int, downloadedBytes: Long, totalBytes: Long): DownloadStatusTell {
|
||||
// some extra padding for just in case
|
||||
return VideoDownloadManager.downloadStatus[id]
|
||||
?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) {
|
||||
|
@ -101,7 +101,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
} else DownloadStatusTell.IsPaused
|
||||
}
|
||||
|
||||
fun applyMetaData(id:Int, downloadedBytes: Long, totalBytes: Long) {
|
||||
fun applyMetaData(id: Int, downloadedBytes: Long, totalBytes: Long) {
|
||||
val status = getStatus(id, downloadedBytes, totalBytes)
|
||||
|
||||
currentMetaData.apply {
|
||||
|
@ -140,7 +140,8 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
} else {
|
||||
if (doSetProgress) {
|
||||
progressText?.apply {
|
||||
val currentFormattedSizeString = formatShortFileSize(context, downloadedBytes)
|
||||
val currentFormattedSizeString =
|
||||
formatShortFileSize(context, downloadedBytes)
|
||||
val totalFormattedSizeString = formatShortFileSize(context, totalBytes)
|
||||
text =
|
||||
// if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else
|
||||
|
|
|
@ -58,7 +58,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
}
|
||||
|
||||
private var progressBarBackground: View
|
||||
private var statusView: ImageView
|
||||
var statusView: ImageView
|
||||
|
||||
open fun onInflate() {}
|
||||
|
||||
|
@ -248,7 +248,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
} */
|
||||
|
||||
@MainThread
|
||||
private fun setStatusInternal(status : DownloadStatusTell?) {
|
||||
private fun setStatusInternal(status: DownloadStatusTell?) {
|
||||
val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading
|
||||
if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) {
|
||||
val animation = AnimationUtils.loadAnimation(context, waitingAnimation)
|
||||
|
@ -286,7 +286,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
try {
|
||||
setStatusInternal(status)
|
||||
} catch (t : Throwable) {
|
||||
} catch (t: Throwable) {
|
||||
logError(t) // Just in case setStatusInternal throws because thread
|
||||
progressBarBackground.post {
|
||||
setStatusInternal(status)
|
||||
|
|
|
@ -4,7 +4,9 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
|||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||
import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName
|
||||
import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
|
@ -49,10 +51,6 @@ class DownloadFileGenerator(
|
|||
return null
|
||||
}
|
||||
|
||||
private fun cleanDisplayName(name: String): String {
|
||||
return name.substringBeforeLast('.').trim()
|
||||
}
|
||||
|
||||
override suspend fun generateLinks(
|
||||
clearCache: Boolean,
|
||||
type: LoadType,
|
||||
|
@ -69,28 +67,9 @@ class DownloadFileGenerator(
|
|||
|
||||
val cleanDisplay = cleanDisplayName(display)
|
||||
|
||||
VideoDownloadManager.getFolder(ctx, relative, meta.basePath)
|
||||
?.forEach { (name, uri) ->
|
||||
// only these files are allowed, so no videos as subtitles
|
||||
if (listOf(
|
||||
".vtt",
|
||||
".srt",
|
||||
".txt",
|
||||
".ass",
|
||||
".ttml",
|
||||
".sbv",
|
||||
".dfxp"
|
||||
).none { name.contains(it, true) }
|
||||
) return@forEach
|
||||
|
||||
// cant have the exact same file as a subtitle
|
||||
if (name.equals(display, true)) return@forEach
|
||||
|
||||
getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) ->
|
||||
if (isMatchingSubtitle(name, display, cleanDisplay)) {
|
||||
val cleanName = cleanDisplayName(name)
|
||||
|
||||
// we only want files with the approx same name
|
||||
if (!cleanName.startsWith(cleanDisplay, true)) return@forEach
|
||||
|
||||
val realName = cleanName.removePrefix(cleanDisplay)
|
||||
|
||||
subtitleCallback(
|
||||
|
@ -104,6 +83,7 @@ class DownloadFileGenerator(
|
|||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -4,13 +4,13 @@ import android.content.Intent
|
|||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.lagradost.cloudstream3.CommonActivity
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playLink
|
||||
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
|
||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
||||
|
||||
class DownloadedPlayerActivity : AppCompatActivity() {
|
||||
private val dTAG = "DownloadedPlayerAct"
|
||||
|
@ -70,14 +70,7 @@ class DownloadedPlayerActivity : AppCompatActivity() {
|
|||
return
|
||||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
)
|
||||
attachBackPressedCallback { finish() }
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
|
|
@ -7,7 +7,6 @@ import android.os.Bundle
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import com.lagradost.cloudstream3.CommonActivity.screenHeight
|
||||
|
@ -17,6 +16,8 @@ import com.lagradost.cloudstream3.R
|
|||
import com.lagradost.cloudstream3.ui.player.CSPlayerEvent
|
||||
import com.lagradost.cloudstream3.ui.player.PlayerEventSource
|
||||
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
||||
|
||||
open class ResultTrailerPlayer : ResultFragmentPhone() {
|
||||
|
||||
|
@ -156,7 +157,9 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
|
|||
uiReset()
|
||||
|
||||
if (isFullScreenPlayer) {
|
||||
attachBackPressedCallback()
|
||||
activity?.attachBackPressedCallback {
|
||||
updateFullscreen(false)
|
||||
}
|
||||
} else detachBackPressedCallback()
|
||||
}
|
||||
|
||||
|
@ -175,27 +178,4 @@ open class ResultTrailerPlayer : ResultFragmentPhone() {
|
|||
fixPlayerSize()
|
||||
}
|
||||
}
|
||||
|
||||
private var backPressedCallback: OnBackPressedCallback? = null
|
||||
|
||||
private fun attachBackPressedCallback() {
|
||||
if (backPressedCallback == null) {
|
||||
backPressedCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
updateFullscreen(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backPressedCallback?.isEnabled = true
|
||||
|
||||
activity?.onBackPressedDispatcher?.addCallback(
|
||||
activity ?: return,
|
||||
backPressedCallback ?: return
|
||||
)
|
||||
}
|
||||
|
||||
private fun detachBackPressedCallback() {
|
||||
backPressedCallback?.isEnabled = false
|
||||
}
|
||||
}
|
|
@ -677,9 +677,15 @@ object AppContextUtils {
|
|||
}
|
||||
|
||||
fun Context.isNetworkAvailable(): Boolean {
|
||||
val manager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val activeNetworkInfo = manager.activeNetworkInfo
|
||||
return activeNetworkInfo != null && activeNetworkInfo.isConnected || manager.allNetworkInfo?.any { it.isConnected } ?: false
|
||||
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val networkCapabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
connectivityManager.activeNetworkInfo?.isConnected == true
|
||||
}
|
||||
}
|
||||
|
||||
fun splitQuery(url: URL): Map<String, String> {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -20,6 +20,7 @@ import androidx.work.WorkManager
|
|||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.api.Log
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
|
@ -29,12 +30,14 @@ import com.lagradost.cloudstream3.MainActivity
|
|||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.mvvm.launchSafe
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.services.VideoDownloadService
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||
import com.lagradost.cloudstream3.utils.DataStore.removeKey
|
||||
import com.lagradost.cloudstream3.utils.SubtitleUtils.deleteMatchingSubtitles
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||
import com.lagradost.safefile.MediaFileContentType
|
||||
import com.lagradost.safefile.SafeFile
|
||||
|
@ -42,6 +45,8 @@ import kotlinx.coroutines.CancellationException
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
@ -1733,7 +1738,37 @@ object VideoDownloadManager {
|
|||
}
|
||||
}
|
||||
|
||||
fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean {
|
||||
fun deleteFilesAndUpdateSettings(
|
||||
context: Context,
|
||||
ids: Set<Int>,
|
||||
scope: CoroutineScope,
|
||||
onComplete: (Set<Int>) -> Unit = {}
|
||||
) {
|
||||
scope.launchSafe(Dispatchers.IO) {
|
||||
val deleteJobs = ids.map { id ->
|
||||
async {
|
||||
id to deleteFileAndUpdateSettings(context, id)
|
||||
}
|
||||
}
|
||||
val results = deleteJobs.awaitAll()
|
||||
|
||||
val (successfulResults, failedResults) = results.partition { it.second }
|
||||
val successfulIds = successfulResults.map { it.first }.toSet()
|
||||
|
||||
if (failedResults.isNotEmpty()) {
|
||||
failedResults.forEach { (id, _) ->
|
||||
// TODO show a toast if some failed?
|
||||
Log.e("FileDeletion", "Failed to delete file with ID: $id")
|
||||
}
|
||||
} else {
|
||||
Log.i("FileDeletion", "All files deleted successfully")
|
||||
}
|
||||
|
||||
onComplete.invoke(successfulIds)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean {
|
||||
val success = deleteFile(context, id)
|
||||
if (success) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
|
||||
return success
|
||||
|
@ -1759,11 +1794,17 @@ object VideoDownloadManager {
|
|||
private fun deleteFile(context: Context, id: Int): Boolean {
|
||||
val info =
|
||||
context.getKey<DownloadedFileInfo>(KEY_DOWNLOAD_INFO, id.toString()) ?: return false
|
||||
val file = info.toFile(context)
|
||||
|
||||
downloadEvent.invoke(id to DownloadActionType.Stop)
|
||||
downloadProgressEvent.invoke(Triple(id, 0, 0))
|
||||
downloadStatusEvent.invoke(id to DownloadType.IsStopped)
|
||||
downloadDeleteEvent.invoke(id)
|
||||
return info.toFile(context)?.delete() ?: false
|
||||
|
||||
val isFileDeleted = file?.delete() == true || file?.exists() == false
|
||||
if (isFileDeleted) deleteMatchingSubtitles(context, info)
|
||||
|
||||
return isFileDeleted
|
||||
}
|
||||
|
||||
fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? {
|
||||
|
|
|
@ -2,10 +2,8 @@
|
|||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
|
||||
android:id="@+id/download_child_episode_holder"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
android:layout_height="50dp"
|
||||
android:layout_marginBottom="5dp"
|
||||
android:foreground="@drawable/outline_drawable"
|
||||
|
@ -18,7 +16,6 @@
|
|||
|
||||
<androidx.core.widget.ContentLoadingProgressBar
|
||||
android:id="@+id/download_child_episode_progress"
|
||||
|
||||
style="@android:style/Widget.Material.ProgressBar.Horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="5dp"
|
||||
|
@ -56,12 +53,10 @@
|
|||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
|
||||
android:ellipsize="marquee"
|
||||
android:gravity="center_vertical"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
|
||||
android:singleLine="true"
|
||||
android:textColor="?attr/textColor"
|
||||
tools:text="Episode 1 Episode 1 Episode 1 Episode 1 Episode 1 Episode 1 Episode 1" />
|
||||
|
@ -78,7 +73,6 @@
|
|||
tools:text="128MB / 237MB" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<com.lagradost.cloudstream3.ui.download.button.PieFetchButton
|
||||
android:id="@+id/download_button"
|
||||
android:layout_width="@dimen/download_size"
|
||||
|
@ -89,5 +83,16 @@
|
|||
android:focusable="true"
|
||||
android:nextFocusLeft="@id/download_child_episode_holder"
|
||||
android:padding="10dp" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/delete_checkbox"
|
||||
android:layout_width="@dimen/download_size"
|
||||
android:layout_height="@dimen/download_size"
|
||||
android:layout_gravity="center_vertical|end"
|
||||
android:layout_marginStart="-50dp"
|
||||
android:focusable="true"
|
||||
android:nextFocusLeft="@id/download_child_episode_holder"
|
||||
android:padding="10dp"
|
||||
android:visibility="gone" />
|
||||
</GridLayout>
|
||||
</androidx.cardview.widget.CardView>
|
|
@ -77,5 +77,16 @@
|
|||
android:focusable="true"
|
||||
android:nextFocusLeft="@id/episode_holder"
|
||||
android:padding="10dp" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/delete_checkbox"
|
||||
android:layout_width="@dimen/download_size"
|
||||
android:layout_height="@dimen/download_size"
|
||||
android:layout_gravity="center_vertical|end"
|
||||
android:layout_marginStart="-50dp"
|
||||
android:focusable="true"
|
||||
android:nextFocusLeft="@id/episode_holder"
|
||||
android:padding="10dp"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
|
@ -7,13 +7,69 @@
|
|||
android:layout_height="match_parent"
|
||||
android:background="?attr/primaryGrayBackground"
|
||||
android:orientation="vertical"
|
||||
tools:context=".ui.download.DownloadFragment">
|
||||
tools:context=".ui.download.DownloadChildFragment">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@android:color/transparent">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/download_delete_appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:padding="8dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnCancel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:src="@drawable/ic_baseline_close_24"
|
||||
android:contentDescription="@string/cancel"
|
||||
android:padding="8dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:nextFocusLeft="@id/nav_rail_view"
|
||||
app:tint="@android:color/white" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnDelete"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:text="@string/delete"
|
||||
android:textColor="@android:color/white"
|
||||
android:layout_gravity="center_vertical" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/selectItemsText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:text="@string/downloads_delete_select"
|
||||
android:textColor="@android:color/white"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:visibility="gone" />
|
||||
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnToggleAll"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:text="@string/select_all"
|
||||
android:textColor="@android:color/white"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:nextFocusDown="@id/download_child_list" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/download_child_toolbar"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -28,7 +84,6 @@
|
|||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/download_child_list"
|
||||
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/primaryBlackBackground"
|
||||
|
|
|
@ -17,6 +17,62 @@
|
|||
For Scroll add to LinearLayout
|
||||
app:layout_scrollFlags="scroll|enterAlways"
|
||||
-->
|
||||
<LinearLayout
|
||||
android:id="@+id/download_delete_appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:padding="8dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnCancel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:src="@drawable/ic_baseline_close_24"
|
||||
android:contentDescription="@string/cancel"
|
||||
android:padding="8dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:nextFocusLeft="@id/nav_rail_view"
|
||||
app:tint="@android:color/white" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnDelete"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:text="@string/delete"
|
||||
android:textColor="@android:color/white"
|
||||
android:layout_gravity="center_vertical" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/selectItemsText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:text="@string/downloads_delete_select"
|
||||
android:textColor="@android:color/white"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:visibility="gone" />
|
||||
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnToggleAll"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:text="@string/select_all"
|
||||
android:textColor="@android:color/white"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:nextFocusDown="@id/download_list" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/download_storage_appbar"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -137,7 +193,7 @@
|
|||
android:background="?attr/primaryBlackBackground"
|
||||
android:descendantFocusability="afterDescendants"
|
||||
android:nextFocusLeft="@id/nav_rail_view"
|
||||
android:tag = "@string/tv_no_focus_tag"
|
||||
android:tag="@string/tv_no_focus_tag"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior"
|
||||
tools:listitem="@layout/download_header_episode" />
|
||||
|
||||
|
@ -154,11 +210,11 @@
|
|||
|
||||
<!--
|
||||
<ProgressBar
|
||||
android:visibility="visible"
|
||||
tools:visibility="gone"
|
||||
android:id="@+id/download_loading"
|
||||
android:layout_gravity="center"
|
||||
android:layout_width="50dp" android:layout_height="50dp">
|
||||
android:visibility="visible"
|
||||
tools:visibility="gone"
|
||||
android:id="@+id/download_loading"
|
||||
android:layout_gravity="center"
|
||||
android:layout_width="50dp" android:layout_height="50dp">
|
||||
</ProgressBar>-->
|
||||
|
||||
<com.facebook.shimmer.ShimmerFrameLayout
|
||||
|
@ -182,15 +238,10 @@
|
|||
android:orientation="vertical">
|
||||
|
||||
<include layout="@layout/loading_downloads" />
|
||||
|
||||
<include layout="@layout/loading_downloads" />
|
||||
|
||||
<include layout="@layout/loading_downloads" />
|
||||
|
||||
<include layout="@layout/loading_downloads" />
|
||||
|
||||
<include layout="@layout/loading_downloads" />
|
||||
|
||||
<include layout="@layout/loading_downloads" />
|
||||
</LinearLayout>
|
||||
</com.facebook.shimmer.ShimmerFrameLayout>
|
||||
|
@ -201,24 +252,24 @@
|
|||
android:orientation="vertical"
|
||||
android:layout_gravity="bottom|end">
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/open_local_video_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
style="?attr/floatingActionButtonSmallStyle"
|
||||
android:backgroundTint="?attr/primaryGrayBackground"
|
||||
android:src="@drawable/netflix_play"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:tooltipText="@string/open_local_video"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:contentDescription="@string/open_local_video" />
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/open_local_video_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
style="?attr/floatingActionButtonSmallStyle"
|
||||
android:backgroundTint="?attr/primaryGrayBackground"
|
||||
android:src="@drawable/netflix_play"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:tooltipText="@string/open_local_video"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:contentDescription="@string/open_local_video" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||
android:id="@+id/download_stream_button"
|
||||
style="@style/ExtendedFloatingActionButton"
|
||||
android:text="@string/stream"
|
||||
android:textColor="?attr/textColor"
|
||||
app:icon="@drawable/ic_network_stream"
|
||||
android:contentDescription="@string/stream" />
|
||||
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||
android:id="@+id/download_stream_button"
|
||||
style="@style/ExtendedFloatingActionButton"
|
||||
android:text="@string/stream"
|
||||
android:textColor="?attr/textColor"
|
||||
app:icon="@drawable/ic_network_stream"
|
||||
android:contentDescription="@string/stream" />
|
||||
</LinearLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
@ -150,7 +150,11 @@
|
|||
<string name="download_canceled">Download Canceled</string>
|
||||
<string name="download_done">Download Done</string>
|
||||
<string name="download_format" translatable="false">%s - %s</string>
|
||||
<string name="downloads_delete_select">Select Items to Delete</string>
|
||||
<string name="downloads_empty">There are currently no downloads.</string>
|
||||
<string name="offline_file">Available for watching offline</string>
|
||||
<string name="select_all">Select All</string>
|
||||
<string name="deselect_all">Deselect All</string>
|
||||
<string name="update_started">Update Started</string>
|
||||
<string name="stream">Network stream</string>
|
||||
<string name="open_local_video">Open local video</string>
|
||||
|
@ -300,8 +304,10 @@
|
|||
<string name="season_short">S</string>
|
||||
<string name="episode_short">E</string>
|
||||
<string name="no_episodes_found">No Episodes found</string>
|
||||
<string name="delete_file">Delete File</string>
|
||||
<string name="delete">Delete</string>
|
||||
<string name="delete_file">Delete File</string>
|
||||
<string name="delete_files">Delete Files</string>
|
||||
<string name="delete_format" formatted="true">Delete (%1$d | %2$s)</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="pause">Pause</string>
|
||||
<string name="start">Start</string>
|
||||
|
@ -312,6 +318,10 @@
|
|||
<string name="go_back_30">-30</string>
|
||||
<string name="go_forward_30">+30</string>
|
||||
<string name="delete_message" formatted="true">This will permanently delete %s\nAre you sure?</string>
|
||||
<string name="delete_message_multiple" formatted="true">Are you sure you want to permanently delete the following items?\n\n%s</string>
|
||||
<string name="delete_message_series_episodes" formatted="true">Are you sure you want to permanently delete the following episodes in %1$s?\n\n%2$s</string>
|
||||
<string name="delete_message_series_section" formatted="true">You will also permanently delete all episodes in the following series:\n\n%s</string>
|
||||
<string name="delete_message_series_only" formatted="true">Are you sure you want to permanently delete all episodes in the following series?\n\n%s</string>
|
||||
<string name="resume_time_left" formatted="true">%dm\nremaining</string>
|
||||
<string name="resume_remaining" formatted="true">%s\nremaining</string>
|
||||
<string name="status_ongoing">Ongoing</string>
|
||||
|
|
Loading…
Reference in a new issue