diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt index 8ec082520fb..d5dacf7349e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt @@ -17,6 +17,8 @@ import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.newSearchResponseList +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import com.lagradost.cloudstream3.utils.ExtractorLink import kotlinx.coroutines.CoroutineScope @@ -55,18 +57,48 @@ class APIRepository(val api: MainAPI) { val hash: Pair ) + data class SavedHomePageResponse( + val unixTime: Long, + val response: List, + val hash: Pair> + ) + private val cache = atomicListOf() private var cacheIndex: Int = 0 const val CACHE_SIZE = 20 + private val homeCache = atomicListOf() + private var homeCacheIndex: Int = 0 + const val HOME_CACHE_SIZE = 20 + const val HOME_CACHE_FOLDER = "home_cache" + fun getTimeout(desired: Long?): Long { return (desired ?: DEFAULT_TIMEOUT).coerceIn(MIN_TIMEOUT, MAX_TIMEOUT) } + + fun clearCache() { + cache.clear() + homeCache.clear() + CloudStreamApp.removeKeys(HOME_CACHE_FOLDER) + } + + fun hasHomePageCache(apiName: String, page: Int = 1, nameIndex: Int? = null): Boolean { + if (!DataStoreHelper.isCacheEnabled) return false + val lookingForHash = Pair(apiName, Pair(page, nameIndex)) + val cacheTtl = DataStoreHelper.cacheTimeSeconds + val inRam = homeCache.withLock { + homeCache.any { it.hash == lookingForHash && unixTime - it.unixTime < cacheTtl } + } + if (inRam) return true + val diskKey = "${apiName}_${page}_${nameIndex}" + val onDisk = CloudStreamApp.getKey(HOME_CACHE_FOLDER, diskKey) + return onDisk != null && unixTime - onDisk.unixTime < cacheTtl + } } private fun afterPluginsLoaded(forceReload: Boolean) { if (forceReload) { - cache.clear() + clearCache() } } @@ -88,31 +120,37 @@ class APIRepository(val api: MainAPI) { if (isInvalidData(url)) throw ErrorLoadingException() val fixedUrl = api.fixUrl(url) val lookingForHash = Pair(api.name, fixedUrl) + val cacheTtl = DataStoreHelper.cacheTimeSeconds + val isCacheEnabled = DataStoreHelper.isCacheEnabled - val cached = cache.withLock { - var found: LoadResponse? = null - for (item in cache) { - // 10 min save - if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) { - found = item.response - break + if (isCacheEnabled) { + val cached = cache.withLock { + var found: LoadResponse? = null + for (item in cache) { + if (item.hash == lookingForHash && unixTime - item.unixTime < cacheTtl) { + found = item.response + break + } } + found } - found + + if (cached != null) return@withTimeout cached } - if (cached != null) return@withTimeout cached api.load(fixedUrl)?.also { response -> // Remove all blank tags as early as possible response.tags = response.tags?.filter { it.isNotBlank() } val add = SavedLoadResponse(unixTime, response, lookingForHash) - cache.withLock { - if (cache.size > CACHE_SIZE) { - cache[cacheIndex] = add // rolling cache - cacheIndex = (cacheIndex + 1) % CACHE_SIZE - } else { - cache.add(add) + if (isCacheEnabled) { + cache.withLock { + if (cache.size > CACHE_SIZE) { + cache[cacheIndex] = add // rolling cache + cacheIndex = (cacheIndex + 1) % CACHE_SIZE + } else { + cache.add(add) + } } } } ?: throw ErrorLoadingException() @@ -154,11 +192,44 @@ class APIRepository(val api: MainAPI) { } suspend fun getMainPage(page: Int, nameIndex: Int? = null): Resource> { + val lookingForHash = Pair(api.name, Pair(page, nameIndex)) + val cacheTtl = DataStoreHelper.cacheTimeSeconds + val isCacheEnabled = DataStoreHelper.isCacheEnabled + val diskKey = "${api.name}_${page}_${nameIndex}" + + if (isCacheEnabled) { + val cached = homeCache.withLock { + var found: List? = null + for (item in homeCache) { + if (item.hash == lookingForHash && unixTime - item.unixTime < cacheTtl) { + found = item.response + break + } + } + found + } + + if (cached != null) return Resource.Success(cached) + + val cachedOnDisk = CloudStreamApp.getKey(HOME_CACHE_FOLDER, diskKey) + if (cachedOnDisk != null && unixTime - cachedOnDisk.unixTime < cacheTtl) { + homeCache.withLock { + if (homeCache.size > HOME_CACHE_SIZE) { + homeCache[homeCacheIndex] = cachedOnDisk + homeCacheIndex = (homeCacheIndex + 1) % HOME_CACHE_SIZE + } else { + homeCache.add(cachedOnDisk) + } + } + return Resource.Success(cachedOnDisk.response) + } + } + return safeApiCall { withTimeout(getTimeout(api.getMainPageTimeoutMs)) { api.lastHomepageRequest = unixTimeMS - nameIndex?.let { api.mainPage.getOrNull(it) }?.let { data -> + val res = nameIndex?.let { api.mainPage.getOrNull(it) }?.let { data -> listOf( api.getMainPage( page, @@ -191,6 +262,21 @@ class APIRepository(val api: MainAPI) { } } } + + if (isCacheEnabled && res.isNotEmpty()) { + val add = SavedHomePageResponse(unixTime, res, lookingForHash) + homeCache.withLock { + if (homeCache.size > HOME_CACHE_SIZE) { + homeCache[homeCacheIndex] = add // rolling cache + homeCacheIndex = (homeCacheIndex + 1) % HOME_CACHE_SIZE + } else { + homeCache.add(add) + } + } + CloudStreamApp.setKey(HOME_CACHE_FOLDER, diskKey, add) + } + + res } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt index 6bdd1bf492f..53f4211e400 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt @@ -82,7 +82,11 @@ open class ParentItemAdapter( ) { val binding = holder.view if (binding !is HomepageParentBinding) return - (binding.homeChildRecyclerview.adapter as? HomeChildItemAdapter)?.submitList(item.list.list) + (binding.homeChildRecyclerview.adapter as? HomeChildItemAdapter)?.apply { + isHorizontal = item.list.isHorizontalImages + hasNext = item.hasNext + submitList(item.list.list) + } } override fun onBindContent( diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt index 959806e566c..be48d4794dd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt @@ -11,6 +11,7 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.view.isGone +import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.findViewTreeLifecycleOwner @@ -652,7 +653,7 @@ class HomeParentItemAdapterPreview( } private fun updatePreview(preview: Resource>>) { - if (preview is Resource.Success) { + if (preview is Resource.Success || preview is Resource.Loading) { homeNonePadding.apply { val params = layoutParams params.height = 0 @@ -693,6 +694,14 @@ class HomeParentItemAdapterPreview( } } + is Resource.Loading -> { + previewAdapter.submitList(listOf()) + previewViewpager.setCurrentItem(0, false) + previewViewpager.isInvisible = true + previewViewpagerText.isVisible = true + alternativeAccountPadding?.isVisible = false + } + else -> { previewAdapter.submitList(listOf()) previewViewpager.setCurrentItem(0, false) 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 8d48f5a6859..cc4183e5deb 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 @@ -226,7 +226,7 @@ class HomeViewModel : ViewModel() { private val expandable: MutableMap = mutableMapOf() private val _page = - MutableLiveData>>(Resource.Loading()) + MutableLiveData>>() val page: LiveData>> = _page val lock: MutableSet = mutableSetOf() @@ -333,8 +333,10 @@ class HomeViewModel : ViewModel() { } - _page.postValue(Resource.Loading()) - _preview.postValue(Resource.Loading()) + if (!APIRepository.hasHomePageCache(api.name, 1, null)) { + _page.postValue(Resource.Loading()) + _preview.postValue(Resource.Loading()) + } // cancel the current preview expand as that is no longer relevant addJob?.cancel() @@ -357,6 +359,8 @@ class HomeViewModel : ViewModel() { } } + _page.postValue(Resource.Success(expandable)) + val items = data.value.mapNotNull { it?.items }.flatten() @@ -375,28 +379,31 @@ class HomeViewModel : ViewModel() { context?.filterSearchResultByFilmQuality(currentList.shuffled()) ?: currentList.shuffled() - updatePreviewResponses( - previewResponses, - previewResponsesAdded, - randomItems, - 3 - ) - _randomItems.postValue(randomItems) currentShuffledList = randomItems + + addJob?.cancel() + addJob = ioSafe { + updatePreviewResponses( + previewResponses, + previewResponsesAdded, + randomItems, + 3 + ) + + if (previewResponses.isEmpty()) { + _preview.postValue( + Resource.Failure( + false, + "No homepage responses" + ) + ) + } else { + _preview.postValue(Resource.Success((previewResponsesAdded.size < currentShuffledList.size) to previewResponses)) + } + } } } - if (previewResponses.isEmpty()) { - _preview.postValue( - Resource.Failure( - false, - "No homepage responses" - ) - ) - } else { - _preview.postValue(Resource.Success((previewResponsesAdded.size < currentShuffledList.size) to previewResponses)) - } - _page.postValue(Resource.Success(expandable)) } catch (e: Exception) { _randomItems.postValue(emptyList()) logError(e) @@ -512,10 +519,14 @@ class HomeViewModel : ViewModel() { // if we don't need to reload and we have a valid homepage or currently loading the same thing then return val currentLoading = isCurrentlyLoadingName - if (!forceReload && (currentPage is Resource.Success && currentPage.value.isNotEmpty() || (currentLoading != null && currentLoading == preferredApiName))) { + if (!forceReload && ((_apiName.value == preferredApiName && currentPage is Resource.Success && currentPage.value.isNotEmpty()) || (currentLoading != null && currentLoading == preferredApiName))) { return@ioSafe } + if (forceReload) { + APIRepository.clearCache() + } + val api = getApiFromNameNull(preferredApiName) if (preferredApiName == noneApi.name) { // just set to random diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt index c8478a84003..d69c06314bf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt @@ -2,13 +2,17 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.view.View +import android.widget.Toast import androidx.core.content.edit -import androidx.navigation.fragment.findNavController import androidx.navigation.NavOptions +import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat +import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags @@ -16,6 +20,7 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpTo import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard @@ -33,6 +38,42 @@ class SettingsProviders : BasePreferenceFragmentCompat() { setPreferencesFromResource(R.xml.settings_providers, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val cachePref = getPref(R.string.cache_time_key) + val cacheNames = resources.getStringArray(R.array.cache_time_names) + val cacheValues = resources.getIntArray(R.array.cache_time_values) + + fun updateCacheSummary() { + val idx = cacheValues.indexOf(DataStoreHelper.cacheTimeMinutes) + cachePref?.summary = cacheNames.getOrNull(idx) ?: "${DataStoreHelper.cacheTimeMinutes}m" + } + updateCacheSummary() + + cachePref?.setOnPreferenceClickListener { + val currentIndex = cacheValues.indexOf(DataStoreHelper.cacheTimeMinutes).coerceAtLeast(0) + activity?.showBottomDialog( + cacheNames.toList(), + currentIndex, + getString(R.string.cache_time_settings), + false, + {} + ) { selectedIndex -> + DataStoreHelper.cacheTimeMinutes = cacheValues.getOrElse(selectedIndex) { 0 } + updateCacheSummary() + } + true + } + + getPref(R.string.clear_provider_cache_key)?.setOnPreferenceClickListener { + try { + APIRepository.clearCache() + RepoLinkGenerator.cache.clear() + showToast(R.string.clear_provider_cache_cleared, Toast.LENGTH_SHORT) + } catch (e: Exception) { + logError(e) + } + true + } + getPref(R.string.display_sub_key)?.setOnPreferenceClickListener { activity?.getApiDubstatusSettings()?.let { current -> val dublist = DubStatus.entries 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 340266f6c52..62f4c7d823d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -64,15 +64,17 @@ class UserPreferenceDelegate( private val default: T, ) { private val klass: KClass = default::class + private var cache: T? = null private val realKey get() = "${DataStoreHelper.currentAccount}/$key" operator fun getValue(self: Any?, property: KProperty<*>) = - getKeyClass(realKey, klass.java) ?: default + cache ?: getKeyClass(realKey, klass.java).also { newCache -> cache = newCache } ?: default operator fun setValue( self: Any?, property: KProperty<*>, t: T?, ) { + cache = t if (t == null) { removeKey(realKey) } else { @@ -160,6 +162,14 @@ object DataStoreHelper { _resultsSortingMode = value.ordinal } + var cacheTimeMinutes: Int by UserPreferenceDelegate( + "cache_time_pref", + 0 + ) + + val isCacheEnabled: Boolean get() = cacheTimeMinutes > 0 + val cacheTimeSeconds: Long get() = cacheTimeMinutes * 60L + @Serializable data class Account( @JsonProperty("keyIndex") @SerialName("keyIndex") val keyIndex: Int, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d530b59f6e..8aa6dac3804 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -792,4 +792,37 @@ %d downloads queued Live + + cache_time_key + Cache Expiry Time + Control how long Home & Detail data is cached. Set to 0 to disable cache and always fetch latest updates. + + clear_provider_cache_key + Clear Provider Cache + Delete all saved homepage, detail, and extractor cache + Provider cache cleared + + + Off (Default - Always Reload) + 10 Minutes + 30 Minutes + 1 Hour + 3 Hours + 6 Hours + 1 Day + 3 Days + 1 Week + + + + 0 + 10 + 30 + 60 + 180 + 360 + 1440 + 4320 + 10080 + diff --git a/app/src/main/res/xml/settings_providers.xml b/app/src/main/res/xml/settings_providers.xml index b34583e2132..3095dabfaa4 100644 --- a/app/src/main/res/xml/settings_providers.xml +++ b/app/src/main/res/xml/settings_providers.xml @@ -22,6 +22,17 @@ android:title="@string/enable_nsfw_on_providers" app:defaultValue="false" /> + + + +