diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7c4c089d..c6aa8c8e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -16,9 +16,12 @@
+
+
+
@@ -119,6 +122,11 @@
android:resource="@xml/provider_paths" />
+
+
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/activities/main/MainMobileActivity.kt b/app/src/main/java/com/streamflixreborn/streamflix/activities/main/MainMobileActivity.kt
index 8d028b56..06e290ee 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/activities/main/MainMobileActivity.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/activities/main/MainMobileActivity.kt
@@ -315,6 +315,7 @@ class MainMobileActivity : FragmentActivity() {
R.id.home,
R.id.movies,
R.id.tv_shows,
+ R.id.downloads,
R.id.settings,
)
}
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/activities/main/MainTvActivity.kt b/app/src/main/java/com/streamflixreborn/streamflix/activities/main/MainTvActivity.kt
index 3abb23de..2f25ef19 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/activities/main/MainTvActivity.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/activities/main/MainTvActivity.kt
@@ -121,7 +121,7 @@ class MainTvActivity : FragmentActivity() {
}
when (destination.id) {
- R.id.search, R.id.home, R.id.movies, R.id.tv_shows, R.id.settings -> {
+ R.id.search, R.id.home, R.id.movies, R.id.tv_shows, R.id.downloads, R.id.settings -> {
binding.navMain.visibility = View.VISIBLE
updateNavigationVisibility()
}
@@ -158,7 +158,7 @@ class MainTvActivity : FragmentActivity() {
override fun handleOnBackPressed() {
when (navController.currentDestination?.id) {
R.id.home -> if (binding.navMain.hasFocus()) finish() else binding.navMain.requestFocus()
- R.id.settings, R.id.search, R.id.movies, R.id.tv_shows -> {
+ R.id.settings, R.id.search, R.id.movies, R.id.tv_shows, R.id.downloads -> {
navigateToProviderHome(navController)
binding.navMain.requestFocus()
}
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/adapters/AppAdapter.kt b/app/src/main/java/com/streamflixreborn/streamflix/adapters/AppAdapter.kt
index 0ad6fc34..42f67ffd 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/adapters/AppAdapter.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/adapters/AppAdapter.kt
@@ -517,27 +517,27 @@ class AppAdapter(
)
is EpisodeViewHolder -> holder.bind(
items[adjustedPosition] as Episode
- ) // Tu original no pasaba listener, lo respeto
+ )
is FooterViewHolder -> footer?.bind?.invoke(holder.binding)
is GenreViewHolder -> holder.bind(
items[adjustedPosition] as Genre
- ) // Tu original no pasaba listener, lo respeto
+ )
is HeaderViewHolder -> header?.bind?.invoke(holder.binding)
is MovieViewHolder -> holder.bind(
items[adjustedPosition] as Movie
- ) // Los listeners se manejan dentro del ViewHolder
+ )
is PeopleViewHolder -> holder.bind(
items[adjustedPosition] as People
- ) // Tu original no pasaba listener, lo respeto
+ )
is ProviderViewHolder -> holder.bind(
items[adjustedPosition] as Provider
- ) // Tu original no pasaba listener, lo respeto
+ )
is SeasonViewHolder -> holder.bind(
items[adjustedPosition] as Season
- ) // Tu original no pasaba listener, lo respeto
+ )
is TvShowViewHolder -> holder.bind(
items[adjustedPosition] as TvShow
- ) // Los listeners se manejan dentro del ViewHolder
+ )
}
val state = states[holder.layoutPosition]
@@ -550,6 +550,14 @@ class AppAdapter(
}
}
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList) {
+ if (payloads.isNotEmpty() && holder is EpisodeViewHolder && payloads[0] == "download_state") {
+ holder.updateDownloadState()
+ } else {
+ onBindViewHolder(holder, position)
+ }
+ }
+
override fun getItemCount(): Int = items.size +
(header?.let { 1 } ?: 0) +
(onLoadMoreListener?.let { 1 } ?: 0) +
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/adapters/DownloadAdapter.kt b/app/src/main/java/com/streamflixreborn/streamflix/adapters/DownloadAdapter.kt
new file mode 100644
index 00000000..7bda32ce
--- /dev/null
+++ b/app/src/main/java/com/streamflixreborn/streamflix/adapters/DownloadAdapter.kt
@@ -0,0 +1,388 @@
+package com.streamflixreborn.streamflix.adapters
+
+import android.view.KeyEvent
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageButton
+import android.widget.ImageView
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import androidx.viewbinding.ViewBinding
+import com.bumptech.glide.Glide
+import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
+import com.streamflixreborn.streamflix.R
+import com.streamflixreborn.streamflix.database.AppDatabase
+import com.streamflixreborn.streamflix.databinding.ItemDownloadHeaderMobileBinding
+import com.streamflixreborn.streamflix.databinding.ItemDownloadHeaderTvBinding
+import com.streamflixreborn.streamflix.databinding.ItemDownloadMobileBinding
+import com.streamflixreborn.streamflix.databinding.ItemDownloadTvBinding
+import com.streamflixreborn.streamflix.models.Download
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+sealed class DownloadItem {
+ data class Header(
+ val tvShowId: String,
+ val tvShowTitle: String,
+ val seasonNumber: Int
+ ) : DownloadItem()
+
+ data class Download(val download: com.streamflixreborn.streamflix.models.Download) : DownloadItem()
+}
+
+class DownloadsAdapter(
+ private val onPlayClick: (Download) -> Unit,
+ private val onDeleteClick: (Download) -> Unit,
+ private val onTitleClick: (Download) -> Unit,
+ private val onNavigateUp: () -> Unit = {},
+ private val onItemFocused: (Int) -> Unit = {},
+ private val database: AppDatabase? = null,
+ private val isTv: Boolean = true
+) : RecyclerView.Adapter() {
+
+ companion object {
+ private const val TYPE_HEADER_TV = 0
+ private const val TYPE_ITEM_TV = 1
+ private const val TYPE_HEADER_MOBILE = 2
+ private const val TYPE_ITEM_MOBILE = 3
+ }
+
+ private val items = mutableListOf()
+ var progressMap: Map = emptyMap()
+
+ private val activeHolders = mutableMapOf()
+
+ data class DownloadProgress(
+ val progress: Int,
+ val status: String,
+ val speed: Long = 0,
+ val etaSeconds: Long = -1
+ )
+
+ init {
+ setHasStableIds(true)
+ }
+
+ fun submitList(newItems: List) {
+ val oldItems = items.toList()
+ val diffResult = DiffUtil.calculateDiff(DownloadDiffCallback(oldItems, newItems))
+ items.clear()
+ items.addAll(newItems)
+ diffResult.dispatchUpdatesTo(this)
+ }
+
+ fun getCurrentList(): List = items.toList()
+
+ fun indexOfDownload(downloadId: String): Int {
+ return items.indexOfFirst {
+ it is DownloadItem.Download && it.download.id == downloadId
+ }
+ }
+
+ override fun getItemId(position: Int): Long {
+ return when (val item = items.getOrNull(position)) {
+ is DownloadItem.Download -> item.download.id.hashCode().toLong()
+ is DownloadItem.Header -> listOf(
+ "header",
+ item.tvShowId,
+ item.tvShowTitle,
+ item.seasonNumber.toString(),
+ ).joinToString(":").hashCode().toLong()
+ null -> RecyclerView.NO_ID
+ }
+ }
+
+ override fun getItemViewType(position: Int): Int = when {
+ isTv && items[position] is DownloadItem.Header -> TYPE_HEADER_TV
+ isTv && items[position] is DownloadItem.Download -> TYPE_ITEM_TV
+ !isTv && items[position] is DownloadItem.Header -> TYPE_HEADER_MOBILE
+ !isTv && items[position] is DownloadItem.Download -> TYPE_ITEM_MOBILE
+ else -> throw IllegalStateException("Unknown item type")
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+
+ return when (viewType) {
+ TYPE_HEADER_TV -> HeaderViewHolder(ItemDownloadHeaderTvBinding.inflate(inflater, parent, false))
+ TYPE_ITEM_TV -> ItemViewHolder(ItemDownloadTvBinding.inflate(inflater, parent, false))
+ TYPE_HEADER_MOBILE -> HeaderViewHolder(ItemDownloadHeaderMobileBinding.inflate(inflater, parent, false))
+ TYPE_ITEM_MOBILE -> ItemViewHolder(ItemDownloadMobileBinding.inflate(inflater, parent, false))
+ else -> throw IllegalStateException("Unknown view type: $viewType")
+ }
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ when (holder) {
+ is HeaderViewHolder -> holder.bind(items[position] as DownloadItem.Header)
+ is ItemViewHolder -> holder.bind((items[position] as DownloadItem.Download).download)
+ }
+ }
+
+ override fun onBindViewHolder(
+ holder: RecyclerView.ViewHolder,
+ position: Int,
+ payloads: MutableList
+ ) {
+ val item = items.getOrNull(position)
+ if (holder is ItemViewHolder && payloads.isNotEmpty() && item is DownloadItem.Download) {
+ holder.updateDownloadState(item.download)
+ } else {
+ onBindViewHolder(holder, position)
+ }
+ }
+
+ override fun getItemCount(): Int = items.size
+
+ override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
+ super.onViewAttachedToWindow(holder)
+ if (holder is ItemViewHolder) {
+ holder.boundDownloadId?.let { activeHolders[it] = holder }
+ }
+ }
+
+ override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
+ super.onViewDetachedFromWindow(holder)
+ if (holder is ItemViewHolder) {
+ holder.boundDownloadId?.let { activeHolders.remove(it) }
+ }
+ }
+
+ fun updateDownloadProgress(downloadId: String, progress: DownloadProgress) {
+ val progressInfo = progressMap[downloadId] ?: progress
+ activeHolders[downloadId]?.let { holder ->
+ holder.updateProgressOnly(progressInfo)
+ }
+ }
+
+ inner class HeaderViewHolder(binding: ViewBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ private val tvShowTitle: TextView = binding.root.findViewById(R.id.tvShowTitle)
+ private val seasonSubtitle: TextView = binding.root.findViewById(R.id.seasonSubtitle)
+
+ fun bind(header: DownloadItem.Header) {
+ tvShowTitle.text = header.tvShowTitle
+ seasonSubtitle.text = itemView.context.getString(
+ R.string.download_season_label,
+ header.seasonNumber
+ )
+ }
+ }
+
+ inner class ItemViewHolder(
+ private val binding: ViewBinding
+ ) : RecyclerView.ViewHolder(binding.root) {
+
+ private val ivPoster: ImageView = binding.root.findViewById(R.id.ivPoster)
+ private val tvTitle: TextView = binding.root.findViewById(R.id.tvTitle)
+ private val tvSubtitle: TextView = binding.root.findViewById(R.id.tvSubtitle)
+ private val tvStatus: TextView = binding.root.findViewById(R.id.tvStatus)
+ private val progressBar: ProgressBar = binding.root.findViewById(R.id.progressBar)
+ private val tvProgressPercent: TextView = binding.root.findViewById(R.id.tvProgressPercent)
+ private val btnAction: ImageButton = binding.root.findViewById(R.id.btnAction)
+ private val btnDelete: ImageButton = binding.root.findViewById(R.id.btnDelete)
+ private val root: View = binding.root.findViewById(R.id.downloadItemRoot)
+ var boundDownloadId: String? = null
+ private set
+
+ private fun setupInnerControlKeyListener(view: View) {
+ view.setOnKeyListener { _, keyCode, event ->
+ if (event.action == KeyEvent.ACTION_UP &&
+ (keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_DOWN)
+ ) {
+ return@setOnKeyListener false
+ }
+ false
+ }
+ }
+
+ fun bind(download: Download) {
+ if (boundDownloadId != null && boundDownloadId != download.id) {
+ activeHolders.remove(boundDownloadId)
+ }
+ boundDownloadId = download.id
+ activeHolders[download.id] = this
+ tvTitle.text = download.title
+
+ val subtitle = when {
+ download.contentType == Download.ContentType.EPISODE &&
+ download.seasonNumber != null &&
+ download.episodeNumber != null -> {
+ download.subtitle ?: itemView.context.getString(
+ R.string.download_episode_info,
+ download.seasonNumber,
+ download.episodeNumber
+ )
+ }
+ download.subtitle != null -> download.subtitle!!
+ else -> ""
+ }
+
+ tvSubtitle.text = subtitle
+ tvSubtitle.visibility = if (subtitle.isNotEmpty()) View.VISIBLE else View.GONE
+
+ val posterUrl = if (download.contentType == Download.ContentType.EPISODE) {
+ database?.tvShowDao()?.getById(download.tvShowId ?: "")?.poster
+ ?: download.banner
+ } else {
+ download.poster ?: download.banner
+ }
+
+ if (!posterUrl.isNullOrEmpty()) {
+ Glide.with(itemView.context)
+ .load(posterUrl)
+ .placeholder(R.drawable.glide_fallback_cover)
+ .error(R.drawable.glide_fallback_cover)
+ .transition(DrawableTransitionOptions.withCrossFade())
+ .into(ivPoster)
+ } else {
+ ivPoster.setImageResource(R.drawable.glide_fallback_cover)
+ }
+
+ updateDownloadState(download)
+
+ btnAction.setOnClickListener { onPlayClick(download) }
+ btnDelete.setOnClickListener { onDeleteClick(download) }
+ itemView.setOnClickListener { onTitleClick(download) }
+
+ setupInnerControlKeyListener(btnAction)
+ setupInnerControlKeyListener(btnDelete)
+
+ root.setOnFocusChangeListener { _, hasFocus ->
+ root.isSelected = hasFocus
+ if (hasFocus) {
+ val position = bindingAdapterPosition
+ if (position != RecyclerView.NO_POSITION) {
+ onItemFocused(position)
+ }
+ }
+ }
+ }
+
+ fun updateDownloadState(download: Download) {
+ val progressInfo = progressMap[download.id]
+
+ when (download.status) {
+ Download.DownloadStatus.DOWNLOADING,
+ Download.DownloadStatus.QUEUED,
+ Download.DownloadStatus.PAUSED -> {
+ progressBar.visibility = View.VISIBLE
+ tvProgressPercent.visibility = View.VISIBLE
+ progressBar.progress = progressInfo?.progress ?: download.progress
+ tvProgressPercent.text = "${progressBar.progress}%"
+ btnAction.visibility = View.GONE
+ tvStatus.text = progressInfo?.status.orEmpty()
+ tvStatus.visibility = if (tvStatus.text.isEmpty()) View.GONE else View.VISIBLE
+ }
+
+ Download.DownloadStatus.COMPLETED -> {
+ progressBar.visibility = View.GONE
+ tvProgressPercent.visibility = View.GONE
+ btnAction.visibility = View.VISIBLE
+
+ val actualItemId = when (download.contentType) {
+ Download.ContentType.MOVIE -> download.id.removePrefix("movie_")
+ Download.ContentType.EPISODE -> download.id.removePrefix("episode_")
+ }
+
+ database?.let { db ->
+ val scope = CoroutineScope(Dispatchers.IO)
+ scope.launch {
+ val watchState = when (download.contentType) {
+ Download.ContentType.MOVIE -> db.movieDao().getById(actualItemId)
+ Download.ContentType.EPISODE -> db.episodeDao().getById(actualItemId)
+ }
+
+ val stateLabel = when {
+ watchState?.isWatched == true -> itemView.context.getString(R.string.download_status_watched)
+ watchState?.watchHistory != null -> itemView.context.getString(R.string.download_status_watching)
+ else -> itemView.context.getString(R.string.download_status_downloaded)
+ }
+
+ withContext(Dispatchers.Main) {
+ if (boundDownloadId == download.id) {
+ tvStatus.text = stateLabel
+ tvStatus.visibility = View.VISIBLE
+ }
+ }
+ }
+ } ?: run {
+ tvStatus.text = itemView.context.getString(R.string.download_status_downloaded)
+ tvStatus.visibility = View.VISIBLE
+ }
+ }
+
+ else -> {
+ progressBar.visibility = View.GONE
+ tvProgressPercent.visibility = View.GONE
+ btnAction.visibility = View.GONE
+ tvStatus.text = progressInfo?.status.orEmpty()
+ tvStatus.visibility = if (tvStatus.text.isEmpty()) View.GONE else View.VISIBLE
+ }
+ }
+
+ updateFocusNavigation(download.status)
+ }
+
+ private fun updateFocusNavigation(status: Download.DownloadStatus) {
+ val actionVisible = status == Download.DownloadStatus.COMPLETED
+ root.nextFocusRightId = if (actionVisible) R.id.btnAction else R.id.btnDelete
+ btnAction.nextFocusLeftId = R.id.downloadItemRoot
+ btnAction.nextFocusRightId = R.id.btnDelete
+ btnAction.nextFocusDownId = R.id.btnDelete
+ btnDelete.nextFocusLeftId = if (actionVisible) R.id.btnAction else R.id.downloadItemRoot
+ btnDelete.nextFocusRightId = R.id.btnDelete
+ btnDelete.nextFocusUpId = if (actionVisible) R.id.btnAction else R.id.btnDelete
+ }
+
+ fun updateProgressOnly(progressInfo: DownloadProgress) {
+ progressBar.progress = progressInfo.progress
+ tvProgressPercent.text = "${progressInfo.progress}%"
+ tvStatus.text = progressInfo.status
+ tvStatus.visibility = if (progressInfo.status.isEmpty()) View.GONE else View.VISIBLE
+ }
+ }
+
+ private class DownloadDiffCallback(
+ private val oldItems: List,
+ private val newItems: List
+ ) : DiffUtil.Callback() {
+ override fun getOldListSize(): Int = oldItems.size
+
+ override fun getNewListSize(): Int = newItems.size
+
+ override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
+ val oldItem = oldItems[oldItemPosition]
+ val newItem = newItems[newItemPosition]
+ return when {
+ oldItem is DownloadItem.Download && newItem is DownloadItem.Download ->
+ oldItem.download.id == newItem.download.id
+ oldItem is DownloadItem.Header && newItem is DownloadItem.Header ->
+ oldItem.tvShowId == newItem.tvShowId &&
+ oldItem.tvShowTitle == newItem.tvShowTitle &&
+ oldItem.seasonNumber == newItem.seasonNumber
+ else -> false
+ }
+ }
+
+ override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
+ return oldItems[oldItemPosition] == newItems[newItemPosition]
+ }
+
+ override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
+ return if (oldItems[oldItemPosition] is DownloadItem.Download &&
+ newItems[newItemPosition] is DownloadItem.Download
+ ) {
+ Unit
+ } else {
+ null
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/adapters/viewholders/EpisodeViewHolder.kt b/app/src/main/java/com/streamflixreborn/streamflix/adapters/viewholders/EpisodeViewHolder.kt
index eefd5d99..cf1eac3e 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/adapters/viewholders/EpisodeViewHolder.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/adapters/viewholders/EpisodeViewHolder.kt
@@ -30,6 +30,21 @@ import com.streamflixreborn.streamflix.utils.format
import com.streamflixreborn.streamflix.utils.getCurrentFragment
import com.streamflixreborn.streamflix.utils.loadTvShowCardArtwork
import com.streamflixreborn.streamflix.utils.toActivity
+import com.streamflixreborn.streamflix.database.AppDatabase
+import com.streamflixreborn.streamflix.utils.DownloadManager
+import com.streamflixreborn.streamflix.models.Download
+import com.streamflixreborn.streamflix.providers.Provider
+import com.streamflixreborn.streamflix.services.DownloadService
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.findViewTreeLifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import androidx.appcompat.app.AlertDialog
+import android.os.Handler
+import android.os.Looper
+import android.widget.Toast
class EpisodeViewHolder(
private val _binding: ViewBinding
@@ -38,6 +53,8 @@ class EpisodeViewHolder(
) {
private val context = itemView.context
+ private val database: AppDatabase
+ get() = AppDatabase.getInstance(context)
private lateinit var episode: Episode
fun bind(episode: Episode) {
@@ -51,15 +68,104 @@ class EpisodeViewHolder(
}
}
+ fun updateDownloadState() {
+ when (_binding) {
+ is ItemEpisodeMobileBinding -> updateMobileDownloadState(_binding)
+ is ItemEpisodeTvBinding -> updateTvDownloadState(_binding)
+ is ItemEpisodeContinueWatchingMobileBinding -> {}
+ is ItemEpisodeContinueWatchingTvBinding -> {}
+ }
+ }
+
+ private fun updateMobileDownloadState(binding: ItemEpisodeMobileBinding) {
+ val downloadId = "episode_${episode.id}"
+ val existingDownload = database.downloadDao().getDownloadById(downloadId)
+ val isDownloaded = existingDownload?.status == Download.DownloadStatus.COMPLETED || episode.isDownloaded
+
+ binding.btnEpisodeDownload.visibility = when {
+ existingDownload?.status == Download.DownloadStatus.DOWNLOADING -> View.GONE
+ existingDownload?.status == Download.DownloadStatus.QUEUED -> View.GONE
+ isDownloaded -> View.GONE
+ else -> View.VISIBLE
+ }
+
+ binding.btnEpisodeCancelDownload.visibility = when {
+ existingDownload?.status == Download.DownloadStatus.DOWNLOADING -> View.VISIBLE
+ existingDownload?.status == Download.DownloadStatus.QUEUED -> View.VISIBLE
+ else -> View.GONE
+ }
+
+ binding.btnEpisodePlayDownload.visibility = when {
+ isDownloaded -> View.VISIBLE
+ else -> View.GONE
+ }
+
+ val wasVisible = binding.pbEpisodeDownloadProgress.visibility == View.VISIBLE
+ binding.pbEpisodeDownloadProgress.visibility = when {
+ existingDownload?.status == Download.DownloadStatus.DOWNLOADING -> View.VISIBLE
+ existingDownload?.status == Download.DownloadStatus.QUEUED -> View.VISIBLE
+ else -> View.GONE
+ }
+
+ if (!wasVisible && binding.pbEpisodeDownloadProgress.visibility == View.VISIBLE) {
+ binding.pbEpisodeDownloadProgress.invalidate()
+ binding.pbEpisodeDownloadProgress.isIndeterminate = false
+ binding.pbEpisodeDownloadProgress.isIndeterminate = true
+ }
+ }
+
+ private fun updateTvDownloadState(binding: ItemEpisodeTvBinding) {
+ val downloadId = "episode_${episode.id}"
+ val existingDownload = database.downloadDao().getDownloadById(downloadId)
+ val isDownloaded = existingDownload?.status == Download.DownloadStatus.COMPLETED || episode.isDownloaded
+
+ binding.btnEpisodeDownload.visibility = when {
+ existingDownload?.status == Download.DownloadStatus.DOWNLOADING -> View.GONE
+ existingDownload?.status == Download.DownloadStatus.QUEUED -> View.GONE
+ isDownloaded -> View.GONE
+ else -> View.VISIBLE
+ }
+
+ binding.btnEpisodeCancelDownload.visibility = when {
+ existingDownload?.status == Download.DownloadStatus.DOWNLOADING -> View.VISIBLE
+ existingDownload?.status == Download.DownloadStatus.QUEUED -> View.VISIBLE
+ else -> View.GONE
+ }
+
+ binding.btnEpisodePlayDownload.visibility = when {
+ isDownloaded -> View.VISIBLE
+ else -> View.GONE
+ }
+
+ val wasVisible = binding.pbEpisodeDownloadProgress.visibility == View.VISIBLE
+ binding.pbEpisodeDownloadProgress.visibility = when {
+ existingDownload?.status == Download.DownloadStatus.DOWNLOADING -> View.VISIBLE
+ existingDownload?.status == Download.DownloadStatus.QUEUED -> View.VISIBLE
+ else -> View.GONE
+ }
+
+ if (!wasVisible && binding.pbEpisodeDownloadProgress.visibility == View.VISIBLE) {
+ binding.pbEpisodeDownloadProgress.invalidate()
+ binding.pbEpisodeDownloadProgress.isIndeterminate = false
+ binding.pbEpisodeDownloadProgress.isIndeterminate = true
+ }
+ }
+
private fun displayMobileItem(binding: ItemEpisodeMobileBinding) {
+ val downloadId = "episode_${episode.id}"
+ val existingDownload = database.downloadDao().getDownloadById(downloadId)
+ val isDownloaded = existingDownload?.status == Download.DownloadStatus.COMPLETED || episode.isDownloaded
+ val localPath = existingDownload?.localFilePath ?: episode.localFilePath
+
binding.root.apply {
setOnClickListener {
- findNavController().navigate(
- SeasonMobileFragmentDirections.actionSeasonToPlayer(
- id = episode.id,
- title = episode.tvShow?.title ?: "",
- subtitle = episode.season?.takeIf { it.number != 0 }?.let { season ->
+ val bundle = android.os.Bundle().apply {
+ putString("id", episode.id)
+ putString("title", episode.tvShow?.title ?: "")
+ putString(
+ "subtitle",
+ episode.season?.takeIf { it.number != 0 }?.let { season ->
context.getString(
R.string.player_subtitle_tv_show,
season.number,
@@ -76,28 +182,35 @@ class EpisodeViewHolder(
R.string.episode_number,
episode.number
)
+ )
+ )
+ putParcelable("videoType", Video.Type.Episode(
+ id = episode.id,
+ number = episode.number,
+ title = episode.title,
+ poster = episode.poster,
+ overview = episode.overview,
+ tvShow = Video.Type.Episode.TvShow(
+ id = episode.tvShow?.id ?: "",
+ title = episode.tvShow?.title ?: "",
+ poster = episode.tvShow?.poster,
+ banner = episode.tvShow?.banner,
+ releaseDate = episode.tvShow?.released?.format("yyyy-MM-dd"),
+ imdbId = episode.tvShow?.imdbId,
),
- videoType = Video.Type.Episode(
- id = episode.id,
- number = episode.number,
- title = episode.title,
- poster = episode.poster,
- overview = episode.overview,
- tvShow = Video.Type.Episode.TvShow(
- id = episode.tvShow?.id ?: "",
- title = episode.tvShow?.title ?: "",
- poster = episode.tvShow?.poster,
- banner = episode.tvShow?.banner,
- releaseDate = episode.tvShow?.released?.format("yyyy-MM-dd"),
- imdbId = episode.tvShow?.imdbId,
- ),
- season = Video.Type.Episode.Season(
- number = episode.season?.number ?: 0,
- title = episode.season?.title,
- ),
+ season = Video.Type.Episode.Season(
+ number = episode.season?.number ?: 0,
+ title = episode.season?.title,
),
- )
- )
+ isDownloaded = isDownloaded,
+ localFilePath = localPath,
+ ))
+ putBoolean("isLocalFile", isDownloaded && !localPath.isNullOrEmpty())
+ if (isDownloaded && !localPath.isNullOrEmpty()) {
+ putString("localFilePath", localPath)
+ }
+ }
+ findNavController().navigate(R.id.player, bundle)
}
setOnLongClickListener {
ShowOptionsMobileDialog(context, episode)
@@ -131,10 +244,13 @@ class EpisodeViewHolder(
}
}
- binding.tvEpisodeInfo.text = context.getString(
- R.string.episode_number,
- episode.number
- )
+ binding.tvEpisodeInfo.apply {
+ text = context.getString(R.string.episode_number, episode.number)
+ visibility = when {
+ episode.title.isNullOrBlank() -> View.GONE
+ else -> View.VISIBLE
+ }
+ }
binding.tvEpisodeTitle.text = episode.title ?: context.getString(
R.string.episode_number,
@@ -149,16 +265,129 @@ class EpisodeViewHolder(
}
}
binding.tvEpisodeOverview.text = episode.overview ?: ""
+
+ setupDownloadButtonMobile(binding)
+ }
+
+ private fun setupDownloadButtonMobile(binding: ItemEpisodeMobileBinding) {
+ val downloadId = "episode_${episode.id}"
+ val existingDownload = database.downloadDao().getDownloadById(downloadId)
+ val isDownloaded = existingDownload?.status == Download.DownloadStatus.COMPLETED || episode.isDownloaded
+ val localPath = existingDownload?.localFilePath ?: episode.localFilePath
+
+ binding.btnEpisodeDownload.visibility = when {
+ existingDownload?.status == Download.DownloadStatus.DOWNLOADING -> View.GONE
+ existingDownload?.status == Download.DownloadStatus.QUEUED -> View.GONE
+ isDownloaded -> View.GONE
+ else -> View.VISIBLE
+ }
+
+ binding.pbEpisodeDownloadProgress.visibility = when {
+ existingDownload?.status == Download.DownloadStatus.DOWNLOADING -> View.VISIBLE
+ existingDownload?.status == Download.DownloadStatus.QUEUED -> View.VISIBLE
+ else -> View.GONE
+ }
+
+ binding.btnEpisodeCancelDownload.visibility = when {
+ existingDownload?.status == Download.DownloadStatus.DOWNLOADING -> View.VISIBLE
+ existingDownload?.status == Download.DownloadStatus.QUEUED -> View.VISIBLE
+ else -> View.GONE
+ }
+
+ binding.btnEpisodePlayDownload.visibility = when {
+ isDownloaded -> View.VISIBLE
+ else -> View.GONE
+ }
+
+ binding.btnEpisodeDownload.setOnClickListener {
+ binding.btnEpisodeDownload.visibility = View.GONE
+ binding.pbEpisodeDownloadProgress.visibility = View.VISIBLE
+ binding.pbEpisodeDownloadProgress.isIndeterminate = false
+ binding.pbEpisodeDownloadProgress.isIndeterminate = true
+ binding.btnEpisodeCancelDownload.visibility = View.VISIBLE
+ binding.btnEpisodePlayDownload.visibility = View.GONE
+ startDownload(episode)
+ }
+
+ binding.btnEpisodeCancelDownload.setOnClickListener {
+ DownloadManager.getInstance(context).cancelDownload(downloadId)
+ database.downloadDao().cancelDownload(downloadId)
+ database.episodeDao().markAsNotDownloaded(episode.id)
+ binding.btnEpisodeDownload.visibility = View.VISIBLE
+ binding.pbEpisodeDownloadProgress.visibility = View.GONE
+ binding.btnEpisodeCancelDownload.visibility = View.GONE
+ binding.btnEpisodePlayDownload.visibility = View.GONE
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_cancelled), Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ binding.btnEpisodePlayDownload.setOnClickListener {
+ if (localPath != null) {
+ val videoType = Video.Type.Episode(
+ id = episode.id,
+ number = episode.number,
+ title = episode.title,
+ poster = episode.poster,
+ overview = episode.overview,
+ tvShow = Video.Type.Episode.TvShow(
+ id = episode.tvShow?.id ?: "",
+ title = episode.tvShow?.title ?: "",
+ poster = episode.tvShow?.poster,
+ banner = episode.tvShow?.banner,
+ releaseDate = episode.tvShow?.released?.format("yyyy-MM-dd"),
+ imdbId = episode.tvShow?.imdbId,
+ ),
+ season = Video.Type.Episode.Season(
+ number = episode.season?.number ?: 0,
+ title = episode.season?.title,
+ ),
+ )
+ val subtitle = episode.season?.takeIf { it.number != 0 }?.let { season ->
+ context.getString(
+ R.string.player_subtitle_tv_show,
+ season.number,
+ episode.number,
+ episode.title ?: context.getString(
+ R.string.episode_number,
+ episode.number
+ )
+ )
+ } ?: context.getString(
+ R.string.player_subtitle_tv_show_episode_only,
+ episode.number,
+ episode.title ?: context.getString(
+ R.string.episode_number,
+ episode.number
+ )
+ )
+ val bundle = android.os.Bundle().apply {
+ putString("id", episode.id)
+ putString("title", episode.tvShow?.title ?: "")
+ putString("subtitle", subtitle)
+ putParcelable("videoType", videoType)
+ putString("localFilePath", localPath)
+ putBoolean("isLocalFile", true)
+ }
+ itemView.findNavController().navigate(R.id.player, bundle)
+ }
+ }
}
private fun displayTvItem(binding: ItemEpisodeTvBinding) {
+ val downloadId = "episode_${episode.id}"
+ val existingDownload = database.downloadDao().getDownloadById(downloadId)
+ val isDownloaded = existingDownload?.status == Download.DownloadStatus.COMPLETED || episode.isDownloaded
+ val localPath = existingDownload?.localFilePath ?: episode.localFilePath
+
binding.root.apply {
setOnClickListener {
- findNavController().navigate(
- SeasonTvFragmentDirections.actionSeasonToPlayer(
- id = episode.id,
- title = episode.tvShow?.title ?: "",
- subtitle = episode.season?.takeIf { it.number != 0 }?.let { season ->
+ val bundle = android.os.Bundle().apply {
+ putString("id", episode.id)
+ putString("title", episode.tvShow?.title ?: "")
+ putString(
+ "subtitle",
+ episode.season?.takeIf { it.number != 0 }?.let { season ->
context.getString(
R.string.player_subtitle_tv_show,
season.number,
@@ -175,28 +404,35 @@ class EpisodeViewHolder(
R.string.episode_number,
episode.number
)
+ )
+ )
+ putParcelable("videoType", Video.Type.Episode(
+ id = episode.id,
+ number = episode.number,
+ title = episode.title,
+ poster = episode.poster,
+ overview = episode.overview,
+ tvShow = Video.Type.Episode.TvShow(
+ id = episode.tvShow?.id ?: "",
+ title = episode.tvShow?.title ?: "",
+ poster = episode.tvShow?.poster,
+ banner = episode.tvShow?.banner,
+ releaseDate = episode.tvShow?.released?.format("yyyy-MM-dd"),
+ imdbId = episode.tvShow?.imdbId,
),
- videoType = Video.Type.Episode(
- id = episode.id,
- number = episode.number,
- title = episode.title,
- poster = episode.poster,
- overview = episode.overview,
- tvShow = Video.Type.Episode.TvShow(
- id = episode.tvShow?.id ?: "",
- title = episode.tvShow?.title ?: "",
- poster = episode.tvShow?.poster,
- banner = episode.tvShow?.banner,
- releaseDate = episode.tvShow?.released?.format("yyyy-MM-dd"),
- imdbId = episode.tvShow?.imdbId,
- ),
- season = Video.Type.Episode.Season(
- number = episode.season?.number ?: 0,
- title = episode.season?.title,
- ),
+ season = Video.Type.Episode.Season(
+ number = episode.season?.number ?: 0,
+ title = episode.season?.title,
),
- )
- )
+ isDownloaded = isDownloaded,
+ localFilePath = localPath,
+ ))
+ putBoolean("isLocalFile", isDownloaded && !localPath.isNullOrEmpty())
+ if (isDownloaded && !localPath.isNullOrEmpty()) {
+ putString("localFilePath", localPath)
+ }
+ }
+ findNavController().navigate(R.id.player, bundle)
}
setOnLongClickListener {
ShowOptionsTvDialog(context, episode)
@@ -239,10 +475,13 @@ class EpisodeViewHolder(
}
}
- binding.tvEpisodeInfo.text = context.getString(
- R.string.episode_number,
- episode.number
- )
+ binding.tvEpisodeInfo.apply {
+ text = context.getString(R.string.episode_number, episode.number)
+ visibility = when {
+ episode.title.isNullOrBlank() -> View.GONE
+ else -> View.VISIBLE
+ }
+ }
binding.tvEpisodeTitle.text = episode.title ?: context.getString(
R.string.episode_number,
@@ -257,6 +496,278 @@ class EpisodeViewHolder(
}
}
binding.tvEpisodeOverview.text = episode.overview ?: ""
+
+ setupDownloadButtonTv(binding)
+ }
+
+ private fun setupDownloadButtonTv(binding: ItemEpisodeTvBinding) {
+ val downloadId = "episode_${episode.id}"
+ val existingDownload = database.downloadDao().getDownloadById(downloadId)
+ val isDownloaded = existingDownload?.status == Download.DownloadStatus.COMPLETED || episode.isDownloaded
+ val localPath = existingDownload?.localFilePath ?: episode.localFilePath
+
+ binding.actionButtonGroup.setOnFocusChangeListener { _, hasFocus ->
+ if (hasFocus) {
+ val zoomIn = AnimationUtils.loadAnimation(context, R.anim.zoom_in)
+ binding.actionButtonGroup.startAnimation(zoomIn)
+ zoomIn.fillAfter = true
+ binding.root.clearAnimation()
+ val zoomOut = AnimationUtils.loadAnimation(context, R.anim.zoom_out)
+ binding.root.startAnimation(zoomOut)
+ zoomOut.fillAfter = true
+ } else {
+ binding.actionButtonGroup.clearAnimation()
+ }
+ }
+
+ binding.btnEpisodeDownload.visibility = when {
+ existingDownload?.status == Download.DownloadStatus.DOWNLOADING -> View.GONE
+ existingDownload?.status == Download.DownloadStatus.QUEUED -> View.GONE
+ isDownloaded -> View.GONE
+ else -> View.VISIBLE
+ }
+
+ binding.pbEpisodeDownloadProgress.visibility = when {
+ existingDownload?.status == Download.DownloadStatus.DOWNLOADING -> View.VISIBLE
+ existingDownload?.status == Download.DownloadStatus.QUEUED -> View.VISIBLE
+ else -> View.GONE
+ }
+
+ binding.btnEpisodeCancelDownload.visibility = when {
+ existingDownload?.status == Download.DownloadStatus.DOWNLOADING -> View.VISIBLE
+ existingDownload?.status == Download.DownloadStatus.QUEUED -> View.VISIBLE
+ else -> View.GONE
+ }
+
+ binding.btnEpisodePlayDownload.visibility = when {
+ isDownloaded -> View.VISIBLE
+ else -> View.GONE
+ }
+
+ binding.actionButtonGroup.setOnClickListener {
+ when {
+ binding.btnEpisodeDownload.visibility == View.VISIBLE -> {
+ binding.btnEpisodeDownload.visibility = View.GONE
+ binding.pbEpisodeDownloadProgress.visibility = View.VISIBLE
+ binding.pbEpisodeDownloadProgress.isIndeterminate = false
+ binding.pbEpisodeDownloadProgress.isIndeterminate = true
+ binding.btnEpisodeCancelDownload.visibility = View.VISIBLE
+ binding.btnEpisodePlayDownload.visibility = View.GONE
+ startDownload(episode)
+ }
+ binding.btnEpisodeCancelDownload.visibility == View.VISIBLE -> {
+ DownloadManager.getInstance(context).cancelDownload(downloadId)
+ database.downloadDao().cancelDownload(downloadId)
+ database.episodeDao().markAsNotDownloaded(episode.id)
+ binding.btnEpisodeDownload.visibility = View.VISIBLE
+ binding.pbEpisodeDownloadProgress.visibility = View.GONE
+ binding.btnEpisodeCancelDownload.visibility = View.GONE
+ binding.btnEpisodePlayDownload.visibility = View.GONE
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_cancelled), Toast.LENGTH_SHORT).show()
+ }
+ }
+ binding.btnEpisodePlayDownload.visibility == View.VISIBLE && localPath != null -> {
+ val videoType = Video.Type.Episode(
+ id = episode.id,
+ number = episode.number,
+ title = episode.title,
+ poster = episode.poster,
+ overview = episode.overview,
+ tvShow = Video.Type.Episode.TvShow(
+ id = episode.tvShow?.id ?: "",
+ title = episode.tvShow?.title ?: "",
+ poster = episode.tvShow?.poster,
+ banner = episode.tvShow?.banner,
+ releaseDate = episode.tvShow?.released?.format("yyyy-MM-dd"),
+ imdbId = episode.tvShow?.imdbId,
+ ),
+ season = Video.Type.Episode.Season(
+ number = episode.season?.number ?: 0,
+ title = episode.season?.title,
+ ),
+ )
+ val subtitle = episode.season?.takeIf { it.number != 0 }?.let { season ->
+ context.getString(
+ R.string.player_subtitle_tv_show,
+ season.number,
+ episode.number,
+ episode.title ?: context.getString(
+ R.string.episode_number,
+ episode.number
+ )
+ )
+ } ?: context.getString(
+ R.string.player_subtitle_tv_show_episode_only,
+ episode.number,
+ episode.title ?: context.getString(
+ R.string.episode_number,
+ episode.number
+ )
+ )
+ val bundle = android.os.Bundle().apply {
+ putString("id", episode.id)
+ putString("title", episode.tvShow?.title ?: "")
+ putString("subtitle", subtitle)
+ putParcelable("videoType", videoType)
+ putString("localFilePath", localPath)
+ putBoolean("isLocalFile", true)
+ }
+ itemView.findNavController().navigate(R.id.player, bundle)
+ }
+ }
+ }
+
+ val hasAction = binding.btnEpisodeDownload.visibility == View.VISIBLE ||
+ binding.btnEpisodeCancelDownload.visibility == View.VISIBLE ||
+ binding.btnEpisodePlayDownload.visibility == View.VISIBLE
+
+ binding.actionButtonGroup.isFocusable = hasAction
+ binding.actionButtonGroup.isFocusableInTouchMode = hasAction
+ binding.root.nextFocusDownId = if (hasAction) R.id.actionButtonGroup else View.NO_ID
+ }
+
+ private fun startDownload(episode: Episode) {
+ val provider = UserPreferences.currentProvider
+ if (provider == null) {
+ Toast.makeText(context, "No provider selected", Toast.LENGTH_SHORT).show()
+ return
+ }
+ val downloadManager = DownloadManager.getInstance(context)
+ val seasonNumber = episode.season?.number ?: 1
+ val downloadId = "episode_${episode.id}"
+
+ itemView.findViewTreeLifecycleOwner()?.lifecycleScope?.launch(Dispatchers.IO) {
+ try {
+ val existingDownload = database.downloadDao().getDownloadById(downloadId)
+ if (existingDownload?.status == Download.DownloadStatus.COMPLETED) {
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_already_completed), Toast.LENGTH_SHORT).show()
+ updateDownloadState()
+ }
+ return@launch
+ }
+ if (existingDownload?.status == Download.DownloadStatus.DOWNLOADING || existingDownload?.status == Download.DownloadStatus.QUEUED) {
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_already_in_progress), Toast.LENGTH_SHORT).show()
+ updateDownloadState()
+ }
+ return@launch
+ }
+
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_resolving_url, episode.title ?: "Episode ${episode.number}"), Toast.LENGTH_SHORT).show()
+ }
+
+ val videoType = Video.Type.Episode(
+ id = episode.id,
+ number = episode.number,
+ title = episode.title,
+ poster = episode.poster,
+ overview = episode.overview,
+ tvShow = Video.Type.Episode.TvShow(
+ id = episode.tvShow?.id ?: "",
+ title = episode.tvShow?.title ?: "",
+ poster = episode.tvShow?.poster,
+ banner = episode.tvShow?.banner,
+ releaseDate = episode.tvShow?.released?.format("yyyy-MM-dd"),
+ imdbId = episode.tvShow?.imdbId,
+ ),
+ season = Video.Type.Episode.Season(
+ number = seasonNumber,
+ title = episode.season?.title ?: "",
+ ),
+ )
+ val servers = provider.getServers(episode.id, videoType)
+ if (servers.isEmpty()) {
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_no_servers), Toast.LENGTH_SHORT).show()
+ updateDownloadState()
+ }
+ return@launch
+ }
+
+ // Show server selection dialog
+ withContext(Dispatchers.Main) {
+ val serverNames = servers.map { it.name }.toTypedArray()
+ AlertDialog.Builder(context)
+ .setTitle("Select Server")
+ .setItems(serverNames) { _, which ->
+ val selectedServer = servers[which]
+ // Continue with download using selected server - use DownloadManager's scope to survive app closure
+ downloadManager.scope.launch {
+ try {
+ val video = provider.getVideo(selectedServer)
+ val tvShowId = episode.tvShow?.id ?: "unknown"
+ val tvShowTitle = episode.tvShow?.title ?: ""
+ val outputDir = downloadManager.getEpisodeDir(tvShowId, seasonNumber, episode.number)
+ val downloadEntry = Download(
+ id = downloadId,
+ contentType = Download.ContentType.EPISODE,
+ title = episode.title ?: "Episode ${episode.number}",
+ subtitle = "S${seasonNumber} E${episode.number}",
+ poster = episode.poster,
+ banner = episode.tvShow?.banner,
+ videoUrl = video.source,
+ headers = video.headers ?: emptyMap(),
+ mimeType = video.type,
+ status = Download.DownloadStatus.DOWNLOADING,
+ tvShowId = tvShowId,
+ tvShowTitle = tvShowTitle,
+ seasonNumber = seasonNumber,
+ episodeNumber = episode.number,
+ )
+ database.downloadDao().insert(downloadEntry)
+
+ DownloadService.startService(context)
+
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_starting, episode.title ?: "Episode ${episode.number}"), Toast.LENGTH_LONG).show()
+ }
+
+ downloadManager.downloadVideo(
+ downloadId = downloadId,
+ url = video.source,
+ headers = video.headers ?: emptyMap(),
+ outputDir = outputDir,
+ onProgress = { downloaded, total ->
+ val progress = if (total > 0) ((downloaded.toFloat() / total) * 100).toInt() else 0
+ database.downloadDao().updateProgress(downloadId, Download.DownloadStatus.DOWNLOADING, progress, downloaded)
+ },
+ onComplete = { file ->
+ database.downloadDao().markAsCompleted(downloadId, Download.DownloadStatus.COMPLETED, file.absolutePath, System.currentTimeMillis())
+ database.episodeDao().markAsDownloaded(episode.id, file.absolutePath)
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_completed, episode.title ?: "Episode ${episode.number}"), Toast.LENGTH_SHORT).show()
+ }
+ },
+ onError = { error ->
+ database.downloadDao().markAsFailed(downloadId, Download.DownloadStatus.FAILED, error.message)
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_failed, episode.title ?: "Episode ${episode.number}", error.message), Toast.LENGTH_LONG).show()
+ }
+ },
+ )
+ } catch (e: Exception) {
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_failed, episode.title ?: "Episode ${episode.number}", e.message), Toast.LENGTH_LONG).show()
+ updateDownloadState()
+ }
+ }
+ }
+ }
+ .setOnCancelListener {
+ updateDownloadState()
+ }
+ .show()
+ }
+ } catch (e: Exception) {
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_failed, episode.title ?: "Episode ${episode.number}", e.message), Toast.LENGTH_LONG).show()
+ updateDownloadState()
+ }
+ }
+ }
}
private fun displayContinueWatchingMobileItem(binding: ItemEpisodeContinueWatchingMobileBinding) {
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/adapters/viewholders/MovieViewHolder.kt b/app/src/main/java/com/streamflixreborn/streamflix/adapters/viewholders/MovieViewHolder.kt
index bffea37e..50a21d70 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/adapters/viewholders/MovieViewHolder.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/adapters/viewholders/MovieViewHolder.kt
@@ -6,6 +6,8 @@ import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
import android.util.Log
import android.view.View
import android.view.ViewGroup
@@ -47,6 +49,24 @@ import com.streamflixreborn.streamflix.fragments.movie.MovieMobileFragment
import com.streamflixreborn.streamflix.fragments.movie.MovieMobileFragmentDirections
import com.streamflixreborn.streamflix.fragments.movie.MovieTvFragment
import com.streamflixreborn.streamflix.fragments.movie.MovieTvFragmentDirections
+import com.streamflixreborn.streamflix.providers.Provider
+import com.streamflixreborn.streamflix.services.DownloadService
+import com.streamflixreborn.streamflix.ui.ShowOptionsMobileDialog
+import com.streamflixreborn.streamflix.ui.ShowOptionsTvDialog
+import com.streamflixreborn.streamflix.ui.SpacingItemDecoration
+import com.streamflixreborn.streamflix.utils.dp
+import com.streamflixreborn.streamflix.utils.format
+import com.streamflixreborn.streamflix.utils.getCurrentFragment
+import com.streamflixreborn.streamflix.utils.loadMovieBanner
+import com.streamflixreborn.streamflix.utils.loadMoviePoster
+import com.streamflixreborn.streamflix.utils.ArtworkRepair
+import com.streamflixreborn.streamflix.utils.toActivity
+import java.util.Locale
+import com.streamflixreborn.streamflix.utils.DownloadManager
+import com.streamflixreborn.streamflix.models.Download
+import com.streamflixreborn.streamflix.utils.UserPreferences
+import com.streamflixreborn.streamflix.databinding.ContentMovieDirectorsMobileBinding
+import com.streamflixreborn.streamflix.databinding.ContentMovieDirectorsTvBinding
import com.streamflixreborn.streamflix.fragments.movies.MoviesMobileFragment
import com.streamflixreborn.streamflix.fragments.movies.MoviesMobileFragmentDirections
import com.streamflixreborn.streamflix.fragments.movies.MoviesTvFragment
@@ -63,28 +83,9 @@ import com.streamflixreborn.streamflix.fragments.tv_show.TvShowMobileFragment
import com.streamflixreborn.streamflix.fragments.tv_show.TvShowMobileFragmentDirections
import com.streamflixreborn.streamflix.fragments.tv_show.TvShowTvFragment
import com.streamflixreborn.streamflix.fragments.tv_show.TvShowTvFragmentDirections
-import com.streamflixreborn.streamflix.fragments.tv_shows.TvShowsTvFragment
-import com.streamflixreborn.streamflix.fragments.tv_shows.TvShowsTvFragmentDirections
import com.streamflixreborn.streamflix.models.Movie
import com.streamflixreborn.streamflix.models.TvShow
import com.streamflixreborn.streamflix.models.Video
-import com.streamflixreborn.streamflix.ui.ShowOptionsMobileDialog
-import com.streamflixreborn.streamflix.ui.ShowOptionsTvDialog
-import com.streamflixreborn.streamflix.ui.SpacingItemDecoration
-import com.streamflixreborn.streamflix.utils.dp
-import androidx.preference.Preference
-import com.streamflixreborn.streamflix.utils.format
-import com.streamflixreborn.streamflix.utils.getCurrentFragment
-import com.streamflixreborn.streamflix.utils.loadMovieBanner
-import com.streamflixreborn.streamflix.utils.loadMoviePoster
-import com.streamflixreborn.streamflix.utils.ArtworkRepair
-import com.streamflixreborn.streamflix.utils.toActivity
-import java.util.Locale
-import com.streamflixreborn.streamflix.utils.UserPreferences
-import com.streamflixreborn.streamflix.providers.Provider
-import android.view.KeyEvent
-import com.streamflixreborn.streamflix.databinding.ContentMovieDirectorsMobileBinding
-import com.streamflixreborn.streamflix.databinding.ContentMovieDirectorsTvBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -738,7 +739,7 @@ class MovieViewHolder(
dao.upsertFavorite(resolvedMovie, newValue)
- withContext(Dispatchers.Main) {
+ Handler(Looper.getMainLooper()).post {
movie.poster = resolvedMovie.poster
movie.banner = resolvedMovie.banner
movie.isFavorite = newValue
@@ -754,6 +755,106 @@ class MovieViewHolder(
ContextCompat.getDrawable(context, movie.isFavorite.drawable())
)
}
+
+ binding.btnMovieDownload.apply {
+ visibility = View.VISIBLE
+ setOnClickListener {
+ checkProviderAndRun {
+ val provider = UserPreferences.currentProvider
+ if (provider == null) {
+ Toast.makeText(context, "No provider selected", Toast.LENGTH_SHORT).show()
+ return@checkProviderAndRun
+ }
+ val downloadManager = DownloadManager.getInstance(context)
+ val downloadId = "movie_${movie.id}"
+
+ downloadManager.scope.launch(Dispatchers.IO) {
+ try {
+ val existingDownload = database.downloadDao().getDownloadById(downloadId)
+ if (existingDownload?.status == Download.DownloadStatus.COMPLETED) {
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_already_completed), Toast.LENGTH_SHORT).show()
+ }
+ return@launch
+ }
+ if (existingDownload?.status == Download.DownloadStatus.DOWNLOADING || existingDownload?.status == Download.DownloadStatus.QUEUED) {
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_already_in_progress), Toast.LENGTH_SHORT).show()
+ }
+ return@launch
+ }
+
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_resolving_url, movie.title), Toast.LENGTH_SHORT).show()
+ }
+
+ val videoType = Video.Type.Movie(
+ id = movie.id,
+ title = movie.title,
+ releaseDate = movie.released?.format("yyyy-MM-dd") ?: "",
+ poster = movie.poster ?: movie.banner ?: "",
+ imdbId = movie.imdbId,
+ )
+ val servers = provider.getServers(movie.id, videoType)
+ if (servers.isEmpty()) {
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_no_servers), Toast.LENGTH_SHORT).show()
+ }
+ return@launch
+ }
+
+ val video = provider.getVideo(servers.first())
+ val outputDir = downloadManager.getMovieDir(movie.id)
+ val downloadEntry = Download(
+ id = downloadId,
+ contentType = Download.ContentType.MOVIE,
+ title = movie.title,
+ poster = movie.poster,
+ banner = movie.banner,
+ videoUrl = video.source,
+ headers = video.headers ?: emptyMap(),
+ mimeType = video.type,
+ status = Download.DownloadStatus.DOWNLOADING,
+ )
+ database.downloadDao().insert(downloadEntry)
+
+ DownloadService.startService(context)
+
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_starting, movie.title), Toast.LENGTH_LONG).show()
+ }
+
+ downloadManager.downloadVideo(
+ downloadId = downloadId,
+ url = video.source,
+ headers = video.headers ?: emptyMap(),
+ outputDir = outputDir,
+ onProgress = { downloaded, total ->
+ val progress = if (total > 0) ((downloaded.toFloat() / total) * 100).toInt() else 0
+ database.downloadDao().updateProgress(downloadId, Download.DownloadStatus.DOWNLOADING, progress, downloaded)
+ },
+ onComplete = { file ->
+ database.downloadDao().markAsCompleted(downloadId, Download.DownloadStatus.COMPLETED, file.absolutePath, System.currentTimeMillis())
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_completed, movie.title), Toast.LENGTH_SHORT).show()
+ }
+ },
+ onError = { error ->
+ database.downloadDao().markAsFailed(downloadId, Download.DownloadStatus.FAILED, error.message)
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_failed, movie.title, error.message), Toast.LENGTH_LONG).show()
+ }
+ },
+ )
+ } catch (e: Exception) {
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_failed, movie.title, e.message), Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+ }
+ }
+ }
}
private fun displayMovieTv(binding: ContentMovieTvBinding) {
@@ -867,7 +968,7 @@ class MovieViewHolder(
dao.upsertFavorite(resolvedMovie, newValue)
- withContext(Dispatchers.Main) {
+ Handler(Looper.getMainLooper()).post {
movie.poster = resolvedMovie.poster
movie.banner = resolvedMovie.banner
movie.isFavorite = newValue
@@ -883,6 +984,106 @@ class MovieViewHolder(
ContextCompat.getDrawable(context, movie.isFavorite.drawable())
)
}
+
+ binding.btnMovieDownload.apply {
+ visibility = View.VISIBLE
+ setOnClickListener {
+ checkProviderAndRun {
+ val provider = UserPreferences.currentProvider
+ if (provider == null) {
+ Toast.makeText(context, "No provider selected", Toast.LENGTH_SHORT).show()
+ return@checkProviderAndRun
+ }
+ val downloadManager = DownloadManager.getInstance(context)
+ val downloadId = "movie_${movie.id}"
+
+ downloadManager.scope.launch(Dispatchers.IO) {
+ try {
+ val existingDownload = database.downloadDao().getDownloadById(downloadId)
+ if (existingDownload?.status == Download.DownloadStatus.COMPLETED) {
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_already_completed), Toast.LENGTH_SHORT).show()
+ }
+ return@launch
+ }
+ if (existingDownload?.status == Download.DownloadStatus.DOWNLOADING || existingDownload?.status == Download.DownloadStatus.QUEUED) {
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_already_in_progress), Toast.LENGTH_SHORT).show()
+ }
+ return@launch
+ }
+
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_resolving_url, movie.title), Toast.LENGTH_SHORT).show()
+ }
+
+ val videoType = Video.Type.Movie(
+ id = movie.id,
+ title = movie.title,
+ releaseDate = movie.released?.format("yyyy-MM-dd") ?: "",
+ poster = movie.poster ?: movie.banner ?: "",
+ imdbId = movie.imdbId,
+ )
+ val servers = provider.getServers(movie.id, videoType)
+ if (servers.isEmpty()) {
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_no_servers), Toast.LENGTH_SHORT).show()
+ }
+ return@launch
+ }
+
+ val video = provider.getVideo(servers.first())
+ val outputDir = downloadManager.getMovieDir(movie.id)
+ val downloadEntry = Download(
+ id = downloadId,
+ contentType = Download.ContentType.MOVIE,
+ title = movie.title,
+ poster = movie.poster,
+ banner = movie.banner,
+ videoUrl = video.source,
+ headers = video.headers ?: emptyMap(),
+ mimeType = video.type,
+ status = Download.DownloadStatus.DOWNLOADING,
+ )
+ database.downloadDao().insert(downloadEntry)
+
+ DownloadService.startService(context)
+
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_starting, movie.title), Toast.LENGTH_LONG).show()
+ }
+
+ downloadManager.downloadVideo(
+ downloadId = downloadId,
+ url = video.source,
+ headers = video.headers ?: emptyMap(),
+ outputDir = outputDir,
+ onProgress = { downloaded, total ->
+ val progress = if (total > 0) ((downloaded.toFloat() / total) * 100).toInt() else 0
+ database.downloadDao().updateProgress(downloadId, Download.DownloadStatus.DOWNLOADING, progress, downloaded)
+ },
+ onComplete = { file ->
+ database.downloadDao().markAsCompleted(downloadId, Download.DownloadStatus.COMPLETED, file.absolutePath, System.currentTimeMillis())
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_completed, movie.title), Toast.LENGTH_SHORT).show()
+ }
+ },
+ onError = { error ->
+ database.downloadDao().markAsFailed(downloadId, Download.DownloadStatus.FAILED, error.message)
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_failed, movie.title, error.message), Toast.LENGTH_LONG).show()
+ }
+ },
+ )
+ } catch (e: Exception) {
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, context.getString(R.string.download_failed, movie.title, e.message), Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+ }
+ }
+ }
}
private fun displayCastMobile(binding: ContentMovieCastMobileBinding) {
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/adapters/viewholders/TvShowViewHolder.kt b/app/src/main/java/com/streamflixreborn/streamflix/adapters/viewholders/TvShowViewHolder.kt
index 2b95930d..7c4b19af 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/adapters/viewholders/TvShowViewHolder.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/adapters/viewholders/TvShowViewHolder.kt
@@ -3,6 +3,8 @@ package com.streamflixreborn.streamflix.adapters.viewholders
import android.content.Context
import android.content.Intent
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -61,6 +63,8 @@ import com.streamflixreborn.streamflix.utils.loadTvShowBanner
import com.streamflixreborn.streamflix.utils.loadTvShowPoster
import com.streamflixreborn.streamflix.utils.ArtworkRepair
import com.streamflixreborn.streamflix.providers.Provider
+import com.streamflixreborn.streamflix.utils.DownloadManager
+import com.streamflixreborn.streamflix.models.Download
import java.util.Locale
class TvShowViewHolder(
@@ -600,7 +604,7 @@ class TvShowViewHolder(
dao.upsertFavorite(resolvedTvShow, newValue)
- withContext(Dispatchers.Main) {
+ Handler(Looper.getMainLooper()).post {
tvShow.poster = resolvedTvShow.poster
tvShow.banner = resolvedTvShow.banner
tvShow.isFavorite = newValue
@@ -734,7 +738,7 @@ class TvShowViewHolder(
dao.upsertFavorite(resolvedTvShow, newValue)
- withContext(Dispatchers.Main) {
+ Handler(Looper.getMainLooper()).post {
tvShow.poster = resolvedTvShow.poster
tvShow.banner = resolvedTvShow.banner
tvShow.isFavorite = newValue
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/database/AppDatabase.kt b/app/src/main/java/com/streamflixreborn/streamflix/database/AppDatabase.kt
index a215e5b6..1c8567fc 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/database/AppDatabase.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/database/AppDatabase.kt
@@ -7,10 +7,12 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
+import com.streamflixreborn.streamflix.database.dao.DownloadDao
import com.streamflixreborn.streamflix.database.dao.EpisodeDao
import com.streamflixreborn.streamflix.database.dao.MovieDao
import com.streamflixreborn.streamflix.database.dao.SeasonDao
import com.streamflixreborn.streamflix.database.dao.TvShowDao
+import com.streamflixreborn.streamflix.models.Download
import com.streamflixreborn.streamflix.models.Episode
import com.streamflixreborn.streamflix.models.Movie
import com.streamflixreborn.streamflix.models.Season
@@ -23,8 +25,9 @@ import com.streamflixreborn.streamflix.utils.UserPreferences
Movie::class,
Season::class,
TvShow::class,
+ Download::class,
],
- version = 8,
+ version = 10,
exportSchema = false
)
@TypeConverters(Converters::class)
@@ -38,6 +41,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun episodeDao(): EpisodeDao
+ abstract fun downloadDao(): DownloadDao
+
companion object {
@Volatile
@@ -104,6 +109,8 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(MIGRATION_5_6)
.addMigrations(MIGRATION_6_7)
.addMigrations(MIGRATION_7_8)
+ .addMigrations(MIGRATION_8_9)
+ .addMigrations(MIGRATION_9_10)
.build()
}
@@ -182,5 +189,22 @@ abstract class AppDatabase : RoomDatabase() {
// but are now formally declared in Entity classes, requiring a version bump.
}
}
+
+ private val MIGRATION_8_9: Migration = object : Migration(8, 9) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("CREATE TABLE IF NOT EXISTS `downloads` (`id` TEXT NOT NULL, `contentType` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `poster` TEXT, `banner` TEXT, `videoUrl` TEXT NOT NULL, `headers` TEXT, `mimeType` TEXT, `localFilePath` TEXT, `status` TEXT NOT NULL, `progress` INTEGER NOT NULL, `fileSize` INTEGER NOT NULL, `downloadedSize` INTEGER NOT NULL, `errorMessage` TEXT, `createdAt` INTEGER NOT NULL, `completedAt` INTEGER, `tvShowId` TEXT, `tvShowTitle` TEXT, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `quality` TEXT, PRIMARY KEY(`id`))")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_downloads_status` ON `downloads` (`status`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_downloads_contentType` ON `downloads` (`contentType`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_downloads_tvShowId` ON `downloads` (`tvShowId`)")
+ }
+ }
+
+ private val MIGRATION_9_10: Migration = object : Migration(9, 10) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE episodes ADD COLUMN isDownloaded INTEGER NOT NULL DEFAULT 0")
+ db.execSQL("ALTER TABLE episodes ADD COLUMN localFilePath TEXT")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_episodes_tvShow_isDownloaded` ON `episodes` (`tvShow`, `isDownloaded`)")
+ }
+ }
}
}
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/database/Converters.kt b/app/src/main/java/com/streamflixreborn/streamflix/database/Converters.kt
index ed01d14d..9c773572 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/database/Converters.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/database/Converters.kt
@@ -1,6 +1,7 @@
package com.streamflixreborn.streamflix.database
import androidx.room.TypeConverter
+import com.streamflixreborn.streamflix.models.Download
import com.streamflixreborn.streamflix.models.Season
import com.streamflixreborn.streamflix.models.TvShow
import com.streamflixreborn.streamflix.utils.format
@@ -40,4 +41,39 @@ class Converters {
fun toSeason(value: String?): Season? {
return value?.let { Season(it, 0) }
}
+
+ @TypeConverter
+ fun fromDownloadContentType(value: Download.ContentType): String {
+ return value.name
+ }
+
+ @TypeConverter
+ fun toDownloadContentType(value: String): Download.ContentType {
+ return Download.ContentType.valueOf(value)
+ }
+
+ @TypeConverter
+ fun fromDownloadStatus(value: Download.DownloadStatus): String {
+ return value.name
+ }
+
+ @TypeConverter
+ fun toDownloadStatus(value: String): Download.DownloadStatus {
+ return Download.DownloadStatus.valueOf(value)
+ }
+
+ @TypeConverter
+ fun fromStringMap(value: Map?): String? {
+ if (value == null) return null
+ return value.entries.joinToString("|") { "${it.key}=${it.value}" }
+ }
+
+ @TypeConverter
+ fun toStringMap(value: String?): Map {
+ if (value.isNullOrEmpty()) return emptyMap()
+ return value.split("|").associate { entry ->
+ val parts = entry.split("=", limit = 2)
+ if (parts.size == 2) parts[0] to parts[1] else parts[0] to ""
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/database/dao/DownloadDao.kt b/app/src/main/java/com/streamflixreborn/streamflix/database/dao/DownloadDao.kt
new file mode 100644
index 00000000..107bda01
--- /dev/null
+++ b/app/src/main/java/com/streamflixreborn/streamflix/database/dao/DownloadDao.kt
@@ -0,0 +1,86 @@
+package com.streamflixreborn.streamflix.database.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import com.streamflixreborn.streamflix.models.Download
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface DownloadDao {
+
+ @Query("SELECT * FROM downloads ORDER BY createdAt DESC")
+ fun getAllDownloads(): Flow>
+
+ @Query("SELECT * FROM downloads WHERE id = :id")
+ fun getDownloadById(id: String): Download?
+
+ @Query("SELECT * FROM downloads WHERE status = 'DOWNLOADING' OR status = 'QUEUED' OR status = 'PAUSED' ORDER BY createdAt DESC")
+ fun getActiveDownloads(): Flow>
+
+ @Query("SELECT * FROM downloads WHERE status = 'COMPLETED' ORDER BY completedAt DESC")
+ fun getCompletedDownloads(): Flow>
+
+ @Query("SELECT * FROM downloads WHERE contentType = 'MOVIE' AND status = 'COMPLETED' ORDER BY completedAt DESC")
+ fun getCompletedMovies(): Flow>
+
+ @Query("SELECT * FROM downloads WHERE contentType = 'EPISODE' AND status = 'COMPLETED' ORDER BY tvShowTitle, seasonNumber, episodeNumber")
+ fun getCompletedEpisodes(): Flow>
+
+ @Query("SELECT * FROM downloads WHERE tvShowId = :tvShowId AND status = 'COMPLETED' ORDER BY seasonNumber, episodeNumber")
+ fun getCompletedEpisodesForTvShow(tvShowId: String): Flow>
+
+ @Query("SELECT * FROM downloads WHERE tvShowId = :tvShowId AND seasonNumber = :seasonNumber AND status = 'COMPLETED'")
+ fun getCompletedEpisodesForSeason(tvShowId: String, seasonNumber: Int): List
+
+ @Query("SELECT * FROM downloads WHERE videoUrl = :videoUrl LIMIT 1")
+ fun getDownloadByVideoUrl(videoUrl: String): Download?
+
+ @Query("SELECT COUNT(*) FROM downloads WHERE id = :id AND status = 'COMPLETED'")
+ fun isDownloaded(id: String): Int
+
+ @Query("SELECT COUNT(*) FROM downloads WHERE videoUrl = :videoUrl AND status = 'COMPLETED'")
+ fun isVideoUrlDownloaded(videoUrl: String): Int
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun insert(download: Download): Long
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun insertAll(downloads: List): List
+
+ @Update
+ fun update(download: Download)
+
+ @Query("UPDATE downloads SET status = :status, progress = :progress, downloadedSize = :downloadedSize WHERE id = :id")
+ fun updateProgress(id: String, status: Download.DownloadStatus, progress: Int, downloadedSize: Long)
+
+ @Query("UPDATE downloads SET status = :status, localFilePath = :localFilePath, completedAt = :completedAt WHERE id = :id")
+ fun markAsCompleted(id: String, status: Download.DownloadStatus, localFilePath: String, completedAt: Long)
+
+ @Query("UPDATE downloads SET status = :status, errorMessage = :errorMessage WHERE id = :id")
+ fun markAsFailed(id: String, status: Download.DownloadStatus, errorMessage: String?)
+
+ @Query("UPDATE downloads SET status = 'PAUSED' WHERE id = :id")
+ fun pauseDownload(id: String)
+
+ @Query("UPDATE downloads SET status = 'QUEUED' WHERE id = :id")
+ fun resumeDownload(id: String)
+
+ @Query("UPDATE downloads SET status = 'CANCELLED' WHERE id = :id")
+ fun cancelDownload(id: String)
+
+ @Query("DELETE FROM downloads WHERE id = :id")
+ fun deleteById(id: String)
+
+ @Delete
+ fun delete(download: Download)
+
+ @Query("DELETE FROM downloads WHERE status = 'CANCELLED' OR status = 'FAILED'")
+ fun deleteCompletedFailedDownloads()
+
+ @Query("DELETE FROM downloads")
+ fun deleteAll()
+}
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/database/dao/EpisodeDao.kt b/app/src/main/java/com/streamflixreborn/streamflix/database/dao/EpisodeDao.kt
index 11bc312c..7a66c1c2 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/database/dao/EpisodeDao.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/database/dao/EpisodeDao.kt
@@ -147,4 +147,22 @@ interface EpisodeDao {
ORDER BY s.number, e.number
""")
fun getByTvShowIdAndSeasonNumber(tvShowId: String, seasonNumber: Int): List
+
+ @Query("SELECT * FROM episodes WHERE tvShow = :tvShowId AND isDownloaded = 1 ORDER BY season, number")
+ fun getDownloadedEpisodesByTvShowId(tvShowId: String): List
+
+ @Query("SELECT * FROM episodes WHERE tvShow = :tvShowId AND season = :seasonId AND isDownloaded = 1 ORDER BY number")
+ fun getDownloadedEpisodesBySeason(tvShowId: String, seasonId: String): List
+
+ @Query("SELECT * FROM episodes WHERE id = :id AND isDownloaded = 1")
+ fun getDownloadedEpisodeById(id: String): Episode?
+
+ @Query("UPDATE episodes SET isDownloaded = 1, localFilePath = :localFilePath WHERE id = :id")
+ fun markAsDownloaded(id: String, localFilePath: String)
+
+ @Query("UPDATE episodes SET isDownloaded = 0, localFilePath = NULL WHERE id = :id")
+ fun markAsNotDownloaded(id: String)
+
+ @Query("SELECT COUNT(*) FROM episodes WHERE tvShow = :tvShowId AND isDownloaded = 1")
+ fun getDownloadedEpisodeCount(tvShowId: String): Int
}
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/fragments/downloads/DownloadsMobileFragment.kt b/app/src/main/java/com/streamflixreborn/streamflix/fragments/downloads/DownloadsMobileFragment.kt
new file mode 100644
index 00000000..2245b0a8
--- /dev/null
+++ b/app/src/main/java/com/streamflixreborn/streamflix/fragments/downloads/DownloadsMobileFragment.kt
@@ -0,0 +1,330 @@
+package com.streamflixreborn.streamflix.fragments.downloads
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.fragment.findNavController
+import com.streamflixreborn.streamflix.R
+import com.streamflixreborn.streamflix.adapters.DownloadItem
+import com.streamflixreborn.streamflix.adapters.DownloadsAdapter
+import com.streamflixreborn.streamflix.database.AppDatabase
+import com.streamflixreborn.streamflix.databinding.FragmentDownloadsMobileBinding
+import com.streamflixreborn.streamflix.models.Download
+import com.streamflixreborn.streamflix.models.Video
+import com.streamflixreborn.streamflix.utils.DownloadManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+
+class DownloadsMobileFragment : Fragment() {
+
+ private var _binding: FragmentDownloadsMobileBinding? = null
+ private val binding get() = _binding!!
+
+ private val database by lazy { AppDatabase.getInstance(requireContext()) }
+ private val downloadManager by lazy { DownloadManager.getInstance(requireContext()) }
+ private val adapter by lazy {
+ DownloadsAdapter(
+ onPlayClick = { download -> playDownload(download) },
+ onDeleteClick = { download -> showDeleteConfirmation(download) },
+ onTitleClick = { download -> navigateToContent(download) },
+ database = database,
+ isTv = false,
+ )
+ }
+
+ private var currentTab = Tab.ALL
+ private var observeDownloadsJob: Job? = null
+
+ enum class Tab {
+ ALL, MOVIES, EPISODES, DOWNLOADING
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentDownloadsMobileBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ setupRecyclerView()
+ setupTabs()
+ observeDownloads()
+ observeDownloadProgress()
+ updateStorageInfo()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ observeDownloadsJob?.cancel()
+ _binding = null
+ }
+
+ private fun setupRecyclerView() {
+ binding.rvDownloads.adapter = adapter
+ }
+
+ private fun setupTabs() {
+ binding.tabLayout.addOnTabSelectedListener(object :
+ com.google.android.material.tabs.TabLayout.OnTabSelectedListener {
+ override fun onTabSelected(tab: com.google.android.material.tabs.TabLayout.Tab?) {
+ currentTab = when (tab?.position) {
+ 0 -> Tab.ALL
+ 1 -> Tab.MOVIES
+ 2 -> Tab.EPISODES
+ 3 -> Tab.DOWNLOADING
+ else -> Tab.ALL
+ }
+ observeDownloads()
+ }
+
+ override fun onTabUnselected(tab: com.google.android.material.tabs.TabLayout.Tab?) {}
+ override fun onTabReselected(tab: com.google.android.material.tabs.TabLayout.Tab?) {}
+ })
+ }
+
+ private fun observeDownloads() {
+ observeDownloadsJob?.cancel()
+ observeDownloadsJob = viewLifecycleOwner.lifecycleScope.launch {
+ val flow = when (currentTab) {
+ Tab.ALL -> database.downloadDao().getAllDownloads()
+ Tab.MOVIES -> database.downloadDao().getCompletedMovies()
+ Tab.EPISODES -> database.downloadDao().getCompletedEpisodes()
+ Tab.DOWNLOADING -> database.downloadDao().getActiveDownloads()
+ }
+
+ flow.collectLatest { downloads ->
+ withContext(Dispatchers.Main) {
+ val groupedItems = groupDownloads(downloads)
+ adapter.submitList(groupedItems)
+ binding.llEmpty.visibility = if (groupedItems.isEmpty()) View.VISIBLE else View.GONE
+ }
+ }
+ }
+ }
+
+ private fun groupDownloads(downloads: List): List {
+ val result = mutableListOf()
+
+ val (episodes, nonEpisodes) = downloads.partition { it.contentType == Download.ContentType.EPISODE }
+
+ val groupedEpisodes = episodes
+ .groupBy { it.tvShowTitle ?: "Unknown" }
+ .toSortedMap()
+ .mapValues { (_, showDownloads) ->
+ showDownloads.groupBy { it.seasonNumber ?: 0 }
+ .toSortedMap()
+ .flatMap { (season, seasonDownloads) ->
+ listOf(
+ DownloadItem.Header(
+ tvShowId = seasonDownloads.firstOrNull()?.tvShowId ?: "",
+ tvShowTitle = seasonDownloads.firstOrNull()?.tvShowTitle ?: "Unknown",
+ seasonNumber = season,
+ )
+ ) + seasonDownloads.sortedBy { it.episodeNumber ?: 0 }
+ .map { DownloadItem.Download(it) }
+ }
+ }
+ .values
+ .flatten()
+
+ result.addAll(groupedEpisodes)
+
+ if (currentTab == Tab.ALL || currentTab == Tab.MOVIES) {
+ val movies = nonEpisodes.filter { it.contentType == Download.ContentType.MOVIE }
+ .sortedByDescending { it.completedAt ?: 0L }
+ movies.forEach { result.add(DownloadItem.Download(it)) }
+ }
+
+ if (currentTab == Tab.DOWNLOADING) {
+ nonEpisodes.filter { it.contentType == Download.ContentType.MOVIE }
+ .forEach { result.add(DownloadItem.Download(it)) }
+ }
+
+ return result
+ }
+
+ private var lastProgressMap: Map = emptyMap()
+
+ private fun observeDownloadProgress() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ downloadManager.downloadProgress.collectLatest { progressMap ->
+ val adapterProgress = progressMap.mapValues { (_, info) ->
+ DownloadsAdapter.DownloadProgress(
+ progress = info.progress,
+ status = when (info.status) {
+ DownloadManager.DownloadStatus.DOWNLOADING -> getString(R.string.download_status_downloading)
+ DownloadManager.DownloadStatus.PAUSED -> getString(R.string.download_status_paused)
+ DownloadManager.DownloadStatus.COMPLETED -> getString(R.string.download_completed)
+ DownloadManager.DownloadStatus.FAILED -> getString(R.string.download_status_failed)
+ DownloadManager.DownloadStatus.CANCELLED -> getString(R.string.download_status_cancelled)
+ },
+ speed = info.speed,
+ etaSeconds = info.etaSeconds,
+ )
+ }
+
+ for ((id, newProgress) in adapterProgress) {
+ val oldProgress = lastProgressMap[id]
+ if (newProgress != oldProgress) {
+ adapter.updateDownloadProgress(id, newProgress)
+ }
+ }
+
+ adapter.progressMap = adapterProgress
+ lastProgressMap = adapterProgress
+ }
+ }
+ }
+
+ private fun updateStorageInfo() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ val used = downloadManager.getTotalDownloadedSize()
+ val available = downloadManager.getAvailableSpace()
+ binding.tvStorageInfo.text = getString(
+ R.string.download_storage_info,
+ downloadManager.formatFileSize(used),
+ downloadManager.formatFileSize(available)
+ )
+ }
+ }
+
+ private fun playDownload(download: Download) {
+ if (download.status != Download.DownloadStatus.COMPLETED) return
+
+ val localFilePath = download.localFilePath
+ if (localFilePath == null || !File(localFilePath).exists()) {
+ Toast.makeText(requireContext(), getString(R.string.download_file_not_found), Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ val videoType = when (download.contentType) {
+ Download.ContentType.MOVIE -> {
+ Video.Type.Movie(
+ id = download.id,
+ title = download.title,
+ releaseDate = "",
+ poster = download.poster ?: "",
+ imdbId = null,
+ )
+ }
+ Download.ContentType.EPISODE -> {
+ Video.Type.Episode(
+ id = download.id,
+ number = download.episodeNumber ?: 0,
+ title = download.subtitle,
+ poster = download.poster,
+ overview = null,
+ tvShow = Video.Type.Episode.TvShow(
+ id = download.tvShowId ?: "",
+ title = download.tvShowTitle ?: download.title,
+ poster = download.poster,
+ banner = download.banner,
+ releaseDate = null,
+ imdbId = null,
+ ),
+ season = Video.Type.Episode.Season(
+ number = download.seasonNumber ?: 0,
+ title = null,
+ ),
+ )
+ }
+ }
+
+ val subtitle = when (download.contentType) {
+ Download.ContentType.MOVIE -> download.title
+ Download.ContentType.EPISODE -> {
+ if (download.seasonNumber != null && download.episodeNumber != null) {
+ "S${download.seasonNumber} E${download.episodeNumber}" + if (download.subtitle != null) " - ${download.subtitle}" else ""
+ } else {
+ download.subtitle ?: download.title
+ }
+ }
+ }
+
+ val bundle = android.os.Bundle().apply {
+ putString("id", download.id)
+ putString("title", download.title)
+ putString("subtitle", subtitle ?: "")
+ putParcelable("videoType", videoType)
+ putString("localFilePath", localFilePath)
+ putBoolean("isLocalFile", true)
+ }
+
+ findNavController().navigate(R.id.player, bundle)
+ }
+
+ private fun navigateToContent(download: Download) {
+ when (download.contentType) {
+ Download.ContentType.MOVIE -> {
+ try {
+ val action = DownloadsMobileFragmentDirections.actionDownloadsToMovie(download.id)
+ findNavController().navigate(action)
+ } catch (e: Exception) {
+ }
+ }
+ Download.ContentType.EPISODE -> {
+ try {
+ val action = DownloadsMobileFragmentDirections.actionDownloadsToTvShow(
+ download.tvShowId ?: download.id,
+ download.poster,
+ download.banner,
+ )
+ findNavController().navigate(action)
+ } catch (e: Exception) {
+ }
+ }
+ }
+ }
+
+ private fun showDeleteConfirmation(download: Download) {
+ AlertDialog.Builder(requireContext())
+ .setTitle(R.string.download_delete_confirm)
+ .setMessage(R.string.download_delete_message)
+ .setPositiveButton(R.string.download_delete) { _, _ ->
+ deleteDownload(download)
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ }
+
+ private fun deleteDownload(download: Download) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ val localFilePath = download.localFilePath
+ if (localFilePath != null) {
+ val file = File(localFilePath)
+ if (file.exists()) {
+ file.delete()
+ }
+ val dir = file.parentFile
+ if (dir != null && dir.exists() && dir.listFiles()?.isEmpty() == true) {
+ dir.delete()
+ }
+ }
+
+ withContext(Dispatchers.IO) {
+ database.downloadDao().deleteById(download.id)
+ if (download.contentType == Download.ContentType.EPISODE) {
+ val actualEpisodeId = download.id.removePrefix("episode_")
+ database.episodeDao().markAsNotDownloaded(actualEpisodeId)
+ }
+ }
+
+ updateStorageInfo()
+ Toast.makeText(requireContext(), getString(R.string.download_deleted), Toast.LENGTH_SHORT).show()
+ }
+ }
+}
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/fragments/downloads/DownloadsTvFragment.kt b/app/src/main/java/com/streamflixreborn/streamflix/fragments/downloads/DownloadsTvFragment.kt
new file mode 100644
index 00000000..d531d460
--- /dev/null
+++ b/app/src/main/java/com/streamflixreborn/streamflix/fragments/downloads/DownloadsTvFragment.kt
@@ -0,0 +1,580 @@
+package com.streamflixreborn.streamflix.fragments.downloads
+
+import android.os.Bundle
+import android.view.KeyEvent
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.Toast
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.streamflixreborn.streamflix.R
+import com.streamflixreborn.streamflix.adapters.DownloadItem
+import com.streamflixreborn.streamflix.adapters.DownloadsAdapter
+import com.streamflixreborn.streamflix.database.AppDatabase
+import com.streamflixreborn.streamflix.databinding.FragmentDownloadsTvBinding
+import com.streamflixreborn.streamflix.databinding.ItemDownloadHeaderTvBinding
+import com.streamflixreborn.streamflix.models.Download
+import com.streamflixreborn.streamflix.models.Video
+import com.streamflixreborn.streamflix.utils.DownloadManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+
+class DownloadsTvFragment : Fragment() {
+
+ private var _binding: FragmentDownloadsTvBinding? = null
+ private val binding get() = _binding!!
+
+ private val database by lazy { AppDatabase.getInstance(requireContext()) }
+ private val downloadManager by lazy { DownloadManager.getInstance(requireContext()) }
+ private val adapter by lazy {
+ DownloadsAdapter(
+ onPlayClick = { download -> playDownload(download) },
+ onDeleteClick = { download -> showDeleteConfirmation(download) },
+ onTitleClick = { download -> navigateToContent(download) },
+ onNavigateUp = { navigateUpToTabs() },
+ onItemFocused = { position -> ensureHeaderVisibleForFocusedItem(position) },
+ database = database,
+ isTv = true,
+ )
+ }
+
+ private var currentTab = Tab.ALL
+ private var observeDownloadsJob: Job? = null
+ private var cachedHeaderHeight = 0
+ private var pendingScrollToTop = false
+ private var pendingFocusRecovery: FocusRecoveryRequest? = null
+
+ private data class FocusRecoveryRequest(
+ val deletedDownloadId: String,
+ val previousAdapterPosition: Int,
+ )
+
+ enum class Tab {
+ ALL, MOVIES, EPISODES, DOWNLOADING
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentDownloadsTvBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ setupRecyclerView()
+ setupTabs()
+ observeDownloads()
+ observeDownloadProgress()
+ updateStorageInfo()
+
+ getSelectedTabButton().requestFocus()
+
+ binding.tabLayout.setOnFocusChangeListener { _, hasFocus ->
+ if (hasFocus) {
+ val selectedButton = when (currentTab) {
+ Tab.ALL -> binding.tabAll
+ Tab.MOVIES -> binding.tabMovies
+ Tab.EPISODES -> binding.tabEpisodes
+ Tab.DOWNLOADING -> binding.tabDownloading
+ }
+ selectedButton.requestFocus()
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ observeDownloadsJob?.cancel()
+ _binding = null
+ }
+
+ private fun setupRecyclerView() {
+ binding.rvDownloads.adapter = adapter
+
+ binding.rvDownloads.setOnKeyListener { _, keyCode, event ->
+ if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false
+
+ val focusedChild = binding.rvDownloads.findFocus()
+ val viewHolder = focusedChild?.let {
+ binding.rvDownloads.findContainingViewHolder(it)
+ }
+ val position = viewHolder?.bindingAdapterPosition ?: -1
+ val items = adapter.getCurrentList()
+
+ when (keyCode) {
+
+ KeyEvent.KEYCODE_DPAD_LEFT -> {
+ val sideNav = activity?.findViewById(R.id.nav_main)
+ if (sideNav != null) {
+ sideNav.requestFocus()
+ return@setOnKeyListener true
+ }
+ }
+
+ KeyEvent.KEYCODE_DPAD_UP -> {
+ val previousDownloadPosition = findPreviousDownloadPosition(position, items)
+ if (previousDownloadPosition == null) {
+ navigateUpToTabs()
+ return@setOnKeyListener true
+ }
+ if (hasHeaderAbove(previousDownloadPosition, items)) {
+ focusDownloadPosition(previousDownloadPosition, items)
+ return@setOnKeyListener true
+ }
+ return@setOnKeyListener false
+ }
+
+ KeyEvent.KEYCODE_DPAD_DOWN -> {
+ if (items.isEmpty()) return@setOnKeyListener true
+
+ val nextDownloadPosition = findNextDownloadPosition(position, items)
+ if (nextDownloadPosition == null) {
+ return@setOnKeyListener true
+ }
+ }
+ }
+
+ false
+ }
+ }
+
+ private fun navigateUpToTabs() {
+ val selectedButton = when (currentTab) {
+ Tab.ALL -> binding.tabAll
+ Tab.MOVIES -> binding.tabMovies
+ Tab.EPISODES -> binding.tabEpisodes
+ Tab.DOWNLOADING -> binding.tabDownloading
+ }
+ selectedButton.requestFocus()
+ }
+
+ private fun findPreviousDownloadPosition(position: Int, items: List): Int? {
+ for (index in position - 1 downTo 0) {
+ if (items.getOrNull(index) is DownloadItem.Download) return index
+ }
+ return null
+ }
+
+ private fun findNextDownloadPosition(position: Int, items: List): Int? {
+ for (index in position + 1 until items.size) {
+ if (items[index] is DownloadItem.Download) return index
+ }
+ return null
+ }
+
+ private fun hasHeaderAbove(position: Int, items: List): Boolean {
+ return position > 0 && items.getOrNull(position - 1) is DownloadItem.Header
+ }
+
+ private fun focusDownloadPosition(position: Int, items: List) {
+ val layoutManager = binding.rvDownloads.layoutManager as? LinearLayoutManager ?: return
+ val offset = if (hasHeaderAbove(position, items)) resolveHeaderHeight() else 0
+ layoutManager.scrollToPositionWithOffset(position, offset)
+ binding.rvDownloads.post {
+ val target = binding.rvDownloads.findViewHolderForAdapterPosition(position)?.itemView
+ ?: layoutManager.findViewByPosition(position)
+ target?.requestFocus()
+ }
+ }
+
+ private fun ensureHeaderVisibleForFocusedItem(position: Int) {
+ val items = adapter.getCurrentList()
+ if (!hasHeaderAbove(position, items)) return
+
+ binding.rvDownloads.post {
+ val layoutManager = binding.rvDownloads.layoutManager as? LinearLayoutManager ?: return@post
+ val targetView = binding.rvDownloads.findViewHolderForAdapterPosition(position)?.itemView
+ ?: layoutManager.findViewByPosition(position)
+ val headerHeight = resolveHeaderHeight()
+ if (targetView != null && targetView.top < headerHeight) {
+ layoutManager.scrollToPositionWithOffset(position, resolveHeaderHeight())
+ }
+ }
+ }
+
+ private fun resolveHeaderHeight(): Int {
+ if (cachedHeaderHeight > 0) return cachedHeaderHeight
+
+ val headerBinding = ItemDownloadHeaderTvBinding.inflate(layoutInflater, binding.rvDownloads, false)
+ val parentWidth = binding.rvDownloads.width.takeIf { it > 0 }
+ ?: resources.displayMetrics.widthPixels
+ val widthSpec = View.MeasureSpec.makeMeasureSpec(parentWidth, View.MeasureSpec.AT_MOST)
+ val heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
+ headerBinding.root.measure(widthSpec, heightSpec)
+
+ cachedHeaderHeight = headerBinding.root.measuredHeight
+ return cachedHeaderHeight
+ }
+
+ private fun setupTabs() {
+ val sideNavId = R.id.nav_main
+ binding.tabAll.nextFocusLeftId = sideNavId
+ binding.tabMovies.nextFocusLeftId = R.id.tabAll
+ binding.tabEpisodes.nextFocusLeftId = R.id.tabMovies
+ binding.tabDownloading.nextFocusLeftId = R.id.tabEpisodes
+
+ val tabs = listOf(
+ binding.tabAll to Tab.ALL,
+ binding.tabMovies to Tab.MOVIES,
+ binding.tabEpisodes to Tab.EPISODES,
+ binding.tabDownloading to Tab.DOWNLOADING,
+ )
+ tabs.forEach { (button, tab) ->
+ button.setOnClickListener {
+ selectTab(tab, button)
+ }
+ button.setOnKeyListener { _, keyCode, event ->
+ if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
+ val items = adapter.getCurrentList()
+ val firstDownloadPosition = items.indexOfFirst { it is DownloadItem.Download }
+ if (firstDownloadPosition >= 0) {
+ focusDownloadPosition(firstDownloadPosition, items)
+ return@setOnKeyListener true
+ }
+ }
+ false
+ }
+ }
+ selectTab(Tab.ALL, binding.tabAll)
+ }
+
+ private fun selectTab(tab: Tab, selectedButton: Button) {
+ currentTab = tab
+ pendingScrollToTop = true
+ binding.tabAll.isSelected = selectedButton == binding.tabAll
+ binding.tabMovies.isSelected = selectedButton == binding.tabMovies
+ binding.tabEpisodes.isSelected = selectedButton == binding.tabEpisodes
+ binding.tabDownloading.isSelected = selectedButton == binding.tabDownloading
+ observeDownloads()
+ }
+
+ private fun observeDownloads() {
+ observeDownloadsJob?.cancel()
+ observeDownloadsJob = viewLifecycleOwner.lifecycleScope.launch {
+ val flow = when (currentTab) {
+ Tab.ALL -> database.downloadDao().getAllDownloads()
+ Tab.MOVIES -> database.downloadDao().getCompletedMovies()
+ Tab.EPISODES -> database.downloadDao().getCompletedEpisodes()
+ Tab.DOWNLOADING -> database.downloadDao().getActiveDownloads()
+ }
+
+ flow.collectLatest { downloads ->
+ withContext(Dispatchers.Main) {
+ val groupedItems = groupDownloads(downloads)
+ adapter.submitList(groupedItems)
+ maybeRecoverFocusAfterListMutation(groupedItems)
+
+ if (pendingScrollToTop) {
+ pendingScrollToTop = false
+ if (groupedItems.isNotEmpty()) {
+ binding.rvDownloads.scrollToPosition(0)
+ }
+ }
+ binding.llEmpty.visibility = if (groupedItems.isEmpty()) View.VISIBLE else View.GONE
+ binding.rvDownloads.isFocusable = groupedItems.isNotEmpty()
+ binding.rvDownloads.isFocusableInTouchMode = groupedItems.isNotEmpty()
+ if (groupedItems.isEmpty() && binding.rvDownloads.hasFocus()) {
+ getSelectedTabButton().requestFocus()
+ }
+ }
+ }
+ }
+ }
+
+ private fun groupDownloads(downloads: List): List {
+ val result = mutableListOf()
+
+ val (episodes, nonEpisodes) = downloads.partition { it.contentType == Download.ContentType.EPISODE }
+
+ val groupedEpisodes = episodes
+ .groupBy { it.tvShowTitle ?: "Unknown" }
+ .toSortedMap()
+ .mapValues { (_, showDownloads) ->
+ showDownloads.groupBy { it.seasonNumber ?: 0 }
+ .toSortedMap()
+ .flatMap { (season, seasonDownloads) ->
+ listOf(
+ DownloadItem.Header(
+ tvShowId = seasonDownloads.firstOrNull()?.tvShowId ?: "",
+ tvShowTitle = seasonDownloads.firstOrNull()?.tvShowTitle ?: "Unknown",
+ seasonNumber = season,
+ )
+ ) + seasonDownloads.sortedBy { it.episodeNumber ?: 0 }
+ .map { DownloadItem.Download(it) }
+ }
+ }
+ .values
+ .flatten()
+
+ result.addAll(groupedEpisodes)
+
+ if (currentTab == Tab.ALL || currentTab == Tab.MOVIES) {
+ val movies = nonEpisodes.filter { it.contentType == Download.ContentType.MOVIE }
+ .sortedByDescending { it.completedAt ?: 0L }
+ movies.forEach { result.add(DownloadItem.Download(it)) }
+ }
+
+ if (currentTab == Tab.DOWNLOADING) {
+ nonEpisodes.filter { it.contentType == Download.ContentType.MOVIE }
+ .forEach { result.add(DownloadItem.Download(it)) }
+ }
+
+ return result
+ }
+
+ private var lastProgressMap: Map = emptyMap()
+
+ private fun observeDownloadProgress() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ downloadManager.downloadProgress.collectLatest { progressMap ->
+ val adapterProgress = progressMap.mapValues { (_, info) ->
+ DownloadsAdapter.DownloadProgress(
+ progress = info.progress,
+ status = when (info.status) {
+ DownloadManager.DownloadStatus.DOWNLOADING -> getString(R.string.download_status_downloading)
+ DownloadManager.DownloadStatus.PAUSED -> getString(R.string.download_status_paused)
+ DownloadManager.DownloadStatus.COMPLETED -> getString(R.string.download_completed)
+ DownloadManager.DownloadStatus.FAILED -> getString(R.string.download_status_failed)
+ DownloadManager.DownloadStatus.CANCELLED -> getString(R.string.download_status_cancelled)
+ },
+ speed = info.speed,
+ etaSeconds = info.etaSeconds,
+ )
+ }
+
+ for ((id, newProgress) in adapterProgress) {
+ val oldProgress = lastProgressMap[id]
+ if (newProgress != oldProgress) {
+ adapter.updateDownloadProgress(id, newProgress)
+ }
+ }
+
+ adapter.progressMap = adapterProgress
+ lastProgressMap = adapterProgress
+ }
+ }
+ }
+
+ private fun updateStorageInfo() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ val used = downloadManager.getTotalDownloadedSize()
+ val available = downloadManager.getAvailableSpace()
+ binding.tvStorageInfo.text = getString(
+ R.string.download_storage_info,
+ downloadManager.formatFileSize(used),
+ downloadManager.formatFileSize(available)
+ )
+ }
+ }
+
+ private fun playDownload(download: Download) {
+ if (download.status != Download.DownloadStatus.COMPLETED) return
+
+ val localFilePath = download.localFilePath
+ if (localFilePath == null || !File(localFilePath).exists()) {
+ Toast.makeText(requireContext(), "Downloaded file not found", Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ val videoType = when (download.contentType) {
+ Download.ContentType.MOVIE -> {
+ Video.Type.Movie(
+ id = download.id,
+ title = download.title,
+ releaseDate = "",
+ poster = download.poster ?: "",
+ imdbId = null,
+ )
+ }
+ Download.ContentType.EPISODE -> {
+ Video.Type.Episode(
+ id = download.id,
+ number = download.episodeNumber ?: 0,
+ title = download.subtitle,
+ poster = download.poster,
+ overview = null,
+ tvShow = Video.Type.Episode.TvShow(
+ id = download.tvShowId ?: "",
+ title = download.tvShowTitle ?: download.title,
+ poster = download.poster,
+ banner = download.banner,
+ releaseDate = null,
+ imdbId = null,
+ ),
+ season = Video.Type.Episode.Season(
+ number = download.seasonNumber ?: 0,
+ title = null,
+ ),
+ )
+ }
+ }
+
+ val subtitle = when (download.contentType) {
+ Download.ContentType.MOVIE -> download.title
+ Download.ContentType.EPISODE -> {
+ if (download.seasonNumber != null && download.episodeNumber != null) {
+ "S${download.seasonNumber} E${download.episodeNumber}" + if (download.subtitle != null) " - ${download.subtitle}" else ""
+ } else {
+ download.subtitle ?: download.title
+ }
+ }
+ }
+
+ val bundle = android.os.Bundle().apply {
+ putString("id", download.id)
+ putString("title", download.title)
+ putString("subtitle", subtitle ?: "")
+ putParcelable("videoType", videoType)
+ putString("localFilePath", localFilePath)
+ putBoolean("isLocalFile", true)
+ }
+
+ findNavController().navigate(R.id.player, bundle)
+ }
+
+ private fun navigateToContent(download: Download) {
+ when (download.contentType) {
+ Download.ContentType.MOVIE -> {
+ try {
+ val action = DownloadsTvFragmentDirections.actionDownloadsToMovie(download.id)
+ findNavController().navigate(action)
+ } catch (e: Exception) {
+ }
+ }
+ Download.ContentType.EPISODE -> {
+ try {
+ val action = DownloadsTvFragmentDirections.actionDownloadsToTvShow(
+ download.tvShowId ?: download.id,
+ download.poster,
+ download.banner,
+ )
+ findNavController().navigate(action)
+ } catch (e: Exception) {
+ }
+ }
+ }
+ }
+
+ private fun showDeleteConfirmation(download: Download) {
+ val focusedChild = binding.rvDownloads.findFocus()
+ val viewHolder = focusedChild?.let { binding.rvDownloads.findContainingViewHolder(it) }
+ val position = viewHolder?.bindingAdapterPosition ?: -1
+ if (position >= 0) {
+ val item = adapter.getCurrentList().getOrNull(position)
+ if (item is DownloadItem.Download) {
+ pendingFocusRecovery = FocusRecoveryRequest(
+ deletedDownloadId = item.download.id,
+ previousAdapterPosition = position,
+ )
+ }
+ }
+
+ AlertDialog.Builder(requireContext())
+ .setTitle(R.string.download_delete_confirm)
+ .setMessage(R.string.download_delete_message)
+ .setPositiveButton(R.string.download_delete) { _, _ ->
+ deleteDownload(download)
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ pendingFocusRecovery = null
+ }
+ .setOnCancelListener {
+ pendingFocusRecovery = null
+ }
+ .show()
+ }
+
+ private fun deleteDownload(download: Download) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ val localFilePath = download.localFilePath
+ if (localFilePath != null) {
+ val file = File(localFilePath)
+ if (file.exists()) {
+ file.delete()
+ }
+ val dir = file.parentFile
+ if (dir != null && dir.exists() && dir.listFiles()?.isEmpty() == true) {
+ dir.delete()
+ }
+ }
+
+ withContext(Dispatchers.IO) {
+ database.downloadDao().deleteById(download.id)
+ if (download.contentType == Download.ContentType.EPISODE) {
+ val actualEpisodeId = download.id.removePrefix("episode_")
+ database.episodeDao().markAsNotDownloaded(actualEpisodeId)
+ }
+ }
+
+ updateStorageInfo()
+ Toast.makeText(requireContext(), "Download deleted", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun maybeRecoverFocusAfterListMutation(groupedItems: List) {
+ val recovery = pendingFocusRecovery ?: run {
+ return
+ }
+
+ val deletedStillExists = groupedItems.any { item ->
+ item is DownloadItem.Download && item.download.id == recovery.deletedDownloadId
+ }
+ if (deletedStillExists) return
+
+ pendingFocusRecovery = null
+
+ binding.rvDownloads.post {
+ if (!isAdded || view == null) return@post
+
+ val targetPosition = findNearestValidDownloadPosition(
+ startPosition = recovery.previousAdapterPosition,
+ items = groupedItems,
+ )
+
+ if (targetPosition != null) {
+ focusDownloadPosition(targetPosition, groupedItems)
+ } else {
+ binding.rvDownloads.clearFocus()
+ getSelectedTabButton().requestFocus()
+ }
+ }
+ }
+
+ private fun findNearestValidDownloadPosition(
+ startPosition: Int,
+ items: List,
+ ): Int? {
+ if (items.isEmpty()) return null
+
+ val boundedStart = startPosition.coerceIn(0, items.lastIndex)
+ for (index in boundedStart until items.size) {
+ if (items.getOrNull(index) is DownloadItem.Download) return index
+ }
+ for (index in boundedStart - 1 downTo 0) {
+ if (items.getOrNull(index) is DownloadItem.Download) return index
+ }
+ return null
+ }
+
+ private fun getSelectedTabButton(): Button {
+ return when (currentTab) {
+ Tab.ALL -> binding.tabAll
+ Tab.MOVIES -> binding.tabMovies
+ Tab.EPISODES -> binding.tabEpisodes
+ Tab.DOWNLOADING -> binding.tabDownloading
+ }
+ }
+}
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/fragments/player/PlayerMobileFragment.kt b/app/src/main/java/com/streamflixreborn/streamflix/fragments/player/PlayerMobileFragment.kt
index ff3046ad..a204d64a 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/fragments/player/PlayerMobileFragment.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/fragments/player/PlayerMobileFragment.kt
@@ -116,7 +116,7 @@ class PlayerMobileFragment : Fragment() {
private val args by navArgs()
private val database by lazy { AppDatabase.getInstance(requireContext()) }
- private val viewModel by viewModelsFactory { PlayerViewModel(args.videoType, args.id) }
+ private val viewModel by viewModelsFactory { PlayerViewModel(args.videoType, args.id, args.isLocalFile) }
private lateinit var player: ExoPlayer
private lateinit var httpDataSource: HttpDataSource.Factory
@@ -267,7 +267,14 @@ class PlayerMobileFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initializePlayer(false)
- initializeVideo()
+
+ if (args.isLocalFile && args.localFilePath != null) {
+ setupLocalFilePlayback()
+ playLocalFile(args.localFilePath!!)
+ } else {
+ initializeVideo()
+ }
+
gestureHelper = PlayerGestureHelper(
requireContext(),
binding.pvPlayer,
@@ -370,6 +377,10 @@ class PlayerMobileFragment : Fragment() {
displayVideo(state.video, state.server)
}
+ is PlayerViewModel.State.SuccessLoadingLocalFile -> {
+ playLocalFile(state.localFilePath)
+ }
+
is PlayerViewModel.State.FailedLoadingVideo -> {
val nextServer = servers.getOrNull(servers.indexOf(state.server) + 1)
if (nextServer != null) {
@@ -500,7 +511,9 @@ class PlayerMobileFragment : Fragment() {
id = nextEpisode.id,
videoType = nextEpisode,
title = nextEpisode.tvShow.title,
- subtitle = "S${nextEpisode.season.number} E${nextEpisode.number} • ${nextEpisode.title}"
+ subtitle = "S${nextEpisode.season.number} E${nextEpisode.number} • ${nextEpisode.title}",
+ isLocalFile = nextEpisode.isDownloaded,
+ localFilePath = nextEpisode.localFilePath,
)
hideNextEpisodeOverlay()
@@ -745,7 +758,7 @@ class PlayerMobileFragment : Fragment() {
}
}
- private fun updatePlayerScale() {
+ private fun updatePlayerScale() {
val videoSurfaceView = binding.pvPlayer.videoSurfaceView
val playerResize = UserPreferences.playerResize
@@ -772,6 +785,203 @@ class PlayerMobileFragment : Fragment() {
}
}
+ private fun setupLocalFilePlayback() {
+ WindowCompat.getInsetsController(
+ requireActivity().window,
+ requireActivity().window.decorView
+ ).run {
+ systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ hide(WindowInsetsCompat.Type.systemBars())
+ }
+ requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+
+ binding.btnNextEpisodeAction.setOnClickListener {
+ hideNextEpisodeOverlay()
+ playNextEpisodeAcrossSeasons()
+ }
+ binding.btnNextEpisodeDismiss.setOnClickListener {
+ nextEpisodeOverlayDismissed = true
+ hideNextEpisodeOverlay()
+ }
+ }
+
+ private fun setupLocalFileEpisodeNavigation(type: Video.Type.Episode) {
+ nextEpisodeOverlayDismissed = false
+ nextEpisodePrefetchTargetId = null
+
+ if (EpisodeManager.listIsEmpty(type)) {
+ EpisodeManager.clearEpisodes()
+ lifecycleScope.launch(Dispatchers.IO) {
+ EpisodeManager.addEpisodesFromDb(type, database)
+ withContext(Dispatchers.Main) {
+ EpisodeManager.setCurrentEpisode(type)
+ setupEpisodeNavigationButtons()
+ }
+ }
+ } else {
+ EpisodeManager.setCurrentEpisode(type)
+ setupEpisodeNavigationButtons()
+ }
+ }
+
+ private fun playLocalFile(localFilePath: String) {
+ val file = java.io.File(localFilePath)
+ if (!file.exists()) {
+ Toast.makeText(requireContext(), "File not found: $localFilePath", Toast.LENGTH_LONG).show()
+ findNavController().navigateUp()
+ return
+ }
+
+ val videoType = args.videoType
+ if (videoType is Video.Type.Episode) {
+ setupLocalFileEpisodeNavigation(videoType)
+ }
+
+ updatePlayerHeader()
+
+ binding.pvPlayer.controller.binding.btnExoBack.setOnClickListener {
+ findNavController().navigateUp()
+ }
+
+ binding.pvPlayer.controller.binding.exoReplay.setOnClickListener {
+ player.seekTo(0)
+ }
+
+ binding.pvPlayer.controller.binding.btnExoLock.setOnClickListener {
+ binding.pvPlayer.controller.binding.gControlsLock.isGone = true
+ binding.pvPlayer.controller.binding.btnExoUnlock.isVisible = true
+ }
+
+ binding.pvPlayer.controller.binding.btnExoUnlock.setOnClickListener {
+ binding.pvPlayer.controller.binding.gControlsLock.isVisible = true
+ binding.pvPlayer.controller.binding.btnExoUnlock.isGone = true
+ }
+
+ binding.pvPlayer.controller.binding.btnExoPictureInPicture.setOnClickListener {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ Toast.makeText(
+ requireContext(),
+ getString(R.string.player_picture_in_picture_not_supported),
+ Toast.LENGTH_SHORT
+ ).show()
+ } else {
+ enterPIPMode()
+ }
+ }
+
+ binding.pvPlayer.controller.binding.btnExoAspectRatio.setOnClickListener {
+ val newResize = UserPreferences.playerResize.next()
+ zoomToast?.cancel()
+ zoomToast = Toast.makeText(requireContext(), newResize.stringRes, Toast.LENGTH_SHORT)
+ zoomToast?.show()
+
+ UserPreferences.playerResize = newResize
+ binding.pvPlayer.controllerShowTimeoutMs = binding.pvPlayer.controllerShowTimeoutMs
+ updatePlayerScale()
+ }
+
+ binding.pvPlayer.controller.binding.exoSettings.setOnClickListener {
+ binding.pvPlayer.controllerShowTimeoutMs = binding.pvPlayer.controllerShowTimeoutMs
+ binding.settings.show()
+ }
+
+ val uri = localFilePath.toUri()
+ player.setMediaItem(
+ MediaItem.Builder()
+ .setUri(uri)
+ .build()
+ )
+ player.prepare()
+ player.play()
+
+ player.addListener(object : Player.Listener {
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ super.onIsPlayingChanged(isPlaying)
+ binding.pvPlayer.keepScreenOn = isPlaying || UserPreferences.keepScreenOnWhenPaused
+
+ if (isPlaying) {
+ startProgressHandler()
+ } else {
+ stopProgressHandler()
+ }
+
+ val hasUri = player.currentMediaItem?.localConfiguration?.uri
+ ?.toString()?.isNotEmpty()
+ ?: false
+
+ if (!isPlaying && hasUri) {
+ val videoType = args.videoType
+ val watchItem: WatchItem? = when (videoType) {
+ is Video.Type.Movie -> database.movieDao().getById(videoType.id)
+ is Video.Type.Episode -> database.episodeDao().getById(videoType.id)
+ }
+
+ when {
+ player.hasStarted() && !player.hasFinished() -> {
+ watchItem?.isWatched = false
+ watchItem?.watchedDate = null
+ watchItem?.watchHistory = WatchItem.WatchHistory(
+ lastEngagementTimeUtcMillis = System.currentTimeMillis(),
+ lastPlaybackPositionMillis = player.currentPosition,
+ durationMillis = player.duration,
+ )
+ }
+
+ player.hasFinished() -> {
+ watchItem?.isWatched = true
+ watchItem?.watchedDate = Calendar.getInstance()
+ watchItem?.watchHistory = null
+ }
+ }
+
+ when (videoType) {
+ is Video.Type.Movie -> {
+ val provider = UserPreferences.currentProvider ?: return
+ val movie = watchItem as? Movie
+ movie?.let {
+ database.movieDao().update(it)
+ UserDataCache.syncMovieToCache(requireContext(), provider, it)
+ }
+ }
+
+ is Video.Type.Episode -> {
+ val provider = UserPreferences.currentProvider ?: return
+ val episode = watchItem as? Episode
+ episode?.let {
+ if (player.hasFinished()) {
+ database.episodeDao().resetProgressionFromEpisode(videoType.id)
+ UserDataCache.removeEpisodeFromContinueWatching(requireContext(), provider, it.id)
+ queueNextEpisodeForContinueWatching(provider)
+ }
+ database.episodeDao().update(it)
+ if (!player.hasFinished()) {
+ UserDataCache.syncEpisodeToCache(requireContext(), provider, it)
+ }
+
+ it.tvShow?.let { tvShow ->
+ database.tvShowDao().getById(tvShow.id)
+ }?.let { tvShow ->
+ val episodeDao = database.episodeDao()
+ val isStillWatching = episodeDao.hasAnyWatchHistoryForTvShow(tvShow.id)
+
+ database.tvShowDao().save(tvShow.copy().apply {
+ merge(tvShow)
+ isWatching = !player.hasReallyFinished() || isStillWatching
+ })
+ }
+ }
+ }
+ }
+ if (player.hasReallyFinished()) {
+ if (UserPreferences.autoplay) {
+ playNextEpisodeAcrossSeasons(autoplay = true)
+ }
+ }
+ }
+ }
+ })
+ }
+
fun setupEpisodeNavigationButtons() {
val btnPrevious = binding.pvPlayer.controller.binding.btnCustomPrev
val btnNext = binding.pvPlayer.controller.binding.btnCustomNext
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/fragments/player/PlayerTvFragment.kt b/app/src/main/java/com/streamflixreborn/streamflix/fragments/player/PlayerTvFragment.kt
index efd3b816..a5216ce7 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/fragments/player/PlayerTvFragment.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/fragments/player/PlayerTvFragment.kt
@@ -135,7 +135,7 @@ class PlayerTvFragment : Fragment() {
private val args by navArgs()
private val database by lazy { AppDatabase.getInstance(requireContext()) }
- private val viewModel by viewModelsFactory { PlayerViewModel(args.videoType, args.id) }
+ private val viewModel by viewModelsFactory { PlayerViewModel(args.videoType, args.id, args.isLocalFile) }
private lateinit var player: ExoPlayer
private lateinit var httpDataSource: HttpDataSource.Factory
@@ -255,9 +255,15 @@ class PlayerTvFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
initializePlayer(false)
- initializeVideo()
- binding.pvPlayer.onMediaPreviousClicked = ::handleMediaPrevious
- binding.pvPlayer.onMediaNextClicked = ::handleMediaNext
+
+ if (args.isLocalFile && args.localFilePath != null) {
+ setupLocalFilePlayback()
+ playLocalFile(args.localFilePath!!)
+ } else {
+ initializeVideo()
+ binding.pvPlayer.onMediaPreviousClicked = ::handleMediaPrevious
+ binding.pvPlayer.onMediaNextClicked = ::handleMediaNext
+ }
gestureHelper = PlayerGestureHelper(
requireContext(),
binding.pvPlayer,
@@ -400,6 +406,10 @@ class PlayerTvFragment : Fragment() {
displayVideo(state.video, state.server)
}
+ is PlayerViewModel.State.SuccessLoadingLocalFile -> {
+ playLocalFile(state.localFilePath)
+ }
+
is PlayerViewModel.State.FailedLoadingVideo -> {
val nextServer = servers.getOrNull(servers.indexOf(state.server) + 1)
if (nextServer != null) {
@@ -572,6 +582,8 @@ class PlayerTvFragment : Fragment() {
"subtitle",
"S${nextEpisode.season.number} E${nextEpisode.number} • ${nextEpisode.title}"
)
+ putBoolean("isLocalFile", nextEpisode.isDownloaded)
+ putString("localFilePath", nextEpisode.localFilePath)
}
hideNextEpisodeOverlay()
@@ -894,6 +906,209 @@ class PlayerTvFragment : Fragment() {
}
}
+ private fun setupLocalFilePlayback() {
+ binding.pvPlayer.onMediaPreviousClicked = ::handleMediaPrevious
+ binding.pvPlayer.onMediaNextClicked = ::handleMediaNext
+
+ binding.btnNextEpisodeAction.setOnClickListener {
+ hideNextEpisodeOverlay()
+ playNextEpisodeAcrossSeasons()
+ }
+ binding.btnNextEpisodeDismiss.setOnClickListener {
+ nextEpisodeOverlayDismissed = true
+ hideNextEpisodeOverlay()
+ }
+ binding.btnNextEpisodeAction.setOnFocusChangeListener { _, hasFocus ->
+ updateNextEpisodeOverlayAlpha(hasFocus || binding.btnNextEpisodeDismiss.hasFocus())
+ }
+ binding.btnNextEpisodeDismiss.setOnFocusChangeListener { _, hasFocus ->
+ updateNextEpisodeOverlayAlpha(hasFocus || binding.btnNextEpisodeAction.hasFocus())
+ }
+ }
+
+ private fun setupLocalFileEpisodeNavigation(type: Video.Type.Episode) {
+ nextEpisodeOverlayDismissed = false
+ nextEpisodePrefetchTargetId = null
+
+ if (EpisodeManager.listIsEmpty(type)) {
+ EpisodeManager.clearEpisodes()
+ lifecycleScope.launch(Dispatchers.IO) {
+ EpisodeManager.addEpisodesFromDb(type, database)
+ withContext(Dispatchers.Main) {
+ EpisodeManager.setCurrentEpisode(type)
+ setupEpisodeNavigationButtons()
+ }
+ }
+ } else {
+ EpisodeManager.setCurrentEpisode(type)
+ setupEpisodeNavigationButtons()
+ }
+ }
+
+ private fun playLocalFile(localFilePath: String) {
+ val file = File(localFilePath)
+ if (!file.exists()) {
+ Toast.makeText(requireContext(), "File not found: $localFilePath", Toast.LENGTH_LONG).show()
+ findNavController().navigateUp()
+ return
+ }
+
+ val videoType = args.videoType
+ if (videoType is Video.Type.Episode) {
+ setupLocalFileEpisodeNavigation(videoType)
+ }
+
+ updatePlayerHeader()
+
+ binding.pvPlayer.resizeMode = UserPreferences.playerResize.resizeMode
+ binding.pvPlayer.subtitleView?.apply {
+ setFractionalTextSize(SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * UserPreferences.captionTextSize)
+ setStyle(UserPreferences.captionStyle)
+ setPadding(0, 0, 0, UserPreferences.captionMargin.dp(context))
+ }
+
+ binding.pvPlayer.controller.binding.exoReplay.setOnClickListener {
+ player.seekTo(0)
+ }
+
+ binding.pvPlayer.controller.binding.exoProgress.setKeyTimeIncrement(10_000)
+
+ binding.pvPlayer.controller.binding.btnExoAspectRatio.setOnClickListener {
+ val newResize = UserPreferences.playerResize.next()
+ zoomToast?.cancel()
+ zoomToast = Toast.makeText(requireContext(), newResize.stringRes, Toast.LENGTH_SHORT)
+ zoomToast?.show()
+
+ UserPreferences.playerResize = newResize
+ binding.pvPlayer.controllerShowTimeoutMs = binding.pvPlayer.controllerShowTimeoutMs
+ updatePlayerScale()
+ }
+
+ binding.pvPlayer.controller.binding.exoSettings.setOnClickListener {
+ binding.pvPlayer.controllerShowTimeoutMs = binding.pvPlayer.controllerShowTimeoutMs
+ binding.settings.show()
+ }
+
+ binding.settings.setOnLocalSubtitlesClickedListener {
+ pickLocalSubtitle.launch(
+ arrayOf(
+ "text/plain",
+ "text/str",
+ "application/octet-stream",
+ MimeTypes.TEXT_UNKNOWN,
+ MimeTypes.TEXT_VTT,
+ MimeTypes.TEXT_SSA,
+ MimeTypes.APPLICATION_TTML,
+ MimeTypes.APPLICATION_MP4VTT,
+ MimeTypes.APPLICATION_SUBRIP,
+ )
+ )
+ }
+
+ binding.settings.setOnExtraBufferingSelectedListener {
+ displayVideo(
+ currentVideo ?: return@setOnExtraBufferingSelectedListener,
+ currentServer ?: return@setOnExtraBufferingSelectedListener
+ )
+ }
+
+ val uri = localFilePath.toUri()
+ player.setMediaItem(
+ MediaItem.Builder()
+ .setUri(uri)
+ .build()
+ )
+ player.prepare()
+ player.play()
+
+ player.addListener(object : Player.Listener {
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ super.onIsPlayingChanged(isPlaying)
+ binding.pvPlayer.keepScreenOn = isPlaying || UserPreferences.keepScreenOnWhenPaused
+
+ if (isPlaying) {
+ startProgressHandler()
+ } else {
+ stopProgressHandler()
+ }
+
+ val hasUri = player.currentMediaItem?.localConfiguration?.uri
+ ?.toString()?.isNotEmpty()
+ ?: false
+
+ if (!isPlaying && hasUri) {
+ val videoType = args.videoType
+ val watchItem: WatchItem? = when (videoType) {
+ is Video.Type.Movie -> database.movieDao().getById(videoType.id)
+ is Video.Type.Episode -> database.episodeDao().getById(videoType.id)
+ }
+
+ when {
+ player.hasStarted() && !player.hasFinished() -> {
+ watchItem?.isWatched = false
+ watchItem?.watchedDate = null
+ watchItem?.watchHistory = WatchItem.WatchHistory(
+ lastEngagementTimeUtcMillis = System.currentTimeMillis(),
+ lastPlaybackPositionMillis = player.currentPosition,
+ durationMillis = player.duration,
+ )
+ }
+
+ player.hasFinished() -> {
+ watchItem?.isWatched = true
+ watchItem?.watchedDate = Calendar.getInstance()
+ watchItem?.watchHistory = null
+ }
+ }
+
+ when (videoType) {
+ is Video.Type.Movie -> {
+ val provider = UserPreferences.currentProvider ?: return
+ val movie = watchItem as? Movie
+ movie?.let {
+ database.movieDao().update(it)
+ UserDataCache.syncMovieToCache(requireContext(), provider, it)
+ }
+ }
+
+ is Video.Type.Episode -> {
+ val provider = UserPreferences.currentProvider ?: return
+ val episode = watchItem as? Episode
+ episode?.let {
+ if (player.hasFinished()) {
+ database.episodeDao().resetProgressionFromEpisode(videoType.id)
+ UserDataCache.removeEpisodeFromContinueWatching(requireContext(), provider, it.id)
+ queueNextEpisodeForContinueWatching(provider)
+ }
+ database.episodeDao().update(it)
+ if (!player.hasFinished()) {
+ UserDataCache.syncEpisodeToCache(requireContext(), provider, it)
+ }
+
+ it.tvShow?.let { tvShow ->
+ database.tvShowDao().getById(tvShow.id)
+ }?.let { tvShow ->
+ val episodeDao = database.episodeDao()
+ val isStillWatching = episodeDao.hasAnyWatchHistoryForTvShow(tvShow.id)
+
+ database.tvShowDao().save(tvShow.copy().apply {
+ merge(tvShow)
+ isWatching = !player.hasReallyFinished() || isStillWatching
+ })
+ }
+ }
+ }
+ }
+ if (player.hasReallyFinished()) {
+ if (UserPreferences.autoplay) {
+ playNextEpisodeAcrossSeasons(autoplay = true)
+ }
+ }
+ }
+ }
+ })
+ }
+
fun setupEpisodeNavigationButtons() {
val btnPrevious = binding.pvPlayer.controller.binding.btnCustomPrev
val btnNext = binding.pvPlayer.controller.binding.btnCustomNext
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/fragments/player/PlayerViewModel.kt b/app/src/main/java/com/streamflixreborn/streamflix/fragments/player/PlayerViewModel.kt
index 22cf633a..993e9fb9 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/fragments/player/PlayerViewModel.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/fragments/player/PlayerViewModel.kt
@@ -22,6 +22,7 @@ import com.streamflixreborn.streamflix.utils.SubDL
class PlayerViewModel(
videoType: Video.Type,
id: String,
+ isLocalFile: Boolean = false,
) : ViewModel() {
private val _state = MutableStateFlow(State.LoadingServers)
@@ -33,8 +34,10 @@ class PlayerViewModel(
private val _playPreviousOrNextEpisode = MutableSharedFlow()
val playPreviousOrNextEpisode: SharedFlow = _playPreviousOrNextEpisode
init {
- getServers(videoType, id)
- getSubtitles(videoType)
+ if (!isLocalFile) {
+ getServers(videoType, id)
+ getSubtitles(videoType)
+ }
}
fun playEpisode(direction: Direction) {
@@ -67,7 +70,9 @@ class PlayerViewModel(
season = Video.Type.Episode.Season(
number = ep.season.number,
title = ep.season.title
- )
+ ),
+ isDownloaded = ep.isDownloaded,
+ localFilePath = ep.localFilePath,
)
playEpisode(nextEpisode)
@@ -90,8 +95,18 @@ class PlayerViewModel(
}
}
fun playEpisode(episode: Video.Type.Episode) {
- getServers(episode, episode.id)
- getSubtitles(episode)
+ if (episode.isDownloaded && !episode.localFilePath.isNullOrEmpty()) {
+ playLocalEpisode(episode)
+ } else {
+ getServers(episode, episode.id)
+ getSubtitles(episode)
+ }
+ }
+
+ private fun playLocalEpisode(episode: Video.Type.Episode) = viewModelScope.launch(Dispatchers.IO) {
+ lastVideoType = episode
+ lastId = episode.id
+ _state.emit(State.SuccessLoadingLocalFile(episode.localFilePath!!))
}
private fun getServers(videoType: Video.Type, id: String) = viewModelScope.launch(Dispatchers.IO) {
@@ -235,6 +250,7 @@ class PlayerViewModel(
data class LoadingVideo(val server: Video.Server) : State()
data class SuccessLoadingVideo(val video: Video, val server: Video.Server) : State()
data class FailedLoadingVideo(val error: Exception, val server: Video.Server) : State()
+ data class SuccessLoadingLocalFile(val localFilePath: String) : State()
}
sealed class SubtitleState {
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/fragments/season/SeasonMobileFragment.kt b/app/src/main/java/com/streamflixreborn/streamflix/fragments/season/SeasonMobileFragment.kt
index fa512551..dc9b6fe4 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/fragments/season/SeasonMobileFragment.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/fragments/season/SeasonMobileFragment.kt
@@ -5,6 +5,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
+import android.util.Log
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
@@ -12,16 +13,25 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import com.streamflixreborn.streamflix.R
import com.streamflixreborn.streamflix.adapters.AppAdapter
import com.streamflixreborn.streamflix.database.AppDatabase
import com.streamflixreborn.streamflix.databinding.FragmentSeasonMobileBinding
+import com.streamflixreborn.streamflix.models.Download
import com.streamflixreborn.streamflix.models.Episode
+import com.streamflixreborn.streamflix.models.Video
import com.streamflixreborn.streamflix.ui.SpacingItemDecoration
import com.streamflixreborn.streamflix.utils.CacheUtils
+import com.streamflixreborn.streamflix.utils.DownloadManager
+import com.streamflixreborn.streamflix.services.DownloadService
import com.streamflixreborn.streamflix.utils.LoggingUtils
+import com.streamflixreborn.streamflix.utils.UserPreferences
import com.streamflixreborn.streamflix.utils.dp
+import com.streamflixreborn.streamflix.utils.format
import com.streamflixreborn.streamflix.utils.viewModelsFactory
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
class SeasonMobileFragment : Fragment() {
@@ -41,6 +51,7 @@ class SeasonMobileFragment : Fragment() {
}
private val appAdapter = AppAdapter()
+ private var currentEpisodes: List = emptyList()
override fun onCreateView(
inflater: LayoutInflater,
@@ -55,6 +66,8 @@ class SeasonMobileFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
initializeSeason()
+ setupDownloadAllButton()
+ observeDownloadChanges()
viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collect { state ->
@@ -73,7 +86,7 @@ class SeasonMobileFragment : Fragment() {
if (code == 409 && !hasAutoCleared409) {
hasAutoCleared409 = true
CacheUtils.clearAppCache(requireContext())
- android.widget.Toast.makeText(requireContext(), getString(com.streamflixreborn.streamflix.R.string.clear_cache_done_409), android.widget.Toast.LENGTH_SHORT).show()
+ android.widget.Toast.makeText(requireContext(), getString(R.string.clear_cache_done_409), android.widget.Toast.LENGTH_SHORT).show()
viewModel.getSeasonEpisodes(args.seasonId)
return@collect
}
@@ -82,19 +95,19 @@ class SeasonMobileFragment : Fragment() {
state.error.message ?: "",
Toast.LENGTH_SHORT
).show()
- binding.isLoading.apply {
+ binding.isLoading.apply {
pbIsLoading.visibility = View.GONE
gIsLoadingRetry.visibility = View.VISIBLE
- val doRetry = { viewModel.getSeasonEpisodes(args.seasonId) }
- btnIsLoadingRetry.setOnClickListener { doRetry() }
- btnIsLoadingClearCache.setOnClickListener {
- CacheUtils.clearAppCache(requireContext())
- android.widget.Toast.makeText(requireContext(), getString(com.streamflixreborn.streamflix.R.string.clear_cache_done), android.widget.Toast.LENGTH_SHORT).show()
- doRetry()
- }
- btnIsLoadingErrorDetails.setOnClickListener {
- LoggingUtils.showErrorDialog(requireContext(), state.error)
- }
+ val doRetry = { viewModel.getSeasonEpisodes(args.seasonId) }
+ btnIsLoadingRetry.setOnClickListener { doRetry() }
+ btnIsLoadingClearCache.setOnClickListener {
+ CacheUtils.clearAppCache(requireContext())
+ android.widget.Toast.makeText(requireContext(), getString(R.string.clear_cache_done), android.widget.Toast.LENGTH_SHORT).show()
+ doRetry()
+ }
+ btnIsLoadingErrorDetails.setOnClickListener {
+ LoggingUtils.showErrorDialog(requireContext(), state.error)
+ }
}
}
}
@@ -107,7 +120,6 @@ class SeasonMobileFragment : Fragment() {
_binding = null
}
-
private fun initializeSeason() {
binding.tvSeasonTitle.text = args.seasonTitle
@@ -121,11 +133,43 @@ class SeasonMobileFragment : Fragment() {
}
}
+ private fun setupDownloadAllButton() {
+ binding.btnSeasonDownloadAll.setOnClickListener {
+ downloadAllEpisodes()
+ }
+ }
+
+ private fun observeDownloadChanges() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ database.downloadDao().getAllDownloads().flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collect { downloads ->
+ refreshEpisodeStates()
+ }
+ }
+ }
+
+ private var lastDownloadStates: Map = emptyMap()
+
+ private fun refreshEpisodeStates() {
+ if (currentEpisodes.isEmpty()) return
+ for (i in currentEpisodes.indices) {
+ val episode = currentEpisodes[i]
+ val downloadId = "episode_${episode.id}"
+ val currentStatus = database.downloadDao().getDownloadById(downloadId)?.status
+ if (currentStatus != lastDownloadStates[downloadId]) {
+ appAdapter.notifyItemChanged(i, "download_state")
+ lastDownloadStates = lastDownloadStates + (downloadId to currentStatus)
+ }
+ }
+ }
+
private fun displaySeason(episodes: List) {
+ currentEpisodes = episodes
appAdapter.submitList(episodes.onEach { episode ->
episode.itemType = AppAdapter.Type.EPISODE_MOBILE_ITEM
})
+ binding.btnSeasonDownloadAll.visibility = if (episodes.isNotEmpty()) View.VISIBLE else View.GONE
+
val episodeIndex = episodes
.sortedByDescending { it.watchHistory?.lastEngagementTimeUtcMillis }
.firstOrNull { it.watchHistory != null }
@@ -142,4 +186,125 @@ class SeasonMobileFragment : Fragment() {
)
}
}
-}
\ No newline at end of file
+
+ private fun downloadAllEpisodes() {
+ val provider = UserPreferences.currentProvider
+ if (provider == null) {
+ Toast.makeText(requireContext(), "No provider selected", Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ if (currentEpisodes.isEmpty()) {
+ Toast.makeText(requireContext(), "No episodes available", Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ Toast.makeText(requireContext(), "Starting downloads...", Toast.LENGTH_SHORT).show()
+
+ val downloadManager = DownloadManager.getInstance(requireContext())
+ val tvShowId = args.tvShowId
+ val seasonNumber = args.seasonNumber
+ val tvShowTitle = args.tvShowTitle
+
+ downloadManager.scope.launch(Dispatchers.IO) {
+ var queuedCount = 0
+ var skippedCount = 0
+ var failedCount = 0
+
+ for (episode in currentEpisodes) {
+ val downloadId = "episode_${episode.id}"
+ val existingDownload = database.downloadDao().getDownloadById(downloadId)
+
+ if (existingDownload?.status == Download.DownloadStatus.COMPLETED ||
+ existingDownload?.status == Download.DownloadStatus.DOWNLOADING ||
+ existingDownload?.status == Download.DownloadStatus.QUEUED) {
+ skippedCount++
+ continue
+ }
+
+ try {
+ val videoType = Video.Type.Episode(
+ id = episode.id,
+ number = episode.number,
+ title = episode.title,
+ poster = episode.poster,
+ overview = episode.overview,
+ tvShow = Video.Type.Episode.TvShow(
+ id = episode.tvShow?.id ?: "",
+ title = episode.tvShow?.title ?: "",
+ poster = episode.tvShow?.poster,
+ banner = episode.tvShow?.banner,
+ releaseDate = episode.tvShow?.released?.format("yyyy-MM-dd"),
+ imdbId = episode.tvShow?.imdbId,
+ ),
+ season = Video.Type.Episode.Season(
+ number = seasonNumber,
+ title = episode.season?.title ?: "",
+ ),
+ )
+ val servers = provider.getServers(episode.id, videoType)
+ if (servers.isEmpty()) {
+ failedCount++
+ continue
+ }
+
+ val video = provider.getVideo(servers.first())
+ val outputDir = downloadManager.getEpisodeDir(tvShowId, seasonNumber, episode.number)
+ val downloadEntry = Download(
+ id = downloadId,
+ contentType = Download.ContentType.EPISODE,
+ title = episode.title ?: "Episode ${episode.number}",
+ subtitle = "S${seasonNumber} E${episode.number}",
+ poster = episode.poster,
+ banner = episode.tvShow?.banner,
+ videoUrl = video.source,
+ headers = video.headers ?: emptyMap(),
+ mimeType = video.type,
+ status = Download.DownloadStatus.DOWNLOADING,
+ tvShowId = tvShowId,
+ tvShowTitle = tvShowTitle,
+ seasonNumber = seasonNumber,
+ episodeNumber = episode.number,
+ )
+ database.downloadDao().insert(downloadEntry)
+
+ DownloadService.startService(requireContext())
+
+ downloadManager.downloadVideo(
+ downloadId = downloadId,
+ url = video.source,
+ headers = video.headers ?: emptyMap(),
+ outputDir = outputDir,
+ onProgress = { downloaded, total ->
+ val progress = if (total > 0) ((downloaded.toFloat() / total) * 100).toInt() else 0
+ database.downloadDao().updateProgress(downloadId, Download.DownloadStatus.DOWNLOADING, progress, downloaded)
+ },
+ onComplete = { file ->
+ database.downloadDao().markAsCompleted(downloadId, Download.DownloadStatus.COMPLETED, file.absolutePath, System.currentTimeMillis())
+ database.episodeDao().markAsDownloaded(episode.id, file.absolutePath)
+ },
+ onError = { error ->
+ database.downloadDao().markAsFailed(downloadId, Download.DownloadStatus.FAILED, error.message)
+ },
+ )
+ queuedCount++
+
+ kotlinx.coroutines.delay(500)
+ } catch (e: Exception) {
+ Log.e("SeasonMobileFragment", "Failed to queue download for episode ${episode.id}", e)
+ failedCount++
+ }
+ }
+
+ withContext(Dispatchers.Main) {
+ val message = when {
+ queuedCount > 0 && skippedCount > 0 -> "Queued $queuedCount downloads, $skippedCount already in progress"
+ queuedCount > 0 -> "Queued $queuedCount downloads"
+ skippedCount > 0 -> "All episodes already downloaded or in progress"
+ else -> "No episodes to download"
+ }
+ Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/fragments/season/SeasonTvFragment.kt b/app/src/main/java/com/streamflixreborn/streamflix/fragments/season/SeasonTvFragment.kt
index c96da308..619f14c1 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/fragments/season/SeasonTvFragment.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/fragments/season/SeasonTvFragment.kt
@@ -1,6 +1,7 @@
package com.streamflixreborn.streamflix.fragments.season
import android.os.Bundle
+import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -17,12 +18,20 @@ import com.streamflixreborn.streamflix.R
import com.streamflixreborn.streamflix.adapters.AppAdapter
import com.streamflixreborn.streamflix.database.AppDatabase
import com.streamflixreborn.streamflix.databinding.FragmentSeasonTvBinding
+import com.streamflixreborn.streamflix.models.Download
import com.streamflixreborn.streamflix.models.Episode
+import com.streamflixreborn.streamflix.models.Video
import com.streamflixreborn.streamflix.utils.CacheUtils
+import com.streamflixreborn.streamflix.utils.DownloadManager
+import com.streamflixreborn.streamflix.services.DownloadService
import com.streamflixreborn.streamflix.utils.LoggingUtils
+import com.streamflixreborn.streamflix.utils.UserPreferences
import com.streamflixreborn.streamflix.utils.dp
+import com.streamflixreborn.streamflix.utils.format
import com.streamflixreborn.streamflix.utils.viewModelsFactory
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
class SeasonTvFragment : Fragment() {
@@ -42,6 +51,7 @@ class SeasonTvFragment : Fragment() {
}
private val appAdapter = AppAdapter()
+ private var currentEpisodes: List = emptyList()
override fun onCreateView(
inflater: LayoutInflater,
@@ -56,6 +66,8 @@ class SeasonTvFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
initializeSeason()
+ setupDownloadAllButton()
+ observeDownloadChanges()
viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collect { state ->
@@ -72,12 +84,11 @@ class SeasonTvFragment : Fragment() {
}
is SeasonViewModel.State.FailedLoadingEpisodes -> {
- // Auto clear cache on HTTP 409 and retry
val code = (state.error as? retrofit2.HttpException)?.code()
if (code == 409 && !hasAutoCleared409) {
hasAutoCleared409 = true
CacheUtils.clearAppCache(requireContext())
- android.widget.Toast.makeText(requireContext(), getString(com.streamflixreborn.streamflix.R.string.clear_cache_done_409), android.widget.Toast.LENGTH_SHORT).show()
+ android.widget.Toast.makeText(requireContext(), getString(R.string.clear_cache_done_409), android.widget.Toast.LENGTH_SHORT).show()
viewModel.getSeasonEpisodes(args.seasonId)
return@collect
}
@@ -92,7 +103,7 @@ class SeasonTvFragment : Fragment() {
btnIsLoadingRetry.setOnClickListener { viewModel.getSeasonEpisodes(args.seasonId) }
btnIsLoadingClearCache.setOnClickListener {
CacheUtils.clearAppCache(requireContext())
- android.widget.Toast.makeText(requireContext(), getString(com.streamflixreborn.streamflix.R.string.clear_cache_done), android.widget.Toast.LENGTH_SHORT).show()
+ android.widget.Toast.makeText(requireContext(), getString(R.string.clear_cache_done), android.widget.Toast.LENGTH_SHORT).show()
viewModel.getSeasonEpisodes(args.seasonId)
}
btnIsLoadingErrorDetails.setOnClickListener {
@@ -111,7 +122,6 @@ class SeasonTvFragment : Fragment() {
_binding = null
}
-
private fun initializeSeason() {
binding.tvSeasonTitle.text = args.seasonTitle
@@ -123,13 +133,51 @@ class SeasonTvFragment : Fragment() {
}
}
+ private fun setupDownloadAllButton() {
+ binding.btnSeasonDownloadAll.setOnClickListener {
+ downloadAllEpisodes()
+ }
+ }
+
+ private var lastDownloadStatuses: Map = emptyMap()
+
+ private fun observeDownloadChanges() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ database.downloadDao().getAllDownloads().flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collect { downloads ->
+ for (download in downloads) {
+ if (download.status != lastDownloadStatuses[download.id]) {
+ lastDownloadStatuses = lastDownloadStatuses + (download.id to download.status)
+ refreshEpisodeStates()
+ break
+ }
+ }
+ }
+ }
+ }
+
+ private fun refreshEpisodeStates() {
+ if (currentEpisodes.isEmpty()) return
+ for (i in currentEpisodes.indices) {
+ val episode = currentEpisodes[i]
+ val downloadId = "episode_${episode.id}"
+ val currentStatus = database.downloadDao().getDownloadById(downloadId)?.status
+ if (currentStatus != lastDownloadStatuses[downloadId]) {
+ appAdapter.notifyItemChanged(i, "download_state")
+ lastDownloadStatuses = lastDownloadStatuses + (downloadId to currentStatus)
+ }
+ }
+ }
+
private var focusedEpisodeIndex: Int? = null
private fun displaySeason(episodes: List) {
+ currentEpisodes = episodes
val preparedEpisodes = episodes.onEach { episode ->
episode.itemType = AppAdapter.Type.EPISODE_TV_ITEM
}
+ binding.btnSeasonDownloadAll.visibility = if (episodes.isNotEmpty()) View.VISIBLE else View.GONE
+
val lastWatchedIndex = episodes
.filter { it.watchHistory != null }
.sortedByDescending { it.watchHistory?.lastEngagementTimeUtcMillis }
@@ -160,6 +208,124 @@ class SeasonTvFragment : Fragment() {
})
}
+ private fun downloadAllEpisodes() {
+ val provider = UserPreferences.currentProvider
+ if (provider == null) {
+ Toast.makeText(requireContext(), "No provider selected", Toast.LENGTH_SHORT).show()
+ return
+ }
+ if (currentEpisodes.isEmpty()) {
+ Toast.makeText(requireContext(), "No episodes available", Toast.LENGTH_SHORT).show()
+ return
+ }
-}
\ No newline at end of file
+ Toast.makeText(requireContext(), "Starting downloads...", Toast.LENGTH_SHORT).show()
+
+ val downloadManager = DownloadManager.getInstance(requireContext())
+ val tvShowId = args.tvShowId
+ val seasonNumber = args.seasonNumber
+ val tvShowTitle = args.tvShowTitle
+
+ downloadManager.scope.launch(Dispatchers.IO) {
+ var queuedCount = 0
+ var skippedCount = 0
+ var failedCount = 0
+
+ for (episode in currentEpisodes) {
+ val downloadId = "episode_${episode.id}"
+ val existingDownload = database.downloadDao().getDownloadById(downloadId)
+
+ if (existingDownload?.status == Download.DownloadStatus.COMPLETED ||
+ existingDownload?.status == Download.DownloadStatus.DOWNLOADING ||
+ existingDownload?.status == Download.DownloadStatus.QUEUED) {
+ skippedCount++
+ continue
+ }
+
+ try {
+ val videoType = Video.Type.Episode(
+ id = episode.id,
+ number = episode.number,
+ title = episode.title,
+ poster = episode.poster,
+ overview = episode.overview,
+ tvShow = Video.Type.Episode.TvShow(
+ id = episode.tvShow?.id ?: "",
+ title = episode.tvShow?.title ?: "",
+ poster = episode.tvShow?.poster,
+ banner = episode.tvShow?.banner,
+ releaseDate = episode.tvShow?.released?.format("yyyy-MM-dd"),
+ imdbId = episode.tvShow?.imdbId,
+ ),
+ season = Video.Type.Episode.Season(
+ number = seasonNumber,
+ title = episode.season?.title ?: "",
+ ),
+ )
+ val servers = provider.getServers(episode.id, videoType)
+ if (servers.isEmpty()) {
+ failedCount++
+ continue
+ }
+
+ val video = provider.getVideo(servers.first())
+ val outputDir = downloadManager.getEpisodeDir(tvShowId, seasonNumber, episode.number)
+ val downloadEntry = Download(
+ id = downloadId,
+ contentType = Download.ContentType.EPISODE,
+ title = episode.title ?: "Episode ${episode.number}",
+ subtitle = "S${seasonNumber} E${episode.number}",
+ poster = episode.poster,
+ banner = episode.tvShow?.banner,
+ videoUrl = video.source,
+ headers = video.headers ?: emptyMap(),
+ mimeType = video.type,
+ status = Download.DownloadStatus.DOWNLOADING,
+ tvShowId = tvShowId,
+ tvShowTitle = tvShowTitle,
+ seasonNumber = seasonNumber,
+ episodeNumber = episode.number,
+ )
+ database.downloadDao().insert(downloadEntry)
+
+ DownloadService.startService(requireContext())
+
+ downloadManager.downloadVideo(
+ downloadId = downloadId,
+ url = video.source,
+ headers = video.headers ?: emptyMap(),
+ outputDir = outputDir,
+ onProgress = { downloaded, total ->
+ val progress = if (total > 0) ((downloaded.toFloat() / total) * 100).toInt() else 0
+ database.downloadDao().updateProgress(downloadId, Download.DownloadStatus.DOWNLOADING, progress, downloaded)
+ },
+ onComplete = { file ->
+ database.downloadDao().markAsCompleted(downloadId, Download.DownloadStatus.COMPLETED, file.absolutePath, System.currentTimeMillis())
+ database.episodeDao().markAsDownloaded(episode.id, file.absolutePath)
+ },
+ onError = { error ->
+ database.downloadDao().markAsFailed(downloadId, Download.DownloadStatus.FAILED, error.message)
+ },
+ )
+ queuedCount++
+
+ kotlinx.coroutines.delay(500)
+ } catch (e: Exception) {
+ Log.e("SeasonTvFragment", "Failed to queue download for episode ${episode.id}", e)
+ failedCount++
+ }
+ }
+
+ withContext(Dispatchers.Main) {
+ val message = when {
+ queuedCount > 0 && skippedCount > 0 -> "Queued $queuedCount downloads, $skippedCount already in progress"
+ queuedCount > 0 -> "Queued $queuedCount downloads"
+ skippedCount > 0 -> "All episodes already downloaded or in progress"
+ else -> "No episodes to download"
+ }
+ Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/fragments/settings/SettingsMobileFragment.kt b/app/src/main/java/com/streamflixreborn/streamflix/fragments/settings/SettingsMobileFragment.kt
index 5d4a1043..188328d5 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/fragments/settings/SettingsMobileFragment.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/fragments/settings/SettingsMobileFragment.kt
@@ -658,6 +658,21 @@ class SettingsMobileFragment : PreferenceFragmentCompat() {
}
}
+ findPreference("DOWNLOAD_QUALITY")?.apply {
+ value = UserPreferences.downloadQuality.name
+ summaryProvider = Preference.SummaryProvider { pref ->
+ pref.entries.getOrNull(pref.findIndexOfValue(pref.value)) ?: ""
+ }
+ setOnPreferenceChangeListener { preference, newValue ->
+ val quality = UserPreferences.DownloadQuality.valueOf(newValue as String)
+ UserPreferences.downloadQuality = quality
+ if (preference is ListPreference) {
+ preference.value = quality.name
+ }
+ true
+ }
+ }
+
findPreference("IMMERSIVE_MODE")?.apply {
isChecked = UserPreferences.immersiveMode
setOnPreferenceChangeListener { _, newValue ->
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/fragments/settings/SettingsTvFragment.kt b/app/src/main/java/com/streamflixreborn/streamflix/fragments/settings/SettingsTvFragment.kt
index f686a215..5247ad95 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/fragments/settings/SettingsTvFragment.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/fragments/settings/SettingsTvFragment.kt
@@ -772,6 +772,17 @@ class SettingsTvFragment : LeanbackPreferenceFragmentCompat() {
}
}
+ findPreference("DOWNLOAD_QUALITY")?.apply {
+ value = UserPreferences.downloadQuality.name
+ summaryProvider = Preference.SummaryProvider { pref ->
+ pref.entries.getOrNull(pref.findIndexOfValue(pref.value)) ?: ""
+ }
+ setOnPreferenceChangeListener { _, newValue ->
+ UserPreferences.downloadQuality = UserPreferences.DownloadQuality.valueOf(newValue as String)
+ true
+ }
+ }
+
findPreference("key_backup_refresh_cache_tv")?.setOnPreferenceClickListener {
AlertDialog.Builder(requireContext())
.setTitle(R.string.settings_refresh_cache_confirm)
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/models/Download.kt b/app/src/main/java/com/streamflixreborn/streamflix/models/Download.kt
new file mode 100644
index 00000000..0d04aed5
--- /dev/null
+++ b/app/src/main/java/com/streamflixreborn/streamflix/models/Download.kt
@@ -0,0 +1,48 @@
+package com.streamflixreborn.streamflix.models
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity("downloads")
+data class Download(
+ @PrimaryKey
+ var id: String = "",
+
+ var contentType: ContentType = ContentType.MOVIE,
+
+ var title: String = "",
+ var subtitle: String? = null,
+ var poster: String? = null,
+ var banner: String? = null,
+
+ var videoUrl: String = "",
+ var headers: Map = emptyMap(),
+ var mimeType: String? = null,
+
+ var localFilePath: String? = null,
+
+ var status: DownloadStatus = DownloadStatus.QUEUED,
+ var progress: Int = 0,
+ var fileSize: Long = 0,
+ var downloadedSize: Long = 0,
+
+ var errorMessage: String? = null,
+
+ var createdAt: Long = System.currentTimeMillis(),
+ var completedAt: Long? = null,
+
+ var tvShowId: String? = null,
+ var tvShowTitle: String? = null,
+ var seasonNumber: Int? = null,
+ var episodeNumber: Int? = null,
+
+ var quality: String? = null,
+) {
+ enum class ContentType {
+ MOVIE, EPISODE
+ }
+
+ enum class DownloadStatus {
+ QUEUED, DOWNLOADING, PAUSED, COMPLETED, FAILED, CANCELLED
+ }
+}
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/models/Episode.kt b/app/src/main/java/com/streamflixreborn/streamflix/models/Episode.kt
index 4d3876a0..3e9fcaf2 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/models/Episode.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/models/Episode.kt
@@ -16,6 +16,7 @@ import java.util.Calendar
Index(value = ["tvShow", "isWatched"]),
Index(value = ["tvShow", "lastEngagementTimeUtcMillis"]),
Index(value = ["season", "number"]),
+ Index(value = ["tvShow", "isDownloaded"]),
]
)
class Episode(
@@ -29,6 +30,9 @@ class Episode(
var tvShow: TvShow? = null,
var season: Season? = null,
+
+ var isDownloaded: Boolean = false,
+ var localFilePath: String? = null,
) : WatchItem, AppAdapter.Item {
var released = released?.toCalendar()
@@ -42,6 +46,8 @@ class Episode(
if (isWatched != episode.isWatched) return false
if (watchedDate != episode.watchedDate) return false
if (watchHistory != episode.watchHistory) return false
+ if (isDownloaded != episode.isDownloaded) return false
+ if (localFilePath != episode.localFilePath) return false
return true
}
@@ -49,6 +55,8 @@ class Episode(
this.isWatched = episode.isWatched
this.watchedDate = episode.watchedDate
this.watchHistory = episode.watchHistory
+ this.isDownloaded = episode.isDownloaded
+ this.localFilePath = episode.localFilePath
return this
}
@@ -65,6 +73,8 @@ class Episode(
poster: String? = this.poster,
tvShow: TvShow? = this.tvShow,
season: Season? = this.season,
+ isDownloaded: Boolean = this.isDownloaded,
+ localFilePath: String? = this.localFilePath,
) = Episode(
id,
number,
@@ -74,6 +84,8 @@ class Episode(
overview,
tvShow,
season,
+ isDownloaded,
+ localFilePath,
)
override fun equals(other: Any?): Boolean {
@@ -93,6 +105,8 @@ class Episode(
if (isWatched != other.isWatched) return false
if (watchedDate != other.watchedDate) return false
if (watchHistory != other.watchHistory) return false
+ if (isDownloaded != other.isDownloaded) return false
+ if (localFilePath != other.localFilePath) return false
if (!::itemType.isInitialized || !other::itemType.isInitialized) return false
return itemType == other.itemType
}
@@ -109,6 +123,8 @@ class Episode(
result = 31 * result + isWatched.hashCode()
result = 31 * result + (watchedDate?.hashCode() ?: 0)
result = 31 * result + (watchHistory?.hashCode() ?: 0)
+ result = 31 * result + isDownloaded.hashCode()
+ result = 31 * result + (localFilePath?.hashCode() ?: 0)
result = 31 * result + (if (::itemType.isInitialized) itemType.hashCode() else 0)
return result
}
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/models/Video.kt b/app/src/main/java/com/streamflixreborn/streamflix/models/Video.kt
index 2d577a90..ec2413e0 100644
--- a/app/src/main/java/com/streamflixreborn/streamflix/models/Video.kt
+++ b/app/src/main/java/com/streamflixreborn/streamflix/models/Video.kt
@@ -32,6 +32,8 @@ data class Video(
val overview: String?,
val tvShow: TvShow,
val season: Season,
+ val isDownloaded: Boolean = false,
+ val localFilePath: String? = null,
) : Type(), Serializable {
@Parcelize
data class TvShow(
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/services/DownloadService.kt b/app/src/main/java/com/streamflixreborn/streamflix/services/DownloadService.kt
new file mode 100644
index 00000000..68dce981
--- /dev/null
+++ b/app/src/main/java/com/streamflixreborn/streamflix/services/DownloadService.kt
@@ -0,0 +1,187 @@
+package com.streamflixreborn.streamflix.services
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import com.streamflixreborn.streamflix.R
+import com.streamflixreborn.streamflix.activities.main.MainMobileActivity
+import com.streamflixreborn.streamflix.activities.main.MainTvActivity
+import com.streamflixreborn.streamflix.database.AppDatabase
+import com.streamflixreborn.streamflix.models.Download
+import com.streamflixreborn.streamflix.utils.DownloadManager
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlin.jvm.java
+
+class DownloadService : android.app.Service() {
+
+ companion object {
+ const val CHANNEL_ID = "download_channel"
+ const val NOTIFICATION_ID = 1
+ const val ACTION_CANCEL = "com.streamflixreborn.streamflix.CANCEL_DOWNLOAD"
+ const val EXTRA_DOWNLOAD_ID = "download_id"
+
+ fun startService(context: Context) {
+ val intent = Intent(context, DownloadService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stopService(context: Context) {
+ val intent = Intent(context, DownloadService::class.java)
+ context.stopService(intent)
+ }
+ }
+
+ private lateinit var notificationManager: NotificationManager
+ private lateinit var downloadManager: DownloadManager
+ private val serviceScope = CoroutineScope(Dispatchers.IO + Job())
+
+ override fun onCreate() {
+ super.onCreate()
+ notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ downloadManager = DownloadManager.getInstance(this)
+ createNotificationChannel()
+ startForeground(
+ NOTIFICATION_ID,
+ createNotification("Starting download...", 0, null)
+ )
+ observeDownloads()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_CANCEL -> {
+ val downloadId = intent.getStringExtra(EXTRA_DOWNLOAD_ID)
+ if (downloadId != null) {
+ cancelDownload(downloadId)
+ }
+ }
+ }
+
+ return START_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ return null
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "Downloads",
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = "Shows download progress"
+ setSound(null, null)
+ }
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+
+ private fun createNotification(
+ contentText: String,
+ progress: Int,
+ downloadId: String?,
+ ): Notification {
+
+ val isTv = packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
+
+ val targetActivity = if (isTv) {
+ MainTvActivity::class.java
+ } else {
+ MainMobileActivity::class.java
+ }
+
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, targetActivity),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val builder = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("StreamFlix Downloads")
+ .setContentText(contentText)
+ .setSmallIcon(R.drawable.ic_menu_downloads)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+
+ if (progress > 0) {
+ builder.setProgress(100, progress, false)
+ } else {
+ builder.setProgress(100, 0, true)
+ }
+
+ if (downloadId != null) {
+ val cancelIntent = Intent(this, DownloadService::class.java).apply {
+ action = ACTION_CANCEL
+ putExtra(EXTRA_DOWNLOAD_ID, downloadId)
+ }
+ val cancelPendingIntent = PendingIntent.getService(
+ this,
+ 0,
+ cancelIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ builder.addAction(R.drawable.ic_settings_close, "Cancel", cancelPendingIntent)
+ }
+
+ return builder.build()
+ }
+
+ private fun observeDownloads() {
+ serviceScope.launch {
+ downloadManager.downloadProgress.collectLatest { progressMap ->
+ val activeDownloads = progressMap.filter { it.value.status == DownloadManager.DownloadStatus.DOWNLOADING }
+
+ if (activeDownloads.isEmpty()) {
+ stopForeground(true)
+ stopSelf()
+ return@collectLatest
+ }
+
+ val (downloadId, activeDownload) = activeDownloads.entries.first()
+ val database = AppDatabase.getInstance(this@DownloadService)
+ val download = database.downloadDao().getDownloadById(downloadId)
+
+ val contentText = if (download != null) {
+ "${download.title} - ${activeDownload.progress}%"
+ } else {
+ "Downloading... ${activeDownload.progress}%"
+ }
+
+ val notification = createNotification(contentText, activeDownload.progress, downloadId)
+ notificationManager.notify(NOTIFICATION_ID, notification)
+ }
+ }
+ }
+
+ private fun cancelDownload(downloadId: String) {
+ downloadManager.cancelDownload(downloadId)
+ val database = AppDatabase.getInstance(this)
+ database.downloadDao().cancelDownload(downloadId)
+ database.episodeDao().markAsNotDownloaded(downloadId.removePrefix("episode_"))
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ serviceScope.cancel()
+ }
+}
diff --git a/app/src/main/java/com/streamflixreborn/streamflix/utils/DownloadManager.kt b/app/src/main/java/com/streamflixreborn/streamflix/utils/DownloadManager.kt
new file mode 100644
index 00000000..3b47ff4f
--- /dev/null
+++ b/app/src/main/java/com/streamflixreborn/streamflix/utils/DownloadManager.kt
@@ -0,0 +1,505 @@
+package com.streamflixreborn.streamflix.utils
+
+import android.content.Context
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.File
+import java.io.FileOutputStream
+import java.net.URL
+import java.util.concurrent.TimeUnit
+
+class DownloadManager private constructor(private val context: Context) {
+
+ companion object {
+ private const val TAG = "DownloadManager"
+ private const val DOWNLOAD_DIR_NAME = "streamflix_downloads"
+ private const val BUFFER_SIZE = 65536
+
+ @Volatile
+ private var INSTANCE: DownloadManager? = null
+
+ fun getInstance(context: Context): DownloadManager {
+ return INSTANCE ?: synchronized(this) {
+ INSTANCE ?: DownloadManager(context.applicationContext).also { INSTANCE = it }
+ }
+ }
+ }
+
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(5, TimeUnit.MINUTES)
+ .writeTimeout(5, TimeUnit.MINUTES)
+ .followRedirects(true)
+ .followSslRedirects(true)
+ .build()
+
+ internal val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ private val _downloadProgress = MutableStateFlow