Merge remote-tracking branch 'origin/master'

This commit is contained in:
Hosted Weblate 2024-06-24 20:06:38 +02:00
commit 5d4c40f9cf
No known key found for this signature in database
GPG key ID: A3FAAA06E6569B4C
22 changed files with 660 additions and 604 deletions

View file

@ -622,7 +622,7 @@ abstract class MainAPI {
/**Used for testing and can be used to disable the providers if WebView is not available*/ /**Used for testing and can be used to disable the providers if WebView is not available*/
open val usesWebView = false open val usesWebView = false
/** Determines which plugin a given provider is from */ /** Determines which plugin a given provider is from. This is the full path to the plugin. */
var sourcePlugin: String? = null var sourcePlugin: String? = null
open val hasMainPage = false open val hasMainPage = false

View file

@ -67,6 +67,7 @@ abstract class Plugin {
* This will contain your resources if you specified requiresResources in gradle * This will contain your resources if you specified requiresResources in gradle
*/ */
var resources: Resources? = null var resources: Resources? = null
/** Full file path to the plugin. */
var __filename: String? = null var __filename: String? = null
/** /**

View file

@ -18,7 +18,6 @@ import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
import com.lagradost.cloudstream3.APIHolder.removePluginMapping import com.lagradost.cloudstream3.APIHolder.removePluginMapping
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
@ -518,7 +517,7 @@ object PluginManager {
return true return true
} }
pluginInstance.__filename = fileName pluginInstance.__filename = file.absolutePath
if (manifest.requiresResources) { if (manifest.requiresResources) {
Log.d(TAG, "Loading resources for ${data.internalName}") Log.d(TAG, "Loading resources for ${data.internalName}")
// based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk // based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk

View file

@ -0,0 +1,223 @@
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 androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
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.utils.AppUtils.getNameFull
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual
import com.lagradost.cloudstream3.utils.UIHelper.setImage
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
const val DOWNLOAD_ACTION_PLAY_FILE = 0
const val DOWNLOAD_ACTION_DELETE_FILE = 1
const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2
const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3
const val DOWNLOAD_ACTION_DOWNLOAD = 4
const val DOWNLOAD_ACTION_LONG_CLICK = 5
abstract class VisualDownloadCached(
open val currentBytes: Long,
open val totalBytes: Long,
open val data: VideoDownloadHelper.DownloadCached
) {
// Just to be extra-safe with areContentsTheSame
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is VisualDownloadCached) return false
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 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
)
data class DownloadHeaderClickEvent(
val action: Int,
val data: VideoDownloadHelper.DownloadHeaderCached
)
class DownloadAdapter(
private val clickCallback: (DownloadHeaderClickEvent) -> Unit,
private val mediaClickCallback: (DownloadClickEvent) -> Unit,
) : ListAdapter<VisualDownloadCached, DownloadAdapter.DownloadViewHolder>(DiffCallback()) {
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,
) : 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
downloadHeaderPoster.apply {
setImage(d.poster)
setOnClickListener {
clickCallback.invoke(DownloadHeaderClickEvent(1, d))
}
}
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))
}
}
}
is DownloadChildEpisodeBinding -> binding.apply {
if (card == null || card !is VisualDownloadChildCached) return@apply
val d = card.data
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
}
downloadButton.setDefaultClickListener(card.data, downloadChildEpisodeTextExtra, mediaClickCallback)
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 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
)
}
else -> throw IllegalArgumentException("Invalid view type")
}
return DownloadViewHolder(binding, clickCallback, mediaClickCallback)
}
override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) {
holder.bind(getItem(position))
}
override fun getItemViewType(position: Int): Int {
val card = getItem(position)
return if (card is VisualDownloadChildCached) VIEW_TYPE_CHILD else VIEW_TYPE_HEADER
}
class DiffCallback : DiffUtil.ItemCallback<VisualDownloadCached>() {
override fun areItemsTheSame(oldItem: VisualDownloadCached, newItem: VisualDownloadCached): Boolean {
return oldItem.data.id == newItem.data.id
}
override fun areContentsTheSame(oldItem: VisualDownloadCached, newItem: VisualDownloadCached): Boolean {
return oldItem == newItem
}
}
}

View file

@ -1,6 +1,5 @@
package com.lagradost.cloudstream3.ui.download package com.lagradost.cloudstream3.ui.download
import android.app.Activity
import android.content.DialogInterface import android.content.DialogInterface
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
@ -22,7 +21,6 @@ import com.lagradost.cloudstream3.utils.VideoDownloadManager
object DownloadButtonSetup { object DownloadButtonSetup {
fun handleDownloadClick(click: DownloadClickEvent) { fun handleDownloadClick(click: DownloadClickEvent) {
val id = click.data.id val id = click.data.id
if (click.data !is VideoDownloadHelper.DownloadEpisodeCached) return
when (click.action) { when (click.action) {
DOWNLOAD_ACTION_DELETE_FILE -> { DOWNLOAD_ACTION_DELETE_FILE -> {
activity?.let { ctx -> activity?.let { ctx ->

View file

@ -1,94 +0,0 @@
package com.lagradost.cloudstream3.ui.download
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding
import com.lagradost.cloudstream3.utils.AppUtils.getNameFull
import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
const val DOWNLOAD_ACTION_PLAY_FILE = 0
const val DOWNLOAD_ACTION_DELETE_FILE = 1
const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2
const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3
const val DOWNLOAD_ACTION_DOWNLOAD = 4
const val DOWNLOAD_ACTION_LONG_CLICK = 5
data class VisualDownloadChildCached(
val currentBytes: Long,
val totalBytes: Long,
val data: VideoDownloadHelper.DownloadEpisodeCached,
)
data class DownloadClickEvent(val action: Int, val data: VideoDownloadHelper.DownloadEpisodeCached)
class DownloadChildAdapter(
var cardList: List<VisualDownloadChildCached>,
private val clickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return DownloadChildViewHolder(
DownloadChildEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false),
clickCallback
)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is DownloadChildViewHolder -> {
holder.bind(cardList[position])
}
}
}
override fun getItemCount(): Int {
return cardList.size
}
class DownloadChildViewHolder
constructor(
val binding: DownloadChildEpisodeBinding,
private val clickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.ViewHolder(binding.root) {
/*private val title: TextView = itemView.download_child_episode_text
private val extraInfo: TextView = itemView.download_child_episode_text_extra
private val holder: CardView = itemView.download_child_episode_holder
private val progressBar: ContentLoadingProgressBar = itemView.download_child_episode_progress
private val progressBarDownload: ContentLoadingProgressBar = itemView.download_child_episode_progress_downloaded
private val downloadImage: ImageView = itemView.download_child_episode_download*/
fun bind(card: VisualDownloadChildCached) {
val d = card.data
val posDur = getViewPos(d.id)
binding.downloadChildEpisodeProgress.apply {
if (posDur != null) {
val visualPos = posDur.fixVisual()
max = (visualPos.duration / 1000).toInt()
progress = (visualPos.position / 1000).toInt()
visibility = View.VISIBLE
} else {
visibility = View.GONE
}
}
binding.downloadButton.setDefaultClickListener(card.data, binding.downloadChildEpisodeTextExtra, clickCallback)
binding.downloadChildEpisodeText.apply {
text = context.getNameFull(d.name, d.episode, d.season)
isSelected = true // is needed for text repeating
}
binding.downloadChildEpisodeHolder.setOnClickListener {
clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, d))
}
}
}
}

View file

@ -5,7 +5,6 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
@ -40,7 +39,8 @@ class DownloadChildFragment : Fragment() {
super.onDestroyView() super.onDestroyView()
} }
var binding: FragmentChildDownloadsBinding? = null private var binding: FragmentChildDownloadsBinding? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -48,7 +48,7 @@ class DownloadChildFragment : Fragment() {
): View { ): View {
val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false) val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false)
binding = localBinding binding = localBinding
return localBinding.root//inflater.inflate(R.layout.fragment_child_downloads, container, false) return localBinding.root
} }
private fun updateList(folder: String) = main { private fun updateList(folder: String) = main {
@ -60,7 +60,11 @@ class DownloadChildFragment : Fragment() {
}.mapNotNull { }.mapNotNull {
val info = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(ctx, it.id) val info = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(ctx, it.id)
?: return@mapNotNull null ?: return@mapNotNull null
VisualDownloadChildCached(info.fileLength, info.totalBytes, it) VisualDownloadChildCached(
currentBytes = info.fileLength,
totalBytes = info.totalBytes,
data = it,
)
} }
}.sortedBy { it.data.episode + (it.data.season ?: 0) * 100000 } }.sortedBy { it.data.episode + (it.data.season ?: 0) * 100000 }
if (eps.isEmpty()) { if (eps.isEmpty()) {
@ -68,9 +72,7 @@ class DownloadChildFragment : Fragment() {
return@main return@main
} }
(binding?.downloadChildList?.adapter as DownloadChildAdapter? ?: return@main).cardList = (binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(eps)
eps
binding?.downloadChildList?.adapter?.notifyDataSetChanged()
} }
} }
@ -98,31 +100,39 @@ class DownloadChildFragment : Fragment() {
setAppBarNoScrollFlagsOnTV() setAppBarNoScrollFlagsOnTV()
} }
val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> = val adapter = DownloadAdapter(
DownloadChildAdapter( {},
ArrayList(), { downloadClickEvent ->
) { click -> handleDownloadClick(downloadClickEvent)
handleDownloadClick(click) if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) {
setUpDownloadDeleteListener(folder)
}
}
)
binding?.downloadChildList?.apply {
setHasFixedSize(true)
setItemViewCacheSize(20)
this.adapter = adapter
setLinearListLayout(
isHorizontal = false,
nextRight = FOCUS_SELF,
nextDown = FOCUS_SELF,
)
} }
updateList(folder)
}
private fun setUpDownloadDeleteListener(folder: String) {
downloadDeleteEventListener = { id: Int -> downloadDeleteEventListener = { id: Int ->
val list = (binding?.downloadChildList?.adapter as DownloadChildAdapter?)?.cardList val list = (binding?.downloadChildList?.adapter as? DownloadAdapter)?.currentList
if (list != null) { if (list != null) {
if (list.any { it.data.id == id }) { if (list.any { it.data.id == id }) {
updateList(folder) updateList(folder)
} }
} }
} }
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
binding?.downloadChildList?.adapter = adapter
binding?.downloadChildList?.setLinearListLayout(
isHorizontal = false,
nextDown = FOCUS_SELF,
nextRight = FOCUS_SELF
)//layoutManager = GridLayoutManager(context, 1)
updateList(folder)
} }
} }

View file

@ -10,14 +10,15 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
@ -42,11 +43,9 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager
import java.net.URI import java.net.URI
const val DOWNLOAD_NAVIGATE_TO = "downloadpage" const val DOWNLOAD_NAVIGATE_TO = "downloadpage"
class DownloadFragment : Fragment() { class DownloadFragment : Fragment() {
@ -63,33 +62,30 @@ class DownloadFragment : Fragment() {
private fun setList(list: List<VisualDownloadHeaderCached>) { private fun setList(list: List<VisualDownloadHeaderCached>) {
main { main {
(binding?.downloadList?.adapter as DownloadHeaderAdapter?)?.cardList = list (binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(list)
binding?.downloadList?.adapter?.notifyDataSetChanged()
} }
} }
override fun onDestroyView() { override fun onDestroyView() {
if (downloadDeleteEventListener != null) { downloadDeleteEventListener?.let {
VideoDownloadManager.downloadDeleteEvent -= downloadDeleteEventListener!! VideoDownloadManager.downloadDeleteEvent -= it
downloadDeleteEventListener = null
} }
downloadDeleteEventListener = null
binding = null binding = null
super.onDestroyView() super.onDestroyView()
} }
var binding: FragmentDownloadsBinding? = null private var binding: FragmentDownloadsBinding? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View {
downloadsViewModel = downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
ViewModelProvider(this)[DownloadViewModel::class.java]
val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false) val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false)
binding = localBinding binding = localBinding
return localBinding.root//inflater.inflate(R.layout.fragment_downloads, container, false) return localBinding.root
} }
private var downloadDeleteEventListener: ((Int) -> Unit)? = null private var downloadDeleteEventListener: ((Int) -> Unit)? = null
@ -97,7 +93,6 @@ class DownloadFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
hideKeyboard() hideKeyboard()
binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV() binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV()
observe(downloadsViewModel.noDownloadsText) { observe(downloadsViewModel.noDownloadsText) {
@ -108,135 +103,109 @@ class DownloadFragment : Fragment() {
binding?.downloadLoading?.isVisible = false binding?.downloadLoading?.isVisible = false
} }
observe(downloadsViewModel.availableBytes) { observe(downloadsViewModel.availableBytes) {
binding?.downloadFreeTxt?.text = updateStorageInfo(view.context, it, R.string.free_storage, binding?.downloadFreeTxt, binding?.downloadFree)
getString(R.string.storage_size_format).format(
getString(R.string.free_storage),
formatShortFileSize(view.context, it)
)
binding?.downloadFree?.setLayoutWidth(it)
} }
observe(downloadsViewModel.usedBytes) { observe(downloadsViewModel.usedBytes) {
binding?.apply { updateStorageInfo(view.context, it, R.string.used_storage, binding?.downloadUsedTxt, binding?.downloadUsed)
downloadUsedTxt.text = binding?.downloadStorageAppbar?.isVisible = it > 0
getString(R.string.storage_size_format).format(
getString(R.string.used_storage),
formatShortFileSize(view.context, it)
)
downloadUsed.setLayoutWidth(it)
downloadStorageAppbar.isVisible = it > 0
}
} }
observe(downloadsViewModel.downloadBytes) { observe(downloadsViewModel.downloadBytes) {
binding?.apply { updateStorageInfo(view.context, it, R.string.app_storage, binding?.downloadAppTxt, binding?.downloadApp)
downloadAppTxt.text =
getString(R.string.storage_size_format).format(
getString(R.string.app_storage),
formatShortFileSize(view.context, it)
)
downloadApp.setLayoutWidth(it)
}
} }
val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> = val adapter = DownloadAdapter(
DownloadHeaderAdapter(
ArrayList(),
{ click -> { click ->
handleItemClick(click)
},
{ downloadClickEvent ->
handleDownloadClick(downloadClickEvent)
if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) {
setUpDownloadDeleteListener()
}
}
)
binding?.downloadList?.apply {
setHasFixedSize(true)
setItemViewCacheSize(20)
this.adapter = adapter
setLinearListLayout(
isHorizontal = false,
nextRight = FOCUS_SELF,
nextUp = FOCUS_SELF,
nextDown = FOCUS_SELF,
)
}
binding?.downloadStreamButton?.apply {
isGone = isLayout(TV)
setOnClickListener { showStreamInputDialog(it.context) }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
handleScroll(scrollY - oldScrollY)
}
}
downloadsViewModel.updateList(requireContext())
fixPaddingStatusbar(binding?.downloadRoot)
}
private fun handleItemClick(click: DownloadHeaderClickEvent) {
when (click.action) { when (click.action) {
0 -> { 0 -> {
if (click.data.type.isMovieType()) { if (!click.data.type.isMovieType()) {
//wont be called val folder = DataStore.getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString())
} else {
val folder = DataStore.getFolderName(
DOWNLOAD_EPISODE_CACHE,
click.data.id.toString()
)
activity?.navigate( activity?.navigate(
R.id.action_navigation_downloads_to_navigation_download_child, R.id.action_navigation_downloads_to_navigation_download_child,
DownloadChildFragment.newInstance(click.data.name, folder) DownloadChildFragment.newInstance(click.data.name, folder)
) )
} }
} }
1 -> { 1 -> {
(activity as AppCompatActivity?)?.loadResult( (activity as AppCompatActivity?)?.loadResult(click.data.url, click.data.apiName)
click.data.url, }
click.data.apiName
)
} }
} }
}, private fun setUpDownloadDeleteListener() {
{ downloadClickEvent ->
if (downloadClickEvent.data !is VideoDownloadHelper.DownloadEpisodeCached) return@DownloadHeaderAdapter
handleDownloadClick(downloadClickEvent)
if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) {
context?.let { ctx ->
downloadsViewModel.updateList(ctx)
}
}
}
)
downloadDeleteEventListener = { id -> downloadDeleteEventListener = { id ->
val list = (binding?.downloadList?.adapter as DownloadHeaderAdapter?)?.cardList val list = (binding?.downloadList?.adapter as? DownloadAdapter)?.currentList
if (list != null) { if (list?.any { it.data.id == id } == true) {
if (list.any { it.data.id == id }) { context?.let { downloadsViewModel.updateList(it) }
context?.let { ctx ->
setList(ArrayList())
downloadsViewModel.updateList(ctx)
} }
} }
}
}
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
binding?.downloadList?.apply {
this.adapter = adapter
setLinearListLayout(
isHorizontal = false,
nextRight = FOCUS_SELF,
nextUp = FOCUS_SELF,
nextDown = FOCUS_SELF
)
//layoutManager = GridLayoutManager(context, 1)
} }
// Should be visible in emulator layout private fun updateStorageInfo(
binding?.downloadStreamButton?.isGone = isLayout(TV) context: Context,
binding?.downloadStreamButton?.setOnClickListener { bytes: Long,
val dialog = @StringRes stringRes: Int,
Dialog(it.context ?: return@setOnClickListener, R.style.AlertDialogCustom) textView: TextView?,
view: View?
) {
textView?.text = getString(R.string.storage_size_format).format(getString(stringRes), formatShortFileSize(context, bytes))
view?.setLayoutWidth(bytes)
}
private fun showStreamInputDialog(context: Context) {
val dialog = Dialog(context, R.style.AlertDialogCustom)
val binding = StreamInputBinding.inflate(dialog.layoutInflater) val binding = StreamInputBinding.inflate(dialog.layoutInflater)
dialog.setContentView(binding.root) dialog.setContentView(binding.root)
dialog.show() dialog.show()
// If user has clicked the switch do not interfere
var preventAutoSwitching = false var preventAutoSwitching = false
binding.hlsSwitch.setOnClickListener { binding.hlsSwitch.setOnClickListener { preventAutoSwitching = true }
preventAutoSwitching = true
}
fun activateSwitchOnHls(text: String?) {
binding.hlsSwitch.isChecked = normalSafeApiCall {
URI(text).path?.substringAfterLast(".")?.contains("m3u")
} == true
}
binding.streamReferer.doOnTextChanged { text, _, _, _ -> binding.streamReferer.doOnTextChanged { text, _, _, _ ->
if (!preventAutoSwitching) if (!preventAutoSwitching) activateSwitchOnHls(text?.toString(), binding)
activateSwitchOnHls(text?.toString())
} }
(activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt( (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt(0)?.text?.toString()?.let { copy ->
0
)?.text?.toString()?.let { copy ->
val fixedText = copy.trim() val fixedText = copy.trim()
binding.streamUrl.setText(fixedText) binding.streamUrl.setText(fixedText)
activateSwitchOnHls(fixedText) activateSwitchOnHls(fixedText, binding)
} }
binding.applyBtt.setOnClickListener { binding.applyBtt.setOnClickListener {
@ -245,7 +214,6 @@ class DownloadFragment : Fragment() {
showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT) showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT)
} else { } else {
val referer = binding.streamReferer.text?.toString() val referer = binding.streamReferer.text?.toString()
activity?.navigate( activity?.navigate(
R.id.global_to_navigation_player, R.id.global_to_navigation_player,
GeneratorPlayer.newInstance( GeneratorPlayer.newInstance(
@ -257,7 +225,6 @@ class DownloadFragment : Fragment() {
) )
) )
) )
dialog.dismissSafe(activity) dialog.dismissSafe(activity)
} }
} }
@ -266,18 +233,18 @@ class DownloadFragment : Fragment() {
dialog.dismissSafe(activity) dialog.dismissSafe(activity)
} }
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
val dy = scrollY - oldScrollY
if (dy > 0) { //check for scroll down
binding?.downloadStreamButton?.shrink() // hide
} else if (dy < -5) {
binding?.downloadStreamButton?.extend() // show
}
}
}
downloadsViewModel.updateList(requireContext())
fixPaddingStatusbar(binding?.downloadRoot) private fun activateSwitchOnHls(text: String?, binding: StreamInputBinding) {
binding.hlsSwitch.isChecked = normalSafeApiCall {
URI(text).path?.substringAfterLast(".")?.contains("m3u")
} == true
}
private fun handleScroll(dy: Int) {
if (dy > 0) {
binding?.downloadStreamButton?.shrink()
} else if (dy < -5) {
binding?.downloadStreamButton?.extend()
}
} }
} }

View file

@ -1,149 +0,0 @@
package com.lagradost.cloudstream3.ui.download
import android.annotation.SuppressLint
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.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.utils.UIHelper.setImage
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import java.util.*
data class VisualDownloadHeaderCached(
val currentOngoingDownloads: Int,
val totalDownloads: Int,
val totalBytes: Long,
val currentBytes: Long,
val data: VideoDownloadHelper.DownloadHeaderCached,
val child: VideoDownloadHelper.DownloadEpisodeCached?,
)
data class DownloadHeaderClickEvent(
val action: Int,
val data: VideoDownloadHelper.DownloadHeaderCached
)
class DownloadHeaderAdapter(
var cardList: List<VisualDownloadHeaderCached>,
private val clickCallback: (DownloadHeaderClickEvent) -> Unit,
private val movieClickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return DownloadHeaderViewHolder(
DownloadHeaderEpisodeBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
),
clickCallback,
movieClickCallback
)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is DownloadHeaderViewHolder -> {
holder.bind(cardList[position])
}
}
}
override fun getItemCount(): Int {
return cardList.size
}
class DownloadHeaderViewHolder
constructor(
val binding: DownloadHeaderEpisodeBinding,
private val clickCallback: (DownloadHeaderClickEvent) -> Unit,
private val movieClickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.ViewHolder(binding.root) {
/*private val poster: ImageView? = itemView.download_header_poster
private val title: TextView = itemView.download_header_title
private val extraInfo: TextView = itemView.download_header_info
private val holder: CardView = itemView.episode_holder
private val downloadBar: ContentLoadingProgressBar = itemView.download_header_progress_downloaded
private val downloadImage: ImageView = itemView.download_header_episode_download
private val normalImage: ImageView = itemView.download_header_goto_child*/
@SuppressLint("SetTextI18n")
fun bind(card: VisualDownloadHeaderCached) {
val d = card.data
binding.downloadHeaderPoster.apply {
setImage(d.poster)
setOnClickListener {
clickCallback.invoke(DownloadHeaderClickEvent(1, d))
}
}
binding.apply {
binding.downloadHeaderTitle.text = d.name
val mbString = formatShortFileSize(itemView.context, card.totalBytes)
//val isMovie = d.type.isMovieType()
if (card.child != null) {
//downloadHeaderProgressDownloaded.visibility = View.VISIBLE
// downloadHeaderEpisodeDownload.visibility = View.VISIBLE
binding.downloadHeaderGotoChild.visibility = View.GONE
downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, movieClickCallback)
downloadButton.isVisible = true
/*setUpButton(
card.currentBytes,
card.totalBytes,
downloadBar,
downloadImage,
extraInfo,
card.child,
movieClickCallback
)*/
episodeHolder.setOnClickListener {
movieClickCallback.invoke(
DownloadClickEvent(
DOWNLOAD_ACTION_PLAY_FILE,
card.child
)
)
}
} else {
downloadButton.isVisible = false
// downloadHeaderProgressDownloaded.visibility = View.GONE
// downloadHeaderEpisodeDownload.visibility = View.GONE
binding.downloadHeaderGotoChild.visibility = View.VISIBLE
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))
}
}
}
}
}
}

View file

@ -39,6 +39,8 @@ class DownloadViewModel : ViewModel() {
val availableBytes: LiveData<Long> = _availableBytes val availableBytes: LiveData<Long> = _availableBytes
val downloadBytes: LiveData<Long> = _downloadBytes val downloadBytes: LiveData<Long> = _downloadBytes
private var previousVisual: List<VisualDownloadHeaderCached>? = null
fun updateList(context: Context) = viewModelScope.launchSafe { fun updateList(context: Context) = viewModelScope.launchSafe {
val children = withContext(Dispatchers.IO) { val children = withContext(Dispatchers.IO) {
val headers = context.getKeys(DOWNLOAD_EPISODE_CACHE) val headers = context.getKeys(DOWNLOAD_EPISODE_CACHE)
@ -53,7 +55,6 @@ class DownloadViewModel : ViewModel() {
// parentId : downloadsCount // parentId : downloadsCount
val totalDownloads = HashMap<Int, Int>() val totalDownloads = HashMap<Int, Int>()
// Gets all children downloads // Gets all children downloads
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
for (c in children) { for (c in children) {
@ -69,7 +70,7 @@ class DownloadViewModel : ViewModel() {
} }
} }
val cached = withContext(Dispatchers.IO) { // wont fetch useless keys val cached = withContext(Dispatchers.IO) { // Won't fetch useless keys
totalDownloads.entries.filter { it.value > 0 }.mapNotNull { totalDownloads.entries.filter { it.value > 0 }.mapNotNull {
context.getKey<VideoDownloadHelper.DownloadHeaderCached>( context.getKey<VideoDownloadHelper.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE, DOWNLOAD_HEADER_CACHE,
@ -79,7 +80,7 @@ class DownloadViewModel : ViewModel() {
} }
val visual = withContext(Dispatchers.IO) { val visual = withContext(Dispatchers.IO) {
cached.mapNotNull { // TODO FIX cached.mapNotNull {
val downloads = totalDownloads[it.id] ?: 0 val downloads = totalDownloads[it.id] ?: 0
val bytes = totalBytesUsedByChild[it.id] ?: 0 val bytes = totalBytesUsedByChild[it.id] ?: 0
val currentBytes = currentBytesUsedByChild[it.id] ?: 0 val currentBytes = currentBytesUsedByChild[it.id] ?: 0
@ -91,21 +92,25 @@ class DownloadViewModel : ViewModel() {
getFolderName(it.id.toString(), it.id.toString()) getFolderName(it.id.toString(), it.id.toString())
) )
VisualDownloadHeaderCached( VisualDownloadHeaderCached(
0, currentBytes = currentBytes,
downloads, totalBytes = bytes,
bytes, data = it,
currentBytes, child = movieEpisode,
it, currentOngoingDownloads = 0,
movieEpisode totalDownloads = downloads,
) )
}.sortedBy { }.sortedBy {
(it.child?.episode ?: 0) + (it.child?.season?.times(10000) ?: 0) (it.child?.episode ?: 0) + (it.child?.season?.times(10000) ?: 0)
} // episode sorting by episode, lowest to highest } // 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 { try {
val stat = StatFs(Environment.getExternalStorageDirectory().path) val stat = StatFs(Environment.getExternalStorageDirectory().path)
val localBytesAvailable = stat.availableBytes
val localBytesAvailable = stat.availableBytes//stat.blockSizeLong * stat.blockCountLong
val localTotalBytes = stat.blockSizeLong * stat.blockCountLong val localTotalBytes = stat.blockSizeLong * stat.blockCountLong
val localDownloadedBytes = visual.sumOf { it.totalBytes } val localDownloadedBytes = visual.sumOf { it.totalBytes }
@ -120,3 +125,4 @@ class DownloadViewModel : ViewModel() {
_headerCards.postValue(visual) _headerCards.postValue(visual)
} }
} }
}

View file

@ -192,15 +192,15 @@ class EpisodeAdapter(
downloadButton.isVisible = hasDownloadSupport downloadButton.isVisible = hasDownloadSupport
downloadButton.setDefaultClickListener( downloadButton.setDefaultClickListener(
VideoDownloadHelper.DownloadEpisodeCached( VideoDownloadHelper.DownloadEpisodeCached(
card.name, name = card.name,
card.poster, poster = card.poster,
card.episode, episode = card.episode,
card.season, season = card.season,
card.id, id = card.id,
card.parentId, parentId = card.parentId,
card.rating, rating = card.rating,
card.description, description = card.description,
System.currentTimeMillis(), cacheTime = System.currentTimeMillis(),
), null ), null
) { ) {
when (it.action) { when (it.action) {
@ -343,15 +343,15 @@ class EpisodeAdapter(
downloadButton.isVisible = hasDownloadSupport downloadButton.isVisible = hasDownloadSupport
downloadButton.setDefaultClickListener( downloadButton.setDefaultClickListener(
VideoDownloadHelper.DownloadEpisodeCached( VideoDownloadHelper.DownloadEpisodeCached(
card.name, name = card.name,
card.poster, poster = card.poster,
card.episode, episode = card.episode,
card.season, season = card.season,
card.id, id = card.id,
card.parentId, parentId = card.parentId,
card.rating, rating = card.rating,
card.description, description = card.description,
System.currentTimeMillis(), cacheTime = System.currentTimeMillis(),
), null ), null
) { ) {
when (it.action) { when (it.action) {

View file

@ -185,8 +185,6 @@ open class ResultFragmentPhone : FullScreenPlayer() {
} }
binding?.resultFullscreenHolder?.isVisible = !isSuccess && isFullScreenPlayer binding?.resultFullscreenHolder?.isVisible = !isSuccess && isFullScreenPlayer
} }
//player_view?.apply { //player_view?.apply {
//alpha = 0.0f //alpha = 0.0f
//ObjectAnimator.ofFloat(player_view, "alpha", 1f).apply { //ObjectAnimator.ofFloat(player_view, "alpha", 1f).apply {
@ -201,8 +199,6 @@ open class ResultFragmentPhone : FullScreenPlayer() {
//} //}
//startAnimation(fadeIn) //startAnimation(fadeIn)
//} //}
} }
private fun setTrailers(trailers: List<ExtractorLink>?) { private fun setTrailers(trailers: List<ExtractorLink>?) {
@ -630,15 +626,15 @@ open class ResultFragmentPhone : FullScreenPlayer() {
} }
downloadButton.setDefaultClickListener( downloadButton.setDefaultClickListener(
VideoDownloadHelper.DownloadEpisodeCached( VideoDownloadHelper.DownloadEpisodeCached(
ep.name, name = ep.name,
ep.poster, poster = ep.poster,
0, episode = 0,
null, season = null,
ep.id, id = ep.id,
ep.id, parentId = ep.id,
null, rating = null,
null, description = null,
System.currentTimeMillis(), cacheTime = System.currentTimeMillis(),
), ),
null null
) { click -> ) { click ->

View file

@ -705,13 +705,13 @@ class ResultViewModel2 : ViewModel() {
DOWNLOAD_HEADER_CACHE, DOWNLOAD_HEADER_CACHE,
parentId.toString(), parentId.toString(),
VideoDownloadHelper.DownloadHeaderCached( VideoDownloadHelper.DownloadHeaderCached(
apiName, apiName = apiName,
url, url = url,
currentType, type = currentType,
currentHeaderName, name = currentHeaderName,
currentPoster, poster = currentPoster,
parentId, id = parentId,
System.currentTimeMillis(), cacheTime = System.currentTimeMillis(),
) )
) )
@ -722,15 +722,15 @@ class ResultViewModel2 : ViewModel() {
), // 3 deep folder for faster acess ), // 3 deep folder for faster acess
episode.id.toString(), episode.id.toString(),
VideoDownloadHelper.DownloadEpisodeCached( VideoDownloadHelper.DownloadEpisodeCached(
episode.name, name = episode.name,
episode.poster, poster = episode.poster,
episode.episode, episode = episode.episode,
episode.season, season = episode.season,
episode.id, id = episode.id,
parentId, parentId = parentId,
episode.rating, rating = episode.rating,
episode.description, description = episode.description,
System.currentTimeMillis(), cacheTime = System.currentTimeMillis(),
) )
) )
@ -2776,13 +2776,13 @@ class ResultViewModel2 : ViewModel() {
DOWNLOAD_HEADER_CACHE, DOWNLOAD_HEADER_CACHE,
mainId.toString(), mainId.toString(),
VideoDownloadHelper.DownloadHeaderCached( VideoDownloadHelper.DownloadHeaderCached(
apiName, apiName = apiName,
validUrl, url = validUrl,
loadResponse.type, type = loadResponse.type,
loadResponse.name, name = loadResponse.name,
loadResponse.posterUrl, poster = loadResponse.posterUrl,
mainId, id = mainId,
System.currentTimeMillis(), cacheTime = System.currentTimeMillis(),
) )
) )
if (loadTrailers) if (loadTrailers)

View file

@ -33,15 +33,15 @@ object SearchHelper {
DownloadClickEvent( DownloadClickEvent(
DOWNLOAD_ACTION_PLAY_FILE, DOWNLOAD_ACTION_PLAY_FILE,
VideoDownloadHelper.DownloadEpisodeCached( VideoDownloadHelper.DownloadEpisodeCached(
card.name, name = card.name,
card.posterUrl, poster = card.posterUrl,
card.episode ?: 0, episode = card.episode ?: 0,
card.season, season = card.season,
id, id = id,
card.parentId ?: return, parentId = card.parentId ?: return,
null, rating = null,
null, description = null,
System.currentTimeMillis() cacheTime = System.currentTimeMillis(),
) )
) )
) )

View file

@ -2,20 +2,25 @@ package com.lagradost.cloudstream3.ui.settings.testing
import android.app.AlertDialog import android.app.AlertDialog
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.ProviderTestItemBinding import com.lagradost.cloudstream3.databinding.ProviderTestItemBinding
import com.lagradost.cloudstream3.mvvm.getAllMessages import com.lagradost.cloudstream3.mvvm.getAllMessages
import com.lagradost.cloudstream3.mvvm.getStackTracePretty import com.lagradost.cloudstream3.mvvm.getStackTracePretty
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppUtils
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso
import com.lagradost.cloudstream3.utils.TestingUtils import com.lagradost.cloudstream3.utils.TestingUtils
import java.io.File
class TestResultAdapter(override val items: MutableList<Pair<MainAPI, TestingUtils.TestResultProvider>>) : class TestResultAdapter(override val items: MutableList<Pair<MainAPI, TestingUtils.TestResultProvider>>) :
AppUtils.DiffAdapter<Pair<MainAPI, TestingUtils.TestResultProvider>>(items) { AppUtils.DiffAdapter<Pair<MainAPI, TestingUtils.TestResultProvider>>(items) {
@ -36,7 +41,8 @@ class TestResultAdapter(override val items: MutableList<Pair<MainAPI, TestingUti
} }
} }
inner class ProviderTestViewHolder(binding: ProviderTestItemBinding) : RecyclerView.ViewHolder(binding.root) { inner class ProviderTestViewHolder(binding: ProviderTestItemBinding) :
RecyclerView.ViewHolder(binding.root) {
private val languageText: TextView = binding.langIcon private val languageText: TextView = binding.langIcon
private val providerTitle: TextView = binding.mainText private val providerTitle: TextView = binding.mainText
private val statusText: TextView = binding.passedFailedMarker private val statusText: TextView = binding.passedFailedMarker
@ -52,7 +58,11 @@ class TestResultAdapter(override val items: MutableList<Pair<MainAPI, TestingUti
providerTitle.text = api.name providerTitle.text = api.name
val (resultText, resultColor) = if (result.success) { val (resultText, resultColor) = if (result.success) {
if (result.log.any { it.level == TestingUtils.Logger.LogLevel.Warning }) {
R.string.test_warning to R.color.colorTestWarning
} else {
R.string.test_passed to R.color.colorTestPass R.string.test_passed to R.color.colorTestPass
}
} else { } else {
R.string.test_failed to R.color.colorTestFail R.string.test_failed to R.color.colorTestFail
} }
@ -62,17 +72,43 @@ class TestResultAdapter(override val items: MutableList<Pair<MainAPI, TestingUti
val stackTrace = result.exception?.getStackTracePretty(false)?.ifBlank { null } val stackTrace = result.exception?.getStackTracePretty(false)?.ifBlank { null }
val messages = result.exception?.getAllMessages()?.ifBlank { null } val messages = result.exception?.getAllMessages()?.ifBlank { null }
val resultLog = result.log.joinToString("\n")
val fullLog = val fullLog =
result.log + (messages?.let { "\n\n$it" } ?: "") + (stackTrace?.let { "\n\n$it" } ?: "") resultLog +
(messages?.let { "\n\nError: $it" } ?: "") +
(stackTrace?.let { "\n\n$it" } ?: "")
failDescription.text = messages?.lastLine() ?: result.log.lastLine() failDescription.text = messages?.lastLine() ?: resultLog.lastLine()
logButton.setOnClickListener { logButton.setOnClickListener {
val builder: AlertDialog.Builder = val builder: AlertDialog.Builder =
AlertDialog.Builder(it.context, R.style.AlertDialogCustom) AlertDialog.Builder(it.context, R.style.AlertDialogCustom)
builder.setMessage(fullLog) builder.setMessage(fullLog)
.setTitle(R.string.test_log) .setTitle(R.string.test_log)
.show() // Ok button just closes the dialog
.setPositiveButton(R.string.ok) { _, _ -> }
api.sourcePlugin?.let { path ->
val pluginFile = File(path)
// Cannot delete a deleted plugin
if (!pluginFile.exists()) return@let
builder.setNegativeButton(R.string.delete_plugin) { _, _ ->
ioSafe {
val success = PluginManager.deletePlugin(pluginFile)
runOnMainThread {
if (success) {
showToast(R.string.plugin_deleted, Toast.LENGTH_SHORT)
} else {
showToast(R.string.error, Toast.LENGTH_SHORT)
}
}
}
}
}
builder.show()
} }
} }
} }

View file

@ -95,7 +95,7 @@ class TestViewModel : ViewModel() {
providers.clear() providers.clear()
updateProgress() updateProgress()
TestingUtils.getDeferredProviderTests(scope ?: return, apis, ::println) { api, result -> TestingUtils.getDeferredProviderTests(scope ?: return, apis) { api, result ->
addProvider(api, result) addProvider(api, result)
} }
} }

View file

@ -1015,7 +1015,7 @@ abstract class ExtractorApi {
abstract val mainUrl: String abstract val mainUrl: String
abstract val requiresReferer: Boolean abstract val requiresReferer: Boolean
/** Determines which plugin a given extractor is from */ /** Determines which plugin a given provider is from. This is the full path to the plugin. */
var sourcePlugin: String? = null var sourcePlugin: String? = null
//suspend fun getSafeUrl(url: String, referer: String? = null): List<ExtractorLink>? { //suspend fun getSafeUrl(url: String, referer: String? = null): List<ExtractorLink>? {

View file

@ -13,16 +13,55 @@ object TestingUtils {
} }
} }
class TestResultSearch(val results: List<SearchResponse>) : TestResult(true) class Logger {
class TestResultLoad(val extractorData: String) : TestResult(true) enum class LogLevel {
Normal,
Warning,
Error;
}
class TestResultProvider(success: Boolean, val log: String, val exception: Throwable?) : data class Message(val level: LogLevel, val message: String) {
override fun toString(): String {
val level = when (this.level) {
LogLevel.Normal -> ""
LogLevel.Warning -> "Warning: "
LogLevel.Error -> "Error: "
}
return "$level$message"
}
}
private val messageLog = mutableListOf<Message>()
fun getRawLog(): List<Message> = messageLog
fun log(message: String) {
messageLog.add(Message(LogLevel.Normal, message))
}
fun warn(message: String) {
messageLog.add(Message(LogLevel.Warning, message))
}
fun error(message: String) {
messageLog.add(Message(LogLevel.Error, message))
}
}
class TestResultList(val results: List<SearchResponse>) : TestResult(true)
class TestResultLoad(val extractorData: String, val shouldLoadLinks: Boolean) : TestResult(true)
class TestResultProvider(
success: Boolean,
val log: List<Logger.Message>,
val exception: Throwable?
) :
TestResult(success) TestResult(success)
@Throws(AssertionError::class, CancellationException::class) @Throws(AssertionError::class, CancellationException::class)
suspend fun testHomepage( suspend fun testHomepage(
api: MainAPI, api: MainAPI,
logger: (String) -> Unit logger: Logger
): TestResult { ): TestResult {
if (api.hasMainPage) { if (api.hasMainPage) {
try { try {
@ -31,22 +70,33 @@ object TestingUtils {
api.getMainPage(1, MainPageRequest(f.name, f.data, f.horizontalImages)) api.getMainPage(1, MainPageRequest(f.name, f.data, f.horizontalImages))
when { when {
homepage == null -> { homepage == null -> {
logger.invoke("Homepage provider ${api.name} did not correctly load homepage!") logger.error("Provider ${api.name} did not correctly load homepage!")
} }
homepage.items.isEmpty() -> { homepage.items.isEmpty() -> {
logger.invoke("Homepage provider ${api.name} does not contain any items!") logger.warn("Provider ${api.name} does not contain any homepage rows!")
} }
homepage.items.any { it.list.isEmpty() } -> { homepage.items.any { it.list.isEmpty() } -> {
logger.invoke("Homepage provider ${api.name} does not have any items on result!") logger.warn("Provider ${api.name} does not have any items in a homepage row!")
} }
} }
val homePageList = homepage?.items?.flatMap { it.list } ?: emptyList()
return TestResultList(homePageList)
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is NotImplementedError) { when (e) {
is NotImplementedError -> {
Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented") Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented")
} else if (e is CancellationException) { }
is CancellationException -> {
throw e throw e
} }
logError(e)
else -> {
e.message?.let { logger.warn("Exception thrown when loading homepage: \"$it\"") }
}
}
} }
} }
return TestResult.Pass return TestResult.Pass
@ -54,11 +104,13 @@ object TestingUtils {
@Throws(AssertionError::class, CancellationException::class) @Throws(AssertionError::class, CancellationException::class)
private suspend fun testSearch( private suspend fun testSearch(
api: MainAPI api: MainAPI,
testQueries: List<String>,
logger: Logger,
): TestResult { ): TestResult {
val searchQueries = listOf("over", "iron", "guy") val searchResults = testQueries.firstNotNullOfOrNull { query ->
val searchResults = searchQueries.firstNotNullOfOrNull { query ->
try { try {
logger.log("Searching for: $query")
api.search(query).takeIf { !it.isNullOrEmpty() } api.search(query).takeIf { !it.isNullOrEmpty() }
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is NotImplementedError) { if (e is NotImplementedError) {
@ -72,12 +124,11 @@ object TestingUtils {
} }
return if (searchResults.isNullOrEmpty()) { return if (searchResults.isNullOrEmpty()) {
Assert.fail("Api ${api.name} did not return any valid search responses") Assert.fail("Api ${api.name} did not return any search responses")
TestResult.Fail // Should not be reached TestResult.Fail // Should not be reached
} else { } else {
TestResultSearch(searchResults) TestResultList(searchResults)
} }
} }
@ -85,31 +136,27 @@ object TestingUtils {
private suspend fun testLoad( private suspend fun testLoad(
api: MainAPI, api: MainAPI,
result: SearchResponse, result: SearchResponse,
logger: (String) -> Unit logger: Logger
): TestResult { ): TestResult {
try { try {
Assert.assertEquals( if (result.apiName != api.name) {
"Invalid apiName on SearchResponse on ${api.name}", logger.warn("Wrong apiName on SearchResponse: ${api.name} != ${result.apiName}")
result.apiName, }
api.name
)
val loadResponse = api.load(result.url) val loadResponse = api.load(result.url)
if (loadResponse == null) { if (loadResponse == null) {
logger.invoke("Returned null loadResponse on ${result.url} on ${api.name}") logger.error("Returned null loadResponse on ${result.url} on ${api.name}")
return TestResult.Fail return TestResult.Fail
} }
Assert.assertEquals( if (loadResponse.apiName != api.name) {
"Invalid apiName on LoadResponse on ${api.name}", logger.warn("Wrong apiName on LoadResponse: ${api.name} != ${loadResponse.apiName}")
loadResponse.apiName, }
result.apiName
) if (!api.supportedTypes.contains(loadResponse.type)) {
Assert.assertTrue( logger.warn("Api ${api.name} on load does not contain any of the supportedTypes: ${loadResponse.type}")
"Api ${api.name} on load does not contain any of the supportedTypes: ${loadResponse.type}", }
api.supportedTypes.contains(loadResponse.type)
)
val url = when (loadResponse) { val url = when (loadResponse) {
is AnimeLoadResponse -> { is AnimeLoadResponse -> {
@ -117,39 +164,43 @@ object TestingUtils {
loadResponse.episodes.keys.isEmpty() || loadResponse.episodes.keys.any { loadResponse.episodes[it].isNullOrEmpty() } loadResponse.episodes.keys.isEmpty() || loadResponse.episodes.keys.any { loadResponse.episodes[it].isNullOrEmpty() }
if (gotNoEpisodes) { if (gotNoEpisodes) {
logger.invoke("Api ${api.name} got no episodes on ${loadResponse.url}") logger.error("Api ${api.name} got no episodes on ${loadResponse.url}")
return TestResult.Fail return TestResult.Fail
} }
(loadResponse.episodes[loadResponse.episodes.keys.firstOrNull()])?.firstOrNull()?.data (loadResponse.episodes[loadResponse.episodes.keys.firstOrNull()])?.firstOrNull()?.data
} }
is MovieLoadResponse -> { is MovieLoadResponse -> {
val gotNoEpisodes = loadResponse.dataUrl.isBlank() val gotNoEpisodes = loadResponse.dataUrl.isBlank()
if (gotNoEpisodes) { if (gotNoEpisodes) {
logger.invoke("Api ${api.name} got no movie on ${loadResponse.url}") logger.error("Api ${api.name} got no movie on ${loadResponse.url}")
return TestResult.Fail return TestResult.Fail
} }
loadResponse.dataUrl loadResponse.dataUrl
} }
is TvSeriesLoadResponse -> { is TvSeriesLoadResponse -> {
val gotNoEpisodes = loadResponse.episodes.isEmpty() val gotNoEpisodes = loadResponse.episodes.isEmpty()
if (gotNoEpisodes) { if (gotNoEpisodes) {
logger.invoke("Api ${api.name} got no episodes on ${loadResponse.url}") logger.error("Api ${api.name} got no episodes on ${loadResponse.url}")
return TestResult.Fail return TestResult.Fail
} }
loadResponse.episodes.firstOrNull()?.data loadResponse.episodes.firstOrNull()?.data
} }
is LiveStreamLoadResponse -> { is LiveStreamLoadResponse -> {
loadResponse.dataUrl loadResponse.dataUrl
} }
else -> { else -> {
logger.invoke("Unknown load response: ${loadResponse.javaClass.name}") logger.error("Unknown load response: ${loadResponse.javaClass.name}")
return TestResult.Fail return TestResult.Fail
} }
} ?: return TestResult.Fail } ?: return TestResult.Fail
return TestResultLoad(url) return TestResultLoad(url, loadResponse.type != TvType.CustomMedia)
// val loadTest = testLoadResponse(api, load, logger) // val loadTest = testLoadResponse(api, load, logger)
// if (loadTest is TestResultLoad) { // if (loadTest is TestResultLoad) {
@ -174,7 +225,7 @@ object TestingUtils {
private suspend fun testLinkLoading( private suspend fun testLinkLoading(
api: MainAPI, api: MainAPI,
url: String?, url: String?,
logger: (String) -> Unit logger: Logger
): TestResult { ): TestResult {
Assert.assertNotNull("Api ${api.name} has invalid url on episode", url) Assert.assertNotNull("Api ${api.name} has invalid url on episode", url)
if (url == null) return TestResult.Fail // Should never trigger if (url == null) return TestResult.Fail // Should never trigger
@ -182,7 +233,7 @@ object TestingUtils {
var linksLoaded = 0 var linksLoaded = 0
try { try {
val success = api.loadLinks(url, false, {}) { link -> val success = api.loadLinks(url, false, {}) { link ->
logger.invoke("Video loaded: ${link.name}") logger.log("Video loaded: ${link.name}")
Assert.assertTrue( Assert.assertTrue(
"Api ${api.name} returns link with invalid url ${link.url}", "Api ${api.name} returns link with invalid url ${link.url}",
link.url.length > 4 link.url.length > 4
@ -190,7 +241,7 @@ object TestingUtils {
linksLoaded++ linksLoaded++
} }
if (success) { if (success) {
logger.invoke("Links loaded: $linksLoaded") logger.log("Links loaded: $linksLoaded")
return TestResult(linksLoaded > 0) return TestResult(linksLoaded > 0)
} else { } else {
Assert.fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded") Assert.fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded")
@ -200,8 +251,9 @@ object TestingUtils {
is NotImplementedError -> { is NotImplementedError -> {
Assert.fail("Provider has not implemented loadLinks()") Assert.fail("Provider has not implemented loadLinks()")
} }
else -> { else -> {
logger.invoke("Failed link loading on ${api.name} using data: $url") logger.error("Failed link loading on ${api.name} using data: $url")
throw e throw e
} }
} }
@ -212,53 +264,57 @@ object TestingUtils {
fun getDeferredProviderTests( fun getDeferredProviderTests(
scope: CoroutineScope, scope: CoroutineScope,
providers: Array<MainAPI>, providers: Array<MainAPI>,
logger: (String) -> Unit,
callback: (MainAPI, TestResultProvider) -> Unit callback: (MainAPI, TestResultProvider) -> Unit
) { ) {
providers.forEach { api -> providers.forEach { api ->
scope.launch { scope.launch {
var log = "" val logger = Logger()
fun addToLog(string: String) {
log += string + "\n"
logger.invoke(string)
}
fun getLog(): String {
return log.removeSuffix("\n")
}
val result = try { val result = try {
addToLog("Trying ${api.name}") logger.log("Trying ${api.name}")
// Test Homepage // Test Homepage
val homepage = testHomepage(api, logger).success val homepage = testHomepage(api, logger)
Assert.assertTrue("Homepage failed to load", homepage) Assert.assertTrue("Homepage failed to load", homepage.success)
val homePageList = (homepage as? TestResultList)?.results ?: emptyList()
// Test Search Results // Test Search Results
val searchResults = testSearch(api) val searchQueries =
// Use the first 3 home page results as queries since they are guaranteed to exist
(homePageList.take(3).map { it.name } +
// If home page is sparse then use generic search queries
listOf("over", "iron", "guy")).take(3)
val searchResults = testSearch(api, searchQueries, logger)
Assert.assertTrue("Failed to get search results", searchResults.success) Assert.assertTrue("Failed to get search results", searchResults.success)
searchResults as TestResultSearch searchResults as TestResultList
// Test Load and LoadLinks // Test Load and LoadLinks
// Only try the first 3 search results to prevent spamming // Only try the first 3 search results to prevent spamming
val success = searchResults.results.take(3).any { searchResponse -> val success = searchResults.results.take(3).any { searchResponse ->
addToLog("Testing search result: ${searchResponse.url}") logger.log("Testing search result: ${searchResponse.url}")
val loadResponse = testLoad(api, searchResponse, ::addToLog) val loadResponse = testLoad(api, searchResponse, logger)
if (loadResponse !is TestResultLoad) { if (loadResponse !is TestResultLoad) {
false false
} else { } else {
testLinkLoading(api, loadResponse.extractorData, ::addToLog).success if (loadResponse.shouldLoadLinks) {
testLinkLoading(api, loadResponse.extractorData, logger).success
} else {
logger.log("Skipping link loading test")
true
}
} }
} }
if (success) { if (success) {
logger.invoke("Success ${api.name}") logger.log("Success ${api.name}")
TestResultProvider(true, getLog(), null) TestResultProvider(true, logger.getRawLog(), null)
} else { } else {
logger.invoke("Error ${api.name}") logger.error("Link loading failed")
TestResultProvider(false, getLog(), null) TestResultProvider(false, logger.getRawLog(), null)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
TestResultProvider(false, getLog(), e) TestResultProvider(false, logger.getRawLog(), e)
} }
callback.invoke(api, result) callback.invoke(api, result)
} }

View file

@ -3,17 +3,21 @@ package com.lagradost.cloudstream3.utils
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.TvType
object VideoDownloadHelper { object VideoDownloadHelper {
abstract class DownloadCached(
@JsonProperty("id") open val id: Int,
)
data class DownloadEpisodeCached( data class DownloadEpisodeCached(
@JsonProperty("name") val name: String?, @JsonProperty("name") val name: String?,
@JsonProperty("poster") val poster: String?, @JsonProperty("poster") val poster: String?,
@JsonProperty("episode") val episode: Int, @JsonProperty("episode") val episode: Int,
@JsonProperty("season") val season: Int?, @JsonProperty("season") val season: Int?,
@JsonProperty("id") val id: Int,
@JsonProperty("parentId") val parentId: Int, @JsonProperty("parentId") val parentId: Int,
@JsonProperty("rating") val rating: Int?, @JsonProperty("rating") val rating: Int?,
@JsonProperty("description") val description: String?, @JsonProperty("description") val description: String?,
@JsonProperty("cacheTime") val cacheTime: Long, @JsonProperty("cacheTime") val cacheTime: Long,
) override val id: Int,
): DownloadCached(id)
data class DownloadHeaderCached( data class DownloadHeaderCached(
@JsonProperty("apiName") val apiName: String, @JsonProperty("apiName") val apiName: String,
@ -21,9 +25,9 @@ object VideoDownloadHelper {
@JsonProperty("type") val type: TvType, @JsonProperty("type") val type: TvType,
@JsonProperty("name") val name: String, @JsonProperty("name") val name: String,
@JsonProperty("poster") val poster: String?, @JsonProperty("poster") val poster: String?,
@JsonProperty("id") val id: Int,
@JsonProperty("cacheTime") val cacheTime: Long, @JsonProperty("cacheTime") val cacheTime: Long,
) override val id: Int,
): DownloadCached(id)
data class ResumeWatching( data class ResumeWatching(
@JsonProperty("parentId") val parentId: Int, @JsonProperty("parentId") val parentId: Int,

View file

@ -59,12 +59,12 @@
<ImageView <ImageView
android:id="@+id/download_header_goto_child" android:id="@+id/download_header_goto_child"
android:layout_width="50dp" android:layout_width="@dimen/download_size"
android:layout_height="match_parent" android:layout_height="@dimen/download_size"
android:layout_gravity="center_vertical|end" android:layout_gravity="center_vertical|end"
android:layout_marginStart="-50dp" android:layout_marginStart="-50dp"
android:contentDescription="@string/download" android:contentDescription="@string/download"
android:padding="50dp" android:padding="10dp"
android:src="@drawable/ic_baseline_keyboard_arrow_right_24" /> android:src="@drawable/ic_baseline_keyboard_arrow_right_24" />
<com.lagradost.cloudstream3.ui.download.button.PieFetchButton <com.lagradost.cloudstream3.ui.download.button.PieFetchButton

View file

@ -88,4 +88,5 @@
<color name="colorTestPass">#48E484</color> <color name="colorTestPass">#48E484</color>
<color name="colorTestFail">#ea596e</color> <color name="colorTestFail">#ea596e</color>
<color name="colorTestWarning">#FF9800</color>
</resources> </resources>

View file

@ -304,6 +304,7 @@
<string name="start">Start</string> <string name="start">Start</string>
<string name="test_failed">Failed</string> <string name="test_failed">Failed</string>
<string name="test_passed">Passed</string> <string name="test_passed">Passed</string>
<string name="test_warning">Warning</string>
<string name="resume">Resume</string> <string name="resume">Resume</string>
<string name="go_back_30">-30</string> <string name="go_back_30">-30</string>
<string name="go_forward_30">+30</string> <string name="go_forward_30">+30</string>
@ -609,6 +610,7 @@
<string name="plugin">plugins</string> <string name="plugin">plugins</string>
<string name="delete_repository_plugins">This will also delete all repository plugins</string> <string name="delete_repository_plugins">This will also delete all repository plugins</string>
<string name="delete_repository">Delete repository</string> <string name="delete_repository">Delete repository</string>
<string name="delete_plugin">Delete plugin</string>
<string name="setup_extensions_subtext">Download the list of sites you want to use</string> <string name="setup_extensions_subtext">Download the list of sites you want to use</string>
<string name="plugins_downloaded" formatted="true">Downloaded: %d</string> <string name="plugins_downloaded" formatted="true">Downloaded: %d</string>
<string name="plugins_disabled" formatted="true">Disabled: %d</string> <string name="plugins_disabled" formatted="true">Disabled: %d</string>