diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt index 1b48143a63..174477fe44 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt @@ -12,13 +12,22 @@ 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.databinding.DownloadChildEpisodeLargeBinding import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.utils.txt +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import com.lagradost.cloudstream3.utils.downloader.DownloadObjects const val DOWNLOAD_ACTION_PLAY_FILE = 0 @@ -77,6 +86,7 @@ class DownloadAdapter( companion object { private const val VIEW_TYPE_HEADER = 0 private const val VIEW_TYPE_CHILD = 1 + private const val VIEW_TYPE_CHILD_LARGE = 2 } @@ -367,11 +377,133 @@ class DownloadAdapter( } } + private fun bindChildLarge( + binding: DownloadChildEpisodeLargeBinding, + card: VisualDownloadCached.Child? + ) { + if (card == null) return + val data = card.data + + binding.apply { + episodePoster.loadImage(data.poster) + episodeText.text = root.context.getNameFull(data.name, data.episode, data.season) + + val ratingText = data.score?.toString() + + episodeRating.isVisible = !ratingText.isNullOrBlank() + episodeRating.text = ratingText?.let { "Rated: $it" } + + episodeRuntime.isVisible = (data.runtime ?: 0) > 0 + episodeRuntime.text = secondsToReadable(data.runtime ?: 0, "") + + episodeDescript.isVisible = !data.description.isNullOrBlank() + episodeDescript.text = data.description.orEmpty() + + episodeDate.isVisible = data.airDate != null + + data.airDate?.let { airDate -> + val formattedAirDate = SimpleDateFormat.getDateInstance( + DateFormat.LONG, + Locale.getDefault() + ).format(Date(airDate)) + episodeDate.setText(txt(formattedAirDate)) + } + + episodeMetaRow?.isVisible = episodeDate.isVisible || episodeRating.isVisible || episodeRuntime.isVisible + + val posDur = getViewPos(data.id) + episodeProgress.isVisible = posDur != null + + posDur?.let { + val max = (it.duration / 1000).toInt() + val progress = (it.position / 1000).toInt() + + if (max > 0 && progress >= (0.95 * max).toInt()) { + episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) + episodeProgress.isVisible = false + } else { + episodePlayIcon.setImageResource(R.drawable.netflix_play) + episodeProgress.max = max + episodeProgress.progress = progress + episodeProgress.isVisible = true + } + } + + // Download button + val status = downloadButton.getStatus( + data.id, + card.currentBytes, + card.totalBytes + ) + + if (status == DownloadStatusTell.IsDone) { + downloadButton.setProgress(card.currentBytes, card.totalBytes) + downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes) + downloadButton.doSetProgress = false + } else { + downloadButton.resetView() + } + + downloadButton.setDefaultClickListener( + data, + downloadSize, + onItemClickEvent + ) + + downloadButton.isVisible = !isMultiDeleteState + + // Selection / multi-delete parity + downloadChildEpisodeLargeHolder.apply { + when { + isMultiDeleteState -> { + setOnClickListener { + deleteCheckbox?.let { + toggleIsChecked(it, data.id) + } + } + setOnLongClickListener { + deleteCheckbox?.let { + toggleIsChecked(it, data.id) + } + true + } + } + else -> { + setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data) + ) + } + setOnLongClickListener { + onItemSelectionChanged.invoke(data.id, true) + true + } + } + } + } + + if (isMultiDeleteState) { + deleteCheckbox?.setOnCheckedChangeListener { _, isChecked -> + onItemSelectionChanged.invoke(data.id, isChecked) + } + } else { + deleteCheckbox?.setOnCheckedChangeListener(null) + } + + deleteCheckbox.apply { + this?.isVisible = isMultiDeleteState + this?.isChecked = card.isSelected + } + } + } + + override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState { val inflater = LayoutInflater.from(parent.context) val binding = when (viewType) { VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false) VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false) + VIEW_TYPE_CHILD_LARGE -> DownloadChildEpisodeLargeBinding.inflate(inflater, parent, false) else -> throw IllegalArgumentException("Invalid view type") } return ViewHolderState(binding) @@ -383,25 +515,32 @@ class DownloadAdapter( position: Int ) { when (val binding = holder.view) { - is DownloadHeaderEpisodeBinding -> bindHeader( - binding, - item as? VisualDownloadCached.Header - ) + is DownloadHeaderEpisodeBinding -> + bindHeader(binding, item as? VisualDownloadCached.Header) - is DownloadChildEpisodeBinding -> bindChild( - binding, - item as? VisualDownloadCached.Child - ) + is DownloadChildEpisodeBinding -> + bindChild(binding, item as? VisualDownloadCached.Child) + + is DownloadChildEpisodeLargeBinding -> + bindChildLarge(binding, item as? VisualDownloadCached.Child) } } override fun customContentViewType(item: VisualDownloadCached): Int { return when (item) { - is VisualDownloadCached.Child -> VIEW_TYPE_CHILD is VisualDownloadCached.Header -> VIEW_TYPE_HEADER + is VisualDownloadCached.Child -> { + val poster = item.data.poster + if (poster.isNullOrBlank()) { + VIEW_TYPE_CHILD + } else { + VIEW_TYPE_CHILD_LARGE + } + } } } + @SuppressLint("NotifyDataSetChanged") fun setIsMultiDeleteState(value: Boolean) { if (isMultiDeleteState == value) return diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt index 7ff3904d8d..96a16c6cab 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt @@ -11,6 +11,7 @@ import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil3.dispose +import com.lagradost.api.Log import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R @@ -171,6 +172,8 @@ class EpisodeAdapter( score = item.score, description = item.description, cacheTime = System.currentTimeMillis(), + runtime = item.runTime, + airDate = item.airDate ), null ) { when (it.action) { @@ -392,6 +395,8 @@ class EpisodeAdapter( score = item.score, description = item.description, cacheTime = System.currentTimeMillis(), + runtime = item.runTime, + airDate = item.airDate ), null ) { when (it.action) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index af8d229a93..603e00eaec 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -799,6 +799,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { score = ep.score, description = ep.description, cacheTime = System.currentTimeMillis(), + runtime = ep.runTime, + airDate = ep.airDate ), null ) { click -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 6eab987fc6..6fd2cc7f70 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -698,6 +698,230 @@ class ResultViewModel2 : ViewModel() { index to list }.toMap() } + + private fun downloadSubtitle( + context: Context?, + link: ExtractorSubtitleLink, + fileName: String, + folder: String + ) { + ioSafe { + VideoDownloadManager.downloadThing( + context ?: return@ioSafe, + link, + "$fileName ${link.name}", + folder, + if (link.url.contains(".srt")) "srt" else "vtt", + false, + null, createNotificationCallback = {} + ) + } + } + + private fun getFolder(currentType: TvType, titleName: String): String { + return if (currentType.isEpisodeBased()) { + val sanitizedFileName = VideoDownloadManager.sanitizeFilename(titleName) + "${currentType.getFolderPrefix()}/$sanitizedFileName" + } else currentType.getFolderPrefix() + } + + private fun downloadSubtitle( + context: Context?, + link: SubtitleData, + meta: VideoDownloadManager.DownloadEpisodeMetadata, + ) { + context?.let { ctx -> + val fileName = VideoDownloadManager.getFileName(ctx, meta) + val folder = getFolder(meta.type ?: return, meta.mainName) + downloadSubtitle( + ctx, + ExtractorSubtitleLink(link.name, link.url, "", link.headers), + fileName, + folder + ) + } + } + + fun startDownload( + context: Context?, + episode: ResultEpisode, + currentIsMovie: Boolean, + currentHeaderName: String, + currentType: TvType, + currentPoster: String?, + apiName: String, + parentId: Int, + url: String, + links: List, + subs: List? + ) { + try { + if (context == null) return + + val meta = + getMeta( + episode, + currentHeaderName, + apiName, + currentPoster, + currentIsMovie, + currentType + ) + + val folder = getFolder(currentType, currentHeaderName) + + val src = "$DOWNLOAD_NAVIGATE_TO/$parentId" // url ?: return@let + + // SET VISUAL KEYS + setKey( + DOWNLOAD_HEADER_CACHE, + parentId.toString(), + VideoDownloadHelper.DownloadHeaderCached( + apiName = apiName, + url = url, + type = currentType, + name = currentHeaderName, + poster = currentPoster, + id = parentId, + cacheTime = System.currentTimeMillis(), + ) + ) + + setKey( + DataStore.getFolderName( + DOWNLOAD_EPISODE_CACHE, + parentId.toString() + ), // 3 deep folder for faster acess + episode.id.toString(), + VideoDownloadHelper.DownloadEpisodeCached( + name = episode.name, + poster = episode.poster, + episode = episode.episode, + season = episode.season, + id = episode.id, + parentId = parentId, + score = episode.score, + description = episode.description, + cacheTime = System.currentTimeMillis(), + runtime = episode.runTime, + airDate = episode.airDate + ) + ) + + // DOWNLOAD VIDEO + VideoDownloadManager.downloadEpisodeUsingWorker( + context, + src,//url ?: return, + folder, + meta, + links + ) + + // 1. Checks if the lang should be downloaded + // 2. Makes it into the download format + // 3. Downloads it as a .vtt file + val downloadList = SubtitlesFragment.getDownloadSubsLanguageTagIETF() + + subs?.filter { subtitle -> + downloadList.any { langTagIETF -> + subtitle.languageCode == langTagIETF || + subtitle.originalName.contains( + fromTagToEnglishLanguageName( + langTagIETF + ) ?: langTagIETF + ) + } + } + ?.map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) } + ?.take(3) // max subtitles download hardcoded (?_?) + ?.forEach { link -> + val fileName = VideoDownloadManager.getFileName(context, meta) + downloadSubtitle(context, link, fileName, folder) + } + } catch (e: Exception) { + logError(e) + } + } + + suspend fun downloadEpisode( + activity: Activity?, + episode: ResultEpisode, + currentIsMovie: Boolean, + currentHeaderName: String, + currentType: TvType, + currentPoster: String?, + apiName: String, + parentId: Int, + url: String, + ) { + ioSafe { + val generator = RepoLinkGenerator(listOf(episode)) + val currentLinks = mutableSetOf() + val currentSubs = mutableSetOf() + generator.generateLinks( + clearCache = false, + sourceTypes = LOADTYPE_INAPP_DOWNLOAD, + callback = { + it.first?.let { link -> + currentLinks.add(link) + } + }, + subtitleCallback = { sub -> + currentSubs.add(sub) + }) + + if (currentLinks.isEmpty()) { + main { + showToast( + R.string.no_links_found_toast, + Toast.LENGTH_SHORT + ) + } + return@ioSafe + } else { + main { + showToast( + R.string.download_started, + Toast.LENGTH_SHORT + ) + } + } + + startDownload( + activity, + episode, + currentIsMovie, + currentHeaderName, + currentType, + currentPoster, + apiName, + parentId, + url, + sortUrls(currentLinks), + sortSubs(currentSubs), + ) + } + } + + private fun getMeta( + episode: ResultEpisode, + titleName: String, + apiName: String, + currentPoster: String?, + currentIsMovie: Boolean, + tvType: TvType, + ): VideoDownloadManager.DownloadEpisodeMetadata { + return VideoDownloadManager.DownloadEpisodeMetadata( + episode.id, + VideoDownloadManager.sanitizeFilename(titleName), + apiName, + episode.poster ?: currentPoster, + episode.name, + if (currentIsMovie) null else episode.season, + if (currentIsMovie) null else episode.episode, + tvType, + ) + } } private val _watchStatus: MutableLiveData = MutableLiveData(WatchType.NONE) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt index e69de29bb2..d9bd33dfd7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt @@ -0,0 +1,57 @@ +package com.lagradost.cloudstream3.utils + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.TvType +object VideoDownloadHelper { + abstract class DownloadCached( + @JsonProperty("id") open val id: Int, + ) + + data class DownloadEpisodeCached( + @JsonProperty("name") val name: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("episode") val episode: Int, + @JsonProperty("season") val season: Int?, + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("score") var score: Score? = null, + @JsonProperty("description") val description: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + @JsonProperty("airDate") val airDate: Long? = null, + @JsonProperty("runtime") val runtime: Int? = null, + override val id: Int, + ): DownloadCached(id) { + @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) + @Deprecated( + "`rating` is the old scoring system, use score instead", + replaceWith = ReplaceWith("score"), + level = DeprecationLevel.ERROR + ) + var rating: Int? = null + set(value) { + if (value != null) { + @Suppress("DEPRECATION_ERROR") + score = Score.fromOld(value) + } + } + } + + data class DownloadHeaderCached( + @JsonProperty("apiName") val apiName: String, + @JsonProperty("url") val url: String, + @JsonProperty("type") val type: TvType, + @JsonProperty("name") val name: String, + @JsonProperty("poster") val poster: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + override val id: Int, + ): DownloadCached(id) + + data class ResumeWatching( + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("episodeId") val episodeId: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("season") val season: Int?, + @JsonProperty("updateTime") val updateTime: Long, + @JsonProperty("isFromDownload") val isFromDownload: Boolean, + ) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt index 1d945a6b4a..a3d8cb7892 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt @@ -69,6 +69,8 @@ object DownloadObjects { @JsonProperty("score") var score: Score? = null, @JsonProperty("description") val description: String?, @JsonProperty("cacheTime") val cacheTime: Long, + @JsonProperty("airDate") val airDate: Long? = null, + @JsonProperty("runtime") val runtime: Int? = null, override val id: Int, ) : DownloadCached(id) { @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) diff --git a/app/src/main/res/layout/download_child_episode_large.xml b/app/src/main/res/layout/download_child_episode_large.xml new file mode 100644 index 0000000000..31769bd5da --- /dev/null +++ b/app/src/main/res/layout/download_child_episode_large.xml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +