diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 306d2c77..e389f7b0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,7 +48,7 @@ android { targetSdk = 33 versionCode = 55 - versionName = "3.2.5" + versionName = "3.2.6" resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d827a3e8..11b82afb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,7 +11,7 @@ - + + + + + + + + + + + diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 1f208502..a1bd0177 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -54,13 +54,17 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2A import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringRepo +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringSearch import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO +import com.lagradost.cloudstream3.ui.home.HomeViewModel +import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST import com.lagradost.cloudstream3.ui.search.SearchFragment import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isEmulatorSettings +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings import com.lagradost.cloudstream3.ui.settings.SettingsGeneral import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY @@ -69,6 +73,7 @@ import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable import com.lagradost.cloudstream3.utils.AppUtils.loadCache import com.lagradost.cloudstream3.utils.AppUtils.loadRepository import com.lagradost.cloudstream3.utils.AppUtils.loadResult +import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStore.getKey @@ -83,6 +88,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.requestRW import com.lagradost.cloudstream3.utils.USER_PROVIDER_API import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API @@ -289,6 +295,19 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { nextSearchQuery = URLDecoder.decode(str.substringAfter("$appStringSearch://"), "UTF-8") nav_view.selectedItemId = R.id.navigation_search + } else if (safeURI(str)?.scheme == appStringResumeWatching) { + val id = + str.substringAfter("$appStringResumeWatching://").toIntOrNull() + ?: return false + ioSafe { + val resumeWatchingCard = + HomeViewModel.getResumeWatching()?.firstOrNull { it.id == id } + ?: return@ioSafe + activity.loadSearchResult( + resumeWatchingCard, + START_ACTION_RESUME_LATEST + ) + } } else if (!isWebview) { if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) { this.navigate(R.id.navigation_downloads) @@ -449,12 +468,33 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { onUserLeaveHint(this) } + private fun showConfirmExitDialog() { + val builder: AlertDialog.Builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) + builder.setTitle(R.string.confirm_exit_dialog) + builder.apply { + setPositiveButton(R.string.yes) { _, _ -> super.onBackPressed() } + setNegativeButton(R.string.no) { _, _ -> } + } + builder.show() + } + private fun backPressed() { this.window?.navigationBarColor = this.colorFromAttribute(R.attr.primaryGrayBackground) this.updateLocale() - super.onBackPressed() this.updateLocale() + + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment + val navController = navHostFragment?.navController + val isAtHome = + navController?.currentDestination?.matchDestination(R.id.navigation_home) == true + + if (isAtHome && isTrueTvSettings()) { + showConfirmExitDialog() + } else { + super.onBackPressed() + } } override fun onBackPressed() { diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt index be915baf..f09bf8fe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -48,6 +48,9 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { // Instantly start the search given a query const val appStringSearch = "cloudstreamsearch" + // Instantly resume watching a show + const val appStringResumeWatching = "cloudstreamcontinuewatching" + val unixTime: Long get() = System.currentTimeMillis() / 1000L val unixTimeMs: Long diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index 00b53fa7..b7803e6c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -7,6 +7,7 @@ import android.content.DialogInterface import android.content.Intent import android.content.res.Configuration import android.net.Uri +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -56,6 +57,7 @@ import com.lagradost.cloudstream3.ui.search.* import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.utils.AppUtils.addProgramsToContinueWatching import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable import com.lagradost.cloudstream3.utils.AppUtils.loadResult import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult @@ -552,7 +554,7 @@ class HomeFragment : Fragment() { observe(homeViewModel.preview) { preview -> // Always reset the padding, otherwise the will move lower and lower - // home_fix_padding?.setPadding(0, 0, 0, 0) + // home_fix_padding?.setPadding(0, 0, 0, 0) home_fix_padding?.let { v -> val params = v.layoutParams params.height = 0 @@ -596,7 +598,7 @@ class HomeFragment : Fragment() { val callback: OnPageChangeCallback = object : OnPageChangeCallback() { override fun onPageSelected(position: Int) { - // home_search?.isIconified = true + // home_search?.isIconified = true //home_search?.isVisible = true //home_search?.clearFocus() @@ -927,11 +929,13 @@ class HomeFragment : Fragment() { resumeWatching ) - //if (context?.isTvSettings() == true) { - // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // context?.addProgramsToContinueWatching(resumeWatching.mapNotNull { it as? DataStoreHelper.ResumeWatchingResult }) - // } - //} + if (isTrueTvSettings()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ioSafe { + activity?.addProgramsToContinueWatching(resumeWatching.mapNotNull { it as? DataStoreHelper.ResumeWatchingResult }) + } + } + } home_watch_child_more_info?.setOnClickListener { activity?.loadHomepageList( diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index 9d75b0f0..3bb196e2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -36,6 +36,42 @@ import java.util.* import kotlin.collections.set class HomeViewModel : ViewModel() { + companion object { + suspend fun getResumeWatching(): List? { + val resumeWatching = withContext(Dispatchers.IO) { + getAllResumeStateIds()?.mapNotNull { id -> + getLastWatched(id) + }?.sortedBy { -it.updateTime } + } + val resumeWatchingResult = withContext(Dispatchers.IO) { + resumeWatching?.mapNotNull { resume -> + + val data = getKey( + DOWNLOAD_HEADER_CACHE, + resume.parentId.toString() + ) ?: return@mapNotNull null + + val watchPos = getViewPos(resume.episodeId) + + DataStoreHelper.ResumeWatchingResult( + data.name, + data.url, + data.apiName, + data.type, + data.poster, + watchPos, + resume.episodeId, + resume.parentId, + resume.episode, + resume.season, + resume.isFromDownload + ) + } + } + return resumeWatchingResult + } + } + private var repo: APIRepository? = null private val _apiName = MutableLiveData() @@ -66,36 +102,7 @@ class HomeViewModel : ViewModel() { val preview: LiveData>>> = _preview fun loadResumeWatching() = viewModelScope.launchSafe { - val resumeWatching = withContext(Dispatchers.IO) { - getAllResumeStateIds()?.mapNotNull { id -> - getLastWatched(id) - }?.sortedBy { -it.updateTime } - } - - // val resumeWatchingResult = ArrayList() - - val resumeWatchingResult = withContext(Dispatchers.IO) { - resumeWatching?.map { resume -> - val data = getKey( - DOWNLOAD_HEADER_CACHE, - resume.parentId.toString() - ) ?: return@map null - val watchPos = getViewPos(resume.episodeId) - DataStoreHelper.ResumeWatchingResult( - data.name, - data.url, - data.apiName, - data.type, - data.poster, - watchPos, - resume.episodeId, - resume.parentId, - resume.episode, - resume.season, - resume.isFromDownload - ) - }?.filterNotNull() - } + val resumeWatchingResult = getResumeWatching() resumeWatchingResult?.let { _resumeWatching.postValue(it) } 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 cf3fbfde..597316c3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt @@ -3,9 +3,7 @@ package com.lagradost.cloudstream3.utils import android.annotation.SuppressLint import android.app.Activity import android.app.Activity.RESULT_CANCELED -import android.content.ContentValues -import android.content.Context -import android.content.Intent +import android.content.* import android.content.pm.PackageManager import android.database.Cursor import android.media.AudioAttributes @@ -26,7 +24,6 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.annotation.WorkerThread import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.text.HtmlCompat import androidx.core.text.toSpanned @@ -35,9 +32,7 @@ import androidx.fragment.app.FragmentActivity import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import androidx.tvprovider.media.tv.PreviewChannelHelper -import androidx.tvprovider.media.tv.TvContractCompat -import androidx.tvprovider.media.tv.WatchNextProgram +import androidx.tvprovider.media.tv.* import androidx.tvprovider.media.tv.WatchNextProgram.fromCursor import com.fasterxml.jackson.module.kotlin.readValue import com.google.android.gms.cast.framework.CastContext @@ -51,6 +46,7 @@ import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEv import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.plugins.RepositoryManager +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching import com.lagradost.cloudstream3.ui.WebviewFragment import com.lagradost.cloudstream3.ui.result.ResultFragment import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings @@ -58,9 +54,13 @@ import com.lagradost.cloudstream3.ui.settings.extensions.PluginsViewModel.Compan import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds +import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.FillerEpisodeCheck.toClassDir import com.lagradost.cloudstream3.utils.JsUnpacker.Companion.load import com.lagradost.cloudstream3.utils.UIHelper.navigate +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import okhttp3.Cache import java.io.* import java.net.URL @@ -110,7 +110,8 @@ object AppUtils { @SuppressLint("RestrictedApi") private fun buildWatchNextProgramUri( context: Context, - card: DataStoreHelper.ResumeWatchingResult + card: DataStoreHelper.ResumeWatchingResult, + resumeWatching: VideoDownloadHelper.ResumeWatching? ): WatchNextProgram { val isSeries = card.type?.isMovieType() == false val title = if (isSeries) { @@ -129,15 +130,18 @@ object AppUtils { .setWatchNextType(TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) .setTitle(title) .setPosterArtUri(Uri.parse(card.posterUrl)) - .setIntentUri(Uri.parse(card.url)) //TODO FIX intent + .setIntentUri(Uri.parse(card.id?.let { + "$appStringResumeWatching://$it" + } ?: card.url)) .setInternalProviderId(card.url) - //.setLastEngagementTimeUtcMillis(System.currentTimeMillis()) + .setLastEngagementTimeUtcMillis( + resumeWatching?.updateTime ?: System.currentTimeMillis() + ) card.watchPos?.let { builder.setDurationMillis(it.duration.toInt()) builder.setLastPlaybackPositionMillis(it.position.toInt()) } - // .setLastEngagementTimeUtcMillis() //TODO if (isSeries) card.episode?.let { @@ -147,6 +151,27 @@ object AppUtils { return builder.build() } + @SuppressLint("RestrictedApi") + fun getAllWatchNextPrograms(context: Context): Set { + val COLUMN_WATCH_NEXT_ID_INDEX = 0 + val cursor = context.contentResolver.query( + TvContractCompat.WatchNextPrograms.CONTENT_URI, + WatchNextProgram.PROJECTION, + /* selection = */ null, + /* selectionArgs = */ null, + /* sortOrder = */ null + ) + val set = mutableSetOf() + cursor?.use { + if (it.moveToFirst()) { + do { + set.add(cursor.getLong(COLUMN_WATCH_NEXT_ID_INDEX)) + } while (it.moveToNext()) + } + } + return set + } + /** * Find the Watch Next program for given id. * Returns the first instance available. @@ -164,7 +189,7 @@ object AppUtils { WatchNextProgram.PROJECTION, /* selection = */ null, /* selectionArgs = */ null, - /* sortOrder= */ null + /* sortOrder = */ null ) cursor?.use { if (it.moveToFirst()) { @@ -195,17 +220,32 @@ object AppUtils { } } + /** Prevents losing data when removing and adding simultaneously */ + private val continueWatchingLock = Mutex() + // https://github.com/googlearchive/leanback-homescreen-channels/blob/master/app/src/main/java/com/google/android/tvhomescreenchannels/SampleTvProvider.java @SuppressLint("RestrictedApi") @WorkerThread - fun Context.addProgramsToContinueWatching(data: List) { + suspend fun Context.addProgramsToContinueWatching(data: List) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val context = this - ioSafe { - data.forEach { episodeInfo -> + continueWatchingLock.withLock { + // A way to get all last watched timestamps + val timeStampHashMap = HashMap() + getAllResumeStateIds()?.forEach { id -> + val lastWatched = getLastWatched(id) ?: return@forEach + timeStampHashMap[lastWatched.parentId] = lastWatched + } + + val currentProgramIds = data.mapNotNull { episodeInfo -> try { - val (program, id) = getWatchNextProgramByVideoId(episodeInfo.url, context) - val nextProgram = buildWatchNextProgramUri(context, episodeInfo) + val customId = "${episodeInfo.id}|${episodeInfo.apiName}|${episodeInfo.url}" + val (program, id) = getWatchNextProgramByVideoId(customId, context) + val nextProgram = buildWatchNextProgramUri( + context, + episodeInfo, + timeStampHashMap[episodeInfo.id] + ) // If the program is already in the Watch Next row, update it if (program != null && id != null) { @@ -213,13 +253,25 @@ object AppUtils { nextProgram, id, ) + id } else { PreviewChannelHelper(context) .publishWatchNextProgram(nextProgram) } } catch (e: Exception) { logError(e) + null } + }.toSet() + + val allOldPrograms = getAllWatchNextPrograms(context) - currentProgramIds + + // Ensures synced watch next progress by deleting all old programs. + allOldPrograms.forEach { + context.contentResolver.delete( + TvContractCompat.buildWatchNextProgramUri(it), + null, null + ) } } } @@ -267,7 +319,7 @@ object AppUtils { fun Activity.downloadAllPluginsDialog(repositoryUrl: String, repositoryName: String) { runOnUiThread { val context = this - val builder: AlertDialog.Builder = AlertDialog.Builder(this) + val builder: AlertDialog.Builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) builder.setTitle( repositoryName ) @@ -279,7 +331,7 @@ object AppUtils { downloadAll(context, repositoryUrl, null) } - setNegativeButton(R.string.cancel) { _, _ -> } + setNegativeButton(R.string.no) { _, _ -> } } builder.show() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index db042b95..4a4e6505 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -655,4 +655,8 @@ History Show skip popups for opening/ending Too much text. Unable to save to clipboard. + Mark as watched + Are you sure you want to exit? + Yes + No