Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@
android:foregroundServiceType="dataSync"
android:exported="false" />

<service
android:name=".services.DownloadQueueService"
android:enabled="true"
android:foregroundServiceType="dataSync"
android:exported="false" />

<!-- Necessary for WorkManager services: https://stackoverflow.com/a/77186316 -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
Expand Down
12 changes: 4 additions & 8 deletions app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -188,13 +188,7 @@ import java.nio.charset.Charset
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.system.exitProcess
import androidx.core.net.toUri
import androidx.tvprovider.media.tv.Channel
import androidx.tvprovider.media.tv.TvContractCompat
import android.content.ComponentName
import android.content.ContentUris

import com.lagradost.cloudstream3.ui.home.HomeFragment
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.TvChannelUtils

class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
Expand Down Expand Up @@ -495,6 +489,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
R.id.navigation_downloads,
R.id.navigation_settings,
R.id.navigation_download_child,
R.id.navigation_download_queue,
R.id.navigation_subtitles,
R.id.navigation_chrome_subtitles,
R.id.navigation_settings_player,
Expand Down Expand Up @@ -2042,7 +2037,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
}
)


// Start the download queue
DownloadQueueManager.init(this)
}

/** Biometric stuff **/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename
import com.lagradost.cloudstream3.utils.extractorApis
import com.lagradost.cloudstream3.utils.txt
import dalvik.system.PathClassLoader
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package com.lagradost.cloudstream3.services

import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.os.Build.VERSION.SDK_INT
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update

class DownloadQueueService : Service() {
companion object {
const val TAG = "DownloadQueueService"
const val DOWNLOAD_QUEUE_CHANNEL_ID = "cloudstream3.download.queue"
const val DOWNLOAD_QUEUE_CHANNEL_NAME = "Download queue service"
const val DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION = "App download queue notification."
const val DOWNLOAD_QUEUE_NOTIFICATION_ID = 917194232 // Random unique
var isRunning = false

fun getIntent(
context: Context,
): Intent {
return Intent(context, DownloadQueueService::class.java)
}

private val _downloadInstances: MutableStateFlow<List<VideoDownloadManager.EpisodeDownloadInstance>> =
MutableStateFlow(emptyList())
val downloadInstances: StateFlow<List<VideoDownloadManager.EpisodeDownloadInstance>> =
_downloadInstances

private val totalDownloadFlow =
downloadInstances.combine(DownloadQueueManager.queue) { instances, queue ->
instances to queue
}.combine(VideoDownloadManager.currentDownloads) { (instances, queue), currentDownloads ->
Triple(instances, queue, currentDownloads)
}
}


private val baseNotification by lazy {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent =
PendingIntentCompat.getActivity(this, 0, intent, 0, false)

val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, 0).format(0)
val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, 0).format(0)

NotificationCompat.Builder(this, DOWNLOAD_QUEUE_CHANNEL_ID)
.setOngoing(true) // Make it persistent
.setAutoCancel(false)
.setColorized(false)
.setOnlyAlertOnce(true)
.setSilent(true)
.setShowWhen(false)
// If low priority then the notification might not show :(
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setColor(this.colorFromAttribute(R.attr.colorPrimary))
.setContentText(activeDownloads)
.setSubText(activeQueue)
.setContentIntent(pendingIntent)
.setSmallIcon(R.drawable.download_icon_load)
}


private fun updateNotification(context: Context, downloads: Int, queued: Int) {
val activeDownloads =
resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads)
val activeQueue =
resources.getQuantityString(R.plurals.downloads_queued, queued).format(queued)

val newNotification = baseNotification
.setContentText(activeDownloads)
.setSubText(activeQueue)
.build()

NotificationManagerCompat.from(context)
.notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification)
}

@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
override fun onCreate() {
isRunning = true
val context: Context = this // To make code more readable

Log.d(TAG, "Download queue service started.")
this.createNotificationChannel(
DOWNLOAD_QUEUE_CHANNEL_ID,
DOWNLOAD_QUEUE_CHANNEL_NAME,
DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION
)
if (SDK_INT >= 29) {
startForeground(
DOWNLOAD_QUEUE_NOTIFICATION_ID,
baseNotification.build(),
FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
startForeground(DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build())
}

ioSafe {
totalDownloadFlow
.takeWhile { (instances, queue) -> isRunning && (instances.isNotEmpty() || queue.isNotEmpty()) }
.collect { (instances, queue, currentDownloads) ->
// Remove completed or failed
_downloadInstances.update { currentInstances ->
currentInstances.filterNot { it.isCompleted || it.isFailed }
}

val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context)
val currentInstances = instances.size

val newDownloads = minOf(
// Cannot exceed the max downloads
maxOf(0, maxDownloads - currentInstances),
// Cannot start more downloads than the queue size
queue.size
)

repeat(newDownloads) {
val downloadInstance =
DownloadQueueManager.popQueue(context) ?: return@repeat
downloadInstance.startDownload()
_downloadInstances.update { currentInstances ->
currentInstances + downloadInstance
}
}

// The downloads actually displayed to the user with a notification
val currentVisualDownloads =
currentDownloads.size + instances.count {
currentDownloads.contains(it.downloadQueueWrapper.id)
.not()
}
// Just the queue
val currentVisualQueue = queue.size

updateNotification(context, currentVisualDownloads, currentVisualQueue)
}

stopSelf()
}
}

override fun onDestroy() {
Log.d(TAG, "Download queue service stopped.")
isRunning = false
super.onDestroy()
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY // We want the service restarted if its killed
}

override fun onBind(intent: Intent?): IBinder? = null

override fun onTimeout(reason: Int) {
stopSelf()
Log.e(TAG, "Service stopped due to timeout: $reason")
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl
import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.TimeUnit

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.services
import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch

/** Handle notification actions such as pause/resume downloads */
class VideoDownloadService : Service() {

private val downloadScope = CoroutineScope(Dispatchers.Default)
Expand Down Expand Up @@ -42,19 +43,3 @@ class VideoDownloadService : Service() {
super.onDestroy()
}
}
// override fun onHandleIntent(intent: Intent?) {
// if (intent != null) {
// val id = intent.getIntExtra("id", -1)
// val type = intent.getStringExtra("type")
// if (id != -1 && type != null) {
// val state = when (type) {
// "resume" -> VideoDownloadManager.DownloadActionType.Resume
// "pause" -> VideoDownloadManager.DownloadActionType.Pause
// "stop" -> VideoDownloadManager.DownloadActionType.Stop
// else -> return
// }
// VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
// }
// }
// }
//}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects

const val DOWNLOAD_ACTION_PLAY_FILE = 0
const val DOWNLOAD_ACTION_DELETE_FILE = 1
Expand All @@ -34,35 +34,35 @@ const val DOWNLOAD_ACTION_LOAD_RESULT = 1
sealed class VisualDownloadCached {
abstract val currentBytes: Long
abstract val totalBytes: Long
abstract val data: VideoDownloadHelper.DownloadCached
abstract val data: DownloadObjects.DownloadCached
abstract var isSelected: Boolean

data class Child(
override val currentBytes: Long,
override val totalBytes: Long,
override val data: VideoDownloadHelper.DownloadEpisodeCached,
override val data: DownloadObjects.DownloadEpisodeCached,
override var isSelected: Boolean,
) : VisualDownloadCached()

data class Header(
override val currentBytes: Long,
override val totalBytes: Long,
override val data: VideoDownloadHelper.DownloadHeaderCached,
override val data: DownloadObjects.DownloadHeaderCached,
override var isSelected: Boolean,
val child: VideoDownloadHelper.DownloadEpisodeCached?,
val child: DownloadObjects.DownloadEpisodeCached?,
val currentOngoingDownloads: Int,
val totalDownloads: Int,
) : VisualDownloadCached()
}

data class DownloadClickEvent(
val action: Int,
val data: VideoDownloadHelper.DownloadEpisodeCached
val data: DownloadObjects.DownloadEpisodeCached
)

data class DownloadHeaderClickEvent(
val action: Int,
val data: VideoDownloadHelper.DownloadHeaderCached
val data: DownloadObjects.DownloadHeaderCached
)

class DownloadAdapter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ import com.lagradost.cloudstream3.ui.player.ExtractorUri
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import kotlinx.coroutines.MainScope

object DownloadButtonSetup {
Expand Down Expand Up @@ -82,7 +84,7 @@ object DownloadButtonSetup {
} else {
val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id)
if (pkg != null) {
VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg)
DownloadQueueManager.addToQueue(pkg.toWrapper())
} else {
VideoDownloadManager.downloadEvent.invoke(
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume)
Expand All @@ -95,7 +97,7 @@ object DownloadButtonSetup {
DOWNLOAD_ACTION_LONG_CLICK -> {
activity?.let { act ->
val length =
VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(
VideoDownloadManager.getDownloadFileInfo(
act,
click.data.id
)?.fileLength
Expand All @@ -112,22 +114,25 @@ object DownloadButtonSetup {

DOWNLOAD_ACTION_PLAY_FILE -> {
activity?.let { act ->
val parent = getKey<VideoDownloadHelper.DownloadHeaderCached>(
val parent = getKey<DownloadObjects.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE,
click.data.parentId.toString()
) ?: return

val episodes = getKeys(DOWNLOAD_EPISODE_CACHE)
?.mapNotNull {
getKey<VideoDownloadHelper.DownloadEpisodeCached>(it)
getKey<DownloadObjects.DownloadEpisodeCached>(it)
}
?.filter { it.parentId == click.data.parentId }

val items = mutableListOf<ExtractorUri>()
val allRelevantEpisodes = episodes?.sortedWith(compareBy<VideoDownloadHelper.DownloadEpisodeCached> { it.season ?: 0 }.thenBy { it.episode })
val allRelevantEpisodes =
episodes?.sortedWith(compareBy<DownloadObjects.DownloadEpisodeCached> {
it.season ?: 0
}.thenBy { it.episode })

allRelevantEpisodes?.forEach {
val keyInfo = getKey<VideoDownloadManager.DownloadedFileInfo>(
val keyInfo = getKey<DownloadObjects.DownloadedFileInfo>(
VideoDownloadManager.KEY_DOWNLOAD_INFO,
it.id.toString()
) ?: return@forEach
Expand Down
Loading