diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 56e5a334..fae7bb08 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 744203c4..5a97be8f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3 import android.app.PictureInPictureParams +import android.content.Context import android.content.pm.PackageManager import android.os.Build import android.os.Bundle @@ -36,6 +37,7 @@ class MainActivity : AppCompatActivity() { var isInPlayer: Boolean = false var canShowPipMode: Boolean = false var isInPIPMode: Boolean = false + lateinit var mainContext : MainActivity } private fun enterPIPMode() { @@ -83,6 +85,8 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + mainContext = this + setContentView(R.layout.activity_main) val navView: BottomNavigationView = findViewById(R.id.nav_view) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt index b2e7d4a0..4eb3a7fb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt @@ -22,9 +22,10 @@ import kotlinx.android.synthetic.main.result_episode.view.episode_holder import kotlinx.android.synthetic.main.result_episode.view.episode_text import kotlinx.android.synthetic.main.result_episode_large.view.* -const val ACTION_PLAY_EPISODE_IN_PLAYER = 1 const val ACTION_RELOAD_EPISODE = 4 const val ACTION_CHROME_CAST_EPISODE = 2 +const val ACTION_DOWNLOAD_EPISODE = 3 +const val ACTION_PLAY_EPISODE_IN_PLAYER = 1 data class EpisodeClickEvent(val action: Int, val data: ResultEpisode) @@ -145,10 +146,12 @@ class EpisodeAdapter( if (castContext.castState == CastState.CONNECTED) { clickCallback.invoke(EpisodeClickEvent(ACTION_CHROME_CAST_EPISODE, card)) } else { - clickCallback.invoke(EpisodeClickEvent(ACTION_PLAY_EPISODE_IN_PLAYER, card)) + // clickCallback.invoke(EpisodeClickEvent(ACTION_PLAY_EPISODE_IN_PLAYER, card)) + clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, card)) } } else { - clickCallback.invoke(EpisodeClickEvent(ACTION_PLAY_EPISODE_IN_PLAYER, card)) + // clickCallback.invoke(EpisodeClickEvent(ACTION_PLAY_EPISODE_IN_PLAYER, card)) + clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, card)) } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt index 675f985e..971ab207 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt @@ -45,7 +45,9 @@ import com.lagradost.cloudstream3.ui.player.PlayerData import com.lagradost.cloudstream3.ui.player.PlayerFragment import com.lagradost.cloudstream3.utils.CastHelper.startCast import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos + import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.VideoDownloadManager import jp.wasabeef.glide.transformations.BlurTransformation import kotlinx.android.synthetic.main.fragment_result.* @@ -258,7 +260,6 @@ class ResultFragment : Fragment() { currentLoadingCount++ when (episodeClick.action) { ACTION_CHROME_CAST_EPISODE -> { - val skipLoading = if (apiName != null) { getApiFromName(apiName).instantLinkLoading } else false @@ -327,7 +328,19 @@ class ResultFragment : Fragment() { } }*/ } - + ACTION_DOWNLOAD_EPISODE -> { + val tempUrl = url + if (tempUrl != null) { + viewModel.loadEpisode(episodeClick.data, true) { data -> + if (data is Resource.Success) { + VideoDownloadManager.DownloadEpisode(requireContext(), + tempUrl, + episodeClick.data, + data.value) + } + } + } + } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt index b9a9643a..e656fc4b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt @@ -148,7 +148,7 @@ class SearchFragment : Fragment() { allApi.providersActive = requireActivity().getApiSettings() //searchViewModel.search("iron man") - //(activity as AppCompatActivity).loadResult("https://shiro.is/overlord-dubbed", "overlord-dubbed", "Shiro") + (activity as AppCompatActivity).loadResult("https://shiro.is/overlord-dubbed", "overlord-dubbed", "Shiro") /* (requireActivity() as AppCompatActivity).supportFragmentManager.beginTransaction() .setCustomAnimations(R.anim.enter_anim, diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadManager.kt new file mode 100644 index 00000000..eb794f01 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadManager.kt @@ -0,0 +1,310 @@ +package com.lagradost.cloudstream3.utils + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.os.Environment +import androidx.annotation.DrawableRes +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.net.toUri +import com.bumptech.glide.Glide +import com.google.android.exoplayer2.database.ExoDatabaseProvider +import com.google.android.exoplayer2.offline.DownloadManager +import com.google.android.exoplayer2.offline.DownloadRequest +import com.google.android.exoplayer2.offline.DownloadService +import com.google.android.exoplayer2.offline.DownloadService.sendAddDownload +import com.google.android.exoplayer2.offline.StreamKey +import com.google.android.exoplayer2.scheduler.Requirements +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory +import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor +import com.google.android.exoplayer2.upstream.cache.SimpleCache +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.UIHelper.colorFromAttribute +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import java.io.File +import java.util.concurrent.Executor + + +const val CHANNEL_ID = "cloudstream3.general" +const val CHANNEL_NAME = "Downloads" +const val CHANNEL_DESCRIPT = "The download notification channel" + +object VideoDownloadManager { + @DrawableRes + const val imgDone = R.drawable.rddone + + @DrawableRes + const val imgDownloading = R.drawable.rdload + + @DrawableRes + const val imgPaused = R.drawable.rdpause + + @DrawableRes + const val imgStopped = R.drawable.rderror + + @DrawableRes + const val imgError = R.drawable.rderror + + @DrawableRes + const val pressToPauseIcon = R.drawable.ic_baseline_pause_24 + + @DrawableRes + const val pressToResumeIcon = R.drawable.ic_baseline_play_arrow_24 + + @DrawableRes + const val pressToStopIcon = R.drawable.exo_icon_stop + + enum class DownloadType { + IsPaused, + IsDownloading, + IsDone, + IsFailed, + IsStopped, + } + + enum class DownloadActionType { + Pause, + Resume, + Stop, + } + + private var hasCreatedNotChanel = false + private fun Context.createNotificationChannel() { + hasCreatedNotChanel = true + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = CHANNEL_NAME //getString(R.string.channel_name) + val descriptionText = CHANNEL_DESCRIPT//getString(R.string.channel_description) + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { + description = descriptionText + } + // Register the channel with the system + val notificationManager: NotificationManager = + this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + private val cachedBitmaps = hashMapOf() + private fun Context.getImageBitmapFromUrl(url: String): Bitmap? { + if (cachedBitmaps.containsKey(url)) { + return cachedBitmaps[url] + } + + val bitmap = Glide.with(this) + .asBitmap() + .load(url).into(720, 720) + .get() + if (bitmap != null) { + cachedBitmaps[url] = bitmap + } + return null + } + + fun createNotification( + context: Context, + text: String, + source: String, + ep: ResultEpisode, + state: DownloadType, + progress: Long, + total: Long, + ) { + val intent = Intent(context, MainActivity::class.java).apply { + data = source.toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + val pendingIntent: PendingIntent = PendingIntent.getActivity(context, 0, intent, 0) + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setAutoCancel(true) + .setColorized(true) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(context.colorFromAttribute(R.attr.colorPrimary)) + .setContentText(text) + .setSmallIcon( + when (state) { + DownloadType.IsDone -> imgDone + DownloadType.IsDownloading -> imgDownloading + DownloadType.IsPaused -> imgPaused + DownloadType.IsFailed -> imgError + DownloadType.IsStopped -> imgStopped + } + ) + .setContentIntent(pendingIntent) + + if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { + builder.setProgress(total.toInt(), progress.toInt(), false) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (ep.poster != null) { + val poster = context.getImageBitmapFromUrl(ep.poster) + if (poster != null) + builder.setLargeIcon(poster) + } + } + if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val actionTypes: MutableList = ArrayList() + // INIT + if (state == DownloadType.IsDownloading) { + actionTypes.add(DownloadActionType.Pause) + actionTypes.add(DownloadActionType.Stop) + } + + if (state == DownloadType.IsPaused) { + actionTypes.add(DownloadActionType.Resume) + actionTypes.add(DownloadActionType.Stop) + } + + // ADD ACTIONS + for ((index, i) in actionTypes.withIndex()) { + val _resultIntent = Intent(context, DownloadService::class.java) + + _resultIntent.putExtra( + "type", when (i) { + DownloadActionType.Resume -> "resume" + DownloadActionType.Pause -> "pause" + DownloadActionType.Stop -> "stop" + } + ) + + _resultIntent.putExtra("id", ep.id) + + val pending: PendingIntent = PendingIntent.getService( + context, 4337 + index + ep.id, + _resultIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ) + + builder.addAction( + NotificationCompat.Action( + when (i) { + DownloadActionType.Resume -> pressToResumeIcon + DownloadActionType.Pause -> pressToPauseIcon + DownloadActionType.Stop -> pressToStopIcon + }, when (i) { + DownloadActionType.Resume -> "Resume" + DownloadActionType.Pause -> "Pause" + DownloadActionType.Stop -> "Stop" + }, pending + ) + ) + } + } + + if (!hasCreatedNotChanel) { + context.createNotificationChannel() + } + + with(NotificationManagerCompat.from(context)) { + // notificationId is a unique int for each notification that you must define + notify(ep.id, builder.build()) + } + } + + //https://exoplayer.dev/downloading-media.html + fun DownloadSingleEpisode(context: Context, source: String, ep: ResultEpisode, link: ExtractorLink) { + val url = link.url + val headers = mapOf("User-Agent" to USER_AGENT, "Referer" to link.referer) + + // Note: This should be a singleton in your app. + val databaseProvider = ExoDatabaseProvider(context) + + val downloadDirectory = File(Environment.getExternalStorageDirectory().path + "/Download/" + (ep.name ?: "Episode ${ep.episode}")) // File(context.cacheDir, "video_${ep.id}") + + // A download cache should not evict media, so should use a NoopCacheEvictor. + val downloadCache = SimpleCache( + downloadDirectory, + NoOpCacheEvictor(), + databaseProvider) + + // Create a factory for reading the data from the network. + val dataSourceFactory = DefaultHttpDataSourceFactory() + + // Choose an executor for downloading data. Using Runnable::run will cause each download task to + // download data on its own thread. Passing an executor that uses multiple threads will speed up + // download tasks that can be split into smaller parts for parallel execution. Applications that + // already have an executor for background downloads may wish to reuse their existing executor. + val downloadExecutor = Executor { obj: Runnable -> obj.run() } + + + // Create the download manager. + val downloadManager = DownloadManager( + context, + databaseProvider, + downloadCache, + dataSourceFactory, + downloadExecutor) + + val requirements = Requirements(Requirements.NETWORK) + // Optionally, setters can be called to configure the download manager. + downloadManager.requirements = requirements + downloadManager.maxParallelDownloads = 3 + val builder = DownloadRequest.Builder(ep.id.toString(), link.url.toUri()) + + val downloadRequest: DownloadRequest = builder.build() + + DownloadService.sendAddDownload( + context, + VideoDownloadService::class.java, + downloadRequest, + /* foreground= */ true) +/* + val disposable = url.download(header = headers) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy( + onNext = { progress -> + createNotification( + context, + "Downloading ${progress.downloadSizeStr()}/${progress.totalSizeStr()}", + source, + ep, + DownloadType.IsDownloading, + progress.downloadSize, + progress.totalSize + ) + }, + onComplete = { + createNotification( + context, + "Download Done", + source, + ep, + DownloadType.IsDone, + 0, 0 + ) + }, + onError = { + createNotification( + context, + "Download Failed", + source, + ep, + DownloadType.IsFailed, + 0, 0 + ) + } + )*/ + } + + public fun DownloadEpisode(context: Context, source: String, ep: ResultEpisode, links: List) { + val validLinks = links.filter { !it.isM3u8 } + if (validLinks.isNotEmpty()) { + DownloadSingleEpisode(context, source, ep, validLinks.first()) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadService.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadService.kt new file mode 100644 index 00000000..97b93ee2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadService.kt @@ -0,0 +1,97 @@ +package com.lagradost.cloudstream3.utils + +import android.app.IntentService +import android.app.Notification +import android.app.PendingIntent +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.net.toUri +import com.google.android.exoplayer2.offline.Download +import com.google.android.exoplayer2.offline.DownloadManager +import com.google.android.exoplayer2.offline.DownloadService +import com.google.android.exoplayer2.scheduler.PlatformScheduler +import com.google.android.exoplayer2.scheduler.Scheduler +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.UIHelper.colorFromAttribute +import java.lang.Exception + +private const val JOB_ID = 1 +private const val FOREGROUND_NOTIFICATION_ID = 1 + +class VideoDownloadService : DownloadService( + FOREGROUND_NOTIFICATION_ID, + DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL, + CHANNEL_ID, + R.string.exo_download_notification_channel_name, /* channelDescriptionResourceId= */ + 0) { + override fun getDownloadManager(): DownloadManager { + val ctx = this + return ExoPlayerHelper.downloadManager.apply { + requirements = DownloadManager.DEFAULT_REQUIREMENTS + maxParallelDownloads = 3 + addListener( + object : DownloadManager.Listener { + + override fun onDownloadChanged( + downloadManager: DownloadManager, + download: Download, + finalException: Exception?, + ) { + val intent = Intent(ctx, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + val pendingIntent: PendingIntent = PendingIntent.getActivity(ctx, 0, intent, 0) + val builder = NotificationCompat.Builder(ctx, CHANNEL_ID) + .setAutoCancel(true) + .setColorized(true) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(colorFromAttribute(R.attr.colorPrimary)) + .setContentText("${download.bytesDownloaded} / ${download.contentLength}") + .setSmallIcon( + VideoDownloadManager.imgDownloading + ) + .setProgress((download.bytesDownloaded / 1000).toInt(), + (download.contentLength / 1000).toInt(), + false) // in case the size is over 2gb / 1000 + .setContentIntent(pendingIntent) + builder.build() + with(NotificationManagerCompat.from(ctx)) { + // notificationId is a unique int for each notification that you must define + notify(download.request.id.hashCode(), builder.build()) + } + super.onDownloadChanged(downloadManager, download, finalException) + } + } + ) + } + } + + override fun getScheduler(): Scheduler = + PlatformScheduler(this, JOB_ID) + + override fun getForegroundNotification(downloads: MutableList): Notification { + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + val pendingIntent: PendingIntent = PendingIntent.getActivity(this, 0, intent, 0) + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setAutoCancel(true) + .setColorized(true) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(colorFromAttribute(R.attr.colorPrimary)) + .setContentText("Downloading ${downloads.size} item${if (downloads.size == 1) "" else "s"}") + .setSmallIcon( + VideoDownloadManager.imgDownloading + ) + .setContentIntent(pendingIntent) + return builder.build() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ExoPlayerHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ExoPlayerHelper.kt new file mode 100644 index 00000000..960fac6c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ExoPlayerHelper.kt @@ -0,0 +1,33 @@ +package com.lagradost.cloudstream3.utils + +import com.google.android.exoplayer2.database.ExoDatabaseProvider +import com.google.android.exoplayer2.offline.DownloadManager +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory +import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor +import com.google.android.exoplayer2.upstream.cache.SimpleCache +import com.lagradost.cloudstream3.MainActivity +import java.util.concurrent.Executor + +object ExoPlayerHelper { + private val context = MainActivity.mainContext + val databaseProvider = ExoDatabaseProvider(context) + val downloadExecutor = Executor { obj: Runnable -> obj.run() } + val dataSourceFactory = DefaultHttpDataSourceFactory() + + val downloadCache: SimpleCache by lazy { + SimpleCache( + context.cacheDir, + LeastRecentlyUsedCacheEvictor(20 * 1024 * 1024), + databaseProvider + ) + } + + val downloadManager: DownloadManager by lazy { + DownloadManager(context, + databaseProvider, + downloadCache, + dataSourceFactory, + downloadExecutor) + } + +} \ No newline at end of file diff --git a/app/src/main/res/drawable/.idea/misc.xml b/app/src/main/res/drawable/.idea/misc.xml new file mode 100644 index 00000000..639900d1 --- /dev/null +++ b/app/src/main/res/drawable/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/.idea/modules.xml b/app/src/main/res/drawable/.idea/modules.xml new file mode 100644 index 00000000..1b046284 --- /dev/null +++ b/app/src/main/res/drawable/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/.idea/vcs.xml b/app/src/main/res/drawable/.idea/vcs.xml new file mode 100644 index 00000000..bc599707 --- /dev/null +++ b/app/src/main/res/drawable/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rddone.xml b/app/src/main/res/drawable/rddone.xml new file mode 100644 index 00000000..8657a246 --- /dev/null +++ b/app/src/main/res/drawable/rddone.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rderror.xml b/app/src/main/res/drawable/rderror.xml new file mode 100644 index 00000000..a29d2ada --- /dev/null +++ b/app/src/main/res/drawable/rderror.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rdload.xml b/app/src/main/res/drawable/rdload.xml new file mode 100644 index 00000000..31988d83 --- /dev/null +++ b/app/src/main/res/drawable/rdload.xml @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rdpause.xml b/app/src/main/res/drawable/rdpause.xml new file mode 100644 index 00000000..f1fc1b9f --- /dev/null +++ b/app/src/main/res/drawable/rdpause.xml @@ -0,0 +1,19 @@ + + + + \ No newline at end of file