From 511d34a54cd3cb06efdcca48f19197ac42e2a428 Mon Sep 17 00:00:00 2001 From: neura Date: Tue, 31 Mar 2026 18:08:28 -0600 Subject: [PATCH 1/4] Add configurable TV mode playback flow --- .../lagradost/cloudstream3/MainActivity.kt | 4 +- .../cloudstream3/ui/home/HomeFragment.kt | 114 +++- .../cloudstream3/ui/home/HomeViewModel.kt | 14 +- .../ui/library/LibraryFragment.kt | 35 +- .../ui/player/AbstractPlayerFragment.kt | 36 +- .../cloudstream3/ui/player/GeneratorPlayer.kt | 346 ++++++++++- .../cloudstream3/ui/result/ResultFragment.kt | 1 + .../ui/result/ResultFragmentPhone.kt | 21 + .../ui/result/ResultFragmentTv.kt | 23 + .../ui/result/ResultViewModel2.kt | 134 +++- .../cloudstream3/ui/settings/SettingsUI.kt | 217 ++++++- .../cloudstream3/utils/DataStoreHelper.kt | 15 + .../cloudstream3/utils/TvModeHelper.kt | 583 ++++++++++++++++++ app/src/main/res/layout/fragment_home.xml | 21 +- app/src/main/res/layout/fragment_home_tv.xml | 34 +- app/src/main/res/layout/fragment_result.xml | 16 + .../main/res/layout/fragment_result_tv.xml | 26 + .../main/res/layout/player_custom_layout.xml | 2 +- .../res/layout/player_custom_layout_tv.xml | 2 +- .../main/res/layout/player_tv_mode_button.xml | 8 + .../res/layout/player_tv_mode_button_tv.xml | 8 + .../res/values/donottranslate-strings.xml | 11 + app/src/main/res/values/strings.xml | 36 ++ app/src/main/res/xml/settings_ui.xml | 70 ++- 24 files changed, 1698 insertions(+), 79 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/TvModeHelper.kt create mode 100644 app/src/main/res/layout/player_tv_mode_button.xml create mode 100644 app/src/main/res/layout/player_tv_mode_button_tv.xml diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index a7c0a8a2795..c21742339ee 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -370,7 +370,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ?: return false ioSafe { val resumeWatchingCard = - HomeViewModel.getResumeWatching()?.firstOrNull { it.id == id } + HomeViewModel.getResumeWatching().firstOrNull { it.id == id } ?: return@ioSafe activity.loadSearchResult( resumeWatchingCard, @@ -2061,4 +2061,4 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa false } } -} \ No newline at end of file +} 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 375b2313f50..6ae69810e44 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 @@ -73,6 +73,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.EmptyEvent import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso +import com.lagradost.cloudstream3.utils.TvModeHelper import com.lagradost.cloudstream3.utils.TvChannelUtils import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding @@ -576,6 +577,12 @@ class HomeFragment : BaseFragment( super.onDestroyView() } + override fun onResume() { + super.onResume() + homeViewModel.reloadStored() + binding?.let { updateQuickActionButtons(it) } + } + private val apiChangeClickListener = View.OnClickListener { view -> view.context.selectHomepage(currentApiName) { api -> homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true) @@ -590,13 +597,85 @@ class HomeFragment : BaseFragment( } private var currentApiName: String? = null - private var toggleRandomButton = false + private var homeQuickActionMode = TvModeHelper.HomeQuickActionMode.NONE + private var latestHomepageCards: List = emptyList() private var bottomSheetDialog: BottomSheetDialog? = null private var homeMasterAdapter: HomeParentItemAdapterPreview? = null var lastSavedHomepage: String? = null + private fun updateShortcutSettings(context: Context) { + homeQuickActionMode = TvModeHelper.getHomeQuickActionMode(context) + } + + private fun updateQuickActionButtons( + binding: FragmentHomeBinding, + homepageCards: List = latestHomepageCards + ) { + val context = context ?: return + updateShortcutSettings(context) + latestHomepageCards = homepageCards.distinctBy { it.url } + TvModeHelper.rememberHomepageCandidates(latestHomepageCards) + + val hasRandomItems = latestHomepageCards.isNotEmpty() + val hasTvModeItems = latestHomepageCards.isNotEmpty() + val isPhoneLayout = isLayout(PHONE) + val showRandomButton = homeQuickActionMode == TvModeHelper.HomeQuickActionMode.RANDOM + val showTvModeButton = + homeQuickActionMode == TvModeHelper.HomeQuickActionMode.TV_MODE && TvModeHelper.isEnabled(context) + + val randomClickListener = View.OnClickListener { + TvModeHelper.stopSession() + latestHomepageCards.randomOrNull()?.let { card -> + activity?.loadSearchResult(card) + } + } + val tvModeClickListener = View.OnClickListener { + TvModeHelper.startFromHome(activity, latestHomepageCards) + } + + binding.homeRandom.isVisible = showRandomButton && isPhoneLayout && hasRandomItems + binding.homeRandomButtonTv.isVisible = showRandomButton && !isPhoneLayout && hasRandomItems + binding.homeTvMode.isVisible = showTvModeButton && isPhoneLayout && hasTvModeItems + binding.homeTvModeButtonTv.isVisible = + showTvModeButton && !isPhoneLayout && hasTvModeItems + + binding.homeRandom.setOnClickListener(randomClickListener) + binding.homeRandomButtonTv.setOnClickListener(randomClickListener) + binding.homeTvMode.setOnClickListener(tvModeClickListener) + binding.homeTvModeButtonTv.setOnClickListener(tvModeClickListener) + + if (!isPhoneLayout) { + val previewRightFocus = when { + binding.homeRandomButtonTv.isVisible -> R.id.home_random_button_tv + binding.homeTvModeButtonTv.isVisible -> R.id.home_tv_mode_button_tv + else -> R.id.home_switch_account + } + val accountLeftFocus = when { + binding.homeTvModeButtonTv.isVisible -> R.id.home_tv_mode_button_tv + binding.homeRandomButtonTv.isVisible -> R.id.home_random_button_tv + else -> R.id.home_preview_search_button + } + val tvModeLeftFocus = if (binding.homeRandomButtonTv.isVisible) { + R.id.home_random_button_tv + } else { + R.id.home_preview_search_button + } + val randomRightFocus = if (binding.homeTvModeButtonTv.isVisible) { + R.id.home_tv_mode_button_tv + } else { + R.id.home_switch_account + } + + binding.homePreviewSearchButton.nextFocusRightId = previewRightFocus + binding.homeRandomButtonTv.nextFocusRightId = randomRightFocus + binding.homeTvModeButtonTv.nextFocusLeftId = tvModeLeftFocus + binding.homeTvModeButtonTv.nextFocusRightId = R.id.home_switch_account + binding.homeSwitchAccount.nextFocusLeftId = accountLeftFocus + } + } + fun saveHomepageToTV(page: Map) { // No need to update for phone if (isLayout(PHONE)) { @@ -681,10 +760,12 @@ class HomeFragment : BaseFragment( if (dy > 0) { //check for scroll down homeApiFab.shrink() // hide homeRandom.shrink() + homeTvMode.shrink() } else if (dy < -5) { if (isLayout(PHONE)) { homeApiFab.extend() // show homeRandom.extend() + homeTvMode.extend() } } } else { @@ -723,14 +804,11 @@ class HomeFragment : BaseFragment( //Load value for toggling Random button. Hide at startup context?.let { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) - toggleRandomButton = - settingsManager.getBoolean( - getString(R.string.random_button_key), - false - ) + updateShortcutSettings(it) binding.homeRandom.visibility = View.GONE binding.homeRandomButtonTv.visibility = View.GONE + binding.homeTvMode.visibility = View.GONE + binding.homeTvModeButtonTv.visibility = View.GONE } observe(homeViewModel.apiName) { apiName -> @@ -761,24 +839,10 @@ class HomeFragment : BaseFragment( homeMasterRecycler.isVisible = true homeLoadingShimmer.stopShimmer() //home_loaded?.isVisible = true - if (toggleRandomButton) { - val distinct = d.values - .flatMap { it.list.list } - .distinctBy { it.url } - val hasItems = distinct.isNotEmpty() - val isPhone = isLayout(PHONE) - val randomClickListener = View.OnClickListener { - distinct.randomOrNull()?.let { activity.loadSearchResult(it) } - } - - homeRandom.isVisible = isPhone && hasItems - homeRandom.setOnClickListener(randomClickListener) - homeRandomButtonTv.isVisible = !isPhone && hasItems - homeRandomButtonTv.setOnClickListener(randomClickListener) - } else { - homeRandom.isGone = true - homeRandomButtonTv.isGone = true - } + val distinct = d.values + .flatMap { it.list.list } + .distinctBy { it.url } + updateQuickActionButtons(this, distinct) } is Resource.Failure -> { 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 e0609c0e57b..973e63ab161 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 @@ -59,14 +59,14 @@ import java.util.concurrent.CopyOnWriteArrayList class HomeViewModel : ViewModel() { companion object { - suspend fun getResumeWatching(): List? { + suspend fun getResumeWatching(): List { val resumeWatching = withContext(Dispatchers.IO) { getAllResumeStateIds()?.mapNotNull { id -> getLastWatched(id) - }?.sortedBy { -it.updateTime } + }?.sortedBy { -it.updateTime } ?: emptyList() } val resumeWatchingResult = withContext(Dispatchers.IO) { - resumeWatching?.mapNotNull { resume -> + resumeWatching.mapNotNull { resume -> val headerCache = getKey( DOWNLOAD_HEADER_CACHE, resume.parentId.toString() @@ -153,15 +153,13 @@ class HomeViewModel : ViewModel() { private fun loadResumeWatching() = viewModelScope.launchSafe { val resumeWatchingResult = getResumeWatching() - if (isLayout(TV) && resumeWatchingResult != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (isLayout(TV) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ioSafe { // this WILL crash on non tvs, so keep this inside a try catch activity?.addProgramsToContinueWatching(resumeWatchingResult) } } - resumeWatchingResult?.let { - _resumeWatching.postValue(it) - } + _resumeWatching.postValue(resumeWatchingResult) } fun loadStoredData(preferredWatchStatus: Set?) = viewModelScope.launchSafe { @@ -549,4 +547,4 @@ class HomeViewModel : ViewModel() { } reloadAccount() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt index 6e28c128d1c..b50421ddafd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt @@ -50,6 +50,7 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog +import com.lagradost.cloudstream3.utils.TvModeHelper import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import java.util.concurrent.CopyOnWriteArrayList @@ -95,6 +96,15 @@ class LibraryFragment : BaseFragment( override fun pickLayout(): Int? = if (isLayout(PHONE)) R.layout.fragment_library else R.layout.fragment_library_tv + override fun onResume() { + super.onResume() + context?.let { + toggleRandomButton = + TvModeHelper.getHomeQuickActionMode(it) == TvModeHelper.HomeQuickActionMode.RANDOM + } + binding?.let { updateRandomVisibility(it) } + } + override fun onSaveInstanceState(outState: Bundle) { binding?.viewpager?.currentItem?.let { currentItem -> outState.putInt(VIEWPAGER_ITEM_KEY, currentItem) @@ -192,12 +202,8 @@ class LibraryFragment : BaseFragment( //Load value for toggling Random button. Hide at startup context?.let { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) toggleRandomButton = - settingsManager.getBoolean( - getString(R.string.random_button_key), - false - ) + TvModeHelper.getHomeQuickActionMode(it) == TvModeHelper.HomeQuickActionMode.RANDOM binding.libraryRandom.visibility = View.GONE binding.libraryRandomButtonTv.visibility = View.GONE } @@ -382,18 +388,15 @@ class LibraryFragment : BaseFragment( binding.searchBar.setExpanded(true) } - // Set up random button click listener - if (toggleRandomButton) { - val randomClickListener = View.OnClickListener { - val position = libraryViewModel.currentPage.value ?: 0 - val syncIdName = libraryViewModel.currentSyncApi?.syncIdName ?: return@OnClickListener - pages[position].items.randomOrNull()?.let { item -> - loadLibraryItem(syncIdName, item.syncId, item) - } + val randomClickListener = View.OnClickListener { + val position = libraryViewModel.currentPage.value ?: 0 + val syncIdName = libraryViewModel.currentSyncApi?.syncIdName ?: return@OnClickListener + pages[position].items.randomOrNull()?.let { item -> + loadLibraryItem(syncIdName, item.syncId, item) } - libraryRandom.setOnClickListener(randomClickListener) - libraryRandomButtonTv.setOnClickListener(randomClickListener) } + libraryRandom.setOnClickListener(randomClickListener) + libraryRandomButtonTv.setOnClickListener(randomClickListener) updateRandomVisibility(binding) // Only stop loading after 300ms to hide the fade effect the viewpager produces when updating @@ -569,4 +572,4 @@ class LibraryFragment : BaseFragment( } } -class MenuSearchView(context: Context) : SearchView(context) \ No newline at end of file +class MenuSearchView(context: Context) : SearchView(context) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index 1fbdd9f4e01..7ce1942f789 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -55,6 +55,7 @@ import com.lagradost.cloudstream3.utils.AppContextUtils import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.EpisodeSkip +import com.lagradost.cloudstream3.utils.TvModeHelper import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage @@ -270,6 +271,8 @@ abstract class AbstractPlayerFragment( throw NotImplementedError() } + open fun onPlaybackExhausted() = Unit + private fun requestAudioFocus() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) @@ -289,7 +292,18 @@ abstract class AbstractPlayerFragment( context?.getString(R.string.no_links_found_toast) + "\n" + message, Toast.LENGTH_LONG ) - activity?.popCurrentPage() + onPlaybackExhausted() + val continuedTvMode = context?.let { ctx -> + if (!TvModeHelper.isManagedPlayback(ctx)) { + false + } else { + player.release() + TvModeHelper.playNextFromSession(activity, replaceExisting = true) + } + } == true + if (!continuedTvMode) { + activity?.popCurrentPage() + } } } @@ -526,13 +540,17 @@ abstract class AbstractPlayerFragment( is VideoEndedEvent -> { context?.let { ctx -> - // Only play next episode if autoplay is on (default) - if (PreferenceManager.getDefaultSharedPreferences(ctx) - ?.getBoolean( - ctx.getString(R.string.autoplay_next_key), - true - ) == true - ) { + val isTvModePlayback = TvModeHelper.isManagedPlayback(ctx) + val shouldPlayNext = if (isTvModePlayback) { + TvModeHelper.shouldForceContinuousPlayback(ctx) + } else { + PreferenceManager.getDefaultSharedPreferences(ctx).getBoolean( + ctx.getString(R.string.autoplay_next_key), + true + ) + } + + if (shouldPlayNext) { player.handleEvent( CSPlayerEvent.NextEpisode, source = PlayerEventSource.Player @@ -752,4 +770,4 @@ abstract class AbstractPlayerFragment( subtitleHolder = root.findViewById(R.id.subtitle_holder) return root } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index ad7c8915f4b..fd604bf6c6c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -32,6 +32,7 @@ import androidx.core.content.edit import androidx.core.text.toSpanned import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.media3.common.Format.NO_VALUE @@ -45,6 +46,7 @@ import androidx.media3.ui.PlayerNotificationManager.MediaDescriptionAdapter import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey @@ -54,6 +56,7 @@ import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType @@ -90,6 +93,7 @@ import com.lagradost.cloudstream3.ui.result.ResultFragment import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.SyncViewModel +import com.lagradost.cloudstream3.ui.result.getId import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR @@ -114,6 +118,7 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName import com.lagradost.cloudstream3.utils.SubtitleHelper.languages +import com.lagradost.cloudstream3.utils.TvModeHelper import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe @@ -175,7 +180,14 @@ class GeneratorPlayer : FullScreenPlayer() { private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none private var binding: FragmentPlayerBinding? = null + private var playerTvModeButton: MaterialButton? = null private var allMeta: List? = null + private var tvModeLoadingTimeoutJob: Job? = null + private var tvModeBufferTimeoutJob: Job? = null + private var tvModeBufferRetryCount = 0 + private var tvModePlaybackStarted = false + private var tvModeLastStatus = CSPlayerLoading.IsBuffering + private var tvModeSkipInProgress = false private fun startLoading() { player.release() currentSelectedSubtitles = null @@ -223,11 +235,253 @@ class GeneratorPlayer : FullScreenPlayer() { override fun playerStatusChanged() { super.playerStatusChanged() + val newStatus = currentPlayerStatus + val previousStatus = tvModeLastStatus + + when (newStatus) { + CSPlayerLoading.IsPlaying, CSPlayerLoading.IsPaused -> { + tvModePlaybackStarted = true + cancelTvModeBufferTimeout() + } + + CSPlayerLoading.IsBuffering -> { + scheduleTvModeBufferTimeout() + if (tvModePlaybackStarted && + previousStatus != CSPlayerLoading.IsBuffering && + shouldUseTvModeStallProtection() + ) { + tvModeBufferRetryCount += 1 + val currentContext = context + if (currentContext != null && + tvModeBufferRetryCount >= TvModeHelper.getStallRetryLimit(currentContext) + ) { + maybeSkipStalledTvModeContent("buffer_retry_limit") + } + } + } + + CSPlayerLoading.IsEnded -> { + cancelTvModeWatchdogs() + } + } + + if (newStatus != CSPlayerLoading.IsBuffering) { + cancelTvModeBufferTimeout() + } + tvModeLastStatus = newStatus + if (player.getIsPlaying()) { viewModel.forceClearCache = false } } + private fun getCurrentTvModeSelection(): Triple? { + val loadResponse = viewModel.getLoadResponse() ?: return null + val primaryUrl = loadResponse.uniqueUrl.ifBlank { loadResponse.url } + val fallbackUrl = loadResponse.url + val episodeId = (currentMeta as? ResultEpisode)?.id ?: (viewModel.getMeta() as? ResultEpisode)?.id + return Triple(primaryUrl, fallbackUrl, episodeId) + } + + private fun markCurrentTvModeFailure() { + val currentContext = context ?: return + if (!TvModeHelper.isManagedPlayback(currentContext)) return + + val (primaryUrl, fallbackUrl, episodeId) = getCurrentTvModeSelection() ?: return + TvModeHelper.rejectPlayback(primaryUrl, episodeId, fallbackUrl) + } + + private fun continueTvModeAfterFailure(): Boolean { + val currentContext = context ?: return false + if (!TvModeHelper.isManagedPlayback(currentContext)) return false + + cancelTvModeWatchdogs() + markCurrentTvModeFailure() + player.release() + val didContinue = TvModeHelper.playNextFromSession(activity, replaceExisting = true) + if (!didContinue) { + tvModeSkipInProgress = false + } + return didContinue + } + + private fun shouldUseTvModeStallProtection(): Boolean { + val currentContext = context ?: return false + return TvModeHelper.isManagedPlayback(currentContext) && + TvModeHelper.isStallProtectionEnabled(currentContext) + } + + private fun cancelTvModeLoadingTimeout() { + tvModeLoadingTimeoutJob?.cancel() + tvModeLoadingTimeoutJob = null + } + + private fun cancelTvModeBufferTimeout() { + tvModeBufferTimeoutJob?.cancel() + tvModeBufferTimeoutJob = null + } + + private fun cancelTvModeWatchdogs() { + cancelTvModeLoadingTimeout() + cancelTvModeBufferTimeout() + } + + private fun resetTvModeStallTracking() { + cancelTvModeWatchdogs() + tvModeBufferRetryCount = 0 + tvModePlaybackStarted = false + tvModeLastStatus = CSPlayerLoading.IsBuffering + tvModeSkipInProgress = false + } + + private fun maybeSkipStalledTvModeContent(reason: String): Boolean { + if (!shouldUseTvModeStallProtection()) return false + if (tvModeSkipInProgress) return true + + Log.i(TAG, "Skipping stalled TV mode content due to $reason") + tvModeSkipInProgress = true + return continueTvModeAfterFailure() + } + + private fun scheduleTvModeLoadingTimeout() { + val currentContext = context ?: return + if (!shouldUseTvModeStallProtection()) return + + val timeoutSeconds = TvModeHelper.getLoadingTimeoutSeconds(currentContext) + cancelTvModeLoadingTimeout() + tvModeLoadingTimeoutJob = lifecycleScope.launch { + delay(timeoutSeconds * 1000L) + if (viewModel.loadingLinks.value is Resource.Loading) { + maybeSkipStalledTvModeContent("loading_timeout") + } + } + } + + private fun scheduleTvModeBufferTimeout() { + val currentContext = context ?: return + if (!shouldUseTvModeStallProtection()) return + + val timeoutSeconds = TvModeHelper.getLoadingTimeoutSeconds(currentContext) + cancelTvModeBufferTimeout() + tvModeBufferTimeoutJob = lifecycleScope.launch { + delay(timeoutSeconds * 1000L) + if (!this@GeneratorPlayer.isActive) return@launch + if (currentPlayerStatus == CSPlayerLoading.IsBuffering) { + maybeSkipStalledTvModeContent("buffer_timeout") + } + } + } + + private fun ensurePlayerTvModeButton() { + val currentContext = context ?: return + val controlsHolder = playerBinding?.playerLockHolder ?: return + + (playerTvModeButton?.parent as? ViewGroup)?.let { parent -> + if (parent !== controlsHolder) { + parent.removeView(playerTvModeButton) + playerTvModeButton = null + } + } + + if (playerTvModeButton != null) return + + val layoutRes = if (isLayout(TV or EMULATOR)) { + R.layout.player_tv_mode_button_tv + } else { + R.layout.player_tv_mode_button + } + + val button = LayoutInflater.from(currentContext) + .inflate(layoutRes, controlsHolder, false) as? MaterialButton ?: return + + val hideNames = PreferenceManager.getDefaultSharedPreferences(currentContext).getBoolean( + currentContext.getString(R.string.hide_player_control_names_key), + false + ) + if (hideNames) { + button.textSize = 0f + button.iconPadding = 0 + button.iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START + button.setPadding(0, 0, 0, 0) + } + + button.isVisible = false + button.contentDescription = button.text + val insertAfter = playerBinding?.playerTracksBtt + val insertIndex = insertAfter?.let { controlsHolder.indexOfChild(it) + 1 } ?: controlsHolder.childCount + controlsHolder.addView(button, insertIndex) + playerTvModeButton = button + } + + private fun updatePlayerTvModeButton() { + ensurePlayerTvModeButton() + val button = playerTvModeButton ?: return + val currentContext = context ?: return + val featureEnabled = TvModeHelper.isEnabled(currentContext) + val sessionActive = TvModeHelper.hasSession() + val buttonId = button.id + + button.isVisible = featureEnabled + if (featureEnabled) { + button.text = currentContext.getString( + if (sessionActive) R.string.tv_mode_player_on else R.string.tv_mode_player_off + ) + button.contentDescription = button.text + + if (layout == R.layout.fragment_player) { + val accentColor = currentContext.colorFromAttribute(R.attr.colorPrimary) + val textColor = accentColor + val inactiveColor = ContextCompat.getColor(currentContext, android.R.color.white) + val resolvedColor = if (sessionActive) textColor else inactiveColor + button.iconTint = ColorStateList.valueOf(resolvedColor) + button.setTextColor(resolvedColor) + } + } + + val rightTarget = when { + playerBinding?.playerSkipOp?.isVisible == true -> R.id.player_skip_op + playerBinding?.playerSkipEpisode?.isVisible == true -> R.id.player_skip_episode + else -> R.id.player_tracks_btt + } + val defaultSkipOpLeftId = if (isLayout(TV or EMULATOR)) { + R.id.player_pause_play + } else { + R.id.player_sources_btt + } + + playerBinding?.playerTracksBtt?.nextFocusRightId = + if (featureEnabled) buttonId else rightTarget + playerBinding?.playerSkipOp?.nextFocusLeftId = + if (featureEnabled) buttonId else defaultSkipOpLeftId + button.nextFocusLeftId = R.id.player_tracks_btt + button.nextFocusRightId = rightTarget + button.nextFocusUpId = R.id.player_pause_play + button.nextFocusDownId = rightTarget + } + + private fun clearContinueWatchingForTvMode() { + val currentContext = context ?: return + if (!TvModeHelper.hasSession()) return + if (TvModeHelper.shouldIncludeInContinueWatching(currentContext)) return + + val parentIds = linkedSetOf() + when (val meta = currentMeta) { + is ResultEpisode -> parentIds.add(meta.parentId) + is ExtractorUri -> meta.parentId?.let(parentIds::add) + } + when (val meta = nextMeta) { + is ResultEpisode -> parentIds.add(meta.parentId) + is ExtractorUri -> meta.parentId?.let(parentIds::add) + } + viewModel.getLoadResponse()?.let { response -> + parentIds.add(response.getId()) + } + + parentIds.forEach { parentId -> + DataStoreHelper.removeLastWatched(parentId) + } + } + private fun noSubtitles(): Boolean { return setSubtitles(null, true) } @@ -508,6 +762,7 @@ class GeneratorPlayer : FullScreenPlayer() { currentSelectedLink = link currentMeta = viewModel.getMeta() nextMeta = viewModel.getNextMeta() + resetTvModeStallTracking() allMeta = viewModel.getAllMeta()?.filterIsInstance()?.map { episode -> // Refresh all the episodes watch duration getViewPos(episode.id)?.let { data -> @@ -518,6 +773,8 @@ class GeneratorPlayer : FullScreenPlayer() { isActive = true setPlayerDimen(null) setTitle() + updatePlayerTvModeButton() + clearContinueWatchingForTvMode() if (!sameEpisode) hasRequestedStamps = false @@ -540,6 +797,7 @@ class GeneratorPlayer : FullScreenPlayer() { preview = isFullScreenPlayer ) } + scheduleTvModeBufferTimeout() if (!sameEpisode) { player.addTimeStamps(emptyList()) // clear stamps @@ -1539,8 +1797,12 @@ class GeneratorPlayer : FullScreenPlayer() { private fun noLinksFound() { viewModel.forceClearCache = true + cancelTvModeWatchdogs() showToast(R.string.no_links_found_toast, Toast.LENGTH_SHORT) + if (continueTvModeAfterFailure()) { + return + } activity?.popCurrentPage() } @@ -1604,6 +1866,14 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun nextEpisode() { + val currentContext = context + if (currentContext != null && TvModeHelper.isManagedPlayback(currentContext)) { + isNextEpisode = true + player.release() + TvModeHelper.playNextFromSession(activity, replaceExisting = true) + return + } + if (viewModel.hasNextEpisode() == true) { isNextEpisode = true player.release() @@ -1624,6 +1894,10 @@ class GeneratorPlayer : FullScreenPlayer() { return links.isNotEmpty() && links.indexOf(currentSelectedLink) + 1 < links.size } + override fun onPlaybackExhausted() { + markCurrentTvModeFailure() + } + override fun nextMirror() { val links = sortLinks(currentQualityProfile) if (links.isEmpty()) { @@ -1642,6 +1916,8 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onDestroy() { ResultFragment.updateUI() + TvModeHelper.clearManagedPlayback() + cancelTvModeWatchdogs() currentVerifyLink?.cancel() super.onDestroy() } @@ -1671,13 +1947,22 @@ class GeneratorPlayer : FullScreenPlayer() { val percentage = position * 100L / duration - DataStoreHelper.setViewPosAndResume( - viewModel.getId(), - position, - duration, - currentMeta, - nextMeta - ) + val shouldSkipContinueWatching = context?.let { ctx -> + TvModeHelper.hasSession() && !TvModeHelper.shouldIncludeInContinueWatching(ctx) + } == true + + if (shouldSkipContinueWatching) { + DataStoreHelper.setViewPos(viewModel.getId(), position, duration) + clearContinueWatchingForTvMode() + } else { + DataStoreHelper.setViewPosAndResume( + viewModel.getId(), + position, + duration, + currentMeta, + nextMeta + ) + } var isOpVisible = false when (val meta = currentMeta) { @@ -1996,6 +2281,8 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun onDestroyView() { + cancelTvModeWatchdogs() + playerTvModeButton = null binding = null super.onDestroyView() } @@ -2204,6 +2491,48 @@ class GeneratorPlayer : FullScreenPlayer() { } } + ensurePlayerTvModeButton() + playerTvModeButton?.setOnClickListener { + autoHide() + val currentContext = context ?: return@setOnClickListener + + if (TvModeHelper.hasSession()) { + TvModeHelper.stopSession() + updatePlayerTvModeButton() + return@setOnClickListener + } + + val loadResponse = viewModel.getLoadResponse() ?: return@setOnClickListener + val primaryUrl = loadResponse.uniqueUrl.ifBlank { loadResponse.url } + val fallbackUrl = loadResponse.url + val episodeMeta = (currentMeta as? ResultEpisode) ?: (viewModel.getMeta() as? ResultEpisode) + val shouldUseCurrentShow = + episodeMeta != null && + TvModeHelper.getPlayerStartMode(currentContext) == TvModeHelper.TvModePlayerStartMode.CURRENT_SHOW + + val didStart = if (shouldUseCurrentShow) { + TvModeHelper.startForResult( + activity, + primaryUrl, + loadResponse.apiName, + loadResponse.name, + episodeMeta.season + ) + } else { + TvModeHelper.startGlobalSessionFromCache( + activity, + loadResponse.recommendations ?: emptyList() + ) + } + + if (didStart) { + TvModeHelper.markPlaybackManaged(primaryUrl, episodeMeta?.id, fallbackUrl) + clearContinueWatchingForTvMode() + updatePlayerTvModeButton() + } + } + updatePlayerTvModeButton() + observe(viewModel.currentStamps) { stamps -> player.addTimeStamps(stamps) } @@ -2212,9 +2541,11 @@ class GeneratorPlayer : FullScreenPlayer() { when (it) { is Resource.Loading -> { startLoading() + scheduleTvModeLoadingTimeout() } is Resource.Success -> { + cancelTvModeLoadingTimeout() // provider returned false //if (it.value != true) { // showToast(activity, R.string.unexpected_error, Toast.LENGTH_SHORT) @@ -2223,6 +2554,7 @@ class GeneratorPlayer : FullScreenPlayer() { } is Resource.Failure -> { + cancelTvModeLoadingTimeout() showToast(it.errorString, Toast.LENGTH_LONG) startPlayer() } 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 cbf94fd9796..0d1544034cd 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 @@ -24,6 +24,7 @@ import com.lagradost.cloudstream3.utils.UiImage const val START_ACTION_RESUME_LATEST = 1 const val START_ACTION_LOAD_EP = 2 +const val START_ACTION_TV_MODE = 3 /** * Future proofed way to mark episodes as watched diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index c9da385f63b..b0c52ea457b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -80,6 +80,7 @@ import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogInstant import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog +import com.lagradost.cloudstream3.utils.TvModeHelper import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe @@ -328,6 +329,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { super.onResume() PanelsChildGestureRegionObserver.Provider.get() .addGestureRegionsUpdateListener(gestureRegionsListener) + resultBinding?.resultTvModeButton?.isVisible = + context?.let { TvModeHelper.isEnabled(it) } == true && viewModel.hasEpisodeContent() } override fun onStop() { @@ -352,6 +355,18 @@ open class ResultFragmentPhone : FullScreenPlayer() { // ===== setup ===== fixSystemBarsPadding(view) val storedData = getStoredData() ?: return + val startLocalTvMode = { + if (TvModeHelper.startForResult( + activity, + storedData.url, + storedData.apiName, + storedData.name, + viewModel.getCurrentSeasonSelection() + ) + ) { + viewModel.startTvModePlayback(activity) + } + } activity?.window?.decorView?.clearFocus() activity?.loadCache() context?.updateHasTrailers() @@ -707,8 +722,13 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultEpisodes.isVisible = episodes is Resource.Success resultBatchDownloadButton.isVisible = episodes is Resource.Success && episodes.value.isNotEmpty() + resultTvModeButton.isVisible = + context?.let { TvModeHelper.isEnabled(it) } == true && + episodes is Resource.Success && + viewModel.hasEpisodeContent() if (episodes is Resource.Success) { + resultTvModeButton.setOnClickListener { startLocalTvMode.invoke() } (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) // Show quality dialog with all sources @@ -782,6 +802,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { observeNullable(viewModel.movie) { data -> resultBinding?.apply { resultPlayMovie.isVisible = data is Resource.Success + resultTvModeButton.isGone = true downloadButton.isVisible = data is Resource.Success && viewModel.currentRepo?.api?.hasDownloadSupport == true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt index 70ca117432d..f4ddea93dac 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt @@ -56,6 +56,7 @@ import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPres import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogInstant +import com.lagradost.cloudstream3.utils.TvModeHelper import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard @@ -195,6 +196,8 @@ class ResultFragmentTv : BaseFragment( activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) afterPluginsLoadedEvent += ::reloadViewModel super.onResume() + binding?.resultTvMode?.isVisible = + context?.let { TvModeHelper.isEnabled(it) } == true && viewModel.hasEpisodeContent() } override fun onStop() { @@ -258,6 +261,18 @@ class ResultFragmentTv : BaseFragment( override fun onBindingCreated(binding: FragmentResultTvBinding) { // ===== setup ===== val storedData = getStoredData() ?: return + val startLocalTvMode = { + if (TvModeHelper.startForResult( + activity, + storedData.url, + storedData.apiName, + storedData.name, + viewModel.getCurrentSeasonSelection() + ) + ) { + viewModel.startTvModePlayback(activity) + } + } activity?.window?.decorView?.clearFocus() activity?.loadCache() hideKeyboard() @@ -289,6 +304,7 @@ class ResultFragmentTv : BaseFragment( val views = listOf( resultPlayMovieButton, resultPlaySeriesButton, + resultTvModeButton, resultResumeSeriesButton, resultPlayTrailerButton, resultBookmarkButton, @@ -324,6 +340,7 @@ class ResultFragmentTv : BaseFragment( mapOf( resultPlayMovieButton to resultPlayMovieText, resultPlaySeriesButton to resultPlaySeriesText, + resultTvModeButton to resultTvModeText, resultResumeSeriesButton to resultResumeSeriesText, resultPlayTrailerButton to resultPlayTrailerText, resultBookmarkButton to resultBookmarkText, @@ -676,6 +693,7 @@ class ResultFragmentTv : BaseFragment( } binding.apply { + resultTvMode.isGone = true (data as? Resource.Success)?.value?.let { (_, ep) -> resultPlayMovieButton.setOnClickListener { viewModel.handleAction( @@ -810,6 +828,9 @@ class ResultFragmentTv : BaseFragment( // resultEpisodeLoading.isVisible = episodes is Resource.Loading if (episodes is Resource.Success) { + resultTvMode.isVisible = + context?.let { TvModeHelper.isEnabled(it) } == true && + viewModel.hasEpisodeContent() val lastWatchedIndex = episodes.value.indexOfLast { ep -> ep.getWatchProgress() >= NEXT_WATCH_EPISODE_PERCENTAGE.toFloat() / 100.0f || ep.videoWatchState == VideoWatchState.Watched } @@ -846,11 +867,13 @@ class ResultFragmentTv : BaseFragment( if (!hasLoadedEpisodesOnce) { hasLoadedEpisodesOnce = true resultPlaySeries.isVisible = resultResumeSeries.isGone && !comingSoon + resultTvMode.setOnClickListener { startLocalTvMode.invoke() } resultEpisodesShow.isVisible = true && !comingSoon resultPlaySeriesButton.requestFocus() } } + resultTvModeButton.setOnClickListener { startLocalTvMode.invoke() } (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) } 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 6eab987fc6e..425c758a5f3 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 @@ -101,6 +101,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.FillerEpisodeCheck import com.lagradost.cloudstream3.utils.INFER_TYPE import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.TvModeHelper import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.VIDEO_WATCH_STATE @@ -2474,6 +2475,133 @@ class ResultViewModel2 : ViewModel() { } fun hasLoaded() = currentResponse != null + fun hasEpisodeContent() = currentEpisodes.values.any { it.isNotEmpty() } + fun getCurrentSeasonSelection(): Int? = currentIndex?.season + fun startTvModePlayback(activity: Activity?) { + if (activity == null) return + tryStartTvModePlayback(activity) + } + + private fun continueTvModePlayback( + activity: Activity, + primaryUrl: String, + fallbackUrl: String, + episodeId: Int? = null, + ) { + TvModeHelper.rejectPlayback(primaryUrl, episodeId, fallbackUrl) + TvModeHelper.playNextFromSession(activity, replaceExisting = true) + } + + private fun stopTvModePlayback(activity: Activity, messageRes: Int = R.string.tv_mode_no_playable_content) { + TvModeHelper.stopSession() + showToast(activity, messageRes, Toast.LENGTH_SHORT) + } + + private fun tryStartTvModePlayback(activity: Activity) { + val response = currentResponse ?: run { + TvModeHelper.playNextFromSession(activity, replaceExisting = true) + return + } + val primaryUrl = response.uniqueUrl.ifBlank { response.url } + val fallbackUrl = response.url + val isLocalSession = TvModeHelper.isLocalSession(primaryUrl, fallbackUrl) + val hasEpisodeContent = hasEpisodeContent() + val isMovie = response.isMovie() && !hasEpisodeContent + + if (!TvModeHelper.acceptsLoadedContent( + activity, + isMovie, + hasEpisodeContent, + primaryUrl, + fallbackUrl + ) + ) { + continueTvModePlayback(activity, primaryUrl, fallbackUrl) + return + } + + if (isMovie && !isLocalSession) { + val movie = getMovie() ?: run { + continueTvModePlayback(activity, primaryUrl, fallbackUrl) + return + } + TvModeHelper.markPlaybackManaged(primaryUrl, movie.id, fallbackUrl) + handleAction( + EpisodeClickEvent( + getPlayerAction(activity), + movie + ) + ) + return + } + + if (!hasEpisodeContent) { + if (isLocalSession) { + stopTvModePlayback(activity) + } else { + continueTvModePlayback(activity, primaryUrl, fallbackUrl) + } + return + } + + val seasonFilter = TvModeHelper.getLocalSeasonFilter(primaryUrl, fallbackUrl) + val availableEntries = currentEpisodes.entries.filter { entry -> + entry.value.isNotEmpty() && (seasonFilter == null || entry.key.season == seasonFilter) + } + if (availableEntries.isEmpty()) { + if (isLocalSession) { + stopTvModePlayback(activity) + } else { + continueTvModePlayback(activity, primaryUrl, fallbackUrl) + } + return + } + + val preferredDub = TvModeHelper.resolveDubStatus( + activity, + availableEntries.map { it.key.dubStatus } + ) + val filteredEntries = availableEntries.filter { it.key.dubStatus == preferredDub } + .ifEmpty { availableEntries } + val candidateEpisodes = filteredEntries + .flatMap { (indexer, episodes) -> episodes.map { episode -> indexer to episode } } + val rejectedEpisodeIds = TvModeHelper.getRejectedEpisodeIds(primaryUrl, fallbackUrl) + val playableEpisodes = candidateEpisodes + .filterNot { (_, episode) -> rejectedEpisodeIds.contains(episode.id) } + if (playableEpisodes.isEmpty()) { + stopTvModePlayback(activity) + return + } + + val recentEpisodeIds = TvModeHelper.getRecentEpisodeIds(primaryUrl, fallbackUrl) + val randomEpisode = playableEpisodes + .filterNot { (_, episode) -> recentEpisodeIds.contains(episode.id) } + .ifEmpty { playableEpisodes } + .randomOrNull() + ?: run { + stopTvModePlayback(activity) + return + } + + val (indexer, episode) = randomEpisode + val targetRange = currentRanges[indexer]?.firstOrNull { range -> + episode.episode in range.startEpisode..range.endEpisode + } ?: currentRanges[indexer]?.lastOrNull() + + if (targetRange == null) { + continueTvModePlayback(activity, primaryUrl, fallbackUrl, episode.id) + return + } + + postEpisodeRange(indexer, targetRange, currentSorting ?: DataStoreHelper.resultsSortingMode) + TvModeHelper.markPlaybackManaged(primaryUrl, episode.id, fallbackUrl) + handleAction( + EpisodeClickEvent( + getPlayerAction(activity), + episode + ) + ) + } private fun handleAutoStart(activity: Activity?, autostart: AutoResume?) = viewModelScope.launchSafe { @@ -2511,6 +2639,10 @@ class ResultViewModel2 : ViewModel() { ) ) } + + START_ACTION_TV_MODE -> { + tryStartTvModePlayback(activity) + } } } @@ -2674,4 +2806,4 @@ class ResultViewModel2 : ViewModel() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt index f4c522bf981..913cfabdade 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt @@ -28,6 +28,7 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpTo import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog +import com.lagradost.cloudstream3.utils.TvModeHelper import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.toPx @@ -43,6 +44,83 @@ class SettingsUI : BasePreferenceFragmentCompat() { hideKeyboard() setPreferencesFromResource(R.xml.settings_ui, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val homeQuickActionPref = getPref(R.string.home_quick_action_key) + val tvModeTogglePref = getPref(R.string.tv_mode_button_key) + val tvModeContentPref = getPref(R.string.tv_mode_content_key) + val tvModeDubPref = getPref(R.string.tv_mode_dub_preference_key) + val tvModeSeasonPref = getPref(R.string.tv_mode_season_scope_key) + val tvModePlayerStartPref = getPref(R.string.tv_mode_player_start_key) + val tvModeLoopPref = getPref(R.string.tv_mode_loop_key) + val tvModeContinueWatchingPref = getPref(R.string.tv_mode_continue_watching_key) + val tvModeStallProtectionPref = getPref(R.string.tv_mode_stall_protection_key) + val tvModeRetryLimitPref = getPref(R.string.tv_mode_retry_limit_key) + val tvModeLoadingTimeoutPref = getPref(R.string.tv_mode_loading_timeout_key) + + fun getTvModeContentMode(): TvModeHelper.TvModeContentMode { + return TvModeHelper.TvModeContentMode.fromValue( + settingsManager.getInt( + getString(R.string.tv_mode_content_key), + TvModeHelper.TvModeContentMode.BOTH.value + ) + ) + } + + fun getTvModeDubPreference(): TvModeHelper.TvModeDubPreference { + return TvModeHelper.TvModeDubPreference.fromValue( + settingsManager.getInt( + getString(R.string.tv_mode_dub_preference_key), + TvModeHelper.TvModeDubPreference.PREFER_DUBBED.value + ) + ) + } + + fun getTvModeSeasonMode(): TvModeHelper.TvModeSeasonMode { + return TvModeHelper.TvModeSeasonMode.fromValue( + settingsManager.getInt( + getString(R.string.tv_mode_season_scope_key), + TvModeHelper.TvModeSeasonMode.SELECTED_SEASON_ONLY.value + ) + ) + } + + fun getHomeQuickActionMode(): TvModeHelper.HomeQuickActionMode { + return TvModeHelper.getHomeQuickActionMode(requireContext()) + } + + fun getTvModePlayerStartMode(): TvModeHelper.TvModePlayerStartMode { + return TvModeHelper.getPlayerStartMode(requireContext()) + } + + fun isTvModeStallProtectionEnabled(): Boolean { + return TvModeHelper.isStallProtectionEnabled(requireContext()) + } + + fun updateTvModePreferenceState( + isEnabledOverride: Boolean? = null, + stallProtectionEnabledOverride: Boolean? = null, + ) { + val isEnabled = isEnabledOverride ?: settingsManager.getBoolean( + getString(R.string.tv_mode_button_key), + false + ) + homeQuickActionPref?.summary = getString(getHomeQuickActionMode().labelRes) + tvModeContentPref?.isVisible = isEnabled + tvModeDubPref?.isVisible = isEnabled + tvModeSeasonPref?.isVisible = isEnabled + tvModePlayerStartPref?.isVisible = isEnabled + tvModeLoopPref?.isVisible = isEnabled + tvModeContinueWatchingPref?.isVisible = isEnabled + tvModeStallProtectionPref?.isVisible = isEnabled + val showStallProtectionDetails = isEnabled && ( + stallProtectionEnabledOverride ?: isTvModeStallProtectionEnabled() + ) + tvModeRetryLimitPref?.isVisible = showStallProtectionDetails + tvModeLoadingTimeoutPref?.isVisible = showStallProtectionDetails + tvModeContentPref?.summary = getString(getTvModeContentMode().labelRes) + tvModeDubPref?.summary = getString(getTvModeDubPreference().labelRes) + tvModeSeasonPref?.summary = getString(getTvModeSeasonMode().labelRes) + tvModePlayerStartPref?.summary = getString(getTvModePlayerStartMode().labelRes) + } (getPref(R.string.overscan_key)?.hideOn(PHONE or EMULATOR) as? SeekBarPreference)?.setOnPreferenceChangeListener { pref, newValue -> val padding = (newValue as? Int)?.toPx ?: return@setOnPreferenceChangeListener true @@ -247,5 +325,142 @@ class SettingsUI : BasePreferenceFragmentCompat() { ) return@setOnPreferenceClickListener true } + + homeQuickActionPref?.setOnPreferenceClickListener { + val modes = TvModeHelper.HomeQuickActionMode.entries + val selectedMode = getHomeQuickActionMode() + + activity?.showBottomDialog( + items = modes.map { getString(it.labelRes) }, + selectedIndex = modes.indexOf(selectedMode), + name = getString(R.string.home_quick_action_settings), + showApply = true, + dismissCallback = {}, + callback = { selectedIndex -> + val chosenMode = modes[selectedIndex] + var enabledOverride: Boolean? = null + settingsManager.edit { + putInt(getString(R.string.home_quick_action_key), chosenMode.value) + if (chosenMode == TvModeHelper.HomeQuickActionMode.TV_MODE && + !settingsManager.getBoolean(getString(R.string.tv_mode_button_key), false) + ) { + putBoolean(getString(R.string.tv_mode_button_key), true) + enabledOverride = true + } + } + updateTvModePreferenceState(enabledOverride) + } + ) + true + } + + tvModeTogglePref?.setOnPreferenceChangeListener { _, newValue -> + if (newValue == false) { + TvModeHelper.stopSession() + } + updateTvModePreferenceState(newValue as? Boolean) + true + } + + tvModeStallProtectionPref?.setOnPreferenceChangeListener { _, newValue -> + updateTvModePreferenceState( + stallProtectionEnabledOverride = newValue as? Boolean + ) + true + } + + tvModeContentPref?.setOnPreferenceClickListener { + val modes = TvModeHelper.TvModeContentMode.entries + val selectedMode = getTvModeContentMode() + + activity?.showBottomDialog( + items = modes.map { getString(it.labelRes) }, + selectedIndex = modes.indexOf(selectedMode), + name = getString(R.string.tv_mode_content_settings), + showApply = true, + dismissCallback = {}, + callback = { selectedIndex -> + settingsManager.edit { + putInt( + getString(R.string.tv_mode_content_key), + modes[selectedIndex].value + ) + } + updateTvModePreferenceState() + } + ) + true + } + + tvModeDubPref?.setOnPreferenceClickListener { + val preferences = TvModeHelper.TvModeDubPreference.entries + val selectedPreference = getTvModeDubPreference() + + activity?.showBottomDialog( + items = preferences.map { getString(it.labelRes) }, + selectedIndex = preferences.indexOf(selectedPreference), + name = getString(R.string.tv_mode_dub_settings), + showApply = true, + dismissCallback = {}, + callback = { selectedIndex -> + settingsManager.edit { + putInt( + getString(R.string.tv_mode_dub_preference_key), + preferences[selectedIndex].value + ) + } + updateTvModePreferenceState() + } + ) + true + } + + tvModeSeasonPref?.setOnPreferenceClickListener { + val seasonModes = TvModeHelper.TvModeSeasonMode.entries + val selectedMode = getTvModeSeasonMode() + + activity?.showBottomDialog( + items = seasonModes.map { getString(it.labelRes) }, + selectedIndex = seasonModes.indexOf(selectedMode), + name = getString(R.string.tv_mode_season_settings), + showApply = true, + dismissCallback = {}, + callback = { selectedIndex -> + settingsManager.edit { + putInt( + getString(R.string.tv_mode_season_scope_key), + seasonModes[selectedIndex].value + ) + } + updateTvModePreferenceState() + } + ) + true + } + + tvModePlayerStartPref?.setOnPreferenceClickListener { + val startModes = TvModeHelper.TvModePlayerStartMode.entries + val selectedMode = getTvModePlayerStartMode() + + activity?.showBottomDialog( + items = startModes.map { getString(it.labelRes) }, + selectedIndex = startModes.indexOf(selectedMode), + name = getString(R.string.tv_mode_player_start_settings), + showApply = true, + dismissCallback = {}, + callback = { selectedIndex -> + settingsManager.edit { + putInt( + getString(R.string.tv_mode_player_start_key), + startModes[selectedIndex].value + ) + } + updateTvModePreferenceState() + } + ) + true + } + + updateTvModePreferenceState() } -} \ No newline at end of file +} 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 19caead21ee..5ea0f4765d9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -549,6 +549,7 @@ object DataStoreHelper { fun removeLastWatched(parentId: Int?) { if (parentId == null) return removeKey("$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString()) + removeLastWatchedOld(parentId) } fun getLastWatched(id: Int?): DownloadObjects.ResumeWatching? { @@ -664,6 +665,20 @@ object DataStoreHelper { } } + val shouldSkipResumeWatching = context?.let { ctx -> + TvModeHelper.hasSession() && !TvModeHelper.shouldIncludeInContinueWatching(ctx) + } == true + + if (shouldSkipResumeWatching) { + listOf(currentEpisode, nextEpisode).forEach { meta -> + when (meta) { + is ResultEpisode -> removeLastWatched(meta.parentId) + is ExtractorUri -> removeLastWatched(meta.parentId) + } + } + return + } + val percentage = position * 100L / duration val nextEp = percentage >= NEXT_WATCH_EPISODE_PERCENTAGE val resumeMeta = if (nextEp) nextEpisode else currentEpisode diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TvModeHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TvModeHelper.kt new file mode 100644 index 00000000000..9f9d4cc6d9d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TvModeHelper.kt @@ -0,0 +1,583 @@ +package com.lagradost.cloudstream3.utils + +import android.app.Activity +import android.content.Context +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.navigation.NavOptions +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.ui.result.ResultFragment +import com.lagradost.cloudstream3.ui.result.START_ACTION_TV_MODE +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import java.util.ArrayDeque + +object TvModeHelper { + enum class TvModeContentMode(val value: Int, @StringRes val labelRes: Int) { + SERIES_ONLY(0, R.string.tv_mode_content_series_only), + MOVIES_ONLY(1, R.string.tv_mode_content_movies_only), + BOTH(2, R.string.tv_mode_content_both), + ; + + companion object { + fun fromValue(value: Int): TvModeContentMode { + return entries.firstOrNull { it.value == value } ?: BOTH + } + } + } + + enum class TvModeDubPreference(val value: Int, @StringRes val labelRes: Int) { + PREFER_DUBBED(0, R.string.tv_mode_dub_prefer_dubbed), + PREFER_SUBBED(1, R.string.tv_mode_dub_prefer_subbed), + RANDOM(2, R.string.tv_mode_dub_random), + ; + + companion object { + fun fromValue(value: Int): TvModeDubPreference { + return entries.firstOrNull { it.value == value } ?: PREFER_DUBBED + } + } + } + + enum class TvModeSeasonMode(val value: Int, @StringRes val labelRes: Int) { + SELECTED_SEASON_ONLY(0, R.string.tv_mode_season_selected), + ANY_SEASON(1, R.string.tv_mode_season_any), + ; + + companion object { + fun fromValue(value: Int): TvModeSeasonMode { + return entries.firstOrNull { it.value == value } ?: SELECTED_SEASON_ONLY + } + } + } + + enum class HomeQuickActionMode(val value: Int, @StringRes val labelRes: Int) { + NONE(0, R.string.home_quick_action_none), + RANDOM(1, R.string.home_quick_action_random), + TV_MODE(2, R.string.home_quick_action_tv_mode), + ; + + companion object { + fun fromValue(value: Int): HomeQuickActionMode { + return entries.firstOrNull { it.value == value } ?: NONE + } + } + } + + enum class TvModePlayerStartMode(val value: Int, @StringRes val labelRes: Int) { + CURRENT_SHOW(0, R.string.tv_mode_player_start_current_show), + GLOBAL_RANDOM(1, R.string.tv_mode_player_start_global_random), + ; + + companion object { + fun fromValue(value: Int): TvModePlayerStartMode { + return entries.firstOrNull { it.value == value } ?: CURRENT_SHOW + } + } + } + + private data class ResultNavigationData( + val url: String, + val apiName: String, + val name: String, + ) + + private sealed interface TvModeSession { + var managedPlayback: Boolean + + data class Global( + val candidates: List, + val recentUrls: ArrayDeque = ArrayDeque(), + val rejectedUrls: LinkedHashSet = linkedSetOf(), + override var managedPlayback: Boolean = false, + ) : TvModeSession + + data class LocalShow( + val navigation: ResultNavigationData, + val selectedSeason: Int?, + val seasonMode: TvModeSeasonMode, + val recentEpisodeIds: ArrayDeque = ArrayDeque(), + val rejectedEpisodeIds: LinkedHashSet = linkedSetOf(), + override var managedPlayback: Boolean = false, + ) : TvModeSession { + fun matches(primaryUrl: String?, fallbackUrl: String?): Boolean { + return navigation.url == primaryUrl || navigation.url == fallbackUrl + } + } + } + + private var currentSession: TvModeSession? = null + private var cachedHomepageCandidates: List = emptyList() + + fun hasSession(): Boolean { + return currentSession != null + } + + fun isEnabled(context: Context): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean( + context.getString(R.string.tv_mode_button_key), + false + ) + } + + fun shouldForceContinuousPlayback(context: Context): Boolean { + return isManagedPlayback(context) && isLoopEnabled(context) + } + + fun isManagedPlayback(context: Context): Boolean { + return isEnabled(context) && (currentSession?.managedPlayback == true) + } + + fun isLoopEnabled(context: Context): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean( + context.getString(R.string.tv_mode_loop_key), + true + ) + } + + fun isLocalSession(primaryUrl: String?, fallbackUrl: String? = null): Boolean { + val session = currentSession as? TvModeSession.LocalShow ?: return false + return session.matches(primaryUrl, fallbackUrl) + } + + fun getLocalSeasonFilter(primaryUrl: String?, fallbackUrl: String? = null): Int? { + val session = currentSession as? TvModeSession.LocalShow ?: return null + if (!session.matches(primaryUrl, fallbackUrl)) return null + return if (session.seasonMode == TvModeSeasonMode.SELECTED_SEASON_ONLY) { + session.selectedSeason + } else { + null + } + } + + fun getRecentEpisodeIds(primaryUrl: String?, fallbackUrl: String? = null): Set { + val session = currentSession as? TvModeSession.LocalShow ?: return emptySet() + if (!session.matches(primaryUrl, fallbackUrl)) return emptySet() + return session.recentEpisodeIds.toSet() + } + + fun getRejectedEpisodeIds(primaryUrl: String?, fallbackUrl: String? = null): Set { + val session = currentSession as? TvModeSession.LocalShow ?: return emptySet() + if (!session.matches(primaryUrl, fallbackUrl)) return emptySet() + return session.rejectedEpisodeIds.toSet() + } + + fun rememberHomepageCandidates(candidates: List) { + cachedHomepageCandidates = candidates.distinctBy { it.url } + } + + fun getHomeQuickActionMode(context: Context): HomeQuickActionMode { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + val key = context.getString(R.string.home_quick_action_key) + if (preferences.contains(key)) { + return HomeQuickActionMode.fromValue( + preferences.getInt(key, HomeQuickActionMode.NONE.value) + ) + } + + return when { + preferences.getBoolean(context.getString(R.string.random_button_key), false) -> { + HomeQuickActionMode.RANDOM + } + + preferences.getBoolean(context.getString(R.string.tv_mode_button_key), false) -> { + HomeQuickActionMode.TV_MODE + } + + else -> HomeQuickActionMode.NONE + } + } + + fun getPlayerStartMode(context: Context): TvModePlayerStartMode { + return TvModePlayerStartMode.fromValue( + PreferenceManager.getDefaultSharedPreferences(context).getInt( + context.getString(R.string.tv_mode_player_start_key), + TvModePlayerStartMode.CURRENT_SHOW.value + ) + ) + } + + fun getEligibleCandidates(context: Context, candidates: List): List { + val contentMode = getContentMode(context) + return candidates.filter { it.matchesContentMode(contentMode) }.ifEmpty { candidates } + } + + fun resolveDubStatus(context: Context, available: Collection): DubStatus? { + val normalized = available.distinct() + if (normalized.isEmpty()) return null + + return when (getDubPreference(context)) { + TvModeDubPreference.PREFER_DUBBED -> { + normalized.firstOrNull { it == DubStatus.Dubbed } + ?: normalized.firstOrNull { it == DubStatus.Subbed } + ?: normalized.randomOrNull() + } + + TvModeDubPreference.PREFER_SUBBED -> { + normalized.firstOrNull { it == DubStatus.Subbed } + ?: normalized.firstOrNull { it == DubStatus.Dubbed } + ?: normalized.randomOrNull() + } + + TvModeDubPreference.RANDOM -> normalized.randomOrNull() + } + } + + @Synchronized + fun startFromHome(activity: Activity?, candidates: List): Boolean { + if (!startGlobalSession(activity, candidates)) return false + return playNextFromSession(activity, replaceExisting = false) + } + + @Synchronized + fun startGlobalSession(activity: Activity?, candidates: List): Boolean { + if (activity == null) return false + if (!isEnabled(activity)) return false + + val distinctCandidates = candidates.distinctBy { it.url } + if (distinctCandidates.isEmpty()) { + stopSession() + showToast(activity, R.string.tv_mode_no_eligible_titles, Toast.LENGTH_SHORT) + return false + } + + rememberHomepageCandidates(distinctCandidates) + currentSession = TvModeSession.Global(candidates = distinctCandidates) + return true + } + + @Synchronized + fun startGlobalSessionFromCache( + activity: Activity?, + fallbackCandidates: List = emptyList(), + ): Boolean { + val candidates = cachedHomepageCandidates.ifEmpty { + fallbackCandidates.distinctBy { it.url } + } + return startGlobalSession(activity, candidates) + } + + @Synchronized + fun startForResult( + activity: Activity?, + url: String, + apiName: String, + name: String, + selectedSeason: Int?, + ): Boolean { + if (activity == null) return false + if (!isEnabled(activity)) return false + + currentSession = TvModeSession.LocalShow( + navigation = ResultNavigationData(url = url, apiName = apiName, name = name), + selectedSeason = selectedSeason, + seasonMode = getSeasonMode(activity), + ) + return true + } + + @Synchronized + fun playNextFromSession(activity: Activity?, replaceExisting: Boolean): Boolean { + val session = currentSession ?: return false + val validActivity = activity ?: return false + if (!isEnabled(validActivity)) { + stopSession() + return false + } + + session.managedPlayback = false + + return when (session) { + is TvModeSession.Global -> { + val next = pickCandidate(validActivity, session) ?: run { + stopSession() + showToast(validActivity, R.string.tv_mode_no_playable_content, Toast.LENGTH_SHORT) + return false + } + + navigateToResult(validActivity, next, replaceExisting) + true + } + + is TvModeSession.LocalShow -> { + navigateToStoredResult(validActivity, session.navigation, replaceExisting) + true + } + } + } + + @Synchronized + fun markPlaybackManaged( + primaryUrl: String?, + episodeId: Int? = null, + fallbackUrl: String? = null, + ) { + currentSession?.let { session -> + session.managedPlayback = true + when (session) { + is TvModeSession.Global -> { + if (!primaryUrl.isNullOrBlank()) { + rememberRecentUrl(session, primaryUrl) + } else if (!fallbackUrl.isNullOrBlank()) { + rememberRecentUrl(session, fallbackUrl) + } + } + + is TvModeSession.LocalShow -> { + if (session.matches(primaryUrl, fallbackUrl) && episodeId != null) { + rememberRecentEpisode(session, episodeId) + } + } + } + } + } + + @Synchronized + fun rejectPlayback( + primaryUrl: String?, + episodeId: Int? = null, + fallbackUrl: String? = null, + ) { + currentSession?.let { session -> + session.managedPlayback = false + when (session) { + is TvModeSession.Global -> { + resolveSessionUrl(primaryUrl, fallbackUrl)?.let { url -> + session.rejectedUrls.add(url) + } + } + + is TvModeSession.LocalShow -> { + if (session.matches(primaryUrl, fallbackUrl) && episodeId != null) { + session.rejectedEpisodeIds.add(episodeId) + } + } + } + } + } + + @Synchronized + fun clearManagedPlayback() { + currentSession?.managedPlayback = false + } + + @Synchronized + fun stopSession() { + currentSession = null + } + + fun acceptsLoadedContent( + context: Context, + isMovie: Boolean, + hasEpisodeContent: Boolean, + primaryUrl: String?, + fallbackUrl: String? = null, + ): Boolean { + if (isLocalSession(primaryUrl, fallbackUrl)) { + return hasEpisodeContent + } + + return when (getContentMode(context)) { + TvModeContentMode.SERIES_ONLY -> hasEpisodeContent + TvModeContentMode.MOVIES_ONLY -> isMovie + TvModeContentMode.BOTH -> isMovie || hasEpisodeContent + } + } + + fun shouldIncludeInContinueWatching(context: Context): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean( + context.getString(R.string.tv_mode_continue_watching_key), + true + ) + } + + fun isStallProtectionEnabled(context: Context): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean( + context.getString(R.string.tv_mode_stall_protection_key), + false + ) + } + + fun getStallRetryLimit(context: Context): Int { + return PreferenceManager.getDefaultSharedPreferences(context).getInt( + context.getString(R.string.tv_mode_retry_limit_key), + 3 + ) + } + + fun getLoadingTimeoutSeconds(context: Context): Int { + return PreferenceManager.getDefaultSharedPreferences(context).getInt( + context.getString(R.string.tv_mode_loading_timeout_key), + 20 + ) + } + + private fun getContentMode(context: Context): TvModeContentMode { + return TvModeContentMode.fromValue( + PreferenceManager.getDefaultSharedPreferences(context).getInt( + context.getString(R.string.tv_mode_content_key), + TvModeContentMode.BOTH.value + ) + ) + } + + private fun getDubPreference(context: Context): TvModeDubPreference { + return TvModeDubPreference.fromValue( + PreferenceManager.getDefaultSharedPreferences(context).getInt( + context.getString(R.string.tv_mode_dub_preference_key), + TvModeDubPreference.PREFER_DUBBED.value + ) + ) + } + + private fun getSeasonMode(context: Context): TvModeSeasonMode { + return TvModeSeasonMode.fromValue( + PreferenceManager.getDefaultSharedPreferences(context).getInt( + context.getString(R.string.tv_mode_season_scope_key), + TvModeSeasonMode.SELECTED_SEASON_ONLY.value + ) + ) + } + + private fun navigateToResult(activity: Activity, card: SearchResponse, replaceExisting: Boolean) { + val isPhoneLayout = isLayout(PHONE) + val globalNavigationId = if (isPhoneLayout) { + R.id.global_to_navigation_results_phone + } else { + R.id.global_to_navigation_results_tv + } + val resultDestinationId = if (isPhoneLayout) { + R.id.navigation_results_phone + } else { + R.id.navigation_results_tv + } + val navOptions = if (replaceExisting) { + NavOptions.Builder() + .setPopUpTo(resultDestinationId, true) + .build() + } else { + null + } + + activity.navigate( + globalNavigationId, + ResultFragment.newInstance(card, START_ACTION_TV_MODE), + navOptions + ) + } + + private fun navigateToStoredResult( + activity: Activity, + navigation: ResultNavigationData, + replaceExisting: Boolean, + ) { + val isPhoneLayout = isLayout(PHONE) + val globalNavigationId = if (isPhoneLayout) { + R.id.global_to_navigation_results_phone + } else { + R.id.global_to_navigation_results_tv + } + val resultDestinationId = if (isPhoneLayout) { + R.id.navigation_results_phone + } else { + R.id.navigation_results_tv + } + val navOptions = if (replaceExisting) { + NavOptions.Builder() + .setPopUpTo(resultDestinationId, true) + .build() + } else { + null + } + + activity.navigate( + globalNavigationId, + ResultFragment.newInstance( + navigation.url, + navigation.apiName, + navigation.name, + START_ACTION_TV_MODE, + 0 + ), + navOptions + ) + } + + private fun pickCandidate( + context: Context, + session: TvModeSession.Global, + ): SearchResponse? { + val availableCandidates = session.candidates + .filterNot { candidate -> session.rejectedUrls.contains(candidate.url) } + if (availableCandidates.isEmpty()) return null + + val preferredCandidates = getEligibleCandidates(context, availableCandidates) + val candidatePool = if (preferredCandidates.isNotEmpty()) { + preferredCandidates + } else { + availableCandidates + } + + val freshCandidates = candidatePool.filterNot { candidate -> + session.recentUrls.contains(candidate.url) + } + + return (if (freshCandidates.isNotEmpty()) freshCandidates else candidatePool).randomOrNull() + } + + private fun rememberRecentUrl(session: TvModeSession.Global, url: String) { + session.recentUrls.remove(url) + session.recentUrls.addLast(url) + + val maxRecentSize = when { + session.candidates.size <= 4 -> 2 + session.candidates.size <= 12 -> 4 + else -> 8 + } + + while (session.recentUrls.size > maxRecentSize) { + if (session.recentUrls.isNotEmpty()) { + session.recentUrls.removeFirst() + } + } + } + + private fun rememberRecentEpisode(session: TvModeSession.LocalShow, episodeId: Int) { + session.recentEpisodeIds.remove(episodeId) + session.recentEpisodeIds.addLast(episodeId) + + val maxRecentSize = 6 + while (session.recentEpisodeIds.size > maxRecentSize) { + if (session.recentEpisodeIds.isNotEmpty()) { + session.recentEpisodeIds.removeFirst() + } + } + } + + private fun SearchResponse.matchesContentMode(contentMode: TvModeContentMode): Boolean { + return when (contentMode) { + TvModeContentMode.SERIES_ONLY -> type.isTvModeSeries() + TvModeContentMode.MOVIES_ONLY -> type.isTvModeMovie() + TvModeContentMode.BOTH -> type.isTvModeSeries() || type.isTvModeMovie() + } + } + + private fun resolveSessionUrl(primaryUrl: String?, fallbackUrl: String?): String? { + return primaryUrl?.takeIf { it.isNotBlank() } ?: fallbackUrl?.takeIf { it.isNotBlank() } + } + + private fun TvType?.isTvModeSeries(): Boolean { + return this?.isEpisodeBased() == true || this == TvType.OVA + } + + private fun TvType?.isTvModeMovie(): Boolean { + return this == TvType.Movie || this == TvType.AnimeMovie + } +} diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 99a764deee8..d1688289ad9 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -248,10 +248,29 @@ tools:ignore="ContentDescription" tools:visibility="visible" /> + + - \ No newline at end of file + + + + diff --git a/app/src/main/res/layout/fragment_home_tv.xml b/app/src/main/res/layout/fragment_home_tv.xml index d1d5c9e3bd9..0b4ea481b1b 100644 --- a/app/src/main/res/layout/fragment_home_tv.xml +++ b/app/src/main/res/layout/fragment_home_tv.xml @@ -242,6 +242,24 @@ android:visibility="gone" app:tint="@color/player_on_button_tv_attr" /> + + - \ No newline at end of file + + + diff --git a/app/src/main/res/layout/fragment_result.xml b/app/src/main/res/layout/fragment_result.xml index a5c933d6a03..6f796d4ab58 100644 --- a/app/src/main/res/layout/fragment_result.xml +++ b/app/src/main/res/layout/fragment_result.xml @@ -717,6 +717,22 @@ + +