diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3c855d28..9cbccbe5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -184,8 +184,8 @@ dependencies { //implementation("com.github.TorrentStream:TorrentStream-Android:2.7.0") // Downloading - implementation("androidx.work:work-runtime:2.7.1") - implementation("androidx.work:work-runtime-ktx:2.7.1") + implementation("androidx.work:work-runtime:2.8.0") + implementation("androidx.work:work-runtime-ktx:2.8.0") // Networking // implementation("com.squareup.okhttp3:okhttp:4.9.2") diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt index 3958984e..c20786c3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt @@ -1327,7 +1327,7 @@ fun LoadResponse?.isAnimeBased(): Boolean { fun TvType?.isEpisodeBased(): Boolean { if (this == null) return false - return (this == TvType.TvSeries || this == TvType.Anime) + return (this == TvType.TvSeries || this == TvType.Anime || this == TvType.AsianDrama) } @@ -1351,6 +1351,7 @@ interface EpisodeResponse { var showStatus: ShowStatus? var nextAiring: NextAiring? var seasonNames: List? + fun getLatestEpisodes(): Map } @JvmName("addSeasonNamesString") @@ -1419,7 +1420,18 @@ data class AnimeLoadResponse( override var nextAiring: NextAiring? = null, override var seasonNames: List? = null, override var backgroundPosterUrl: String? = null, -) : LoadResponse, EpisodeResponse +) : LoadResponse, EpisodeResponse { + override fun getLatestEpisodes(): Map { + return episodes.map { (status, episodes) -> + val maxSeason = episodes.maxOfOrNull { it.season ?: Int.MIN_VALUE } + .takeUnless { it == Int.MIN_VALUE } + status to episodes + .filter { it.season == maxSeason } + .maxOfOrNull { it.episode ?: Int.MIN_VALUE } + .takeUnless { it == Int.MIN_VALUE } + }.toMap() + } +} /** * If episodes already exist appends the list. @@ -1617,7 +1629,17 @@ data class TvSeriesLoadResponse( override var nextAiring: NextAiring? = null, override var seasonNames: List? = null, override var backgroundPosterUrl: String? = null, -) : LoadResponse, EpisodeResponse +) : LoadResponse, EpisodeResponse { + override fun getLatestEpisodes(): Map { + val maxSeason = + episodes.maxOfOrNull { it.season ?: Int.MIN_VALUE }.takeUnless { it == Int.MIN_VALUE } + val max = episodes + .filter { it.season == maxSeason } + .maxOfOrNull { it.episode ?: Int.MIN_VALUE } + .takeUnless { it == Int.MIN_VALUE } + return mapOf(DubStatus.None to max) + } +} suspend fun MainAPI.newTvSeriesLoadResponse( name: String, diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index 3533d6a8..0dee57eb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -16,6 +16,7 @@ import com.google.gson.Gson import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.APIHolder.removePluginMapping +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey @@ -165,11 +166,11 @@ object PluginManager { private var loadedLocalPlugins = false private val gson = Gson() - private suspend fun maybeLoadPlugin(activity: Activity, file: File) { + private suspend fun maybeLoadPlugin(context: Context, file: File) { val name = file.name if (file.extension == "zip" || file.extension == "cs3") { loadPlugin( - activity, + context, file, PluginData(name, null, false, file.absolutePath, PLUGIN_VERSION_NOT_SET) ) @@ -199,7 +200,7 @@ object PluginManager { // var allCurrentOutDatedPlugins: Set = emptySet() - suspend fun loadSinglePlugin(activity: Activity, apiName: String): Boolean { + suspend fun loadSinglePlugin(context: Context, apiName: String): Boolean { return (getPluginsOnline().firstOrNull { // Most of the time the provider ends with Provider which isn't part of the api name it.internalName.replace("provider", "", ignoreCase = true) == apiName @@ -209,7 +210,7 @@ object PluginManager { })?.let { savedData -> // OnlinePluginData(savedData, onlineData) loadPlugin( - activity, + context, File(savedData.filePath), savedData ) @@ -371,11 +372,11 @@ object PluginManager { /** * Use updateAllOnlinePluginsAndLoadThem * */ - fun loadAllOnlinePlugins(activity: Activity) { + fun loadAllOnlinePlugins(context: Context) { // Load all plugins as fast as possible! (getPluginsOnline()).toList().apmap { pluginData -> loadPlugin( - activity, + context, File(pluginData.filePath), pluginData ) @@ -398,7 +399,7 @@ object PluginManager { * @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins * and reload all pages even if they are previously valid **/ - fun loadAllLocalPlugins(activity: Activity, forceReload: Boolean) { + fun loadAllLocalPlugins(context: Context, forceReload: Boolean) { val dir = File(LOCAL_PLUGINS_PATH) removeKey(PLUGINS_KEY_LOCAL) @@ -416,7 +417,7 @@ object PluginManager { Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: $sortedPlugins") sortedPlugins?.sortedBy { it.name }?.apmap { file -> - maybeLoadPlugin(activity, file) + maybeLoadPlugin(context, file) } loadedLocalPlugins = true @@ -441,14 +442,14 @@ object PluginManager { /** * @return True if successful, false if not * */ - private suspend fun loadPlugin(activity: Activity, file: File, data: PluginData): Boolean { + private suspend fun loadPlugin(context: Context, file: File, data: PluginData): Boolean { val fileName = file.nameWithoutExtension val filePath = file.absolutePath currentlyLoading = fileName Log.i(TAG, "Loading plugin: $data") return try { - val loader = PathClassLoader(filePath, activity.classLoader) + val loader = PathClassLoader(filePath, context.classLoader) var manifest: Plugin.Manifest loader.getResourceAsStream("manifest.json").use { stream -> if (stream == null) { @@ -492,22 +493,22 @@ object PluginManager { addAssetPath.invoke(assets, file.absolutePath) pluginInstance.resources = Resources( assets, - activity.resources.displayMetrics, - activity.resources.configuration + context.resources.displayMetrics, + context.resources.configuration ) } plugins[filePath] = pluginInstance classLoaders[loader] = pluginInstance urlPlugins[data.url ?: filePath] = pluginInstance - pluginInstance.load(activity) + pluginInstance.load(context) Log.i(TAG, "Loaded plugin ${data.internalName} successfully") currentlyLoading = null true } catch (e: Throwable) { Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}") showToast( - activity, - activity.getString(R.string.plugin_load_fail).format(fileName), + context.getActivity(), + context.getString(R.string.plugin_load_fail).format(fileName), Toast.LENGTH_LONG ) currentlyLoading = null diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt new file mode 100644 index 00000000..d1b1b660 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt @@ -0,0 +1,218 @@ +package com.lagradost.cloudstream3.services + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.net.toUri +import androidx.work.* +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings +import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.utils.AppUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.Coroutines.ioWork +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 kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeUnit + +const val SUBSCRIPTION_CHANNEL_ID = "cloudstream3.subscriptions" +const val SUBSCRIPTION_WORK_NAME = "work_subscription" +const val SUBSCRIPTION_CHANNEL_NAME = "Subscriptions" +const val SUBSCRIPTION_CHANNEL_DESCRIPTION = "Notifications for new episodes on subscribed shows" +const val SUBSCRIPTION_NOTIFICATION_ID = 938712897 // Random unique + +class SubscriptionWorkManager(val context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + companion object { + fun enqueuePeriodicWork(context: Context?) { + if (context == null) return + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val periodicSyncDataWork = + PeriodicWorkRequest.Builder(SubscriptionWorkManager::class.java, 6, TimeUnit.HOURS) + .addTag(SUBSCRIPTION_WORK_NAME) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + SUBSCRIPTION_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + periodicSyncDataWork + ) + + // Uncomment below for testing + +// val oneTimeSyncDataWork = +// OneTimeWorkRequest.Builder(SubscriptionWorkManager::class.java) +// .addTag(SUBSCRIPTION_WORK_NAME) +// .setConstraints(constraints) +// .build() +// +// WorkManager.getInstance(context).enqueue(oneTimeSyncDataWork) + } + } + + private val progressNotificationBuilder = + NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID) + .setAutoCancel(false) + .setColorized(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(context.colorFromAttribute(R.attr.colorPrimary)) + .setContentTitle(context.getString(R.string.subscription_in_progress_notification)) + .setSmallIcon(R.drawable.quantum_ic_refresh_white_24) + .setProgress(0, 0, true) + + private val updateNotificationBuilder = + NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID) + .setColorized(true) + .setOnlyAlertOnce(true) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(context.colorFromAttribute(R.attr.colorPrimary)) + .setSmallIcon(R.drawable.ic_cloudstream_monochrome_big) + + private val notificationManager: NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + private fun updateProgress(max: Int, progress: Int, indeterminate: Boolean) { + notificationManager.notify( + SUBSCRIPTION_NOTIFICATION_ID, progressNotificationBuilder + .setProgress(max, progress, indeterminate) + .build() + ) + } + + override suspend fun doWork(): Result { +// println("Update subscriptions!") + context.createNotificationChannel( + SUBSCRIPTION_CHANNEL_ID, + SUBSCRIPTION_CHANNEL_NAME, + SUBSCRIPTION_CHANNEL_DESCRIPTION + ) + + safeApiCall { + setForeground( + ForegroundInfo( + SUBSCRIPTION_NOTIFICATION_ID, + progressNotificationBuilder.build() + ) + ) + } + + val subscriptions = getAllSubscriptions() + + if (subscriptions.isEmpty()) { + WorkManager.getInstance(context).cancelWorkById(this.id) + return Result.success() + } + + val max = subscriptions.size + var progress = 0 + + updateProgress(max, progress, true) + + // We need all plugins loaded. + PluginManager.loadAllOnlinePlugins(context) + PluginManager.loadAllLocalPlugins(context, false) + + subscriptions.apmap { savedData -> + try { + val id = savedData.id ?: return@apmap null + val api = getApiFromNameNull(savedData.apiName) ?: return@apmap null + + // Reasonable timeout to prevent having this worker run forever. + val response = withTimeoutOrNull(60_000) { + api.load(savedData.url) as? EpisodeResponse + } ?: return@apmap null + + val dubPreference = + getDub(id) ?: if ( + context.getApiDubstatusSettings().contains(DubStatus.Dubbed) + ) { + DubStatus.Dubbed + } else { + DubStatus.Subbed + } + + val latestEpisodes = response.getLatestEpisodes() + val latestPreferredEpisode = latestEpisodes[dubPreference] + + val (shouldUpdate, latestEpisode) = if (latestPreferredEpisode != null) { + val latestSeenEpisode = + savedData.lastSeenEpisodeCount[dubPreference] ?: Int.MIN_VALUE + val shouldUpdate = latestPreferredEpisode > latestSeenEpisode + shouldUpdate to latestPreferredEpisode + } else { + val latestEpisode = latestEpisodes[DubStatus.None] ?: Int.MIN_VALUE + val latestSeenEpisode = + savedData.lastSeenEpisodeCount[DubStatus.None] ?: Int.MIN_VALUE + val shouldUpdate = latestEpisode > latestSeenEpisode + shouldUpdate to latestEpisode + } + + DataStoreHelper.updateSubscribedData( + id, + savedData, + response + ) + + if (shouldUpdate) { + val updateHeader = savedData.name + val updateDescription = txt( + R.string.subscription_episode_released, + latestEpisode, + savedData.name + ).asString(context) + + val intent = Intent(context, MainActivity::class.java).apply { + data = savedData.url.toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + val pendingIntent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE + ) + } else { + PendingIntent.getActivity(context, 0, intent, 0) + } + + val poster = ioWork { savedData.posterUrl?.let { url -> context.getImageBitmapFromUrl(url, savedData.posterHeaders) } } + val updateNotification = + updateNotificationBuilder.setContentTitle(updateHeader) + .setContentText(updateDescription) + .setContentIntent(pendingIntent) + .setLargeIcon(poster) + .build() + + notificationManager.notify(id, updateNotification) + } + + // You can probably get some issues here since this is async but it does not matter much. + updateProgress(max, ++progress, false) + } catch (_: Throwable) { + } + } + + return Result.success() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt index 0b081220..7dd43fe7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt @@ -9,6 +9,7 @@ import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.Coroutines.ioWork +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState @@ -74,13 +75,16 @@ class LocalList : SyncAPI { group.value.mapNotNull { getBookmarkedData(it.first)?.toLibraryItem(it.first.toString()) } - } + } + mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull { + it.toLibraryItem() + }) } val baseMap = WatchType.values().filter { it != WatchType.NONE }.associate { // None is not something to display it.stringRes to emptyList() - } + } + mapOf(R.string.subscription_list_name to emptyList()) + return SyncAPI.LibraryMetadata( (baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) }, setOf( 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 68dd1c0e..bdef14b5 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 @@ -15,6 +15,7 @@ import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter import android.widget.ImageView +import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone import androidx.core.view.isVisible @@ -27,12 +28,14 @@ import com.google.android.material.chip.ChipDrawable import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.updateHasTrailers +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.mvvm.* +import com.lagradost.cloudstream3.services.SubscriptionWorkManager import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD @@ -850,7 +853,7 @@ open class ResultFragment : ResultTrailerPlayer() { } observe(viewModel.page) { data -> - if(data == null) return@observe + if (data == null) return@observe when (data) { is Resource.Success -> { val d = data.value @@ -904,6 +907,36 @@ open class ResultFragment : ResultTrailerPlayer() { updateList(d.actors ?: emptyList()) } + observeNullable(viewModel.subscribeStatus) { isSubscribed -> + result_subscribe?.isVisible = isSubscribed != null + if (isSubscribed == null) return@observeNullable + + val drawable = if (isSubscribed) { + R.drawable.ic_baseline_notifications_active_24 + } else { + R.drawable.baseline_notifications_none_24 + } + + result_subscribe?.setImageResource(drawable) + } + + result_subscribe?.setOnClickListener { + val isSubscribed = + viewModel.toggleSubscriptionStatus() ?: return@setOnClickListener + + val message = if (isSubscribed) { + // Kinda icky to have this here, but it works. + SubscriptionWorkManager.enqueuePeriodicWork(context) + R.string.subscription_new + } else { + R.string.subscription_deleted + } + + val name = (viewModel.page.value as? Resource.Success)?.value?.title + ?: txt(R.string.no_data).asStringNull(context) ?: "" + showToast(activity, txt(message, name), Toast.LENGTH_SHORT) + } + result_open_in_browser?.isVisible = d.url.startsWith("http") result_open_in_browser?.setOnClickListener { val i = Intent(ACTION_VIEW) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index afaaeef9..2983b41d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -16,6 +16,7 @@ import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.getId import com.lagradost.cloudstream3.APIHolder.unixTime +import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.getCastSession import com.lagradost.cloudstream3.CommonActivity.showToast @@ -414,6 +415,9 @@ class ResultViewModel2 : ViewModel() { private val _episodeSynopsis: MutableLiveData = MutableLiveData(null) val episodeSynopsis: LiveData = _episodeSynopsis + private val _subscribeStatus: MutableLiveData = MutableLiveData(null) + val subscribeStatus: LiveData = _subscribeStatus + companion object { const val TAG = "RVM2" private const val EPISODE_RANGE_SIZE = 20 @@ -815,6 +819,42 @@ class ResultViewModel2 : ViewModel() { } } + /** + * @return true if the new status is Subscribed, false if not. Null if not possible to subscribe. + **/ + fun toggleSubscriptionStatus(): Boolean? { + val isSubscribed = _subscribeStatus.value ?: return null + val response = currentResponse ?: return null + if (response !is EpisodeResponse) return null + + val currentId = response.getId() + + if (isSubscribed) { + DataStoreHelper.removeSubscribedData(currentId) + } else { + val current = DataStoreHelper.getSubscribedData(currentId) + + DataStoreHelper.setSubscribedData( + currentId, + DataStoreHelper.SubscribedData( + currentId, + current?.bookmarkedTime ?: unixTimeMS, + unixTimeMS, + response.getLatestEpisodes(), + response.name, + response.url, + response.apiName, + response.type, + response.posterUrl, + response.year + ) + ) + } + + _subscribeStatus.postValue(!isSubscribed) + return !isSubscribed + } + private fun startChromecast( activity: Activity?, result: ResultEpisode, @@ -1473,7 +1513,8 @@ class ResultViewModel2 : ViewModel() { this.engName, this.name, this.japName - ).filter { it.length > 2 }.distinct(), // the reason why we filter is due to not wanting smth like " " or "?" + ).filter { it.length > 2 } + .distinct(), // the reason why we filter is due to not wanting smth like " " or "?" TrackerType.getTypes(this.type), this.year ) @@ -1670,6 +1711,16 @@ class ResultViewModel2 : ViewModel() { postResume() } + private fun postSubscription(loadResponse: LoadResponse) { + if (loadResponse.isEpisodeBased()) { + val id = loadResponse.getId() + val data = DataStoreHelper.getSubscribedData(id) + DataStoreHelper.updateSubscribedData(id, data, loadResponse as? EpisodeResponse) + val isSubscribed = data != null + _subscribeStatus.postValue(isSubscribed) + } + } + private fun postEpisodeRange(indexer: EpisodeIndexer?, range: EpisodeRange?) { if (range == null || indexer == null) { return @@ -1806,6 +1857,7 @@ class ResultViewModel2 : ViewModel() { ) { currentResponse = loadResponse postPage(loadResponse, apiRepository) + postSubscription(loadResponse) if (updateEpisodes) postEpisodes(loadResponse, updateFillers) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt index 4b1053b1..860144ee 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt @@ -4,6 +4,8 @@ import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.app.Activity import android.app.Activity.RESULT_CANCELED +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.* import android.content.pm.PackageManager import android.database.Cursor @@ -196,6 +198,22 @@ object AppUtils { animation.start() } + fun Context.createNotificationChannel(channelId: String, channelName: String, description: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = + NotificationChannel(channelId, channelName, importance).apply { + this.description = description + } + + // Register the channel with the system. + val notificationManager: NotificationManager = + this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(channel) + } + } + @SuppressLint("RestrictedApi") fun getAllWatchNextPrograms(context: Context): Set { val COLUMN_WATCH_NEXT_ID_INDEX = 0 diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 281c9c44..516cd990 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -1,16 +1,13 @@ package com.lagradost.cloudstream3.utils import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder.capitalize +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys import com.lagradost.cloudstream3.AcraApplication.Companion.setKey -import com.lagradost.cloudstream3.DubStatus -import com.lagradost.cloudstream3.SearchQuality -import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.WatchType @@ -20,6 +17,7 @@ const val VIDEO_POS_DUR = "video_pos_dur" const val VIDEO_WATCH_STATE = "video_watch_state" const val RESULT_WATCH_STATE = "result_watch_state" const val RESULT_WATCH_STATE_DATA = "result_watch_state_data" +const val RESULT_SUBSCRIBED_STATE_DATA = "result_subscribed_state_data" const val RESULT_RESUME_WATCHING = "result_resume_watching_2" // changed due to id changes const val RESULT_RESUME_WATCHING_OLD = "result_resume_watching" const val RESULT_RESUME_WATCHING_HAS_MIGRATED = "result_resume_watching_migrated" @@ -42,6 +40,37 @@ object DataStoreHelper { return this } + /** + * Used to display notifications on new episodes and posters in library. + **/ + data class SubscribedData( + @JsonProperty("id") override var id: Int?, + @JsonProperty("subscribedTime") val bookmarkedTime: Long, + @JsonProperty("latestUpdatedTime") val latestUpdatedTime: Long, + @JsonProperty("lastSeenEpisodeCount") val lastSeenEpisodeCount: Map, + @JsonProperty("name") override val name: String, + @JsonProperty("url") override val url: String, + @JsonProperty("apiName") override val apiName: String, + @JsonProperty("type") override var type: TvType? = null, + @JsonProperty("posterUrl") override var posterUrl: String?, + @JsonProperty("year") val year: Int?, + @JsonProperty("quality") override var quality: SearchQuality? = null, + @JsonProperty("posterHeaders") override var posterHeaders: Map? = null, + ) : SearchResponse { + fun toLibraryItem(): SyncAPI.LibraryItem? { + return SyncAPI.LibraryItem( + name, + url, + id?.toString() ?: return null, + null, + null, + null, + latestUpdatedTime, + apiName, type, posterUrl, posterHeaders, quality, this.id + ) + } + } + data class BookmarkedData( @JsonProperty("id") override var id: Int?, @JsonProperty("bookmarkedTime") val bookmarkedTime: Long, @@ -63,7 +92,7 @@ object DataStoreHelper { null, null, null, - null, + latestUpdatedTime, apiName, type, posterUrl, posterHeaders, quality, this.id ) } @@ -75,9 +104,7 @@ object DataStoreHelper { @JsonProperty("apiName") override val apiName: String, @JsonProperty("type") override var type: TvType? = null, @JsonProperty("posterUrl") override var posterUrl: String?, - @JsonProperty("watchPos") val watchPos: PosDur?, - @JsonProperty("id") override var id: Int?, @JsonProperty("parentId") val parentId: Int?, @JsonProperty("episode") val episode: Int?, @@ -204,6 +231,41 @@ object DataStoreHelper { return getKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) } + fun getAllSubscriptions(): List { + return getKeys("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA")?.mapNotNull { + getKey(it) + } ?: emptyList() + } + + fun removeSubscribedData(id: Int?) { + if (id == null) return + AccountManager.localListApi.requireLibraryRefresh = true + removeKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString()) + } + + /** + * Set new seen episodes and update time + **/ + fun updateSubscribedData(id: Int?, data: SubscribedData?, episodeResponse: EpisodeResponse?) { + if (id == null || data == null || episodeResponse == null) return + val newData = data.copy( + latestUpdatedTime = unixTimeMS, + lastSeenEpisodeCount = episodeResponse.getLatestEpisodes() + ) + setKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString(), newData) + } + + fun setSubscribedData(id: Int?, data: SubscribedData) { + if (id == null) return + setKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString(), data) + AccountManager.localListApi.requireLibraryRefresh = true + } + + fun getSubscribedData(id: Int?): SubscribedData? { + if (id == null) return null + return getKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString()) + } + fun setViewPos(id: Int?, pos: Long, dur: Long) { if (id == null) return if (dur < 30_000) return // too short diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt index 1625981e..dcb1e047 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt @@ -14,6 +14,7 @@ import androidx.core.app.NotificationCompat import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.utils.AppUtils.createNotificationChannel import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import kotlinx.coroutines.delay @@ -47,24 +48,12 @@ class PackageInstallerService : Service() { .setSmallIcon(R.drawable.rdload) } - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = - NotificationChannel(UPDATE_CHANNEL_ID, UPDATE_CHANNEL_NAME, importance).apply { - description = UPDATE_CHANNEL_DESCRIPTION - } - - // Register the channel with the system - val notificationManager: NotificationManager = - this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(channel) - } - } - override fun onCreate() { - createNotificationChannel() + this.createNotificationChannel( + UPDATE_CHANNEL_ID, + UPDATE_CHANNEL_NAME, + UPDATE_CHANNEL_DESCRIPTION + ) startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build()) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index a629dad9..2902b76b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -20,6 +20,7 @@ import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager +import com.bumptech.glide.load.model.GlideUrl import com.fasterxml.jackson.annotation.JsonProperty import com.hippo.unifile.UniFile import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull @@ -213,7 +214,7 @@ object VideoDownloadManager { } private val cachedBitmaps = hashMapOf() - private fun Context.getImageBitmapFromUrl(url: String): Bitmap? { + fun Context.getImageBitmapFromUrl(url: String, headers: Map? = null): Bitmap? { try { if (cachedBitmaps.containsKey(url)) { return cachedBitmaps[url] @@ -221,12 +222,14 @@ object VideoDownloadManager { val bitmap = GlideApp.with(this) .asBitmap() - .load(url).into(720, 720) + .load(GlideUrl(url) { headers ?: emptyMap() }) + .into(720, 720) .get() + if (bitmap != null) { cachedBitmaps[url] = bitmap } - return null + return bitmap } catch (e: Exception) { logError(e) return null @@ -426,7 +429,7 @@ object VideoDownloadManager { } private const val reservedChars = "|\\?*<\":>+[]/\'" - fun sanitizeFilename(name: String, removeSpaces: Boolean= false): String { + fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { var tempName = name for (c in reservedChars) { tempName = tempName.replace(c, ' ') @@ -1612,7 +1615,7 @@ object VideoDownloadManager { .mapIndexed { index, any -> DownloadQueueResumePackage(index, any) } .toTypedArray() setKey(KEY_RESUME_QUEUE_PACKAGES, dQueue) - } catch (t : Throwable) { + } catch (t: Throwable) { logError(t) } } diff --git a/app/src/main/res/drawable/baseline_notifications_none_24.xml b/app/src/main/res/drawable/baseline_notifications_none_24.xml new file mode 100644 index 00000000..cf589c6d --- /dev/null +++ b/app/src/main/res/drawable/baseline_notifications_none_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_cloudstream_monochrome_big.xml b/app/src/main/res/drawable/ic_cloudstream_monochrome_big.xml new file mode 100644 index 00000000..4b8964f8 --- /dev/null +++ b/app/src/main/res/drawable/ic_cloudstream_monochrome_big.xml @@ -0,0 +1,27 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_result_swipe.xml b/app/src/main/res/layout/fragment_result_swipe.xml index 9d21547a..27729bf8 100644 --- a/app/src/main/res/layout/fragment_result_swipe.xml +++ b/app/src/main/res/layout/fragment_result_swipe.xml @@ -57,6 +57,7 @@ android:layout_width="match_parent" android:layout_height="50dp" android:id="@+id/media_route_button_holder" + android:animateLayoutChanges="true" android:layout_gravity="center_vertical|end"> + + Looks like your library is empty :(\nLogin to a library account or add shows to your local library Looks like this list is empty, try switching to another one Safe mode file found!\nNot loading any extensions on startup until file is removed. + Updating subscribed shows + Subscribed + Subscribed to %s + Unsubscribed from %s + Episode %d released! \ No newline at end of file