diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index a7c0a8a2795..3a45caba932 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, @@ -509,6 +509,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa R.id.navigation_settings_player, R.id.navigation_settings_updates, R.id.navigation_settings_ui, + R.id.navigation_settings_tv_mode, R.id.navigation_settings_account, R.id.navigation_settings_providers, R.id.navigation_settings_general, @@ -579,6 +580,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa R.id.navigation_settings_player, R.id.navigation_settings_updates, R.id.navigation_settings_ui, + R.id.navigation_settings_tv_mode, R.id.navigation_settings_account, R.id.navigation_settings_providers, R.id.navigation_settings_general, @@ -2061,4 +2063,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..31daa41a213 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 @@ -111,6 +112,8 @@ abstract class AbstractPlayerFragment( open fun playerStatusChanged() {} + open fun onUserSeekStarted() {} + open fun playerDimensionsLoaded(width: Int, height: Int) { throw NotImplementedError() } @@ -270,6 +273,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 +294,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 +542,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 @@ -572,6 +592,7 @@ abstract class AbstractPlayerFragment( var resume = false progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener { override fun onScrubStart(previewBar: PreviewBar?) { + onUserSeekStarted() val hasPreview = player.hasPreview() progressBar.isPreviewEnabled = hasPreview resume = player.getIsPlaying() @@ -626,10 +647,13 @@ abstract class AbstractPlayerFragment( * and once by the UI even if it should only be registered once by the UI */ playerView?.findViewById(R.id.exo_progress) ?.addListener(object : TimeBar.OnScrubListener { - override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit + override fun onScrubStart(timeBar: TimeBar, position: Long) { + onUserSeekStarted() + } override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { if (canceled) return + onUserSeekStarted() val playerDuration = player.getDuration() ?: return val playerPosition = player.getPosition() ?: return mainCallback( @@ -752,4 +776,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..c55d50ea0a9 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,15 @@ 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 var tvModeIgnoreBufferingFromManualSeek = false private fun startLoading() { player.release() currentSelectedSubtitles = null @@ -223,11 +236,294 @@ class GeneratorPlayer : FullScreenPlayer() { override fun playerStatusChanged() { super.playerStatusChanged() + val newStatus = currentPlayerStatus + val previousStatus = tvModeLastStatus + + when (newStatus) { + CSPlayerLoading.IsPlaying -> { + tvModePlaybackStarted = true + cancelTvModeBufferTimeout() + } + + CSPlayerLoading.IsPaused -> { + cancelTvModeBufferTimeout() + } + + CSPlayerLoading.IsBuffering -> { + if (tvModeIgnoreBufferingFromManualSeek) { + cancelTvModeBufferTimeout() + } else { + 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 ignoreTvModeStallProtectionUntilPlaybackResumes() { + if (!shouldUseTvModeStallProtection()) return + tvModeIgnoreBufferingFromManualSeek = true + cancelTvModeBufferTimeout() + } + + private fun maybeClearTvModeManualSeekProtection(event: PositionEvent) { + if (!tvModeIgnoreBufferingFromManualSeek) return + if (event.source != PlayerEventSource.Player) return + if (currentPlayerStatus != CSPlayerLoading.IsPlaying) return + + tvModeIgnoreBufferingFromManualSeek = false + } + + override fun onUserSeekStarted() { + ignoreTvModeStallProtectionUntilPlaybackResumes() + } + + private fun resetTvModeStallTracking() { + cancelTvModeWatchdogs() + tvModeBufferRetryCount = 0 + tvModePlaybackStarted = false + tvModeLastStatus = CSPlayerLoading.IsBuffering + tvModeSkipInProgress = false + tvModeIgnoreBufferingFromManualSeek = 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 + if (tvModeIgnoreBufferingFromManualSeek) 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") + } + } + } + + override fun mainCallback(event: PlayerEvent) { + if (event is PositionEvent && + event.source == PlayerEventSource.UI && + event.fromMs != event.toMs + ) { + ignoreTvModeStallProtectionUntilPlaybackResumes() + } + if (event is PositionEvent) { + maybeClearTvModeManualSeekProtection(event) + } + super.mainCallback(event) + } + + 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 +804,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 +815,8 @@ class GeneratorPlayer : FullScreenPlayer() { isActive = true setPlayerDimen(null) setTitle() + updatePlayerTvModeButton() + clearContinueWatchingForTvMode() if (!sameEpisode) hasRequestedStamps = false @@ -540,6 +839,7 @@ class GeneratorPlayer : FullScreenPlayer() { preview = isFullScreenPlayer ) } + scheduleTvModeBufferTimeout() if (!sameEpisode) { player.addTimeStamps(emptyList()) // clear stamps @@ -1539,8 +1839,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 +1908,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 +1936,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,34 +1958,50 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onDestroy() { ResultFragment.updateUI() + TvModeHelper.clearManagedPlayback() + cancelTvModeWatchdogs() currentVerifyLink?.cancel() super.onDestroy() } - var maxEpisodeSet: Int? = null - var hasRequestedStamps: Boolean = false - override fun playerPositionChanged(position: Long, duration: Long) { - // Don't save livestream data - if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true) return + private data class SubtitleFilterConfig( + val languages: List = emptyList(), + val filterByLanguage: Boolean = false, + ) - // Don't save NSFW data - if ((currentMeta as? ResultEpisode)?.tvType == TvType.NSFW) return + private fun shouldIgnorePlaybackProgress(): Boolean { + val tvType = (currentMeta as? ResultEpisode)?.tvType + return tvType?.isLiveStream() == true || tvType == TvType.NSFW + } - if (duration <= 0L) return // idk how you achieved this, but div by zero crash - if (!hasRequestedStamps) { - hasRequestedStamps = true - val fetchStamps = context?.let { ctx -> - val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - settingsManager.getBoolean( - ctx.getString(R.string.enable_skip_op_from_database), - true - ) - } ?: true - if (fetchStamps) - viewModel.loadStamps(duration) + private fun maybeLoadStamps(duration: Long) { + if (hasRequestedStamps) return + + hasRequestedStamps = true + val fetchStamps = context?.let { ctx -> + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + settingsManager.getBoolean( + ctx.getString(R.string.enable_skip_op_from_database), + true + ) + } ?: true + + if (fetchStamps) { + viewModel.loadStamps(duration) } + } - val percentage = position * 100L / duration + private fun shouldSkipContinueWatching(): Boolean { + val currentContext = context ?: return false + return TvModeHelper.hasSession() && !TvModeHelper.shouldIncludeInContinueWatching(currentContext) + } + + private fun persistPlaybackProgress(position: Long, duration: Long) { + if (shouldSkipContinueWatching()) { + DataStoreHelper.setViewPos(viewModel.getId(), position, duration) + clearContinueWatchingForTvMode() + return + } DataStoreHelper.setViewPosAndResume( viewModel.getId(), @@ -1678,41 +2010,264 @@ class GeneratorPlayer : FullScreenPlayer() { currentMeta, nextMeta ) + } - var isOpVisible = false - when (val meta = currentMeta) { - is ResultEpisode -> { - if (percentage >= UPDATE_SYNC_PROGRESS_PERCENTAGE && (maxEpisodeSet - ?: -1) < meta.episode - ) { - context?.let { ctx -> - val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - if (settingsManager.getBoolean( - ctx.getString(R.string.episode_sync_enabled_key), true - ) - ) maxEpisodeSet = meta.episode - sync.modifyMaxEpisode(meta.totalEpisodeIndex ?: meta.episode) - } - } + private fun updateSyncProgress(percentage: Long) { + val meta = currentMeta as? ResultEpisode ?: return + if (percentage < UPDATE_SYNC_PROGRESS_PERCENTAGE || (maxEpisodeSet ?: -1) >= meta.episode) { + return + } - if (meta.tvType.isAnimeOp()) isOpVisible = percentage < SKIP_OP_VIDEO_PERCENTAGE + context?.let { ctx -> + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + if (settingsManager.getBoolean(ctx.getString(R.string.episode_sync_enabled_key), true)) { + maxEpisodeSet = meta.episode } + sync.modifyMaxEpisode(meta.totalEpisodeIndex ?: meta.episode) } + } + private fun isSkipOpVisible(percentage: Long): Boolean { + val meta = currentMeta as? ResultEpisode ?: return false + return meta.tvType.isAnimeOp() && percentage < SKIP_OP_VIDEO_PERCENTAGE + } + + private fun updateEpisodeNavigationButtons(isOpVisible: Boolean) { playerBinding?.playerSkipOp?.isVisible = isOpVisible - when { - isLayout(PHONE) -> - playerBinding?.playerSkipEpisode?.isVisible = - !isOpVisible && viewModel.hasNextEpisode() == true + if (isLayout(PHONE)) { + playerBinding?.playerSkipEpisode?.isVisible = + !isOpVisible && viewModel.hasNextEpisode() == true + return + } + + val hasNextEpisode = viewModel.hasNextEpisode() == true + playerBinding?.playerGoForward?.isVisible = hasNextEpisode + playerBinding?.playerGoForwardRoot?.isVisible = hasNextEpisode + } + + private fun readSubtitleFilterConfig(ctx: Context): SubtitleFilterConfig { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + showName = settingsManager.getBoolean(ctx.getString(R.string.show_name_key), true) + showResolution = settingsManager.getBoolean(ctx.getString(R.string.show_resolution_key), true) + showMediaInfo = settingsManager.getBoolean(ctx.getString(R.string.show_media_info_key), false) + limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_title_limit_key), 0) + updateForcedEncoding(ctx) + + val shouldFilterByLanguage = + settingsManager.getBoolean(getString(R.string.filter_sub_lang_key), false) + if (!shouldFilterByLanguage) { + return SubtitleFilterConfig() + } + + val preferredLanguages = settingsManager.getStringSet( + getString(R.string.provider_lang_key), + mutableSetOf("en") + ) + + return SubtitleFilterConfig( + languages = preferredLanguages?.mapNotNull { + fromTagToEnglishLanguageName(it)?.lowercase() + } ?: emptyList(), + filterByLanguage = true, + ) + } - else -> { - val hasNextEpisode = viewModel.hasNextEpisode() == true - playerBinding?.playerGoForward?.isVisible = hasNextEpisode - playerBinding?.playerGoForwardRoot?.isVisible = hasNextEpisode + private fun restorePlayerState(savedInstanceState: Bundle?) { + unwrapBundle(savedInstanceState) + unwrapBundle(arguments) + sync.updateUserData() + preferredAutoSelectSubtitles = getAutoSelectLanguageTagIETF() + + if (currentSelectedLink == null) { + viewModel.loadLinks() + } + } + + private fun setupPlayerControls() { + binding?.overlayLoadingSkipButton?.setOnClickListener { + startPlayer() + } + + binding?.playerLoadingGoBack?.setOnClickListener { + exitFullscreen() + player.release() + activity?.popCurrentPage() + } + + playerBinding?.downloadHeader?.setOnClickListener { + it?.isVisible = false + } + + playerBinding?.downloadHeaderToggle?.setOnClickListener { + playerBinding?.downloadHeader?.let { + it.isVisible = !it.isVisible } + } + } + private fun togglePlayerTvMode() { + autoHide() + val currentContext = context ?: return + + if (TvModeHelper.hasSession()) { + TvModeHelper.stopSession() + updatePlayerTvModeButton() + return + } + + val loadResponse = viewModel.getLoadResponse() ?: return + 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() + } + } + + private fun setupPlayerTvModeControl() { + ensurePlayerTvModeButton() + playerTvModeButton?.setOnClickListener { + togglePlayerTvMode() + } + updatePlayerTvModeButton() + } + + private fun observeLoadingLinks() { + observe(viewModel.loadingLinks) { + when (it) { + is Resource.Loading -> { + startLoading() + scheduleTvModeLoadingTimeout() + } + + is Resource.Success -> { + cancelTvModeLoadingTimeout() + startPlayer() + } + + is Resource.Failure -> { + cancelTvModeLoadingTimeout() + showToast(it.errorString, Toast.LENGTH_LONG) + startPlayer() + } + } + } + } + + private fun updateSkipLoadingButton(isVisible: Boolean) { + binding?.overlayLoadingSkipButton?.apply { + this.isVisible = isVisible + val value = viewModel.currentLinks.value + if (value.isNullOrEmpty()) { + setText(R.string.skip_loading) + } else { + text = "${context.getString(R.string.skip_loading)} (${value.size})" + } + } + } + + private fun maybeAutoStartLoadedLinks() { + safe { + if (currentLinks.any { link -> + getLinkPriority(currentQualityProfile, link.first) >= + QualityDataHelper.AUTO_SKIP_PRIORITY + } + ) { + startPlayer() + } + } + } + + private fun observeCurrentLinks() { + observe(viewModel.currentLinks) { + currentLinks = it + val turnVisible = it.isNotEmpty() && lastUsedGenerator?.canSkipLoading == true + val wasGone = binding?.overlayLoadingSkipButton?.isGone == true + + updateSkipLoadingButton(turnVisible) + maybeAutoStartLoadedLinks() + + if (turnVisible && wasGone) { + binding?.overlayLoadingSkipButton?.requestFocus() + } + } + } + + private fun applySubtitleFilter( + subtitles: Set, + config: SubtitleFilterConfig, + ): Set { + if (!config.filterByLanguage || config.languages.isEmpty()) { + return subtitles + } + + val filteredSubtitles = mutableSetOf() + Log.i("subfilter", "Filtering subtitle") + config.languages.forEach { language -> + Log.i("subfilter", "Lang: $language") + filteredSubtitles += subtitles.filter { + it.originalName.contains(language, ignoreCase = true) || + it.origin != SubtitleOrigin.URL + } } + return filteredSubtitles + } + + private fun observeCurrentSubs(config: SubtitleFilterConfig) { + observe(viewModel.currentSubs) { set -> + currentSubs = applySubtitleFilter(set, config) + player.setActiveSubtitles(set) + + if (set.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) { + autoSelectSubtitles() + } + } + } + + private fun observePlayerViewModel(config: SubtitleFilterConfig) { + observe(viewModel.currentStamps) { stamps -> + player.addTimeStamps(stamps) + } + + observeLoadingLinks() + observeCurrentLinks() + observeCurrentSubs(config) + } + + var maxEpisodeSet: Int? = null + var hasRequestedStamps: Boolean = false + override fun playerPositionChanged(position: Long, duration: Long) { + if (shouldIgnorePlaybackProgress()) return + if (duration <= 0L) return // idk how you achieved this, but div by zero crash + + maybeLoadStamps(duration) + + val percentage = position * 100L / duration + persistPlaybackProgress(position, duration) + updateSyncProgress(percentage) + updateEpisodeNavigationButtons(isSkipOpVisible(percentage)) if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) { viewModel.preLoadNextLinks() @@ -1996,6 +2551,8 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun onDestroyView() { + cancelTvModeWatchdogs() + playerTvModeButton = null binding = null super.onDestroyView() } @@ -2151,139 +2708,12 @@ class GeneratorPlayer : FullScreenPlayer() { @SuppressLint("SetTextI18n") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - var langFilterList = listOf() - var filterSubByLang = false + val subtitleFilterConfig = context?.let(::readSubtitleFilterConfig) ?: SubtitleFilterConfig() - context?.let { ctx -> - val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - showName = settingsManager.getBoolean(ctx.getString(R.string.show_name_key), true) - showResolution = settingsManager.getBoolean(ctx.getString(R.string.show_resolution_key), true) - showMediaInfo = settingsManager.getBoolean(ctx.getString(R.string.show_media_info_key), false) - limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_title_limit_key), 0) - updateForcedEncoding(ctx) - filterSubByLang = - settingsManager.getBoolean(getString(R.string.filter_sub_lang_key), false) - if (filterSubByLang) { - val langFromPrefMedia = settingsManager.getStringSet( - this.getString(R.string.provider_lang_key), mutableSetOf("en") - ) - langFilterList = langFromPrefMedia?.mapNotNull { - fromTagToEnglishLanguageName(it)?.lowercase() ?: return@mapNotNull null - } ?: listOf() - } - } - - unwrapBundle(savedInstanceState) - unwrapBundle(arguments) - - sync.updateUserData() - - preferredAutoSelectSubtitles = getAutoSelectLanguageTagIETF() - - if (currentSelectedLink == null) { - viewModel.loadLinks() - } - - binding?.overlayLoadingSkipButton?.setOnClickListener { - startPlayer() - } - - binding?.playerLoadingGoBack?.setOnClickListener { - exitFullscreen() - player.release() - activity?.popCurrentPage() - } - - playerBinding?.downloadHeader?.setOnClickListener { - it?.isVisible = false - } - - playerBinding?.downloadHeaderToggle?.setOnClickListener { - playerBinding?.downloadHeader?.let { - it.isVisible = !it.isVisible - } - } - - observe(viewModel.currentStamps) { stamps -> - player.addTimeStamps(stamps) - } - - observe(viewModel.loadingLinks) { - when (it) { - is Resource.Loading -> { - startLoading() - } - - is Resource.Success -> { - // provider returned false - //if (it.value != true) { - // showToast(activity, R.string.unexpected_error, Toast.LENGTH_SHORT) - //} - startPlayer() - } - - is Resource.Failure -> { - showToast(it.errorString, Toast.LENGTH_LONG) - startPlayer() - } - } - } - - observe(viewModel.currentLinks) { - currentLinks = it - val turnVisible = it.isNotEmpty() && lastUsedGenerator?.canSkipLoading == true - val wasGone = binding?.overlayLoadingSkipButton?.isGone == true - - binding?.overlayLoadingSkipButton?.apply { - isVisible = turnVisible - val value = viewModel.currentLinks.value - if (value.isNullOrEmpty()) { - setText(R.string.skip_loading) - } else { - text = "${context.getString(R.string.skip_loading)} (${value.size})" - } - } - - safe { - if (currentLinks.any { link -> - getLinkPriority(currentQualityProfile, link.first) >= - QualityDataHelper.AUTO_SKIP_PRIORITY - } - ) { - startPlayer() - } - } - - if (turnVisible && wasGone) { - binding?.overlayLoadingSkipButton?.requestFocus() - } - } - - observe(viewModel.currentSubs) { set -> - val setOfSub = mutableSetOf() - if (langFilterList.isNotEmpty() && filterSubByLang) { - Log.i("subfilter", "Filtering subtitle") - langFilterList.forEach { lang -> - Log.i("subfilter", "Lang: $lang") - setOfSub += set.filter { - it.originalName.contains(lang, ignoreCase = true) || - it.origin != SubtitleOrigin.URL - } - } - currentSubs = setOfSub - } else { - currentSubs = set - } - player.setActiveSubtitles(set) - - // If the file is downloaded then do not select auto select the subtitles - // Downloaded subtitles cannot be selected immediately after loading since - // player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles - // Resulting in unselecting the downloaded subtitle - if (set.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) { - autoSelectSubtitles() - } - } + restorePlayerState(savedInstanceState) + setupPlayerControls() + setupPlayerTvModeControl() + observePlayerViewModel(subtitleFilterConfig) } } 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..b8752c3b31b 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 @@ -20,10 +20,12 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.TvModeHelper 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 @@ -285,10 +287,14 @@ object ResultFragment { val name = arguments?.getString(NAME_BUNDLE) ?: return null val showFillers = settingsManager.getBoolean(context.getString(R.string.show_fillers_key), false) - val dubStatus = if (context.getApiDubstatusSettings() - .contains(DubStatus.Dubbed) - ) DubStatus.Dubbed else DubStatus.Subbed val startAction = arguments?.getInt(START_ACTION_BUNDLE) + val dubStatus = if (startAction == START_ACTION_TV_MODE) { + TvModeHelper.resolveDubStatus(context, DubStatus.entries) ?: DubStatus.Subbed + } else if (context.getApiDubstatusSettings().contains(DubStatus.Dubbed)) { + DubStatus.Dubbed + } else { + DubStatus.Subbed + } val playerAction = getPlayerAction(context) 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..82e44439831 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.hasTvModeEpisodeContent() } 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.hasTvModeEpisodeContent() 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..ca1c2d435f0 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.hasTvModeEpisodeContent() } 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.hasTvModeEpisodeContent() 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..d04509454cc 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 @@ -427,6 +428,12 @@ data class ExtractedTrailerData( ) class ResultViewModel2 : ViewModel() { + private enum class TvModeResolvedContent { + MOVIE, + SERIES, + UNSUPPORTED, + } + private var currentResponse: LoadResponse? = null var EPISODE_RANGE_SIZE: Int = 20 fun clear() { @@ -454,6 +461,7 @@ class ResultViewModel2 : ViewModel() { private var currentId: Int? = null private var fillers: Map = emptyMap() private var generator: IGenerator? = null + private var pendingTvModeStart: Boolean = false private var preferDubStatus: DubStatus? = null private var preferStartEpisode: Int? = null private var preferStartSeason: Int? = null @@ -2360,8 +2368,29 @@ class ResultViewModel2 : ViewModel() { currentRanges = ranges + val primaryUrl = loadResponse.uniqueUrl.ifBlank { loadResponse.url } + val fallbackUrl = loadResponse.url + val isGlobalTvModeAnimeLoad = + pendingTvModeStart && + loadResponse is AnimeLoadResponse && + !TvModeHelper.isLocalSession(primaryUrl, fallbackUrl) + val allowedTvModeDubStatuses = if (isGlobalTvModeAnimeLoad) { + context?.let { TvModeHelper.getAllowedAnimeDubStatuses(it, currentDubStatus) } + ?: emptySet() + } else { + emptySet() + } + val candidateKeys = when { + allowedTvModeDubStatuses.isNotEmpty() -> { + ranges.keys.filter { index -> allowedTvModeDubStatuses.contains(index.dubStatus) } + } + + isGlobalTvModeAnimeLoad -> emptyList() + else -> ranges.keys.toList() + } + // this takes the indexer most preferable by the user given the current sorting - val min = ranges.keys.minByOrNull { index -> + val min = candidateKeys.minByOrNull { index -> kotlin.math.abs( index.season - (preferStartSeason ?: 1) ) + if (index.dubStatus == preferDubStatus) 0 else 100000 @@ -2373,7 +2402,11 @@ class ResultViewModel2 : ViewModel() { it.startEpisode >= (preferStartEpisode ?: 0) } ?: ranger?.lastOrNull() - postEpisodeRange(min, range, DataStoreHelper.resultsSortingMode) + if (min != null && range != null) { + postEpisodeRange(min, range, DataStoreHelper.resultsSortingMode) + } else { + clearEpisodeSelection() + } postResume() } @@ -2381,6 +2414,20 @@ class ResultViewModel2 : ViewModel() { _resumeWatching.postValue(resume()) } + private fun clearEpisodeSelection() { + currentIndex = null + currentRange = null + generator = null + _selectedRange.postValue(null) + _selectedRangeIndex.postValue(-1) + _selectedSeason.postValue(null) + _selectedSeasonIndex.postValue(-1) + _selectedDubStatus.postValue(null) + _selectedDubStatusIndex.postValue(-1) + _episodes.postValue(Resource.Success(emptyList())) + _episodesCountText.postValue(null) + } + private fun resume(): ResumeWatchingStatus? { val correctId = currentId ?: return null val resume = getLastWatched(correctId) @@ -2474,6 +2521,176 @@ class ResultViewModel2 : ViewModel() { } fun hasLoaded() = currentResponse != null + fun hasEpisodeContent() = currentEpisodes.values.any { it.isNotEmpty() } + + private fun isMovieLikePlaybackEntry(): Boolean { + val episodes = currentEpisodes.values.flatten() + if (episodes.size != 1) return false + + val entry = episodes.first() + return entry.episode <= 0 && entry.season == null + } + + private fun resolveTvModeContent(): TvModeResolvedContent { + val response = currentResponse ?: return TvModeResolvedContent.UNSUPPORTED + + return when { + response is LiveStreamLoadResponse || response is TorrentLoadResponse -> { + TvModeResolvedContent.UNSUPPORTED + } + + response is MovieLoadResponse -> TvModeResolvedContent.MOVIE + response.isEpisodeBased() -> TvModeResolvedContent.SERIES + response.isMovie() -> TvModeResolvedContent.MOVIE + isMovieLikePlaybackEntry() -> TvModeResolvedContent.MOVIE + hasEpisodeContent() -> TvModeResolvedContent.SERIES + else -> TvModeResolvedContent.UNSUPPORTED + } + } + + fun hasTvModeEpisodeContent() = resolveTvModeContent() == TvModeResolvedContent.SERIES + 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 contentKind = resolveTvModeContent() + val hasSeriesContent = contentKind == TvModeResolvedContent.SERIES + val isMovie = contentKind == TvModeResolvedContent.MOVIE + + if (!TvModeHelper.acceptsLoadedContent( + activity, + isMovie, + hasSeriesContent, + primaryUrl, + fallbackUrl + ) + ) { + if (isLocalSession) { + stopTvModePlayback(activity) + } else { + 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 (!hasSeriesContent) { + 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 filteredEntries = if (response is AnimeLoadResponse) { + val allowedDubStatuses = TvModeHelper.getAllowedAnimeDubStatuses( + activity, + availableEntries.map { it.key.dubStatus } + ) + availableEntries.filter { entry -> allowedDubStatuses.contains(entry.key.dubStatus) } + } else { + availableEntries + } + if (filteredEntries.isEmpty()) { + if (isLocalSession) { + stopTvModePlayback(activity) + } else { + continueTvModePlayback(activity, primaryUrl, fallbackUrl) + } + return + } + 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 +2728,10 @@ class ResultViewModel2 : ViewModel() { ) ) } + + START_ACTION_TV_MODE -> { + tryStartTvModePlayback(activity) + } } } @@ -2590,6 +2811,7 @@ class ResultViewModel2 : ViewModel() { _page.postValue(Resource.Loading(url)) _episodes.postValue(Resource.Loading()) + pendingTvModeStart = autostart?.startAction == START_ACTION_TV_MODE preferDubStatus = dubStatus currentShowFillers = showFillers @@ -2639,7 +2861,9 @@ class ResultViewModel2 : ViewModel() { if (!isActive) return@ioSafe val mainId = loadResponse.getId() - preferDubStatus = getDub(mainId) ?: preferDubStatus + if (!(pendingTvModeStart && data.value is AnimeLoadResponse)) { + preferDubStatus = getDub(mainId) ?: preferDubStatus + } preferStartEpisode = getResultEpisode(mainId) preferStartSeason = getResultSeason(mainId) ?: 1 @@ -2674,4 +2898,4 @@ class ResultViewModel2 : ViewModel() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsTvMode.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsTvMode.kt new file mode 100644 index 00000000000..610e20a9674 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsTvMode.kt @@ -0,0 +1,160 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.os.Bundle +import android.view.View +import androidx.core.content.edit +import androidx.preference.Preference +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat +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 +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog +import com.lagradost.cloudstream3.utils.TvModeHelper +import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard + +class SettingsTvMode : BasePreferenceFragmentCompat() { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setUpToolbar(R.string.tv_mode_settings) + setPaddingBottom() + setToolBarScrollFlags() + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + hideKeyboard() + setPreferencesFromResource(R.xml.settings_tv_mode, rootKey) + + val ctx = context ?: return + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + val tvModeNoticePref = findPreference("tv_mode_activation_notice") + val tvModeContentPref = getPref(R.string.tv_mode_content_key) + val tvModeSeasonPref = getPref(R.string.tv_mode_season_scope_key) + val tvModePlayerStartPref = getPref(R.string.tv_mode_player_start_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( + ctx.getString(R.string.tv_mode_content_key), + TvModeHelper.TvModeContentMode.BOTH.value + ) + ) + } + + fun getTvModeSeasonMode(): TvModeHelper.TvModeSeasonMode { + return TvModeHelper.TvModeSeasonMode.fromValue( + settingsManager.getInt( + ctx.getString(R.string.tv_mode_season_scope_key), + TvModeHelper.TvModeSeasonMode.SELECTED_SEASON_ONLY.value + ) + ) + } + + fun getTvModePlayerStartMode(): TvModeHelper.TvModePlayerStartMode { + return TvModeHelper.getPlayerStartMode(ctx) + } + + fun isTvModeStallProtectionEnabled(): Boolean { + return TvModeHelper.isStallProtectionEnabled(ctx) + } + + fun updateTvModePreferenceState(stallProtectionEnabledOverride: Boolean? = null) { + val isEnabled = TvModeHelper.isEnabled(ctx) + tvModeNoticePref?.isVisible = !isEnabled + val showStallProtectionDetails = + stallProtectionEnabledOverride ?: isTvModeStallProtectionEnabled() + tvModeRetryLimitPref?.isVisible = showStallProtectionDetails + tvModeLoadingTimeoutPref?.isVisible = showStallProtectionDetails + tvModeContentPref?.summary = ctx.getString(getTvModeContentMode().labelRes) + tvModeSeasonPref?.summary = ctx.getString(getTvModeSeasonMode().labelRes) + tvModePlayerStartPref?.summary = ctx.getString(getTvModePlayerStartMode().labelRes) + } + + tvModeStallProtectionPref?.setOnPreferenceChangeListener { _, newValue -> + updateTvModePreferenceState( + stallProtectionEnabledOverride = newValue as? Boolean + ) + true + } + + tvModeContentPref?.setOnPreferenceClickListener { + val currentActivity = activity ?: return@setOnPreferenceClickListener false + val modes = TvModeHelper.TvModeContentMode.entries + val selectedMode = getTvModeContentMode() + + currentActivity.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 + } + + tvModeSeasonPref?.setOnPreferenceClickListener { + val currentActivity = activity ?: return@setOnPreferenceClickListener false + val seasonModes = TvModeHelper.TvModeSeasonMode.entries + val selectedMode = getTvModeSeasonMode() + + currentActivity.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 currentActivity = activity ?: return@setOnPreferenceClickListener false + val startModes = TvModeHelper.TvModePlayerStartMode.entries + val selectedMode = getTvModePlayerStartMode() + + currentActivity.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() + } +} 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..a777ae7311b 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,7 +28,9 @@ 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.navigate import com.lagradost.cloudstream3.utils.UIHelper.toPx class SettingsUI : BasePreferenceFragmentCompat() { @@ -42,7 +44,18 @@ class SettingsUI : BasePreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_ui, rootKey) - val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val ctx = context ?: return + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + val homeQuickActionPref = getPref(R.string.home_quick_action_key) + val tvModeSettingsPref = getPref(R.string.tv_mode_settings_menu_key) + + fun getHomeQuickActionMode(): TvModeHelper.HomeQuickActionMode { + return TvModeHelper.getHomeQuickActionMode(ctx) + } + + fun updatePreferenceState() { + homeQuickActionPref?.summary = getString(getHomeQuickActionMode().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 +260,37 @@ class SettingsUI : BasePreferenceFragmentCompat() { ) return@setOnPreferenceClickListener true } + + homeQuickActionPref?.setOnPreferenceClickListener { + val currentActivity = activity ?: return@setOnPreferenceClickListener false + val modes = TvModeHelper.HomeQuickActionMode.entries + val selectedMode = getHomeQuickActionMode() + + currentActivity.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] + settingsManager.edit { + putInt(getString(R.string.home_quick_action_key), chosenMode.value) + } + if (chosenMode != TvModeHelper.HomeQuickActionMode.TV_MODE) { + TvModeHelper.stopSession() + } + updatePreferenceState() + } + ) + true + } + + tvModeSettingsPref?.setOnPreferenceClickListener { + activity?.navigate(R.id.action_navigation_global_to_navigation_settings_tv_mode, Bundle()) + true + } + + updatePreferenceState() } -} \ 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..b4a3e23ef00 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TvModeHelper.kt @@ -0,0 +1,779 @@ +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.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.APIHolder.getApiFromUrlNull +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.LiveStreamLoadResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.TorrentLoadResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.metaproviders.SyncRedirector +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.ui.APIRepository +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.AppContextUtils.getApiDubstatusSettings +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import java.util.ArrayDeque + +object TvModeHelper { + private enum class ResolvedContentKind { + MOVIE, + SERIES, + UNSUPPORTED, + } + + 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(), + val resolvedContentKinds: MutableMap = linkedMapOf(), + 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 getHomeQuickActionMode(context) == HomeQuickActionMode.TV_MODE + } + + 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 + } + + 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) } + } + + fun resolveDubStatus(context: Context, available: Collection): DubStatus? { + val normalized = available.distinct() + if (normalized.isEmpty()) return null + val allowed = getAllowedAnimeDubStatuses(context, normalized) + if (allowed.isEmpty()) return null + + return when { + allowed.contains(DubStatus.None) && + allowed.contains(DubStatus.Subbed) && + !allowed.contains(DubStatus.Dubbed) -> { + normalized.firstOrNull { it == DubStatus.None } + ?: normalized.firstOrNull { it == DubStatus.Subbed } + } + + allowed.contains(DubStatus.Subbed) && + !allowed.contains(DubStatus.Dubbed) -> { + normalized.firstOrNull { it == DubStatus.Subbed } + ?: normalized.firstOrNull { it == DubStatus.None } + } + + allowed.contains(DubStatus.Dubbed) && + !allowed.contains(DubStatus.Subbed) && + !allowed.contains(DubStatus.None) -> { + normalized.firstOrNull { it == DubStatus.Dubbed } + } + + else -> allowed.randomOrNull() + } + } + + fun getAllowedAnimeDubStatuses( + context: Context, + available: Collection, + ): Set { + val normalized = available.distinct() + if (normalized.isEmpty()) return emptySet() + + val selected = context.getApiDubstatusSettings() + val hasNone = selected.contains(DubStatus.None) + val hasDubbed = selected.contains(DubStatus.Dubbed) + val hasSubbed = selected.contains(DubStatus.Subbed) + + val hasAvailableDubbed = normalized.contains(DubStatus.Dubbed) + val hasAvailableSubbed = normalized.contains(DubStatus.Subbed) + val hasAvailableNone = normalized.contains(DubStatus.None) + + return when { + hasDubbed && !hasSubbed -> { + normalized.filterTo(linkedSetOf()) { it == DubStatus.Dubbed } + } + + !hasDubbed && hasSubbed && !hasNone -> { + normalized.filterTo(linkedSetOf()) { + it == DubStatus.Subbed || (!hasAvailableSubbed && it == DubStatus.None) + } + } + + !hasDubbed && hasSubbed && hasNone -> { + normalized.filterTo(linkedSetOf()) { + it == DubStatus.None || it == DubStatus.Subbed + } + } + + !hasDubbed && !hasSubbed && hasNone -> { + when { + hasAvailableDubbed || hasAvailableSubbed -> { + normalized.filterTo(linkedSetOf()) { + it == DubStatus.Dubbed || it == DubStatus.Subbed + } + } + + else -> normalized.filterTo(linkedSetOf()) { it == DubStatus.None } + } + } + + hasDubbed && hasSubbed -> { + when { + hasAvailableDubbed || hasAvailableSubbed -> { + normalized.filterTo(linkedSetOf()) { + it == DubStatus.Dubbed || it == DubStatus.Subbed + } + } + + else -> normalized.filterTo(linkedSetOf()) { it == DubStatus.None } + } + } + + else -> normalized.toSet() + } + } + + @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 -> { + validActivity.ioSafe { + val next = pickCandidate(validActivity, session) + validActivity.main { + if (next == null) { + stopSession() + showToast( + validActivity, + R.string.tv_mode_no_playable_content, + Toast.LENGTH_SHORT + ) + } else { + 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, + hasSeriesContent: Boolean, + primaryUrl: String?, + fallbackUrl: String? = null, + ): Boolean { + if (isLocalSession(primaryUrl, fallbackUrl)) { + return hasSeriesContent + } + + return when (getContentMode(context)) { + TvModeContentMode.SERIES_ONLY -> hasSeriesContent + TvModeContentMode.MOVIES_ONLY -> isMovie + TvModeContentMode.BOTH -> isMovie || hasSeriesContent + } + } + + 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 { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + val key = context.getString(R.string.tv_mode_dub_preference_key) + if (preferences.contains(key)) { + return TvModeDubPreference.fromValue( + preferences.getInt( + key, + TvModeDubPreference.PREFER_DUBBED.value + ) + ) + } + + return TvModeDubPreference.RANDOM + } + + 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 suspend 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 contentMode = getContentMode(context) + val freshCandidates = availableCandidates.filterNot { candidate -> + session.recentUrls.contains(candidate.url) + } + val staleCandidates = availableCandidates.filter { candidate -> + session.recentUrls.contains(candidate.url) + } + val orderedCandidates = + prioritizeCandidates(session, freshCandidates, contentMode) + + prioritizeCandidates(session, staleCandidates, contentMode) + + return when (contentMode) { + TvModeContentMode.BOTH -> orderedCandidates.firstOrNull() + TvModeContentMode.MOVIES_ONLY -> { + orderedCandidates.firstOrNull { candidate -> + resolveCandidateContentKind(session, candidate) == ResolvedContentKind.MOVIE + } + } + + TvModeContentMode.SERIES_ONLY -> { + orderedCandidates.firstOrNull { candidate -> + resolveCandidateContentKind(session, candidate) == ResolvedContentKind.SERIES + } + } + } + } + + private fun prioritizeCandidates( + session: TvModeSession.Global, + candidates: List, + contentMode: TvModeContentMode, + ): List { + return candidates + .shuffled() + .sortedBy { candidate -> getCandidatePriority(session, candidate, contentMode) } + } + + private fun getCandidatePriority( + session: TvModeSession.Global, + candidate: SearchResponse, + contentMode: TvModeContentMode, + ): Int { + val cachedKind = session.resolvedContentKinds[candidate.url] + val hintedKind = candidate.guessContentKind() + val effectiveKind = cachedKind ?: hintedKind + + return when (contentMode) { + TvModeContentMode.BOTH -> if (effectiveKind == ResolvedContentKind.UNSUPPORTED) 1 else 0 + TvModeContentMode.MOVIES_ONLY -> when (effectiveKind) { + ResolvedContentKind.MOVIE -> 0 + null -> 1 + ResolvedContentKind.SERIES -> 2 + ResolvedContentKind.UNSUPPORTED -> 3 + } + + TvModeContentMode.SERIES_ONLY -> when (effectiveKind) { + ResolvedContentKind.SERIES -> 0 + null -> 1 + ResolvedContentKind.MOVIE -> 2 + ResolvedContentKind.UNSUPPORTED -> 3 + } + } + } + + private suspend fun resolveCandidateContentKind( + session: TvModeSession.Global, + candidate: SearchResponse, + ): ResolvedContentKind { + session.resolvedContentKinds[candidate.url]?.let { return it } + + val api = getApiFromNameNull(candidate.apiName) ?: getApiFromUrlNull(candidate.url) + ?: return ResolvedContentKind.UNSUPPORTED.also { + session.resolvedContentKinds[candidate.url] = it + } + + val validUrl = (safeApiCall { + SyncRedirector.redirect(candidate.url, api) + } as? Resource.Success)?.value + ?: return ResolvedContentKind.UNSUPPORTED.also { + session.resolvedContentKinds[candidate.url] = it + } + + val resolvedKind = when (val data = APIRepository(api).load(validUrl)) { + is Resource.Success -> data.value.resolveContentKind() + else -> ResolvedContentKind.UNSUPPORTED + } + + session.resolvedContentKinds[candidate.url] = resolvedKind + return resolvedKind + } + + 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 SearchResponse.guessContentKind(): ResolvedContentKind? { + return when { + type.isTvModeSeries() -> ResolvedContentKind.SERIES + type.isTvModeMovie() -> ResolvedContentKind.MOVIE + else -> null + } + } + + private fun LoadResponse.resolveContentKind(): ResolvedContentKind { + return when { + this is LiveStreamLoadResponse || this is TorrentLoadResponse -> { + ResolvedContentKind.UNSUPPORTED + } + + this.isEpisodeBased() -> ResolvedContentKind.SERIES + this.isMovie() -> ResolvedContentKind.MOVIE + this.type.isTvModeSeries() -> ResolvedContentKind.SERIES + this.type.isTvModeMovie() -> ResolvedContentKind.MOVIE + else -> ResolvedContentKind.UNSUPPORTED + } + } + + 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 @@ + +