Major performance and bug fixes to downloads (#1164)

This commit is contained in:
Luna712 2024-07-04 11:37:08 -06:00 committed by GitHub
parent 29ec554334
commit 03b8b6e637
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 195 additions and 170 deletions

View file

@ -2,6 +2,7 @@ package com.lagradost.cloudstream3
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.extractor.downloader.Request
import org.schabi.newpipe.extractor.downloader.Response
@ -18,7 +19,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
val dataToSend: ByteArray? = request.dataToSend()
var requestBody: RequestBody? = null
if (dataToSend != null) {
requestBody = RequestBody.create(null, dataToSend)
requestBody = dataToSend.toRequestBody(null, 0, dataToSend.size)
}
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
.method(httpMethod, requestBody).url(url)

View file

@ -13,9 +13,10 @@ import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding
import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell
import com.lagradost.cloudstream3.utils.AppUtils.getNameFull
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.UIHelper.setImage
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
@ -26,6 +27,9 @@ const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3
const val DOWNLOAD_ACTION_DOWNLOAD = 4
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,
@ -93,110 +97,128 @@ class DownloadAdapter(
private val mediaClickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.ViewHolder(binding.root) {
@SuppressLint("SetTextI18n")
fun bind(card: VisualDownloadCached?) {
when (binding) {
is DownloadHeaderEpisodeBinding -> binding.apply {
if (card == null || card !is VisualDownloadHeaderCached) return@apply
val d = card.data
is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadHeaderCached)
is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadChildCached)
}
}
downloadHeaderPoster.apply {
setImage(d.poster)
setOnClickListener {
clickCallback.invoke(DownloadHeaderClickEvent(1, d))
}
}
@SuppressLint("SetTextI18n")
private fun bindHeader(card: VisualDownloadHeaderCached?) {
if (binding !is DownloadHeaderEpisodeBinding) return
card ?: return
val d = card.data
downloadHeaderTitle.text = d.name
val mbString = formatShortFileSize(itemView.context, card.totalBytes)
if (card.child != null) {
downloadHeaderGotoChild.isVisible = false
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,
if (card.totalDownloads == 1) downloadHeaderInfo.context.getString(
R.string.episode
) else downloadHeaderInfo.context.getString(
R.string.episodes
),
mbString
)
} catch (t: Throwable) {
// You probably formatted incorrectly
downloadHeaderInfo.text = "Error"
logError(t)
}
episodeHolder.setOnClickListener {
clickCallback.invoke(DownloadHeaderClickEvent(0, d))
}
binding.apply {
downloadHeaderPoster.apply {
setImage(d.poster)
setOnClickListener {
clickCallback.invoke(DownloadHeaderClickEvent(DOWNLOAD_ACTION_LOAD_RESULT, d))
}
}
is DownloadChildEpisodeBinding -> binding.apply {
if (card == null || card !is VisualDownloadChildCached) return@apply
val d = card.data
downloadHeaderTitle.text = d.name
val formattedSizeString = formatShortFileSize(itemView.context, card.totalBytes)
val posDur = DataStoreHelper.getViewPos(d.id)
downloadChildEpisodeProgress.apply {
if (posDur != null) {
val visualPos = posDur.fixVisual()
max = (visualPos.duration / 1000).toInt()
progress = (visualPos.position / 1000).toInt()
isVisible = true
} else isVisible = false
if (card.child != null) {
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
downloadHeaderInfo.text = formattedSizeString
} else downloadButton.doSetProgress = true
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)
}
downloadButton.setDefaultClickListener(card.data, downloadChildEpisodeTextExtra, mediaClickCallback)
downloadChildEpisodeText.apply {
text = context.getNameFull(d.name, d.episode, d.season)
isSelected = true // Needed for text repeating
episodeHolder.setOnClickListener {
clickCallback.invoke(DownloadHeaderClickEvent(DOWNLOAD_ACTION_GO_TO_CHILD, d))
}
}
}
}
downloadChildEpisodeHolder.setOnClickListener {
mediaClickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, d))
private fun bindChild(card: VisualDownloadChildCached?) {
if (binding !is DownloadChildEpisodeBinding) return
card ?: return
val d = card.data
binding.apply {
val posDur = getViewPos(d.id)
downloadChildEpisodeProgress.apply {
isVisible = posDur != null
posDur?.let {
val visualPos = it.fixVisual()
max = (visualPos.duration / 1000).toInt()
progress = (visualPos.position / 1000).toInt()
}
}
val status = downloadButton.getStatus(d.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)
// We will let the view model handle this
downloadButton.doSetProgress = false
downloadChildEpisodeTextExtra.text = formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes)
} else downloadButton.doSetProgress = true
downloadButton.setDefaultClickListener(d, downloadChildEpisodeTextExtra, mediaClickCallback)
downloadButton.isVisible = true
downloadChildEpisodeText.apply {
text = context.getNameFull(d.name, d.episode, d.season)
isSelected = true // Needed for text repeating
}
downloadChildEpisodeHolder.setOnClickListener {
mediaClickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, d))
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadViewHolder {
val inflater = LayoutInflater.from(parent.context)
val binding = when (viewType) {
VIEW_TYPE_HEADER -> {
DownloadHeaderEpisodeBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
}
VIEW_TYPE_CHILD -> {
DownloadChildEpisodeBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
}
VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false)
VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false)
else -> throw IllegalArgumentException("Invalid view type")
}
return DownloadViewHolder(binding, clickCallback, mediaClickCallback)
@ -207,8 +229,11 @@ class DownloadAdapter(
}
override fun getItemViewType(position: Int): Int {
val card = getItem(position)
return if (card is VisualDownloadChildCached) VIEW_TYPE_CHILD else VIEW_TYPE_HEADER
return when (getItem(position)) {
is VisualDownloadChildCached -> VIEW_TYPE_CHILD
is VisualDownloadHeaderCached -> VIEW_TYPE_HEADER
else -> throw IllegalArgumentException("Invalid data type at position $position")
}
}
class DiffCallback : DiffUtil.ItemCallback<VisualDownloadCached>() {

View file

@ -35,6 +35,7 @@ class DownloadChildFragment : Fragment() {
override fun onDestroyView() {
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it }
downloadDeleteEventListener = null
binding = null
super.onDestroyView()
}

View file

@ -40,7 +40,6 @@ 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.AppUtils.loadResult
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DataStore
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
@ -65,16 +64,8 @@ class DownloadFragment : Fragment() {
this.layoutParams = param
}
private fun setList(list: List<VisualDownloadHeaderCached>) {
main {
(binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(list)
}
}
override fun onDestroyView() {
downloadDeleteEventListener?.let {
VideoDownloadManager.downloadDeleteEvent -= it
}
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it }
downloadDeleteEventListener = null
binding = null
super.onDestroyView()
@ -100,12 +91,10 @@ class DownloadFragment : Fragment() {
hideKeyboard()
binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV()
observe(downloadsViewModel.noDownloadsText) {
binding?.textNoDownloads?.text = it
}
observe(downloadsViewModel.headerCards) {
setList(it)
(binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it)
binding?.downloadLoading?.isVisible = false
binding?.textNoDownloads?.isVisible = it.isEmpty()
}
observe(downloadsViewModel.availableBytes) {
updateStorageInfo(view.context, it, R.string.free_storage, binding?.downloadFreeTxt, binding?.downloadFree)
@ -164,7 +153,7 @@ class DownloadFragment : Fragment() {
private fun handleItemClick(click: DownloadHeaderClickEvent) {
when (click.action) {
0 -> {
DOWNLOAD_ACTION_GO_TO_CHILD -> {
if (!click.data.type.isMovieType()) {
val folder = DataStore.getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString())
activity?.navigate(
@ -173,7 +162,7 @@ class DownloadFragment : Fragment() {
)
}
}
1 -> {
DOWNLOAD_ACTION_LOAD_RESULT -> {
(activity as AppCompatActivity?)?.loadResult(click.data.url, click.data.apiName)
}
}

View file

@ -16,17 +16,11 @@ 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
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DownloadViewModel : ViewModel() {
private val _noDownloadsText = MutableLiveData<String>().apply {
value = ""
}
val noDownloadsText: LiveData<String> = _noDownloadsText
private val _headerCards =
MutableLiveData<List<VisualDownloadHeaderCached>>().apply { listOf<VisualDownloadHeaderCached>() }
val headerCards: LiveData<List<VisualDownloadHeaderCached>> = _headerCards
@ -43,8 +37,8 @@ class DownloadViewModel : ViewModel() {
fun updateList(context: Context) = viewModelScope.launchSafe {
val children = withContext(Dispatchers.IO) {
val headers = context.getKeys(DOWNLOAD_EPISODE_CACHE)
headers.mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) }
context.getKeys(DOWNLOAD_EPISODE_CACHE)
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) }
.distinctBy { it.id } // Remove duplicates
}
@ -57,10 +51,10 @@ class DownloadViewModel : ViewModel() {
// Gets all children downloads
withContext(Dispatchers.IO) {
for (c in children) {
val childFile = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, c.id) ?: continue
children.forEach { c ->
val childFile = getDownloadFileInfoAndUpdateSettings(context, c.id) ?: return@forEach
if (childFile.fileLength <= 1) continue
if (childFile.fileLength <= 1) return@forEach
val len = childFile.totalBytes
val flen = childFile.fileLength

View file

@ -1,7 +1,7 @@
package com.lagradost.cloudstream3.ui.download.button
import android.content.Context
import android.text.format.Formatter
import android.text.format.Formatter.formatShortFileSize
import android.util.AttributeSet
import android.widget.FrameLayout
import android.widget.TextView
@ -9,6 +9,8 @@ import androidx.annotation.LayoutRes
import androidx.core.view.isVisible
import androidx.core.widget.ContentLoadingProgressBar
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.mainWork
import com.lagradost.cloudstream3.utils.VideoDownloadManager
typealias DownloadStatusTell = VideoDownloadManager.DownloadType
@ -34,7 +36,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
lateinit var progressBar: ContentLoadingProgressBar
var progressText: TextView? = null
/*val gid: String? get() = sessionIdToGid[persistentId]
/* val gid: String? get() = sessionIdToGid[persistentId]
// used for resuming data
var _lastRequestOverride: UriRequest? = null
@ -44,7 +46,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
_lastRequestOverride = value
}
var files: List<AbstractClient.JsonFile> = emptyList()*/
var files: List<AbstractClient.JsonFile> = emptyList() */
protected var isZeroBytes: Boolean = true
fun inflate(@LayoutRes layout: Int) {
@ -55,9 +57,12 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
resetViewData()
}
var doSetProgress = true
open fun resetViewData() {
// lastRequest = null
isZeroBytes = true
doSetProgress = true
persistentId = null
}
@ -68,37 +73,45 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
persistentId = id
currentMetaData.id = id
VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id)?.let { savedData ->
val downloadedBytes = savedData.fileLength
val totalBytes = savedData.totalBytes
if (!doSetProgress) return
/*lastRequest = savedData.uriRequest
files = savedData.files
ioSafe {
val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id)
var totalBytes: Long = 0
var downloadedBytes: Long = 0
for (file in savedData.files) {
downloadedBytes += file.completedLength
totalBytes += file.length
}*/
setProgress(downloadedBytes, totalBytes)
// some extra padding for just in case
val status = VideoDownloadManager.downloadStatus[id]
?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) DownloadStatusTell.IsDone else DownloadStatusTell.IsPaused
currentMetaData.apply {
this.id = id
this.downloadedLength = downloadedBytes
this.totalLength = totalBytes
this.status = status
mainWork {
if (savedData != null) {
val downloadedBytes = savedData.fileLength
val totalBytes = savedData.totalBytes
setProgress(downloadedBytes, totalBytes)
applyMetaData(id, downloadedBytes, totalBytes)
} else run { resetView() }
}
setStatus(status)
} ?: run {
resetView()
}
}
abstract fun setStatus(status: VideoDownloadManager.DownloadType?)
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) {
DownloadStatusTell.IsDone
} else DownloadStatusTell.IsPaused
}
fun applyMetaData(id:Int, downloadedBytes: Long, totalBytes: Long) {
val status = getStatus(id, downloadedBytes, totalBytes)
currentMetaData.apply {
this.id = id
this.downloadedLength = downloadedBytes
this.totalLength = totalBytes
this.status = status
}
setStatus(status)
}
open fun setProgress(downloadedBytes: Long, totalBytes: Long) {
isZeroBytes = downloadedBytes == 0L
progressBar.post {
@ -124,13 +137,15 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
if (isZeroBytes) {
progressText?.isVisible = false
} else {
progressText?.apply {
val currentMbString = Formatter.formatShortFileSize(context, downloadedBytes)
val totalMbString = Formatter.formatShortFileSize(context, totalBytes)
text =
//if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else
context?.getString(R.string.download_size_format)
?.format(currentMbString, totalMbString)
if (doSetProgress) {
progressText?.apply {
val currentFormattedSizeString = formatShortFileSize(context, downloadedBytes)
val totalFormattedSizeString = formatShortFileSize(context, totalBytes)
text =
// if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else
context?.getString(R.string.download_size_format)
?.format(currentFormattedSizeString, totalFormattedSizeString)
}
}
}
@ -167,8 +182,8 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
override fun onAttachedToWindow() {
VideoDownloadManager.downloadStatusEvent += ::downloadStatusEvent
//VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent
//VideoDownloadManager.downloadEvent += ::downloadEvent
// VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent
// VideoDownloadManager.downloadEvent += ::downloadEvent
VideoDownloadManager.downloadProgressEvent += ::downloadProgressEvent
val pid = persistentId
@ -182,8 +197,8 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
override fun onDetachedFromWindow() {
VideoDownloadManager.downloadStatusEvent -= ::downloadStatusEvent
//VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent
//VideoDownloadManager.downloadEvent -= ::downloadEvent
// VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent
// VideoDownloadManager.downloadEvent -= ::downloadEvent
VideoDownloadManager.downloadProgressEvent -= ::downloadProgressEvent
super.onDetachedFromWindow()
@ -198,5 +213,4 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
* Get a clean slate again, might be useful in recyclerview?
* */
abstract fun resetView()
}

View file

@ -13,7 +13,6 @@ import androidx.annotation.MainThread
import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
@ -29,7 +28,6 @@ import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES
open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
BaseFetchButton(context, attributeSet) {
@ -303,6 +301,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
setStatus(null)
currentMetaData = DownloadMetadata(0, 0, 0, null)
isZeroBytes = true
doSetProgress = true
progressBar.progress = 0
}

View file

@ -17,6 +17,7 @@ import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
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.cloudstream3.APIHolder.getApiFromNameNull
@ -28,7 +29,6 @@ import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.services.VideoDownloadService
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main
@ -234,10 +234,10 @@ object VideoDownloadManager {
return cachedBitmaps[url]
}
val bitmap = com.bumptech.glide.Glide.with(this)
val bitmap = Glide.with(this)
.asBitmap()
.load(GlideUrl(url) { headers ?: emptyMap() })
.into(720, 720)
.submit(720, 720)
.get()
if (bitmap != null) {

View file

@ -143,17 +143,14 @@
<TextView
android:id="@+id/text_no_downloads"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:textAlignment="center"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
android:layout_gravity="center"
android:layout_margin="30dp"
android:text="@string/downloads_empty"
android:gravity="center"
android:visibility="gone"
tools:visibility="visible" />
<!--
<ProgressBar

View file

@ -149,6 +149,7 @@
<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_empty">There are currently no downloads.</string>
<string name="update_started">Update Started</string>
<string name="stream">Network stream</string>
<string name="open_local_video">Open local video</string>
@ -340,6 +341,10 @@
<string name="livestreams">Livestreams</string>
<string name="nsfw">NSFW</string>
<string name="others">Others</string>
<plurals name="episodes" translatable="false">
<item quantity="one">@string/episode</item>
<item quantity="other">@string/episodes</item>
</plurals>
<!--singular-->
<string name="movies_singular">Movie</string>
<string name="tv_series_singular">Series</string>