From 5523d1eedce370a09b72e9f670c9c1805f75689e Mon Sep 17 00:00:00 2001 From: Misael Jimenez Date: Tue, 10 Nov 2026 00:21:32 -0600 Subject: [PATCH 1/5] Add recent live channels switcher to the player --- .../cloudstream3/ui/player/GeneratorPlayer.kt | 364 +++++++++++++++--- .../ui/player/PlayerGeneratorViewModel.kt | 91 +++++ .../cloudstream3/ui/result/EpisodeAdapter.kt | 20 +- .../cloudstream3/utils/DataStoreHelper.kt | 89 +++++ app/src/main/res/values-b+es/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + 6 files changed, 514 insertions(+), 62 deletions(-) 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 49422f3f1e3..47cd13a93fa 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 @@ -25,6 +25,7 @@ import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.MainThread import androidx.annotation.OptIn +import androidx.appcompat.app.AlertDialog import androidx.core.animation.addListener import androidx.core.app.NotificationCompat import androidx.core.app.PendingIntentCompat @@ -37,6 +38,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.media3.common.Format.NO_VALUE import androidx.media3.common.MimeTypes +import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer @@ -61,6 +63,7 @@ import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.databinding.DialogOnlineSubtitlesBinding import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding +import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding import com.lagradost.cloudstream3.isAnimeOp @@ -85,7 +88,10 @@ import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.result.ACTION_CLICK_DEFAULT +import com.lagradost.cloudstream3.ui.result.ACTION_SHOW_OPTIONS +import com.lagradost.cloudstream3.ui.result.ACTION_SHOW_TOAST import com.lagradost.cloudstream3.ui.result.EpisodeAdapter +import com.lagradost.cloudstream3.ui.result.EpisodeClickEvent import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultFragment @@ -110,6 +116,8 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.DataStoreHelper.LiveRecent +import com.lagradost.cloudstream3.utils.toResultEpisode import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.Qualities @@ -149,6 +157,9 @@ class GeneratorPlayer : FullScreenPlayer() { const val CHANNEL_ID = 7340 const val STOP_ACTION = "stopcs3" + /** Bounded automatic reconnects for expired live links. */ + private const val MAX_LIVE_RETRIES = 2 + private val generators = ConcurrentHashMap>() fun newInstance( generator: VideoGenerator<*>, @@ -188,6 +199,12 @@ class GeneratorPlayer : FullScreenPlayer() { private var isPlayerActive: AtomicBoolean = AtomicBoolean(false) private var isNextEpisode: Boolean = false // this is used to reset the watch time + /** Prevents recording the same live channel on every player status tick. */ + private var lastRecordedLiveKey: String? = null + + /** Bounded automatic reconnects for expired live links. */ + private var liveRetryCount = 0 + private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none private val allMeta: List? get() = viewModel.state.generatorState?.allMeta?.filterIsInstance() @@ -239,9 +256,31 @@ class GeneratorPlayer : FullScreenPlayer() { super.playerStatusChanged() if (player.getIsPlaying()) { viewModel.forceClearCache = false + liveRetryCount = 0 + recordCurrentLiveIfNeeded() } } + private fun liveKey(episode: ResultEpisode): String = "${episode.apiName}_${episode.id}" + + /** + * Only records a live channel once playback actually started, so failed or + * expired links never pollute the recents list. + */ + private fun recordCurrentLiveIfNeeded() { + val meta = currentMeta as? ResultEpisode ?: return + if (!meta.tvType.isLiveStream()) return + + val key = liveKey(meta) + if (key == lastRecordedLiveKey) return + + lastRecordedLiveKey = key + DataStoreHelper.addLiveRecent( + meta, + pageUrl = viewModel.state.generatorState?.response?.url + ) + } + private fun noSubtitles(): Boolean { return setSubtitles(null, true) } @@ -548,6 +587,9 @@ class GeneratorPlayer : FullScreenPlayer() { player.addTimeStamps(emptyList()) // clear stamps // Resets subtitle delay, as we watch some other content player.setSubtitleOffset(0) + // A new channel/episode starts, so it may be recorded again once it plays. + lastRecordedLiveKey = null + liveRetryCount = 0 } } @@ -1579,12 +1621,59 @@ class GeneratorPlayer : FullScreenPlayer() { "isPlaying=${player.getIsPlaying()}", exception ) + if (retryExpiredLiveChannel(exception)) return + if (!hasNextMirror()) { viewModel.forceClearCache = true } super.playerError(exception) } + /** + * Retries the current live channel when its link expired. Returns true when a + * retry was scheduled, so the caller must not surface the player error. + */ + private fun retryExpiredLiveChannel(exception: Throwable): Boolean { + val liveMeta = currentMeta as? ResultEpisode + if (liveMeta?.tvType?.isLiveStream() != true) return false + if (!isLiveExpiryError(exception)) return false + if (hasNextMirror()) return false + if (liveRetryCount >= MAX_LIVE_RETRIES) return false + + // The first retry re-extracts from the stored provider data; the second one + // reloads the channel page to get a fresh provider reference. + val usePageFallback = liveRetryCount > 0 + liveRetryCount++ + showToast(R.string.reconnecting_live, Toast.LENGTH_SHORT) + releasePlayer() + viewModel.reloadLiveChannel(usePageFallback) + return true + } + + /** + * Errors typically produced by an expired signed live URL or by a DRM + * authorization that is no longer valid. + */ + private fun isLiveExpiryError(exception: Throwable): Boolean { + val playbackException = exception as? PlaybackException ?: return false + return when (playbackException.errorCode) { + PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, + PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + PlaybackException.ERROR_CODE_REMOTE_ERROR, + PlaybackException.ERROR_CODE_TIMEOUT, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, + PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> true + + else -> { + val message = playbackException.message?.lowercase().orEmpty() + "403" in message || "410" in message || + "expired" in message || "forbidden" in message + } + } + } + private fun noLinksFound() { viewModel.forceClearCache = true val hiddenLinks = @@ -2132,83 +2221,242 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun isThereEpisodes(): Boolean { + val current = currentMeta as? ResultEpisode + if (current?.tvType?.isLiveStream() == true) { + // Do not show a list with only the channel that is already playing. + return getOverlayEpisodes().size >= 2 + } + // Checks if there is a second episode of type ResultEpisode // => There exists more than 1 episode, and they are all ResultEpisode return viewModel.state.generatorState?.allMeta?.getOrNull(1) as? ResultEpisode != null } - override fun showEpisodesOverlay() { - try { - playerBinding?.apply { - playerEpisodeList.setRecycledViewPool(EpisodeAdapter.sharedPool) - playerEpisodeList.adapter = EpisodeAdapter( - false, - { episodeClick -> - if (episodeClick.action == ACTION_CLICK_DEFAULT) { - isNextEpisode = false - releasePlayer() - playerEpisodeOverlay.isGone = true - episodeClick.position?.let { viewModel.loadThisEpisode(it) } - } - }, - { downloadClickEvent -> - DownloadButtonSetup.handleDownloadClick(downloadClickEvent) + /** + * For VOD this is the generator's episode list. For live it is the global + * recent-channel list, keeping the currently playing channel first. + */ + private fun getOverlayEpisodes(): List { + val current = currentMeta as? ResultEpisode + if (current?.tvType?.isLiveStream() != true) return allMeta ?: emptyList() + + val recents: List = DataStoreHelper.getAllLiveRecents() + val episodes = recents.map { it.toResultEpisode() } + val currentKey = liveKey(current) + return if (episodes.any { liveKey(it) == currentKey }) { + episodes + } else { + listOf(current) + episodes + } + } + + private fun refreshLiveRecentsOverlay() { + val current = currentMeta as? ResultEpisode + if (current?.tvType?.isLiveStream() != true) return + + val episodes = getOverlayEpisodes() + (playerBinding?.playerEpisodeList?.adapter as? EpisodeAdapter)?.submitList(episodes) + + if (episodes.size < 2) { + playerBinding?.playerEpisodeOverlay?.isGone = true + } + } + + private fun showLiveRecentOptions(episode: ResultEpisode) { + val ctx = context ?: return + val current = currentMeta as? ResultEpisode + val isCurrent = current != null && liveKey(current) == liveKey(episode) + + val options = buildList Unit>> { + if (isCurrent) { + add(ctx.getString(R.string.reconnect_channel) to ::reconnectCurrentChannel) + } else { + add( + ctx.getString(R.string.remove_from_recents) to { + DataStoreHelper.removeLiveRecent(episode.apiName, episode.id) + refreshLiveRecentsOverlay() } ) - playerEpisodeList.setLinearListLayout( + } + add(ctx.getString(R.string.clear_live_recents) to ::clearLiveRecents) + } + + AlertDialog.Builder(ctx) + .setTitle(episode.name ?: episode.headerName) + .setItems(options.map { it.first }.toTypedArray()) { _, which -> + options.getOrNull(which)?.second?.invoke() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun reconnectCurrentChannel() { + playerBinding?.playerEpisodeOverlay?.isGone = true + releasePlayer() + viewModel.reloadLiveChannel(false) + } + + private fun clearLiveRecents() { + DataStoreHelper.clearLiveRecents() + refreshLiveRecentsOverlay() + } + + override fun showEpisodesOverlay() { + try { + val binding = playerBinding ?: return + val current = currentMeta as? ResultEpisode + val isLive = current?.tvType?.isLiveStream() == true + val episodes = getOverlayEpisodes() + + binding.playerEpisodeList.apply { + setRecycledViewPool(EpisodeAdapter.sharedPool) + adapter = createEpisodeAdapter(current, isLive) + setLinearListLayout( isHorizontal = false, nextUp = FOCUS_SELF, nextDown = FOCUS_SELF, nextRight = FOCUS_SELF, ) - val episodes = allMeta ?: emptyList() - (playerEpisodeList.adapter as? EpisodeAdapter)?.submitList(episodes) - - // Scroll to current episode - viewModel.state.generatorState?.index?.let { index -> - playerEpisodeList.scrollToPosition(index) - // Ensure focus on tv - if (isLayout(TV)) { - playerEpisodeList.post { - val viewHolder = - playerEpisodeList.findViewHolderForAdapterPosition(index) - viewHolder?.itemView?.requestFocus() - viewHolder?.itemView?.let { itemView -> - itemView.isFocusableInTouchMode = true - itemView.requestFocus() - } - } - } - } + (adapter as? EpisodeAdapter)?.submitList(episodes) + } - // update overlay season title - var lastTopIndex = -1 - playerEpisodeList.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - val layoutManager = - recyclerView.layoutManager as? LinearLayoutManager ?: return - val topIndex = layoutManager.findFirstCompletelyVisibleItemPosition() - if (topIndex != RecyclerView.NO_POSITION && topIndex != lastTopIndex) { - @Suppress("AssignedValueIsNeverRead") - lastTopIndex = topIndex - val topItem = episodes.getOrNull(topIndex) - topItem?.let { - playerEpisodeOverlayTitle.setText( - ResultViewModel2.seasonToTxt( - topItem.seasonData, - topItem.seasonIndex - ) - ) - } - } - } - }) + val currentIndex = getOverlayCurrentIndex(episodes, current, isLive) + updateOverlayTitle(binding, episodes, currentIndex, isLive) + scrollToOverlayIndex(binding.playerEpisodeList, currentIndex) + + if (!isLive) { + addSeasonTitleScrollListener(binding, episodes) } } catch (e: Exception) { logError(e) } } + /** Builds the episode/channel list adapter used by the player overlay. */ + private fun createEpisodeAdapter( + current: ResultEpisode?, + isLive: Boolean, + ): EpisodeAdapter = EpisodeAdapter( + false, + { episodeClick -> onEpisodeClick(episodeClick, current, isLive) }, + { downloadClickEvent -> DownloadButtonSetup.handleDownloadClick(downloadClickEvent) }, + ) + + private fun onEpisodeClick( + episodeClick: EpisodeClickEvent, + current: ResultEpisode?, + isLive: Boolean, + ) { + when { + isLive && episodeClick.action == ACTION_CLICK_DEFAULT -> + openLiveRecent(episodeClick.data, current) + + isLive && (episodeClick.action == ACTION_SHOW_OPTIONS || + episodeClick.action == ACTION_SHOW_TOAST) -> + showLiveRecentOptions(episodeClick.data) + + episodeClick.action == ACTION_CLICK_DEFAULT -> { + isNextEpisode = false + releasePlayer() + playerBinding?.playerEpisodeOverlay?.isGone = true + episodeClick.position?.let { viewModel.loadThisEpisode(it) } + } + } + } + + /** + * Switches to another recent live channel in the same player. Selecting the + * channel that is already playing only closes the overlay. + */ + private fun openLiveRecent(selected: ResultEpisode, current: ResultEpisode?) { + val overlay = playerBinding?.playerEpisodeOverlay ?: return + + if (current != null && liveKey(selected) == liveKey(current)) { + overlay.isGone = true + return + } + + val pageUrl = DataStoreHelper.getAllLiveRecents() + .firstOrNull { it.apiName == selected.apiName && it.id == selected.id } + ?.pageUrl + + if (!viewModel.loadLiveRecent(selected, pageUrl)) { + showToast(R.string.live_recents_provider_missing, Toast.LENGTH_SHORT) + return + } + + isNextEpisode = false + releasePlayer() + overlay.isGone = true + } + + private fun getOverlayCurrentIndex( + episodes: List, + current: ResultEpisode?, + isLive: Boolean, + ): Int { + if (!isLive) return viewModel.state.generatorState?.index ?: -1 + + val currentKey = current?.let { liveKey(it) } ?: return -1 + return episodes.indexOfFirst { liveKey(it) == currentKey } + } + + private fun updateOverlayTitle( + binding: PlayerCustomLayoutBinding, + episodes: List, + currentIndex: Int, + isLive: Boolean, + ) { + if (isLive) { + binding.playerEpisodeOverlayTitle.setText(R.string.live_recents) + return + } + + episodes.getOrNull(currentIndex.coerceAtLeast(0))?.let { item -> + binding.playerEpisodeOverlayTitle.setText( + ResultViewModel2.seasonToTxt(item.seasonData, item.seasonIndex) + ) + } + } + + private fun scrollToOverlayIndex(list: RecyclerView, index: Int) { + if (index < 0) return + + list.scrollToPosition(index) + if (!isLayout(TV)) return + + // Ensure focus on tv + list.post { + list.findViewHolderForAdapterPosition(index)?.itemView?.let { itemView -> + itemView.isFocusableInTouchMode = true + itemView.requestFocus() + } + } + } + + /** Keeps the VOD season title in sync while the episode list scrolls. */ + private fun addSeasonTitleScrollListener( + binding: PlayerCustomLayoutBinding, + episodes: List, + ) { + var lastTopIndex = -1 + binding.playerEpisodeList.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return + val topIndex = layoutManager.findFirstCompletelyVisibleItemPosition() + if (topIndex == RecyclerView.NO_POSITION || topIndex == lastTopIndex) return + + @Suppress("AssignedValueIsNeverRead") + lastTopIndex = topIndex + episodes.getOrNull(topIndex)?.let { topItem -> + binding.playerEpisodeOverlayTitle.setText( + ResultViewModel2.seasonToTxt(topItem.seasonData, topItem.seasonIndex) + ) + } + } + }) + } + @MainThread fun releasePlayer() { player.release() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt index cb8cf8bfff5..fa36473ddcf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt @@ -5,7 +5,13 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.LiveStreamLoadResponse import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MovieLoadResponse +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.isLiveStream import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError @@ -14,6 +20,7 @@ import com.lagradost.cloudstream3.ui.player.source_priority.ProfileSettings import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType @@ -316,6 +323,90 @@ class PlayerGeneratorViewModel : ViewModel() { loadLinks() } + /** + * Switches the current player to a recent live channel. + * + * The generator is rebuilt from the stored provider reference so the provider can + * re-extract the stream. A fresh in-memory cache is used when available; expired or + * failing links are handled by forcing a clear and reloading. + */ + fun loadLiveRecent(recent: ResultEpisode, pageUrl: String? = null): Boolean { + if (!recent.tvType.isLiveStream()) return false + + val api = getApiFromNameNull(recent.apiName) ?: return false + val page = ResultViewModel2.LoadResponseFromSearch( + name = recent.headerName, + url = pageUrl ?: recent.data, + apiName = api.name, + type = TvType.Live, + posterUrl = recent.poster, + id = recent.parentId, + ) + + // Use the in-memory link cache if available; re-extract only when needed. + forceClearCache = false + attachGenerator(RepoLinkGenerator(listOf(recent), page = page), 0) + loadLinks() + return true + } + + /** + * Re-extracts the current live channel after its link expired. + * + * When [usePageFallback] is true the channel page is loaded again to obtain a + * fresh provider reference. This covers providers that sign the final URL inside + * [com.lagradost.cloudstream3.MainAPI.load] instead of loadLinks. + */ + fun reloadLiveChannel(usePageFallback: Boolean = false) { + val genState = state.generatorState ?: return + val current = genState.meta as? ResultEpisode ?: return + if (!current.tvType.isLiveStream()) return + + val pageUrl = genState.response?.url + if (!usePageFallback || pageUrl.isNullOrBlank()) { + forceClearCache = true + loadLinks() + return + } + + viewModelScope.launchSafe { + val api = getApiFromNameNull(current.apiName) + if (api == null) { + forceClearCache = true + loadLinks() + return@launchSafe + } + + val response = (safeApiCall { api.load(pageUrl) } as? Resource.Success)?.value + if (response == null) { + forceClearCache = true + loadLinks() + return@launchSafe + } + + val freshData = when (response) { + is LiveStreamLoadResponse -> response.dataUrl + is MovieLoadResponse -> response.dataUrl + is TvSeriesLoadResponse -> response.episodes.firstOrNull { + it.data == current.data || it.name == current.name + }?.data ?: response.episodes.firstOrNull()?.data + + else -> null + } + + if (freshData.isNullOrBlank()) { + forceClearCache = true + loadLinks() + return@launchSafe + } + + val fresh = current.copy(data = freshData) + forceClearCache = true + attachGenerator(RepoLinkGenerator(listOf(fresh), page = response), 0) + loadLinks() + } + } + fun attachGenerator(newGenerator: VideoGenerator<*>, index: Int) { Log.i(TAG, "attachGenerator with generator=$newGenerator and index=$index") generator = newGenerator diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt index 5e550416477..934dd237972 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt @@ -12,6 +12,7 @@ import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickActionHolder +import com.lagradost.cloudstream3.isLiveStream import com.lagradost.cloudstream3.databinding.ResultEpisodeBinding import com.lagradost.cloudstream3.databinding.ResultEpisodeLargeBinding import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable @@ -70,6 +71,19 @@ data class EpisodeClickEvent(val position: Int?, val action: Int, val data: Resu constructor(action: Int, data: ResultEpisode) : this(null, action, data) } +/** + * Live recents are shown with the same adapter as episodes, but they must not be + * formatted as "0. Channel". + */ +private fun episodeDisplayName(item: ResultEpisode, context: Context): String { + val name = item.name + return when { + item.tvType.isLiveStream() -> name?.takeIf { it.isNotBlank() } ?: item.headerName + name == null -> "${context.getString(R.string.episode)} ${item.episode}" + else -> "${item.episode}. $name" + } +} + class EpisodeAdapter( private val hasDownloadSupport: Boolean, private val clickCallback: (EpisodeClickEvent) -> Unit, @@ -202,8 +216,7 @@ class EpisodeAdapter( downloadButton.setPersistentId(item.id) downloadButton.setStatus(status) - val name = - if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" + val name = episodeDisplayName(item, episodeText.context) episodeFiller.isVisible = item.isFiller == true episodeText.text = name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name @@ -423,8 +436,7 @@ class EpisodeAdapter( downloadButton.setPersistentId(item.id) downloadButton.setStatus(status) - val name = - if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" + val name = episodeDisplayName(item, episodeText.context) episodeFiller.isVisible = item.isFiller == true episodeText.text = name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name 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 d15ea791bee..11792ceeeaa 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -22,6 +22,7 @@ import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.isLiveStream import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.WatchType @@ -57,6 +58,8 @@ const val RESULT_RESUME_WATCHING = "result_resume_watching_2" // Changed due to const val RESULT_RESUME_WATCHING_OLD = "result_resume_watching" const val RESULT_RESUME_WATCHING_HAS_MIGRATED = "result_resume_watching_migrated" const val RESULT_EPISODE = "result_episode" +const val RESULT_LIVE_RECENTS = "result_live_recents" +private const val LIVE_RECENTS_LIMIT = 15 const val RESULT_SEASON = "result_season" const val RESULT_DUB = "result_dub" const val KEY_RESULT_SORT = "result_sort" @@ -519,6 +522,71 @@ object DataStoreHelper { @JsonProperty("score") @SerialName("score") override var score: Score? = null, ) : SearchResponse + /** + * Lightweight reference to a live channel the user actually played. + * + * It intentionally stores only the provider reference ([data]) and the optional + * page URL, never the signed [ExtractorLink] URL. Live links expire, so the + * channel is re-extracted from the provider when needed. + */ + @Serializable + data class LiveRecent( + @JsonProperty("apiName") @SerialName("apiName") val apiName: String, + @JsonProperty("data") @SerialName("data") val data: String, + @JsonProperty("pageUrl") @SerialName("pageUrl") val pageUrl: String? = null, + @JsonProperty("headerName") @SerialName("headerName") val headerName: String, + @JsonProperty("name") @SerialName("name") val name: String? = null, + @JsonProperty("poster") @SerialName("poster") val poster: String? = null, + @JsonProperty("id") @SerialName("id") val id: Int, + @JsonProperty("parentId") @SerialName("parentId") val parentId: Int, + @JsonProperty("episode") @SerialName("episode") val episode: Int = 0, + @JsonProperty("season") @SerialName("season") val season: Int? = null, + @JsonProperty("updateTime") @SerialName("updateTime") val updateTime: Long = unixTimeMS, + ) + + fun getAllLiveRecents(): List { + val folder = "$currentAccount/$RESULT_LIVE_RECENTS" + return getKeys(folder)?.mapNotNull { path -> + getKey(path) + }?.sortedByDescending { it.updateTime } ?: emptyList() + } + + fun addLiveRecent(episode: ResultEpisode, pageUrl: String? = null) { + if (!episode.tvType.isLiveStream()) return + + val folder = "$currentAccount/$RESULT_LIVE_RECENTS" + val key = "${episode.apiName}_${episode.id}" + val recent = LiveRecent( + apiName = episode.apiName, + data = episode.data, + pageUrl = pageUrl, + headerName = episode.headerName, + name = episode.name, + poster = episode.poster, + id = episode.id, + parentId = episode.parentId, + episode = episode.episode, + season = episode.season, + updateTime = unixTimeMS, + ) + setKey(folder, key, recent) + + val all = getKeys(folder)?.mapNotNull { getKey(it) } ?: return + if (all.size > LIVE_RECENTS_LIMIT) { + all.sortedBy { it.updateTime } + .take(all.size - LIVE_RECENTS_LIMIT) + .forEach { removeKey(folder, "${it.apiName}_${it.id}") } + } + } + + fun removeLiveRecent(apiName: String, id: Int) { + removeKey("$currentAccount/$RESULT_LIVE_RECENTS", "${apiName}_$id") + } + + fun clearLiveRecents() { + removeKeys("$currentAccount/$RESULT_LIVE_RECENTS") + } + /** * A datastore wide account for future implementations of a multiple account system */ @@ -839,3 +907,24 @@ object DataStoreHelper { get() = getKey>(USER_PINNED_PROVIDERS) ?: emptyArray() set(value) = setKey(USER_PINNED_PROVIDERS, value) } + +fun DataStoreHelper.LiveRecent.toResultEpisode(): ResultEpisode = ResultEpisode( + headerName = headerName, + name = name?.takeIf { it.isNotBlank() } ?: headerName, + poster = poster, + episode = episode, + seasonIndex = null, + season = season, + data = data, + apiName = apiName, + id = id, + index = 0, + position = 0, + duration = 0, + score = null, + description = null, + isFiller = null, + tvType = TvType.Live, + parentId = parentId, + videoWatchState = VideoWatchState.None, +) diff --git a/app/src/main/res/values-b+es/strings.xml b/app/src/main/res/values-b+es/strings.xml index 7cd072981c3..c37b0f084b8 100644 --- a/app/src/main/res/values-b+es/strings.xml +++ b/app/src/main/res/values-b+es/strings.xml @@ -769,4 +769,10 @@ Imágenes por defecto Complementos instalados Ajuste obtenido: +%d dB + Canales recientes + Quitar de recientes + Borrar canales recientes + Reconectar canal + Reconectando canal en vivo… + El proveedor de este canal no está disponible diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c429519967..8c10dacf226 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -814,4 +814,10 @@ Live Installed plugins + Recent channels + Remove from recents + Clear recent channels + Reconnect channel + Reconnecting live channel… + The provider for this channel is not available From 127fea31047a952a78a8e7944a188222559cc8ab Mon Sep 17 00:00:00 2001 From: Misael Jimenez Date: Tue, 10 Nov 2026 16:40:50 -0600 Subject: [PATCH 2/5] Show playback error details while retrying live links --- .../cloudstream3/ui/player/GeneratorPlayer.kt | 16 +++++- .../cloudstream3/ui/player/PlayerView.kt | 50 ++++++++++--------- 2 files changed, 42 insertions(+), 24 deletions(-) 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 47cd13a93fa..4c225655c2d 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 @@ -1644,12 +1644,26 @@ class GeneratorPlayer : FullScreenPlayer() { // reloads the channel page to get a fresh provider reference. val usePageFallback = liveRetryCount > 0 liveRetryCount++ - showToast(R.string.reconnecting_live, Toast.LENGTH_SHORT) + notifyLiveRetry(exception) releasePlayer() viewModel.reloadLiveChannel(usePageFallback) return true } + /** + * Keeps the standard playback error diagnostics visible (error name and code, e.g. + * 2004) while an automatic live retry runs, followed by the reconnecting notice. + * Both are shown in a single toast because [showToast] cancels the previous one. + */ + private fun notifyLiveRetry(exception: Throwable) { + val error = playerHostView?.playerErrorMessage(exception) + val reconnect = context?.getString(R.string.reconnecting_live) + val message = listOfNotNull(error, reconnect).joinToString("\n") + if (message.isNotBlank()) { + showToast(message, Toast.LENGTH_LONG) + } + } + /** * Errors typically produced by an expired signed live URL or by a DRM * authorization that is no longer valid. diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt index c363a29b3aa..2800b9d47c3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt @@ -659,20 +659,26 @@ class PlayerView @JvmOverloads constructor( @MainThread fun playerError(exception: Throwable) { - fun showErrorToast(message: String) { - if (callbacks?.hasNextMirror() == true) { - showToast(message, Toast.LENGTH_SHORT) - callbacks?.nextMirror() - } else { - showToast( - context.getString(R.string.no_links_found_toast) + "\n" + message, - Toast.LENGTH_LONG - ) - (context as? FragmentActivity)?.popCurrentPage() - } + val message = playerErrorMessage(exception) + if (callbacks?.hasNextMirror() == true) { + showToast(message, Toast.LENGTH_SHORT) + callbacks?.nextMirror() + } else { + showToast( + context.getString(R.string.no_links_found_toast) + "\n" + message, + Toast.LENGTH_LONG + ) + (context as? FragmentActivity)?.popCurrentPage() } + } - when (exception) { + /** + * Builds the user-facing description of [exception]. It is public so retry logic can + * surface the same diagnostics as [playerError] without switching mirrors or closing + * the player. + */ + fun playerErrorMessage(exception: Throwable): String { + return when (exception) { is PlaybackException -> { val msg = exception.message ?: "" val errorName = exception.errorCodeName @@ -682,7 +688,7 @@ class PlayerView @JvmOverloads constructor( PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, PlaybackException.ERROR_CODE_IO_UNSPECIFIED, PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED -> - showErrorToast("${context.getString(R.string.source_error)}\n$errorName ($code)\n$msg") + "${context.getString(R.string.source_error)}\n$errorName ($code)\n$msg" PlaybackException.ERROR_CODE_REMOTE_ERROR, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, @@ -690,7 +696,7 @@ class PlayerView @JvmOverloads constructor( PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> - showErrorToast("${context.getString(R.string.remote_error)}\n$errorName ($code)\n$msg") + "${context.getString(R.string.remote_error)}\n$errorName ($code)\n$msg" PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, @@ -698,31 +704,29 @@ class PlayerView @JvmOverloads constructor( PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> - showErrorToast("${context.getString(R.string.render_error)}\n$errorName ($code)\n$msg") + "${context.getString(R.string.render_error)}\n$errorName ($code)\n$msg" PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> - showErrorToast("${context.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg") + "${context.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg" PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> - showErrorToast("${context.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg") + "${context.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg" else -> - showErrorToast("${context.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg") + "${context.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg" } } is SocketTimeoutException -> - showErrorToast("${context.getString(R.string.remote_error)}\n${exception.message}") + "${context.getString(R.string.remote_error)}\n${exception.message}" is ErrorLoadingException -> - exception.message?.let { showErrorToast(it) } - ?: showErrorToast(exception.toString()) + exception.message ?: exception.toString() else -> - exception.message?.let { showErrorToast(it) } - ?: showErrorToast(exception.toString()) + exception.message ?: exception.toString() } } From 5185534a93999b9f55a5847d021fb893dd91f306 Mon Sep 17 00:00:00 2001 From: Misael Jimenez Date: Tue, 10 Nov 2026 18:54:42 -0600 Subject: [PATCH 3/5] Open recent live channels overlay with CH+/CH- --- .../cloudstream3/ui/player/FullScreenPlayer.kt | 13 +++++++++++-- .../cloudstream3/ui/player/GeneratorPlayer.kt | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index d337d53a6c9..b847a3dd79f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -1282,10 +1282,9 @@ open class FullScreenPlayer : AbstractPlayerFragment( KeyEvent.KEYCODE_MENU, KeyEvent.KEYCODE_SETTINGS -> { - if (isLocked || !isThereEpisodes()) { + if (!openEpisodesOverlay()) { return null } - toggleEpisodesOverlay(true) } else -> return null // Avoid capturing all input @@ -1620,6 +1619,16 @@ open class FullScreenPlayer : AbstractPlayerFragment( updateOrientation() } + /** + * Opens the episodes/recents overlay when the player is unlocked and there is + * something to show. Returns false when the overlay cannot be opened. + */ + protected fun openEpisodesOverlay(): Boolean { + if (isLocked || !isThereEpisodes()) return false + toggleEpisodesOverlay(true) + return true + } + private fun toggleEpisodesOverlay(show: Boolean) { if (show && !isShowingEpisodeOverlay) { previousPlayStatus = player.getIsPlaying() 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 4c225655c2d..73251653890 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 @@ -1779,6 +1779,9 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun nextEpisode() { + // Live channels have no next episode, so CH+/CH- open the recent channels overlay. + if (openLiveRecentsOverlay()) return + if (viewModel.hasNextEpisode() == true) { isNextEpisode = true releasePlayer() @@ -1787,6 +1790,8 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun prevEpisode() { + if (openLiveRecentsOverlay()) return + if (viewModel.hasPrevEpisode() == true) { isNextEpisode = true releasePlayer() @@ -1794,6 +1799,15 @@ class GeneratorPlayer : FullScreenPlayer() { } } + /** + * Opens the recent channels overlay when a live channel is playing. Used by the + * CH+/CH- keys, which have no next/previous episode to switch to on live streams. + */ + private fun openLiveRecentsOverlay(): Boolean { + if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() != true) return false + return openEpisodesOverlay() + } + private fun getNextLink(): DisplayLink? { val links = viewModel.state.sortLinks(currentQualityProfile) val currentIndex = links.indexOfFirst { it.link == currentSelectedLink } From ac120e293cb997685a097d9fb8ea77a1875d8582 Mon Sep 17 00:00:00 2001 From: Misael Jimenez Date: Tue, 10 Nov 2026 19:03:58 -0600 Subject: [PATCH 4/5] Move TV focus into the recent live channels overlay --- .../ui/player/FullScreenPlayer.kt | 3 ++- .../cloudstream3/ui/player/GeneratorPlayer.kt | 22 +++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index b847a3dd79f..9aa7699c258 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -1315,7 +1315,8 @@ open class FullScreenPlayer : AbstractPlayerFragment( KeyEvent.KEYCODE_DPAD_DOWN_RIGHT, KeyEvent.KEYCODE_DPAD_UP_LEFT, KeyEvent.KEYCODE_DPAD_UP_RIGHT -> { - if (!isShowing) { + // Let the episode/recents overlay navigate its own list. + if (!isShowing && !isShowingEpisodeOverlay) { return true } else { autoHide() 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 73251653890..c01b1e1d4f4 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.app.PendingIntentCompat import androidx.core.content.ContextCompat import androidx.core.content.edit import androidx.core.text.toSpanned +import androidx.core.view.doOnNextLayout import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider @@ -160,6 +161,9 @@ class GeneratorPlayer : FullScreenPlayer() { /** Bounded automatic reconnects for expired live links. */ private const val MAX_LIVE_RETRIES = 2 + /** Frame attempts used to move TV focus into the overlay list while it lays out. */ + private const val OVERLAY_FOCUS_ATTEMPTS = 5 + private val generators = ConcurrentHashMap>() fun newInstance( generator: VideoGenerator<*>, @@ -2453,11 +2457,25 @@ class GeneratorPlayer : FullScreenPlayer() { list.scrollToPosition(index) if (!isLayout(TV)) return - // Ensure focus on tv + // The overlay is still GONE here, so wait for the list to be laid out before + // moving the TV focus into it. + list.doOnNextLayout { + focusOverlayItem(list, index, OVERLAY_FOCUS_ATTEMPTS) + } + } + + /** + * Moves the TV focus to the overlay item. The list may still be populating after + * [scrollToOverlayIndex], so retry a bounded number of frames before giving up. + */ + private fun focusOverlayItem(list: RecyclerView, index: Int, attemptsLeft: Int) { list.post { - list.findViewHolderForAdapterPosition(index)?.itemView?.let { itemView -> + val itemView = list.findViewHolderForAdapterPosition(index)?.itemView + if (itemView != null && itemView.isShown) { itemView.isFocusableInTouchMode = true itemView.requestFocus() + } else if (attemptsLeft > 0) { + focusOverlayItem(list, index, attemptsLeft - 1) } } } From d27a15153c8161699a3f51bf9cc1193117fde9b2 Mon Sep 17 00:00:00 2001 From: Misael Jimenez Date: Tue, 10 Nov 2026 19:11:12 -0600 Subject: [PATCH 5/5] Keep live playback running while the recents overlay is open --- .../ui/player/FullScreenPlayer.kt | 24 +++++++++++++++++-- .../cloudstream3/ui/player/GeneratorPlayer.kt | 15 ++++++++---- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 9aa7699c258..3f2a6e26c2e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -116,6 +116,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( private var isShowingEpisodeOverlay: Boolean = false private var previousPlayStatus: Boolean = false + private var pausedForEpisodesOverlay: Boolean = false override fun fixLayout(view: View) = Unit @@ -1079,6 +1080,8 @@ open class FullScreenPlayer : AbstractPlayerFragment( } override fun onAutoHideUI() { + // Keep the controls (and the open overlay) visible while browsing. + if (isShowingEpisodeOverlay) return if (player.getIsPlaying()) onClickChange() } @@ -1620,6 +1623,12 @@ open class FullScreenPlayer : AbstractPlayerFragment( updateOrientation() } + /** + * Whether playback must pause while the episodes/recents overlay is open. + * Live streams override this to keep playing while browsing channels. + */ + protected open fun shouldPauseForEpisodesOverlay(): Boolean = true + /** * Opens the episodes/recents overlay when the player is unlocked and there is * something to show. Returns false when the overlay cannot be opened. @@ -1630,15 +1639,26 @@ open class FullScreenPlayer : AbstractPlayerFragment( return true } + /** Closes the episodes/recents overlay and restores playback if it was paused. */ + protected fun closeEpisodesOverlay() { + if (isShowingEpisodeOverlay) toggleEpisodesOverlay(false) + } + private fun toggleEpisodesOverlay(show: Boolean) { if (show && !isShowingEpisodeOverlay) { previousPlayStatus = player.getIsPlaying() - player.handleEvent(CSPlayerEvent.Pause) + pausedForEpisodesOverlay = shouldPauseForEpisodesOverlay() + if (pausedForEpisodesOverlay) { + player.handleEvent(CSPlayerEvent.Pause) + } showEpisodesOverlay() isShowingEpisodeOverlay = true animateEpisodesOverlay(true) } else if (isShowingEpisodeOverlay) { - if (previousPlayStatus) player.handleEvent(CSPlayerEvent.Play) + if (pausedForEpisodesOverlay && previousPlayStatus) { + player.handleEvent(CSPlayerEvent.Play) + } + pausedForEpisodesOverlay = false isShowingEpisodeOverlay = false animateEpisodesOverlay(false) } 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 c01b1e1d4f4..054ef2543f6 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 @@ -1812,6 +1812,11 @@ class GeneratorPlayer : FullScreenPlayer() { return openEpisodesOverlay() } + /** Live streams keep playing while the recent channels overlay is open. */ + override fun shouldPauseForEpisodesOverlay(): Boolean { + return (currentMeta as? ResultEpisode)?.tvType?.isLiveStream() != true + } + private fun getNextLink(): DisplayLink? { val links = viewModel.state.sortLinks(currentQualityProfile) val currentIndex = links.indexOfFirst { it.link == currentSelectedLink } @@ -2290,7 +2295,7 @@ class GeneratorPlayer : FullScreenPlayer() { (playerBinding?.playerEpisodeList?.adapter as? EpisodeAdapter)?.submitList(episodes) if (episodes.size < 2) { - playerBinding?.playerEpisodeOverlay?.isGone = true + closeEpisodesOverlay() } } @@ -2323,7 +2328,7 @@ class GeneratorPlayer : FullScreenPlayer() { } private fun reconnectCurrentChannel() { - playerBinding?.playerEpisodeOverlay?.isGone = true + closeEpisodesOverlay() releasePlayer() viewModel.reloadLiveChannel(false) } @@ -2401,10 +2406,10 @@ class GeneratorPlayer : FullScreenPlayer() { * channel that is already playing only closes the overlay. */ private fun openLiveRecent(selected: ResultEpisode, current: ResultEpisode?) { - val overlay = playerBinding?.playerEpisodeOverlay ?: return + if (playerBinding?.playerEpisodeOverlay == null) return if (current != null && liveKey(selected) == liveKey(current)) { - overlay.isGone = true + closeEpisodesOverlay() return } @@ -2418,8 +2423,8 @@ class GeneratorPlayer : FullScreenPlayer() { } isNextEpisode = false + closeEpisodesOverlay() releasePlayer() - overlay.isGone = true } private fun getOverlayCurrentIndex(