From f79ec6331f423547ddacb44aaa9eec4b14ecd6d1 Mon Sep 17 00:00:00 2001 From: PaduU29 Date: Fri, 1 May 2026 22:47:24 +0200 Subject: [PATCH 1/4] download ts files for offline play --- .../activities/main/MainMobileActivity.kt | 1 + .../activities/main/MainTvActivity.kt | 4 +- .../streamflix/adapters/DownloadAdapter.kt | 247 +++++++++ .../adapters/viewholders/EpisodeViewHolder.kt | 477 ++++++++++++++-- .../adapters/viewholders/MovieViewHolder.kt | 206 ++++++- .../adapters/viewholders/TvShowViewHolder.kt | 8 +- .../streamflix/database/AppDatabase.kt | 26 +- .../streamflix/database/Converters.kt | 36 ++ .../streamflix/database/dao/DownloadDao.kt | 86 +++ .../streamflix/database/dao/EpisodeDao.kt | 18 + .../downloads/DownloadsMobileFragment.kt | 333 +++++++++++ .../downloads/DownloadsTvFragment.kt | 503 +++++++++++++++++ .../fragments/player/PlayerMobileFragment.kt | 129 ++++- .../fragments/player/PlayerTvFragment.kt | 128 ++++- .../fragments/player/PlayerViewModel.kt | 26 +- .../fragments/season/SeasonMobileFragment.kt | 182 +++++- .../fragments/season/SeasonTvFragment.kt | 166 +++++- .../settings/SettingsMobileFragment.kt | 15 + .../fragments/settings/SettingsTvFragment.kt | 11 + .../streamflix/models/Download.kt | 48 ++ .../streamflix/models/Episode.kt | 16 + .../streamflix/models/Video.kt | 2 + .../streamflix/utils/DownloadManager.kt | 516 ++++++++++++++++++ .../streamflix/utils/EpisodeManager.kt | 19 +- .../streamflix/utils/NetworkClient.kt | 152 +++--- .../streamflix/utils/UserPreferences.kt | 16 + app/src/main/res/color/tab_text_color_tv.xml | 7 + .../main/res/drawable/bg_download_button.xml | 19 + .../res/drawable/bg_download_item_mobile.xml | 24 + .../main/res/drawable/bg_download_item_tv.xml | 24 + .../drawable/bg_episode_action_button_tv.xml | 17 + .../main/res/drawable/bg_tab_button_tv.xml | 35 ++ app/src/main/res/drawable/ic_delete.xml | 9 + .../main/res/drawable/ic_menu_downloads.xml | 9 + app/src/main/res/drawable/ic_play.xml | 9 + .../main/res/layout/content_movie_mobile.xml | 20 +- app/src/main/res/layout/content_movie_tv.xml | 19 +- .../res/layout/fragment_downloads_mobile.xml | 126 +++++ .../main/res/layout/fragment_downloads_tv.xml | 149 +++++ .../res/layout/fragment_season_mobile.xml | 16 +- .../main/res/layout/fragment_season_tv.xml | 18 +- .../layout/item_download_header_mobile.xml | 31 ++ .../res/layout/item_download_header_tv.xml | 31 ++ .../main/res/layout/item_download_mobile.xml | 158 ++++++ app/src/main/res/layout/item_download_tv.xml | 148 +++++ .../main/res/layout/item_episode_mobile.xml | 57 ++ app/src/main/res/layout/item_episode_tv.xml | 66 ++- .../res/menu/menu_main_activity_mobile.xml | 5 + .../main/res/menu/menu_main_activity_tv.xml | 5 + .../res/navigation/nav_main_graph_mobile.xml | 24 + .../main/res/navigation/nav_main_graph_tv.xml | 24 + app/src/main/res/values-ar/strings.xml | 56 ++ app/src/main/res/values-de/strings.xml | 56 ++ app/src/main/res/values-es/strings.xml | 56 ++ app/src/main/res/values-fr/strings.xml | 56 ++ app/src/main/res/values-it/strings.xml | 56 ++ app/src/main/res/values-pl/strings.xml | 56 ++ app/src/main/res/values/arrays.xml | 14 + app/src/main/res/values/strings.xml | 59 ++ app/src/main/res/xml/settings_mobile.xml | 9 + app/src/main/res/xml/settings_tv.xml | 8 + 61 files changed, 4683 insertions(+), 164 deletions(-) create mode 100644 app/src/main/java/com/streamflixreborn/streamflix/adapters/DownloadAdapter.kt create mode 100644 app/src/main/java/com/streamflixreborn/streamflix/database/dao/DownloadDao.kt create mode 100644 app/src/main/java/com/streamflixreborn/streamflix/fragments/downloads/DownloadsMobileFragment.kt create mode 100644 app/src/main/java/com/streamflixreborn/streamflix/fragments/downloads/DownloadsTvFragment.kt create mode 100644 app/src/main/java/com/streamflixreborn/streamflix/models/Download.kt create mode 100644 app/src/main/java/com/streamflixreborn/streamflix/utils/DownloadManager.kt create mode 100644 app/src/main/res/color/tab_text_color_tv.xml create mode 100644 app/src/main/res/drawable/bg_download_button.xml create mode 100644 app/src/main/res/drawable/bg_download_item_mobile.xml create mode 100644 app/src/main/res/drawable/bg_download_item_tv.xml create mode 100644 app/src/main/res/drawable/bg_episode_action_button_tv.xml create mode 100644 app/src/main/res/drawable/bg_tab_button_tv.xml create mode 100644 app/src/main/res/drawable/ic_delete.xml create mode 100644 app/src/main/res/drawable/ic_menu_downloads.xml create mode 100644 app/src/main/res/drawable/ic_play.xml create mode 100644 app/src/main/res/layout/fragment_downloads_mobile.xml create mode 100644 app/src/main/res/layout/fragment_downloads_tv.xml create mode 100644 app/src/main/res/layout/item_download_header_mobile.xml create mode 100644 app/src/main/res/layout/item_download_header_tv.xml create mode 100644 app/src/main/res/layout/item_download_mobile.xml create mode 100644 app/src/main/res/layout/item_download_tv.xml 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 8d028b562..06e290ee6 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 3abb23de6..2f25ef197 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/DownloadAdapter.kt b/app/src/main/java/com/streamflixreborn/streamflix/adapters/DownloadAdapter.kt new file mode 100644 index 000000000..f04f952f1 --- /dev/null +++ b/app/src/main/java/com/streamflixreborn/streamflix/adapters/DownloadAdapter.kt @@ -0,0 +1,247 @@ +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.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 + +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() + + data class DownloadProgress( + val progress: Int, + val status: String, + val speed: Long = 0, + val etaSeconds: Long = -1 + ) + + fun submitList(newItems: List) { + items.clear() + items.addAll(newItems) + notifyDataSetChanged() + } + + fun getCurrentList(): List = items.toList() + + fun indexOfDownload(downloadId: String): Int { + return items.indexOfFirst { + it is DownloadItem.Download && it.download.id == downloadId + } + } + + 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 + ) { + if (holder is ItemViewHolder && payloads.isNotEmpty()) { + holder.updateProgress((items[position] as DownloadItem.Download).download) + } else { + onBindViewHolder(holder, position) + } + } + + override fun getItemCount(): Int = items.size + + 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) + + 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) { + 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) + } + + val progressInfo = progressMap[download.id] + tvStatus.text = progressInfo?.status.orEmpty() + tvStatus.visibility = if (tvStatus.text.isEmpty()) View.GONE else View.VISIBLE + + 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 + } + + Download.DownloadStatus.COMPLETED -> { + progressBar.visibility = View.GONE + tvProgressPercent.visibility = View.GONE + btnAction.visibility = View.VISIBLE + } + + else -> { + progressBar.visibility = View.GONE + tvProgressPercent.visibility = View.GONE + btnAction.visibility = View.GONE + } + } + + 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 updateProgress(download: Download) { + val progressInfo = progressMap[download.id] + progressBar.progress = progressInfo?.progress ?: download.progress + tvProgressPercent.text = "${progressBar.progress}%" + tvStatus.text = progressInfo?.status.orEmpty() + tvStatus.visibility = if (tvStatus.text.isEmpty()) View.GONE else View.VISIBLE + } + } +} 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 eefd5d993..3be88941c 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,19 @@ 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 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 android.os.Handler +import android.os.Looper +import android.widget.Toast class EpisodeViewHolder( private val _binding: ViewBinding @@ -38,6 +51,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) { @@ -53,13 +68,19 @@ class EpisodeViewHolder( 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 +97,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) @@ -149,16 +177,123 @@ 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 { + 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 +310,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) @@ -257,6 +399,241 @@ 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 -> 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() + } + 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, 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() + } + return@launch + } + + val video = provider.getVideo(servers.first()) + 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) + + 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() + } + } + } } 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 bffea37ea..f69c0ed68 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 @@ -80,8 +82,10 @@ 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.utils.DownloadManager +import com.streamflixreborn.streamflix.models.Download import com.streamflixreborn.streamflix.providers.Provider +import com.streamflixreborn.streamflix.utils.UserPreferences import android.view.KeyEvent import com.streamflixreborn.streamflix.databinding.ContentMovieDirectorsMobileBinding import com.streamflixreborn.streamflix.databinding.ContentMovieDirectorsTvBinding @@ -738,7 +742,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 +758,104 @@ 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}" + + 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() + } + 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) + + 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 +969,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 +985,104 @@ 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}" + + 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() + } + 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) + + 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 2b95930d2..7c4b19aff 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 a215e5b6c..1c8567fc2 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 ed01d14d2..9c7735725 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 000000000..107bda012 --- /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 11bc312c4..7a66c1c25 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 000000000..57d565e38 --- /dev/null +++ b/app/src/main/java/com/streamflixreborn/streamflix/fragments/downloads/DownloadsMobileFragment.kt @@ -0,0 +1,333 @@ +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) { + val position = adapter.indexOfDownload(id) + if (position >= 0) { + adapter.notifyItemChanged(position, Any()) + } + } + } + + 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 000000000..187e9680e --- /dev/null +++ b/app/src/main/java/com/streamflixreborn/streamflix/fragments/downloads/DownloadsTvFragment.kt @@ -0,0 +1,503 @@ +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 + + 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() + + binding.tabAll.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) + 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()) { + binding.tabAll.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) { + val position = adapter.indexOfDownload(id) + if (position >= 0) { + adapter.notifyItemChanged(position, Any()) + } + } + } + + 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) { + 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(), "Download deleted", Toast.LENGTH_SHORT).show() + } + } +} 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 ddaa49425..e545427c1 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,114 @@ 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 + } + + 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 + } + }) + } + 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 646189ce6..4b597ed56 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,114 @@ class PlayerTvFragment : Fragment() { } } + private fun setupLocalFilePlayback() { + binding.pvPlayer.onMediaPreviousClicked = ::handleMediaPrevious + binding.pvPlayer.onMediaNextClicked = ::handleMediaNext + } + + 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 + } + }) + } + 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 22cf633ac..993e9fb9e 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 fa512551b..723bdbb72 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,24 @@ 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.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 +50,7 @@ class SeasonMobileFragment : Fragment() { } private val appAdapter = AppAdapter() + private var currentEpisodes: List = emptyList() override fun onCreateView( inflater: LayoutInflater, @@ -55,6 +65,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 +85,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 +94,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 +119,6 @@ class SeasonMobileFragment : Fragment() { _binding = null } - private fun initializeSeason() { binding.tvSeasonTitle.text = args.seasonTitle @@ -121,11 +132,35 @@ 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 fun refreshEpisodeStates() { + if (currentEpisodes.isEmpty()) return + for (i in currentEpisodes.indices) { + appAdapter.notifyItemChanged(i) + } + } + 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 +177,123 @@ 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 + + viewLifecycleOwner.lifecycleScope.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) + + 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 c96da308e..4526ed162 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,19 @@ 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.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 +50,7 @@ class SeasonTvFragment : Fragment() { } private val appAdapter = AppAdapter() + private var currentEpisodes: List = emptyList() override fun onCreateView( inflater: LayoutInflater, @@ -56,6 +65,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 +83,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 +102,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 +121,6 @@ class SeasonTvFragment : Fragment() { _binding = null } - private fun initializeSeason() { binding.tvSeasonTitle.text = args.seasonTitle @@ -123,13 +132,44 @@ 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 -> + val currentStatuses = downloads.associate { it.id to it.status } + val statusChanged = currentStatuses != lastDownloadStatuses + lastDownloadStatuses = currentStatuses + if (statusChanged) { + refreshEpisodeStates() + } + } + } + } + + private fun refreshEpisodeStates() { + if (currentEpisodes.isEmpty()) return + for (i in currentEpisodes.indices) { + appAdapter.notifyItemChanged(i) + } + } + 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 +200,122 @@ 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 + } + + 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 + + viewLifecycleOwner.lifecycleScope.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) -} \ No newline at end of file + 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 5d4a1043f..188328d58 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 f686a215e..5247ad957 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 000000000..0d04aed50 --- /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 4d3876a08..3e9fcaf20 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 2d577a90a..ec2413e07 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/utils/DownloadManager.kt b/app/src/main/java/com/streamflixreborn/streamflix/utils/DownloadManager.kt new file mode 100644 index 000000000..6072cabe5 --- /dev/null +++ b/app/src/main/java/com/streamflixreborn/streamflix/utils/DownloadManager.kt @@ -0,0 +1,516 @@ +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() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val _downloadProgress = MutableStateFlow>(emptyMap()) + val downloadProgress: StateFlow> = _downloadProgress.asStateFlow() + + private val activeDownloads = mutableMapOf() + + data class DownloadProgressInfo( + val downloadedBytes: Long, + val totalBytes: Long, + val progress: Int, + val status: DownloadStatus, + val speed: Long = 0, + val etaSeconds: Long = -1, + ) + + enum class DownloadStatus { + DOWNLOADING, PAUSED, COMPLETED, FAILED, CANCELLED + } + + fun getDownloadDir(): File { + val dir = File(context.filesDir, DOWNLOAD_DIR_NAME) + if (!dir.exists()) { + dir.mkdirs() + } + return dir + } + + fun getMovieDir(movieId: String): File { + val dir = File(getDownloadDir(), "movie_$movieId") + if (!dir.exists()) { + dir.mkdirs() + } + return dir + } + + fun getEpisodeDir(tvShowId: String, seasonNumber: Int, episodeNumber: Int): File { + val dir = File(getDownloadDir(), "episode_${tvShowId}_s${seasonNumber}e${episodeNumber}") + if (!dir.exists()) { + dir.mkdirs() + } + return dir + } + + fun getVideoFile(dir: File): File { + return File(dir, "video.mp4") + } + + fun getVideoFileTs(dir: File): File { + return File(dir, "video.ts") + } + + fun downloadVideo( + downloadId: String, + url: String, + headers: Map = emptyMap(), + outputDir: File, + onProgress: (Long, Long) -> Unit = { _, _ -> }, + onComplete: (File) -> Unit = {}, + onError: (Exception) -> Unit = {}, + ) { + if (activeDownloads[downloadId] == true) { + Log.w(TAG, "Download $downloadId is already active") + return + } + + activeDownloads[downloadId] = true + + scope.launch { + try { + if (isHlsUrl(url)) { + downloadHls(downloadId, url, headers, outputDir, onProgress, onComplete, onError) + } else { + downloadDirect(downloadId, url, headers, outputDir, onProgress, onComplete, onError) + } + } catch (e: Exception) { + activeDownloads.remove(downloadId) + Log.e(TAG, "Download failed: ${e.message}", e) + onError(e) + } + } + } + + private fun isHlsUrl(url: String): Boolean { + return url.contains(".m3u8", ignoreCase = true) || url.contains("m3u8", ignoreCase = true) + } + + private fun downloadDirect( + downloadId: String, + url: String, + headers: Map, + outputDir: File, + onProgress: (Long, Long) -> Unit, + onComplete: (File) -> Unit, + onError: (Exception) -> Unit, + ) { + val outputFile = getVideoFile(outputDir) + + val requestBuilder = Request.Builder().url(url) + headers.forEach { (key, value) -> + requestBuilder.addHeader(key, value) + } + + val request = requestBuilder.build() + + val startTime = System.currentTimeMillis() + var lastProgressUpdate = 0L + var lastDownloadedBytes = 0L + + client.newCall(request).enqueue(object : okhttp3.Callback { + override fun onFailure(call: okhttp3.Call, e: java.io.IOException) { + activeDownloads.remove(downloadId) + scope.launch { + onError(e) + } + } + + override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) { + if (!response.isSuccessful) { + activeDownloads.remove(downloadId) + val errorMsg = "HTTP ${response.code}: ${response.message}" + response.close() + scope.launch { + onError(Exception(errorMsg)) + } + return + } + + val contentLength = response.body?.contentLength() ?: -1 + + response.body?.byteStream()?.use { inputStream -> + outputFile.outputStream().use { outputStream -> + val buffer = ByteArray(BUFFER_SIZE) + var downloadedBytes = 0L + var bytesRead: Int + + while (activeDownloads[downloadId] == true) { + bytesRead = inputStream.read(buffer) + if (bytesRead == -1) break + + outputStream.write(buffer, 0, bytesRead) + downloadedBytes += bytesRead + + val elapsed = (System.currentTimeMillis() - startTime).coerceAtLeast(1) + val speed = downloadedBytes * 1000 / elapsed + val remainingBytes = if (contentLength > 0) contentLength - downloadedBytes else -1 + val etaSeconds = if (speed > 0 && remainingBytes > 0) remainingBytes / speed else -1 + + val progress = if (contentLength > 0) { + ((downloadedBytes.toFloat() / contentLength) * 100).toInt() + } else { + 0 + } + + _downloadProgress.value = _downloadProgress.value + mapOf( + downloadId to DownloadProgressInfo( + downloadedBytes = downloadedBytes, + totalBytes = contentLength, + progress = progress, + status = DownloadStatus.DOWNLOADING, + speed = speed, + etaSeconds = etaSeconds, + ) + ) + + onProgress(downloadedBytes, contentLength) + } + } + } + + finishDownload(downloadId, outputFile, activeDownloads[downloadId] != true, onComplete, onError) + } + }) + } + + private fun downloadHls( + downloadId: String, + url: String, + headers: Map, + outputDir: File, + onProgress: (Long, Long) -> Unit, + onComplete: (File) -> Unit, + onError: (Exception) -> Unit, + ) { + try { + val requestBuilder = Request.Builder().url(url) + headers.forEach { (key, value) -> + requestBuilder.addHeader(key, value) + } + + val playlistUrl = URL(url) + val playlistText = client.newCall(requestBuilder.build()).execute().use { response -> + if (!response.isSuccessful) { + throw Exception("Failed to fetch HLS playlist: HTTP ${response.code}") + } + response.body?.string() ?: throw Exception("Empty HLS playlist") + } + + val segmentUrls = parseHlsPlaylist(playlistText, playlistUrl) + + if (segmentUrls.isEmpty()) { + throw Exception("No segments found in HLS playlist") + } + + Log.d(TAG, "Found ${segmentUrls.size} segments to download") + + val outputFile = getVideoFileTs(outputDir) + var totalDownloadedBytes = 0L + var estimatedTotalBytes = 0L + + val tempDir = File(outputDir, "temp_segments") + tempDir.mkdirs() + + val startTime = System.currentTimeMillis() + + for ((index, segmentUrl) in segmentUrls.withIndex()) { + if (activeDownloads[downloadId] != true) { + tempDir.deleteRecursively() + _downloadProgress.value = _downloadProgress.value + mapOf( + downloadId to DownloadProgressInfo( + downloadedBytes = 0, + totalBytes = 0, + progress = 0, + status = DownloadStatus.CANCELLED, + ) + ) + return + } + + val segmentRequest = Request.Builder().url(segmentUrl).apply { + headers.forEach { (key, value) -> + addHeader(key, value) + } + }.build() + + val segmentFile = File(tempDir, "seg_${String.format("%04d", index)}.ts") + + client.newCall(segmentRequest).execute().use { response -> + if (!response.isSuccessful) { + throw Exception("Failed to download segment $index: HTTP ${response.code}") + } + + val segmentLength = response.body?.contentLength() ?: -1 + if (segmentLength > 0 && estimatedTotalBytes == 0L) { + estimatedTotalBytes = segmentLength * segmentUrls.size + } + + response.body?.byteStream()?.use { inputStream -> + FileOutputStream(segmentFile).use { outputStream -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + + while (activeDownloads[downloadId] == true) { + bytesRead = inputStream.read(buffer) + if (bytesRead == -1) break + + outputStream.write(buffer, 0, bytesRead) + totalDownloadedBytes += bytesRead + } + } + } + } + + val elapsed = (System.currentTimeMillis() - startTime).coerceAtLeast(1) + val speed = totalDownloadedBytes * 1000 / elapsed + val remainingBytes = if (estimatedTotalBytes > 0) estimatedTotalBytes - totalDownloadedBytes else -1 + val etaSeconds = if (speed > 0 && remainingBytes > 0) remainingBytes / speed else -1 + + val progress = if (estimatedTotalBytes > 0) { + ((totalDownloadedBytes.toFloat() / estimatedTotalBytes) * 100).toInt().coerceAtMost(100) + } else { + ((index + 1).toFloat() / segmentUrls.size * 100).toInt() + } + + _downloadProgress.value = _downloadProgress.value + mapOf( + downloadId to DownloadProgressInfo( + downloadedBytes = totalDownloadedBytes, + totalBytes = estimatedTotalBytes, + progress = progress, + status = DownloadStatus.DOWNLOADING, + speed = speed, + etaSeconds = etaSeconds, + ) + ) + + onProgress(totalDownloadedBytes, estimatedTotalBytes) + } + + if (activeDownloads[downloadId] == true) { + outputFile.outputStream().use { output -> + for (i in 0 until segmentUrls.size) { + val segmentFile = File(tempDir, "seg_${String.format("%04d", i)}.ts") + if (segmentFile.exists()) { + segmentFile.inputStream().use { it.copyTo(output) } + } + } + } + + tempDir.deleteRecursively() + + _downloadProgress.value = _downloadProgress.value + mapOf( + downloadId to DownloadProgressInfo( + downloadedBytes = outputFile.length(), + totalBytes = outputFile.length(), + progress = 100, + status = DownloadStatus.COMPLETED, + ) + ) + + onComplete(outputFile) + } else { + tempDir.deleteRecursively() + outputFile.delete() + _downloadProgress.value = _downloadProgress.value + mapOf( + downloadId to DownloadProgressInfo( + downloadedBytes = 0, + totalBytes = 0, + progress = 0, + status = DownloadStatus.CANCELLED, + ) + ) + } + } catch (e: Exception) { + activeDownloads.remove(downloadId) + Log.e(TAG, "HLS download failed: ${e.message}", e) + File(outputDir, "temp_segments").deleteRecursively() + scope.launch { + onError(e) + } + } + } + + private fun parseHlsPlaylist(playlistText: String, baseUrl: URL): List { + val lines = playlistText.split("\n").map { it.trim() }.filter { it.isNotEmpty() } + val segmentUrls = mutableListOf() + + var isVariantPlaylist = false + val variantUrls = mutableListOf>() + + for (line in lines) { + if (line.startsWith("#EXT-X-STREAM-INF")) { + isVariantPlaylist = true + } else if (isVariantPlaylist && !line.startsWith("#")) { + val bandwidth = extractBandwidth(lines) + variantUrls.add(Pair(resolveUrl(line, baseUrl), bandwidth)) + } else if (!line.startsWith("#") && !isVariantPlaylist) { + segmentUrls.add(resolveUrl(line, baseUrl)) + } + } + + if (isVariantPlaylist && variantUrls.isNotEmpty()) { + variantUrls.sortByDescending { it.second } + val selectedVariantUrl = selectVariantUrl(variantUrls) + Log.d(TAG, "Fetching variant playlist: $selectedVariantUrl") + + val variantRequest = Request.Builder().url(selectedVariantUrl).build() + val variantText = client.newCall(variantRequest).execute().use { response -> + if (!response.isSuccessful) { + throw Exception("Failed to fetch variant playlist: HTTP ${response.code}") + } + response.body?.string() ?: "" + } + + val variantBaseUrl = URL(selectedVariantUrl) + return parseHlsPlaylist(variantText, variantBaseUrl) + } + + return segmentUrls + } + + private fun selectVariantUrl(variants: List>): String { + val index = when (UserPreferences.downloadQuality) { + UserPreferences.DownloadQuality.BEST -> 0 + UserPreferences.DownloadQuality.HIGH -> ((variants.size - 1) * 0.25f).toInt() + UserPreferences.DownloadQuality.MEDIUM -> ((variants.size - 1) * 0.5f).toInt() + UserPreferences.DownloadQuality.LOW -> variants.lastIndex + }.coerceIn(0, variants.lastIndex) + + return variants[index].first + } + + private fun extractBandwidth(lines: List): Int { + for (line in lines) { + if (line.startsWith("#EXT-X-STREAM-INF")) { + val bandwidthMatch = Regex("BANDWIDTH=(\\d+)").find(line) + if (bandwidthMatch != null) { + return bandwidthMatch.groupValues[1].toInt() + } + } + } + return 0 + } + + private fun resolveUrl(path: String, baseUrl: URL): String { + return if (path.startsWith("http")) { + path + } else { + URL(baseUrl, path).toString() + } + } + + private fun finishDownload( + downloadId: String, + outputFile: File, + isCancelled: Boolean, + onComplete: (File) -> Unit, + onError: (Exception) -> Unit, + ) { + activeDownloads.remove(downloadId) + + if (isCancelled) { + outputFile.delete() + scope.launch { + _downloadProgress.value = _downloadProgress.value + mapOf( + downloadId to DownloadProgressInfo( + downloadedBytes = 0, + totalBytes = 0, + progress = 0, + status = DownloadStatus.CANCELLED, + ) + ) + } + } else { + scope.launch { + _downloadProgress.value = _downloadProgress.value + mapOf( + downloadId to DownloadProgressInfo( + downloadedBytes = outputFile.length(), + totalBytes = outputFile.length(), + progress = 100, + status = DownloadStatus.COMPLETED, + ) + ) + onComplete(outputFile) + } + } + } + + fun cancelDownload(downloadId: String) { + activeDownloads[downloadId] = false + } + + fun deleteDownload(downloadId: String, videoDir: File) { + cancelDownload(downloadId) + videoDir.deleteRecursively() + } + + fun getDownloadedFileSize(dir: File): Long { + val mp4File = getVideoFile(dir) + if (mp4File.exists()) return mp4File.length() + val tsFile = getVideoFileTs(dir) + if (tsFile.exists()) return tsFile.length() + return 0 + } + + fun getAvailableSpace(): Long { + val dir = getDownloadDir() + return dir.freeSpace + } + + fun getTotalDownloadedSize(): Long { + val dir = getDownloadDir() + return dir.walkTopDown().filter { it.isFile }.map { it.length() }.sum() + } + + fun formatFileSize(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB" + else -> String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024)) + } + } +} diff --git a/app/src/main/java/com/streamflixreborn/streamflix/utils/EpisodeManager.kt b/app/src/main/java/com/streamflixreborn/streamflix/utils/EpisodeManager.kt index 98a67c6c0..1a65768b5 100644 --- a/app/src/main/java/com/streamflixreborn/streamflix/utils/EpisodeManager.kt +++ b/app/src/main/java/com/streamflixreborn/streamflix/utils/EpisodeManager.kt @@ -67,6 +67,11 @@ object EpisodeManager { fetchedEpisodes.forEach { episode -> episode.tvShow = episode.tvShow ?: tvShow episode.season = episode.season ?: season + val dbEpisode = database.episodeDao().getById(episode.id) + if (dbEpisode != null && dbEpisode.isDownloaded) { + episode.isDownloaded = true + episode.localFilePath = dbEpisode.localFilePath + } } database.episodeDao().insertAll(fetchedEpisodes) episodesFromDb = fetchedEpisodes @@ -81,6 +86,11 @@ object EpisodeManager { episodesFromDb.forEach { episode -> episode.tvShow = episode.tvShow ?: tvShowContext episode.season = episode.season ?: seasonContext + val dbEpisode = database.episodeDao().getById(episode.id) + if (dbEpisode != null && dbEpisode.isDownloaded) { + episode.isDownloaded = true + episode.localFilePath = dbEpisode.localFilePath + } } addEpisodes(convertToVideoTypeEpisodes(episodesFromDb, database, seasonNumber)) } @@ -140,6 +150,11 @@ object EpisodeManager { nextSeasonEpisodes.forEach { episode -> episode.tvShow = episode.tvShow ?: seasonToLoad.tvShow episode.season = episode.season ?: seasonToLoad + val dbEpisode = database.episodeDao().getById(episode.id) + if (dbEpisode != null && dbEpisode.isDownloaded) { + episode.isDownloaded = true + episode.localFilePath = dbEpisode.localFilePath + } } mergeEpisodes(convertToVideoTypeEpisodes(nextSeasonEpisodes, database, seasonToLoad.number)) @@ -214,7 +229,9 @@ object EpisodeManager { season = Episode.Season( number = seasonFromDb?.number ?: seasonNumber, title = seasonFromDb?.title ?: ep.season?.title - ) + ), + isDownloaded = ep.isDownloaded, + localFilePath = ep.localFilePath, ) } return videoEpisodes diff --git a/app/src/main/java/com/streamflixreborn/streamflix/utils/NetworkClient.kt b/app/src/main/java/com/streamflixreborn/streamflix/utils/NetworkClient.kt index 3748f5442..678c61f05 100644 --- a/app/src/main/java/com/streamflixreborn/streamflix/utils/NetworkClient.kt +++ b/app/src/main/java/com/streamflixreborn/streamflix/utils/NetworkClient.kt @@ -27,7 +27,6 @@ object NetworkClient { private const val TAG = "Cine24hBypass" - // User-Agent Mobile standard per massima compatibilità con Cloudflare const val USER_AGENT = "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36" private val cookieManager by lazy { CookieManager.getInstance() } @@ -56,6 +55,87 @@ object NetworkClient { } } + private var sslSocketFactoryPair: Pair? = null + + private fun getSslSocketFactoryPair(): Pair? { + if (sslSocketFactoryPair != null) return sslSocketFactoryPair + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) return null + + return try { + val cf = CertificateFactory.getInstance("X.509") + val certInput = StreamFlixApp.instance.resources.openRawResource(R.raw.isrg_root_x1) + val isrgCert = certInput.use { cf.generateCertificate(it) } + + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null, null) + setCertificateEntry("isrg_root_x1", isrgCert) + } + + val tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm() + val tmf = TrustManagerFactory.getInstance(tmfAlgorithm).apply { init(keyStore) } + val systemTmf = TrustManagerFactory.getInstance(tmfAlgorithm).apply { init(null as KeyStore?) } + + val systemTrustManager = systemTmf.trustManagers.first { it is X509TrustManager } as X509TrustManager + val customTrustManager = tmf.trustManagers.first { it is X509TrustManager } as X509TrustManager + + val combinedTrustManager = object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) { + systemTrustManager.checkClientTrusted(chain, authType) + } + override fun checkServerTrusted(chain: Array?, authType: String?) { + try { + systemTrustManager.checkServerTrusted(chain, authType) + } catch (e: Exception) { + try { + customTrustManager.checkServerTrusted(chain, authType) + } catch (e2: Exception) { + systemTrustManager.checkServerTrusted(chain, authType) + } + } + } + override fun getAcceptedIssuers(): Array { + return systemTrustManager.acceptedIssuers + customTrustManager.acceptedIssuers + } + } + + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(combinedTrustManager), SecureRandom()) + } + + sslSocketFactoryPair = Pair(sslContext.socketFactory, combinedTrustManager) + sslSocketFactoryPair + } catch (e: Exception) { + Log.e(TAG, "Error setting up SSL compatibility: ${e.message}") + null + } + } + + fun newClient(customizer: ((OkHttpClient.Builder) -> Unit)? = null): OkHttpClient { + val builder = newBuilder() + customizer?.invoke(builder) + return builder.build() + } + + fun newBuilder(): OkHttpClient.Builder { + val builder = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + + val sslPair = getSslSocketFactoryPair() + if (sslPair != null) { + builder.sslSocketFactory(sslPair.first, sslPair.second) + } + + val spec = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0) + .build() + builder.connectionSpecs(listOf(spec, ConnectionSpec.COMPATIBLE_TLS, ConnectionSpec.CLEARTEXT)) + + return builder + } + val default: OkHttpClient by lazy { buildClient(DnsResolver.doh) } val systemDns: OkHttpClient by lazy { buildClient(Dns.SYSTEM) } val noRedirects: OkHttpClient by lazy { buildClient(DnsResolver.doh) { it.followRedirects(false).followSslRedirects(false) } } @@ -78,7 +158,6 @@ object NetworkClient { .addInterceptor { chain -> val original = chain.request() val requestBuilder = original.newBuilder() - // Only set default headers if not already provided by the caller (e.g. an extractor) if (original.header("User-Agent") == null) requestBuilder.header("User-Agent", USER_AGENT) if (original.header("Accept") == null) @@ -100,75 +179,16 @@ object NetworkClient { .readTimeout(30, TimeUnit.SECONDS) .dns(dns) - // Modern and compatible TLS configuration + val sslPair = getSslSocketFactoryPair() + if (sslPair != null) { + builder.sslSocketFactory(sslPair.first, sslPair.second) + } + val spec = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) .tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0) .build() builder.connectionSpecs(listOf(spec, ConnectionSpec.COMPATIBLE_TLS, ConnectionSpec.CLEARTEXT)) - // SSL compatibility for Android < 9.0 (API 28) and ISRG Root X1 for Let's Encrypt - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - try { - // On older Android we manually inject the Let's Encrypt ISRG Root X1 certificate - // and enable older TLS versions just in case. - - val cf = CertificateFactory.getInstance("X.509") - val certInput = StreamFlixApp.instance.resources.openRawResource(R.raw.isrg_root_x1) - val isrgCert = certInput.use { cf.generateCertificate(it) } - - val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { - load(null, null) - setCertificateEntry("isrg_root_x1", isrgCert) - } - - // Initialize TMF with our certificate - val tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm() - val tmf = TrustManagerFactory.getInstance(tmfAlgorithm).apply { - init(keyStore) - } - - // Get system TMF for regular certificates - val systemTmf = TrustManagerFactory.getInstance(tmfAlgorithm).apply { - init(null as KeyStore?) - } - - val systemTrustManager = systemTmf.trustManagers.first { it is X509TrustManager } as X509TrustManager - val customTrustManager = tmf.trustManagers.first { it is X509TrustManager } as X509TrustManager - - // Custom trust manager that trusts both system and our bundled certificate - val combinedTrustManager = object : X509TrustManager { - override fun checkClientTrusted(chain: Array?, authType: String?) { - systemTrustManager.checkClientTrusted(chain, authType) - } - - override fun checkServerTrusted(chain: Array?, authType: String?) { - try { - systemTrustManager.checkServerTrusted(chain, authType) - } catch (e: Exception) { - try { - customTrustManager.checkServerTrusted(chain, authType) - } catch (e2: Exception) { - // Fallback to system check as a last resort, throwing if it fails - systemTrustManager.checkServerTrusted(chain, authType) - } - } - } - - override fun getAcceptedIssuers(): Array { - return systemTrustManager.acceptedIssuers + customTrustManager.acceptedIssuers - } - } - - val sslContext = SSLContext.getInstance("TLS").apply { - init(null, arrayOf(combinedTrustManager), SecureRandom()) - } - - builder.sslSocketFactory(sslContext.socketFactory, combinedTrustManager) - } catch (e: Exception) { - Log.e(TAG, "Error setting up SSL compatibility: ${e.message}") - } - } - if (BuildConfig.DEBUG) { builder.addInterceptor(loggingInterceptor) } diff --git a/app/src/main/java/com/streamflixreborn/streamflix/utils/UserPreferences.kt b/app/src/main/java/com/streamflixreborn/streamflix/utils/UserPreferences.kt index 8469a79df..d8f95b640 100644 --- a/app/src/main/java/com/streamflixreborn/streamflix/utils/UserPreferences.kt +++ b/app/src/main/java/com/streamflixreborn/streamflix/utils/UserPreferences.kt @@ -333,6 +333,21 @@ object UserPreferences { Key.QUALITY_HEIGHT.setInt(value) } + enum class DownloadQuality { + BEST, + HIGH, + MEDIUM, + LOW, + } + + var downloadQuality: DownloadQuality + get() = Key.DOWNLOAD_QUALITY.getString() + ?.let { value -> DownloadQuality.entries.find { it.name == value } } + ?: DownloadQuality.BEST + set(value) { + Key.DOWNLOAD_QUALITY.setString(value.name) + } + var subtitleName: String? get() = Key.SUBTITLE_NAME.getString() set(value) = Key.SUBTITLE_NAME.setString(value) @@ -449,6 +464,7 @@ object UserPreferences { SCREEN_PADDING_X, SCREEN_PADDING_Y, QUALITY_HEIGHT, + DOWNLOAD_QUALITY, SUBTITLE_NAME, STREAMINGCOMMUNITY_DOMAIN, CUEVANA_DOMAIN, diff --git a/app/src/main/res/color/tab_text_color_tv.xml b/app/src/main/res/color/tab_text_color_tv.xml new file mode 100644 index 000000000..be81de408 --- /dev/null +++ b/app/src/main/res/color/tab_text_color_tv.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_download_button.xml b/app/src/main/res/drawable/bg_download_button.xml new file mode 100644 index 000000000..38e34e36d --- /dev/null +++ b/app/src/main/res/drawable/bg_download_button.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_download_item_mobile.xml b/app/src/main/res/drawable/bg_download_item_mobile.xml new file mode 100644 index 000000000..c4fee4882 --- /dev/null +++ b/app/src/main/res/drawable/bg_download_item_mobile.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_download_item_tv.xml b/app/src/main/res/drawable/bg_download_item_tv.xml new file mode 100644 index 000000000..3db78a52e --- /dev/null +++ b/app/src/main/res/drawable/bg_download_item_tv.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_episode_action_button_tv.xml b/app/src/main/res/drawable/bg_episode_action_button_tv.xml new file mode 100644 index 000000000..e4cddc8be --- /dev/null +++ b/app/src/main/res/drawable/bg_episode_action_button_tv.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_tab_button_tv.xml b/app/src/main/res/drawable/bg_tab_button_tv.xml new file mode 100644 index 000000000..58f86cb1f --- /dev/null +++ b/app/src/main/res/drawable/bg_tab_button_tv.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 000000000..156d68a2d --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_downloads.xml b/app/src/main/res/drawable/ic_menu_downloads.xml new file mode 100644 index 000000000..a53cc377a --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_downloads.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 000000000..5e6ee2659 --- /dev/null +++ b/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/content_movie_mobile.xml b/app/src/main/res/layout/content_movie_mobile.xml index 9b426f1fc..8b7a3329c 100644 --- a/app/src/main/res/layout/content_movie_mobile.xml +++ b/app/src/main/res/layout/content_movie_mobile.xml @@ -149,12 +149,26 @@ android:layout_marginStart="10dp" android:layout_marginTop="26dp" android:text="@string/movie_watch_now" - app:layout_constraintEnd_toStartOf="@id/btn_movie_trailer" + app:layout_constraintEnd_toStartOf="@id/btn_movie_download" app:layout_constraintHorizontal_chainStyle="packed" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/tv_movie_genres" app:layout_goneMarginEnd="10dp" /> + + + app:constraint_referenced_ids="btn_movie_watch_now,pb_movie_progress,btn_movie_download,btn_movie_trailer" /> + + diff --git a/app/src/main/res/layout/fragment_downloads_mobile.xml b/app/src/main/res/layout/fragment_downloads_mobile.xml new file mode 100644 index 000000000..89bd48204 --- /dev/null +++ b/app/src/main/res/layout/fragment_downloads_mobile.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_downloads_tv.xml b/app/src/main/res/layout/fragment_downloads_tv.xml new file mode 100644 index 000000000..8c0984341 --- /dev/null +++ b/app/src/main/res/layout/fragment_downloads_tv.xml @@ -0,0 +1,149 @@ + + + + + + + + + +