diff --git a/app/build.gradle b/app/build.gradle index cad0a6f1..e14f524e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -71,8 +71,11 @@ dependencies { implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' // Exoplayer - implementation 'com.google.android.exoplayer:exoplayer:2.14.0' - implementation 'com.google.android.exoplayer:extension-cast:2.14.0' - implementation "com.google.android.exoplayer:extension-mediasession:2.14.0" + implementation 'com.google.android.exoplayer:exoplayer:2.14.1' + implementation 'com.google.android.exoplayer:extension-cast:2.14.1' + implementation "com.google.android.exoplayer:extension-mediasession:2.14.1" //implementation "com.google.android.exoplayer:extension-leanback:2.14.0" + + //download manager + implementation "com.anggrayudi:storage:0.9.0" } \ No newline at end of file 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 0b4980d4..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 @@ -147,11 +147,11 @@ class EpisodeAdapter( 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 e1cde11c..8f5657f0 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 @@ -341,10 +341,19 @@ class ResultFragment : Fragment() { if (tempUrl != null) { viewModel.loadEpisode(episodeClick.data, true) { data -> if (data is Resource.Success) { + val meta = VideoDownloadManager.DownloadEpisodeMetadata( + episodeClick.data.id, + currentHeaderName ?: return@loadEpisode, + apiName ?: return@loadEpisode, + episodeClick.data.poster, + episodeClick.data.name, + episodeClick.data.season, + episodeClick.data.episode + ) VideoDownloadManager.DownloadEpisode( requireContext(), tempUrl, - episodeClick.data, + meta, data.value.links ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadManager.kt index eb794f01..b3ebba2c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadManager.kt @@ -6,31 +6,23 @@ 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.anggrayudi.storage.extension.closeStream +import com.anggrayudi.storage.file.DocumentFileCompat +import com.anggrayudi.storage.file.openOutputStream 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 +import java.io.BufferedInputStream +import java.io.InputStream +import java.net.URL +import java.net.URLConnection const val CHANNEL_ID = "cloudstream3.general" @@ -38,6 +30,11 @@ const val CHANNEL_NAME = "Downloads" const val CHANNEL_DESCRIPT = "The download notification channel" object VideoDownloadManager { + var maxConcurrentDownloads = 3 + + private const val USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + @DrawableRes const val imgDone = R.drawable.rddone @@ -76,6 +73,16 @@ object VideoDownloadManager { Stop, } + data class DownloadEpisodeMetadata( + val id: Int, + val mainName: String, + val sourceApiName: String?, + val poster: String?, + val name: String?, + val season: Int?, + val episode: Int? + ) + private var hasCreatedNotChanel = false private fun Context.createNotificationChannel() { hasCreatedNotChanel = true @@ -111,29 +118,22 @@ object VideoDownloadManager { return null } - fun createNotification( + private fun createNotification( context: Context, - text: String, - source: String, - ep: ResultEpisode, + source: String?, + linkName: String?, + ep: DownloadEpisodeMetadata, 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) + .setContentTitle(ep.mainName) .setSmallIcon( when (state) { DownloadType.IsDone -> imgDone @@ -143,19 +143,72 @@ object VideoDownloadManager { DownloadType.IsStopped -> imgStopped } ) - .setContentIntent(pendingIntent) + + if (ep.sourceApiName != null) { + builder.setSubText(ep.sourceApiName) + } + + if (source != null) { + 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) + builder.setContentIntent(pendingIntent) + } if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { builder.setProgress(total.toInt(), progress.toInt(), false) } + val rowTwoExtra = if (ep.name != null) " - ${ep.name}\n" else "" + val rowTwo = if (ep.season != null && ep.episode != null) { + "S${ep.season}:E${ep.episode}" + rowTwoExtra + } else if (ep.episode != null) { + "Episode ${ep.episode}" + rowTwoExtra + } else { + (ep.name ?: "") + "" + } + 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) } + + val progressPercentage = progress * 100 / total + val progressMbString = "%.1f".format(progress / 1000000f) + val totalMbString = "%.1f".format(total / 1000000f) + + val bigText = + if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { + (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString MB/$totalMbString MB)" + } else if (state == DownloadType.IsFailed) { + "Download Failed - $rowTwo" + } else if (state == DownloadType.IsDone) { + "Download Done - $rowTwo" + } else { + "Download Stopped - $rowTwo" + } + + val bodyStyle = NotificationCompat.BigTextStyle() + bodyStyle.bigText(bigText) + builder.setStyle(bodyStyle) + } else { + val txt = if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { + rowTwo + } else if (state == DownloadType.IsFailed) { + "Download Failed - $rowTwo" + } else if (state == DownloadType.IsDone) { + "Download Done - $rowTwo" + } else { + "Download Stopped - $rowTwo" + } + + builder.setContentText(txt) } + if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val actionTypes: MutableList = ArrayList() // INIT @@ -171,9 +224,9 @@ object VideoDownloadManager { // ADD ACTIONS for ((index, i) in actionTypes.withIndex()) { - val _resultIntent = Intent(context, DownloadService::class.java) + val actionResultIntent = Intent(context, DownloadService::class.java) - _resultIntent.putExtra( + actionResultIntent.putExtra( "type", when (i) { DownloadActionType.Resume -> "resume" DownloadActionType.Pause -> "pause" @@ -181,11 +234,11 @@ object VideoDownloadManager { } ) - _resultIntent.putExtra("id", ep.id) + actionResultIntent.putExtra("id", ep.id) val pending: PendingIntent = PendingIntent.getService( context, 4337 + index + ep.id, - _resultIntent, + actionResultIntent, PendingIntent.FLAG_UPDATE_CURRENT ) @@ -215,96 +268,86 @@ object VideoDownloadManager { } } - //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) + fun DownloadSingleEpisode( + context: Context, + source: String?, + ep: DownloadEpisodeMetadata, + link: ExtractorLink + ): Boolean { + val name = (ep.name ?: "Episode ${ep.episode}") + val path = "Downloads/Anime/$name.mp4" + val dFile = DocumentFileCompat.fromSimplePath(context, basePath = path) ?: return false - // Note: This should be a singleton in your app. - val databaseProvider = ExoDatabaseProvider(context) + val resume = false - val downloadDirectory = File(Environment.getExternalStorageDirectory().path + "/Download/" + (ep.name ?: "Episode ${ep.episode}")) // File(context.cacheDir, "video_${ep.id}") + if (!resume && dFile.exists()) { + if (!dFile.delete()) { + return false + } + } + if (!dFile.exists()) { + dFile.createFile("video/mp4", name) + } - // A download cache should not evict media, so should use a NoopCacheEvictor. - val downloadCache = SimpleCache( - downloadDirectory, - NoOpCacheEvictor(), - databaseProvider) + // OPEN FILE + val fileStream = dFile.openOutputStream(context, resume) ?: return false - // Create a factory for reading the data from the network. - val dataSourceFactory = DefaultHttpDataSourceFactory() + // CONNECT + val connection: URLConnection = URL(link.url).openConnection() - // 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() } + // SET CONNECTION SETTINGS + connection.connectTimeout = 10000 + connection.setRequestProperty("Accept-Encoding", "identity") + connection.setRequestProperty("User-Agent", USER_AGENT) + if (link.referer.isNotEmpty()) connection.setRequestProperty("Referer", link.referer) + if (resume) connection.setRequestProperty("Range", "bytes=${dFile.length()}-") + val resumeLength = (if (resume) dFile.length() else 0) + // ON CONNECTION + connection.connect() + val contentLength = connection.contentLength + if (contentLength < 5000000) return false // less than 5mb + val bytesTotal = contentLength + resumeLength - // Create the download manager. - val downloadManager = DownloadManager( - context, - databaseProvider, - downloadCache, - dataSourceFactory, - downloadExecutor) + // READ DATA FROM CONNECTION + val connectionInputStream: InputStream = BufferedInputStream(connection.inputStream) + val buffer = ByteArray(1024) + var count: Int + var bytesDownloaded = resumeLength - 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()) + fun updateNotification(type : DownloadType) { + createNotification( + context, + source, + link.name, + ep, + type, + bytesDownloaded, + bytesTotal + ) + } - val downloadRequest: DownloadRequest = builder.build() + while (true) { + count = connectionInputStream.read(buffer) + if (count < 0) break + bytesDownloaded += count - 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 - ) - } - )*/ + updateNotification(DownloadType.IsDownloading) + fileStream.write(buffer, 0, count) + } + + // DOWNLOAD EXITED CORRECTLY + updateNotification(DownloadType.IsDone) + fileStream.closeStream() + connectionInputStream.closeStream() + + return true } - public fun DownloadEpisode(context: Context, source: String, ep: ResultEpisode, links: List) { + public fun DownloadEpisode(context: Context, source: String, ep: DownloadEpisodeMetadata, 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 deleted file mode 100644 index 97b93ee2..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadService.kt +++ /dev/null @@ -1,97 +0,0 @@ -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