From fcbb369af4e302f9b5c0e60f39a6c1ea76af02af Mon Sep 17 00:00:00 2001 From: wiojelt <121122059+Viollje@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:04:27 +0300 Subject: [PATCH 1/7] feat: add live TV zapping interface --- .../ui/home/HomeParentItemAdapter.kt | 98 ++--- .../cloudstream3/ui/player/CS3IPlayer.kt | 1 + .../ui/player/FullScreenPlayer.kt | 16 +- .../cloudstream3/ui/player/GeneratorPlayer.kt | 391 ++++++++++++++++++ .../cloudstream3/ui/player/IPlayer.kt | 6 + .../ui/player/PendingZappingStore.kt | 68 +++ .../cloudstream3/ui/player/PlayerView.kt | 9 +- .../ui/player/ZappingChannelAdapter.kt | 196 +++++++++ .../cloudstream3/ui/player/ZappingContext.kt | 28 ++ .../ui/player/ZappingPlayerLauncher.kt | 49 +++ .../cloudstream3/ui/player/ZappingSession.kt | 31 ++ .../ui/player/ZappingSessionStore.kt | 28 ++ .../ui/result/ResultViewModel2.kt | 17 +- .../ui/settings/SettingsPlayerScreen.kt | 8 +- .../main/res/layout/player_custom_layout.xml | 16 +- .../res/layout/player_custom_layout_tv.xml | 14 +- app/src/main/res/values-b+tr/strings.xml | 1 + app/src/main/res/values/strings.xml | 4 + .../cloudstream3/PendingZappingStoreTest.kt | 32 ++ .../cloudstream4/DevicePreferenceStore.kt | 3 +- 20 files changed, 945 insertions(+), 71 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/PendingZappingStore.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingChannelAdapter.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingContext.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingPlayerLauncher.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSession.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSessionStore.kt create mode 100644 app/src/test/java/com/lagradost/cloudstream3/PendingZappingStoreTest.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt index 6bdd1bf492f..f4a790ff496 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt @@ -10,14 +10,19 @@ import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.HomepageParentBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.newSharedPool +import com.lagradost.cloudstream3.ui.player.PendingZappingStore +import com.lagradost.cloudstream3.ui.player.ZappingChannel +import com.lagradost.cloudstream3.ui.player.ZappingContext import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR @@ -42,22 +47,17 @@ open class ParentItemAdapter( id, diffCallback = BaseDiffCallback( itemSame = { a, b -> a.list.name == b.list.name }, - contentSame = { a, b -> - a.list.list == b.list.list - }) + contentSame = { a, b -> a.list.list == b.list.list } + ) ) { companion object { - val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 4) } + val sharedPool = newSharedPool { setMaxRecycledViews(CONTENT, 4) } } data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState(binding) { override fun save(): Bundle = Bundle().apply { val recyclerView = (binding as? HomepageParentBinding)?.homeChildRecyclerview - putParcelable( - "value", - recyclerView?.layoutManager?.onSaveInstanceState() - ) + putParcelable("value", recyclerView?.layoutManager?.onSaveInstanceState()) (recyclerView?.adapter as? BaseAdapter<*, *>)?.save(recyclerView) } @@ -68,28 +68,39 @@ open class ParentItemAdapter( } } - override fun submitList( - list: Collection?, - commitCallback: Runnable? - ) { + override fun submitList(list: Collection?, commitCallback: Runnable?) { super.submitList(list?.sortedBy { it.list.list.isEmpty() }, commitCallback) } - override fun onUpdateContent( - holder: ViewHolderState, - item: HomeViewModel.ExpandableHomepageList, - position: Int - ) { + override fun onUpdateContent(holder: ViewHolderState, item: HomeViewModel.ExpandableHomepageList, position: Int) { val binding = holder.view if (binding !is HomepageParentBinding) return (binding.homeChildRecyclerview.adapter as? HomeChildItemAdapter)?.submitList(item.list.list) } - override fun onBindContent( - holder: ViewHolderState, - item: HomeViewModel.ExpandableHomepageList, - position: Int - ) { + private fun callbackFor(item: HomeViewModel.ExpandableHomepageList): (SearchClickCallback) -> Unit = { callback -> + if (callback.action == SEARCH_ACTION_LOAD && callback.card.type == TvType.Live) { + val channels = item.list.list.filter { it.type == TvType.Live } + val currentIndex = channels.indexOfFirst { + it.url == callback.card.url && it.apiName == callback.card.apiName + } + if (currentIndex >= 0 && channels.isNotEmpty()) { + PendingZappingStore.put( + callback.card.url, + callback.card.apiName, + ZappingContext( + channels = channels.map { + ZappingChannel(it.name, it.url, it.apiName, it.posterUrl) + }, + currentIndex = currentIndex, + ) + ) + } + } + clickCallback(callback) + } + + override fun onBindContent(holder: ViewHolderState, item: HomeViewModel.ExpandableHomepageList, position: Int) { val startFocus = R.id.nav_rail_view val endFocus = FOCUS_SELF val binding = holder.view @@ -101,7 +112,7 @@ open class ParentItemAdapter( homeChildRecyclerview.setRecycledViewPool(HomeChildItemAdapter.sharedPool) homeChildRecyclerview.adapter = HomeChildItemAdapter( id = id + position + 100, - clickCallback = clickCallback, + clickCallback = callbackFor(item), nextFocusUp = homeChildRecyclerview.nextFocusUpId, nextFocusDown = homeChildRecyclerview.nextFocusDownId, ).apply { @@ -113,44 +124,25 @@ open class ParentItemAdapter( currentAdapter.apply { isHorizontal = info.isHorizontalImages hasNext = item.hasNext - this.clickCallback = this@ParentItemAdapter.clickCallback + this.clickCallback = callbackFor(item) nextFocusUp = homeChildRecyclerview.nextFocusUpId nextFocusDown = homeChildRecyclerview.nextFocusDownId submitIncomparableList(item.list.list) } } - homeChildRecyclerview.setLinearListLayout( - isHorizontal = true, - nextLeft = startFocus, - nextRight = endFocus, - ) + homeChildRecyclerview.setLinearListLayout(isHorizontal = true, nextLeft = startFocus, nextRight = endFocus) homeChildMoreInfo.text = info.name - homeChildRecyclerview.addOnScrollListener(object : - RecyclerView.OnScrollListener() { + homeChildRecyclerview.addOnScrollListener(object : RecyclerView.OnScrollListener() { var expandCount = 0 val name = item.list.name - - override fun onScrollStateChanged( - recyclerView: RecyclerView, - newState: Int - ) { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { super.onScrollStateChanged(recyclerView, newState) - val adapter = recyclerView.adapter if (adapter !is HomeChildItemAdapter) return - val count = adapter.itemCount val hasNext = adapter.hasNext - /*println( - "scolling ${recyclerView.isRecyclerScrollable()} ${ - recyclerView.canScrollHorizontally( - 1 - ) - }" - )*/ - //!recyclerView.canScrollHorizontally(1) if (!recyclerView.isRecyclerScrollable() && hasNext && expandCount != count) { expandCount = count expandCallback?.invoke(name) @@ -158,12 +150,7 @@ open class ParentItemAdapter( } }) - //(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged() - if (isLayout(PHONE)) { - homeChildMoreInfo.setOnClickListener { - moreInfoClickCallback.invoke(item) - } - } + if (isLayout(PHONE)) homeChildMoreInfo.setOnClickListener { moreInfoClickCallback.invoke(item) } } } @@ -173,16 +160,13 @@ open class ParentItemAdapter( isLayout(EMULATOR) -> R.layout.homepage_parent_emulator else -> R.layout.homepage_parent } - val inflater = LayoutInflater.from(parent.context) val binding = try { HomepageParentBinding.bind(inflater.inflate(layoutResId, parent, false)) } catch (t: Throwable) { logError(t) - // just in case someone forgot we don't want to crash HomepageParentBinding.inflate(inflater) } - return ParentItemHolder(binding) } } @@ -190,4 +174,4 @@ open class ParentItemAdapter( @Suppress("DEPRECATION") inline fun Bundle.getSafeParcelable(key: String): T? = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getParcelable(key) - else getParcelable(key, T::class.java) \ No newline at end of file + else getParcelable(key, T::class.java) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 9c5685c682b..9ca5eaecc03 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -1639,6 +1639,7 @@ class CS3IPlayer : IPlayer { override fun onRenderedFirstFrame() { super.onRenderedFirstFrame() + event(FirstFrameRenderedEvent(exoPlayer)) onRenderFirst() updatedTime(source = PlayerEventSource.Player) } 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 f38a9ab99fd..fab3dfdcf40 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 @@ -450,7 +450,9 @@ open class FullScreenPlayer : AbstractPlayerFragment( setupKeyEventListener() playerHostView?.verifyVolume() activity?.attachBackPressedCallback("FullScreenPlayer") { - if (isShowingEpisodeOverlay) { + if (handlePlayerBackPressed()) { + return@attachBackPressedCallback + } else if (isShowingEpisodeOverlay) { // isShowingEpisodeOverlay pauses, so this makes it easier to unpause if (isLayout(TV or EMULATOR)) { playerPausePlay?.requestFocus() @@ -848,6 +850,15 @@ open class FullScreenPlayer : AbstractPlayerFragment( autoHide() } + /** + * Gives specialised players a chance to consume TV channel-style DPAD input while the + * controls are hidden. Normal player navigation remains unchanged when this returns false. + */ + protected open fun handleLiveChannelKey(keyCode: Int): Boolean = false + + /** Gives specialised players a chance to close an in-player panel before leaving playback. */ + protected open fun handlePlayerBackPressed(): Boolean = false + override fun playerStatusChanged() { super.playerStatusChanged() scheduleMetadataVisibility() @@ -971,6 +982,9 @@ open class FullScreenPlayer : AbstractPlayerFragment( KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_UP -> { + if (handleLiveChannelKey(keyCode)) { + return true + } if (isShowing || isShowingEpisodeOverlay) { return null } 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 e4616c93bcf..be81d59b153 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 @@ -8,16 +8,22 @@ import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.graphics.Bitmap +import android.graphics.Color import android.graphics.Typeface +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.GradientDrawable import android.os.Build import android.os.Bundle import android.text.Spanned import android.util.Log +import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator import android.widget.AbsListView import android.widget.ArrayAdapter +import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView @@ -34,6 +40,7 @@ import androidx.core.text.toSpanned import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import androidx.media3.common.Format.NO_VALUE import androidx.media3.common.MimeTypes @@ -46,11 +53,13 @@ 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 import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LiveStreamLoadResponse import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId @@ -76,6 +85,7 @@ import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup +import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType @@ -92,6 +102,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.buildResultEpisode import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR @@ -111,6 +122,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName @@ -131,9 +143,11 @@ import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp import com.lagradost.safefile.SafeFile import kotlinx.coroutines.Job +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.Serializable import java.lang.ref.WeakReference import java.util.Calendar @@ -187,6 +201,19 @@ class GeneratorPlayer : FullScreenPlayer() { private var isPlayerActive: AtomicBoolean = AtomicBoolean(false) private var isNextEpisode: Boolean = false // this is used to reset the watch time + private var zappingSession: ZappingSession? = null + private val zappingSwitchInProgress = AtomicBoolean(false) + private var zappingLoadJob: Job? = null + private var zappingOverlayGeneration = 0 + private var zappingTransitionActive = false + private var zappingTransitionPlayer: Any? = null + private var zappingTransitionBitmap: Bitmap? = null + private var zappingChannelList: LinearLayout? = null + private var zappingChannelRecycler: RecyclerView? = null + private var zappingChannelOverlay: FrameLayout? = null + private var zappingChannelOverlayPoster: ImageView? = null + private var zappingChannelAdapter: ZappingChannelAdapter? = null + private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none private val allMeta: List? get() = viewModel.state.generatorState?.allMeta?.filterIsInstance() @@ -439,6 +466,10 @@ class GeneratorPlayer : FullScreenPlayer() { override fun playerUpdated(player: Any?) { super.playerUpdated(player) + if (zappingTransitionActive && player != null) { + zappingTransitionPlayer = player + } + // Cancel the notification when released if (player == null) { cachedPlayerNotificationManager?.setPlayer(null) @@ -1717,9 +1748,361 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onDestroy() { ResultFragment.updateUI() currentVerifyLink?.cancel() + zappingLoadJob?.cancel() + zappingOverlayGeneration++ + zappingTransitionActive = false + zappingTransitionPlayer = null + clearZappingTransitionBitmap() + if (activity?.isChangingConfigurations != true) { + zappingSession?.close() + } super.onDestroy() } + override fun onResume() { + super.onResume() + val enabled = isZappingEnabled() && zappingSession?.current() != null + playerBinding?.playerChannelListBtt?.isVisible = enabled + if (!enabled) toggleZappingList(false) + } + + private fun isZappingEnabled(): Boolean { + return context?.let { ctx -> + PreferenceManager.getDefaultSharedPreferences(ctx) + .getBoolean(getString(R.string.zapping_enabled_key), true) + } == true + } + + private fun zappingOverlayParent(): FrameLayout? { + val rootView: View = playerBinding?.root ?: return null + if (rootView is FrameLayout) return rootView + + // Keep this defensive for alternate player controller layouts. The generated stable + // layouts currently use FrameLayout, but the zapping controls must not disappear merely + // because a future controller root changes its concrete class. + val parent = rootView as? ViewGroup ?: return null + return FrameLayout(rootView.context).also { overlay -> + parent.addView( + overlay, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + ) + } + } + + override fun playerFirstFrameRendered(player: Any?) { + super.playerFirstFrameRendered(player) + if (zappingTransitionActive && zappingTransitionPlayer === player) { + completeZappingTransition() + } + } + + private fun setupZappingUi() { + val session = zappingSession ?: return + if (!isZappingEnabled()) return + val root = zappingOverlayParent() ?: return + if (zappingChannelList != null) return + val button = playerBinding?.playerChannelListBtt ?: return + button.isVisible = true + button.setOnClickListener { toggleZappingList(zappingChannelList?.isVisible != true) } + + val panel = LinearLayout(root.context).apply { + orientation = LinearLayout.VERTICAL + background = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + intArrayOf(Color.TRANSPARENT, 0x66000000), + ) + elevation = 12.toPx.toFloat() + visibility = View.GONE + } + val recycler = RecyclerView(root.context).apply { + layoutManager = LinearLayoutManager(root.context) + overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS + descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS + clipChildren = false + clipToPadding = false + } + val adapter = ZappingChannelAdapter { index, card -> + switchToLiveChannel(index, card) + } + recycler.adapter = adapter + panel.addView(recycler, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + root.addView( + panel, + FrameLayout.LayoutParams(if (isLayout(TV or EMULATOR)) 300.toPx else 260.toPx, ViewGroup.LayoutParams.MATCH_PARENT).apply { + gravity = Gravity.TOP or Gravity.END + } + ) + zappingChannelList = panel + zappingChannelRecycler = recycler + zappingChannelAdapter = adapter + + val channelOverlay = FrameLayout(root.context).apply { + visibility = View.GONE + clipChildren = false + clipToPadding = false + } + val overlayPoster = ImageView(root.context).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + scaleType = ImageView.ScaleType.CENTER_CROP + setBackgroundColor(root.context.colorFromAttribute(R.attr.boxItemBackground)) + } + channelOverlay.addView(overlayPoster) + root.addView( + channelOverlay, + FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + ) + zappingChannelOverlay = channelOverlay + zappingChannelOverlayPoster = overlayPoster + adapter.submit(session.current()?.channels.orEmpty(), session.current()?.currentIndex ?: 0) + } + + private fun toggleZappingList(show: Boolean) { + if (show && !isZappingEnabled()) return + val panel = zappingChannelList ?: return + panel.isVisible = show + if (show) { + playerHostView?.cancelAutoHide() + val state = zappingSession?.current() ?: return + zappingChannelAdapter?.submit(state.channels, state.currentIndex) + zappingChannelRecycler?.post { + val recycler = zappingChannelRecycler ?: return@post + val layoutManager = recycler.layoutManager as? LinearLayoutManager ?: return@post + val itemHeight = (recycler.width * 0.70f * 9f / 16f).toInt() + 2.toPx + val offset = ((recycler.height - itemHeight) / 2).coerceAtLeast(0) + layoutManager.scrollToPositionWithOffset(state.currentIndex, offset) + recycler.post { + recycler.findViewHolderForAdapterPosition(state.currentIndex)?.itemView?.requestFocus() + } + } + } + } + + private fun beginZappingTransition(channel: ZappingChannel, sourceCard: View): Boolean { + if (!isZappingEnabled()) return false + val root = zappingOverlayParent() ?: return false + val overlay = zappingChannelOverlay ?: return false + val poster = zappingChannelOverlayPoster ?: return false + if (root.width <= 0 || root.height <= 0 || sourceCard.width <= 0 || sourceCard.height <= 0) { + return false + } + + val sourceLocation = IntArray(2) + val rootLocation = IntArray(2) + sourceCard.getLocationOnScreen(sourceLocation) + root.getLocationOnScreen(rootLocation) + + val sourceScaleX = sourceCard.scaleX.coerceAtLeast(1f) + val sourceScaleY = sourceCard.scaleY.coerceAtLeast(1f) + val sourceWidth = sourceCard.width * sourceScaleX + val sourceHeight = sourceCard.height * sourceScaleY + val sourceLeft = (sourceLocation[0] - rootLocation[0]).toFloat() - + (sourceWidth - sourceCard.width) / 2f + val sourceTop = (sourceLocation[1] - rootLocation[1]).toFloat() - + (sourceHeight - sourceCard.height) / 2f + val generation = ++zappingOverlayGeneration + + overlay.animate().cancel() + poster.animate().cancel() + val transitionBitmap = createSoftwareTransitionBitmap(sourceCard) ?: return false + zappingTransitionActive = true + zappingTransitionPlayer = null + poster.setImageDrawable(null) + clearZappingTransitionBitmap() + zappingTransitionBitmap = transitionBitmap + poster.setImageBitmap(transitionBitmap) + poster.pivotX = 0f + poster.pivotY = 0f + poster.translationX = sourceLeft + poster.translationY = sourceTop + poster.scaleX = sourceWidth / root.width.toFloat() + poster.scaleY = sourceHeight / root.height.toFloat() + poster.alpha = 1f + overlay.isVisible = true + binding?.playerView?.alpha = 0f + + zappingChannelList?.animate()?.cancel() + zappingChannelList?.animate()?.alpha(0f)?.setDuration(160L)?.setInterpolator( + DecelerateInterpolator() + )?.withEndAction { + if (generation == zappingOverlayGeneration) { + zappingChannelList?.isVisible = false + zappingChannelList?.alpha = 1f + } + }?.start() + + poster.animate() + .translationX(0f) + .translationY(0f) + .scaleX(1f) + .scaleY(1f) + .alpha(0.08f) + .setDuration(850L) + .setInterpolator(DecelerateInterpolator()) + .withEndAction { + if (generation == zappingOverlayGeneration && zappingTransitionActive) { + poster.alpha = 0.08f + } + } + .start() + return true + } + + /** + * ImageLoader may provide a hardware BitmapDrawable. Copy only the selected + * poster to ARGB_8888 before assigning it to the transition ImageView so no + * software Canvas ever tries to draw a hardware bitmap. + */ + private fun createSoftwareTransitionBitmap(sourceCard: View): Bitmap? { + return try { + val card = sourceCard as? ViewGroup ?: return null + val content = card.getChildAt(0) as? ViewGroup ?: return null + val poster = content.getChildAt(0) as? ImageView ?: return null + val bitmap = (poster.drawable as? BitmapDrawable)?.bitmap ?: return null + if (bitmap.isRecycled) return null + bitmap.copy(Bitmap.Config.ARGB_8888, false) + } catch (error: Throwable) { + logError(error) + null + } + } + + private fun clearZappingTransitionBitmap() { + val bitmap = zappingTransitionBitmap + zappingTransitionBitmap = null + if (bitmap != null && !bitmap.isRecycled) { + bitmap.recycle() + } + } + + private fun completeZappingTransition() { + if (!zappingTransitionActive) return + val overlay = zappingChannelOverlay ?: return + val poster = zappingChannelOverlayPoster ?: return + val generation = zappingOverlayGeneration + zappingTransitionPlayer = null + binding?.playerView?.animate() + ?.alpha(1f) + ?.setDuration(280L) + ?.setInterpolator(DecelerateInterpolator()) + ?.start() + poster.animate() + .alpha(0f) + .setDuration(300L) + .setInterpolator(DecelerateInterpolator()) + .withEndAction { + if (generation != zappingOverlayGeneration) return@withEndAction + overlay.isVisible = false + poster.alpha = 1f + poster.setImageDrawable(null) + clearZappingTransitionBitmap() + zappingTransitionActive = false + } + .start() + } + + private fun cancelZappingTransition() { + zappingOverlayGeneration++ + zappingTransitionActive = false + zappingTransitionPlayer = null + zappingChannelOverlay?.animate()?.cancel() + zappingChannelOverlayPoster?.animate()?.cancel() + zappingChannelOverlay?.isVisible = false + zappingChannelOverlayPoster?.alpha = 1f + zappingChannelOverlayPoster?.setImageDrawable(null) + clearZappingTransitionBitmap() + binding?.playerView?.animate()?.cancel() + binding?.playerView?.alpha = 1f + } + + override fun handlePlayerBackPressed(): Boolean { + if (zappingChannelList?.isVisible == true) { + toggleZappingList(false) + playerBinding?.playerChannelListBtt?.requestFocus() + return true + } + return false + } + + private fun switchToLiveChannel(targetIndex: Int, sourceCard: View? = null): Boolean { + if (!isZappingEnabled()) return false + val session = zappingSession ?: return false + val state = session.current() ?: return false + if (targetIndex !in state.channels.indices) return false + if (targetIndex == state.currentIndex) { + toggleZappingList(false) + return true + } + if (!zappingSwitchInProgress.compareAndSet(false, true)) return true + + val target = state.channels[targetIndex] + if (sourceCard == null || !beginZappingTransition(target, sourceCard)) { + toggleZappingList(false) + } + zappingLoadJob?.cancel() + zappingLoadJob = viewLifecycleOwner.lifecycleScope.launch { + var playbackLoadStarted = false + try { + val api = getApiFromNameNull(target.apiName) + ?: error("Provider not found: ${target.apiName}") + val response = withContext(Dispatchers.IO) { + APIRepository(api).load(target.url) + } + val load = (response as? Resource.Success)?.value as? LiveStreamLoadResponse + ?: error("Channel did not return a live response: ${target.name}") + val episode = buildResultEpisode( + headerName = load.name, + name = load.name, + poster = target.posterUrl, + episode = 0, + data = load.dataUrl, + apiName = load.apiName, + id = "${target.apiName}:${target.url}".hashCode(), + index = 0, + tvType = load.type, + parentId = "${target.apiName}:${target.url}".hashCode(), + ) + + releasePlayer() + viewModel.attachGenerator(RepoLinkGenerator(listOf(episode), page = load), 0) + session.select(targetIndex) + zappingChannelAdapter?.submit(state.channels, targetIndex) + viewModel.loadLinks() + playbackLoadStarted = true + } catch (error: Throwable) { + logError(error) + cancelZappingTransition() + showToast(activity, error.message ?: getString(R.string.unexpected_error), Toast.LENGTH_SHORT) + } finally { + if (!playbackLoadStarted) zappingSwitchInProgress.set(false) + } + } + return true + } + + override fun handleLiveChannelKey(keyCode: Int): Boolean { + if (keyCode != android.view.KeyEvent.KEYCODE_DPAD_UP && + keyCode != android.view.KeyEvent.KEYCODE_DPAD_DOWN + ) return false + if (isShowing || isDialogOpen()) return false + if (!isZappingEnabled() || zappingChannelList?.isVisible == true) return false + + val session = zappingSession ?: return false + val context = session.current() ?: return false + + val targetIndex = if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_UP) { + context.previousIndex() + } else { + context.nextIndex() + } + return switchToLiveChannel(targetIndex) + } + var maxEpisodeSet: Int? = null var hasRequestedStamps: Boolean = false override fun playerPositionChanged(position: Long, duration: Long) { @@ -2246,6 +2629,10 @@ class GeneratorPlayer : FullScreenPlayer() { super.onBindingCreated(binding, savedInstanceState) + val sessionBundle = savedInstanceState?.takeIf { it.getString("uuid") != null } ?: arguments + zappingSession = ZappingPlayerLauncher.session(sessionBundle) + setupZappingUi() + // Avoid showing no links found if (generator == null || index == null) { exitPlayer() @@ -2338,6 +2725,10 @@ class GeneratorPlayer : FullScreenPlayer() { observe(viewModel.loadingLinks) { (loading, instance) -> if (instance != viewModel.state.instance) return@observe // Outdated observe + if (zappingSwitchInProgress.get() && loading !is Resource.Loading) { + zappingSwitchInProgress.set(false) + } + when (loading) { is Resource.Loading -> { releasePlayer() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index 0342372667f..cb822c8646d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -119,6 +119,12 @@ data class PlayerAttachedEvent( override val source: PlayerEventSource = PlayerEventSource.Player ) : PlayerEvent() +/** Event emitted when the current player has rendered its first video frame. */ +data class FirstFrameRenderedEvent( + val player: Any?, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() + /** Event from player to inform that subtitles have updated in some way */ data class SubtitlesUpdatedEvent( override val source: PlayerEventSource = PlayerEventSource.Player diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PendingZappingStore.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PendingZappingStore.kt new file mode 100644 index 00000000000..a4ea42224dc --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PendingZappingStore.kt @@ -0,0 +1,68 @@ +package com.lagradost.cloudstream3.ui.player + +import java.util.concurrent.ConcurrentHashMap + +/** + * Short-lived bridge between a live card opened from Home and the player session created later. + * + * The channel list stays in memory instead of being serialized into ResultFragment arguments. + * Entries are consumed once the matching live result reaches the player launch path. + */ +object PendingZappingStore { + private const val MAX_PENDING_AGE_MS = 5 * 60 * 1000L + + private data class Key( + val url: String, + val apiName: String, + ) + + private data class Entry( + val context: ZappingContext, + val createdAtMs: Long, + ) + + private val pending = ConcurrentHashMap() + + fun put(url: String, apiName: String, context: ZappingContext) { + pending[Key(url, apiName)] = Entry(context, System.currentTimeMillis()) + } + + fun peek(url: String, apiName: String): ZappingContext? { + return pending[Key(url, apiName)]?.takeUnless(::isExpired)?.context + } + + fun consume(url: String, apiName: String, channelName: String? = null): ZappingContext? { + val exactKey = Key(url, apiName) + pending.remove(exactKey)?.let { entry -> + return entry.takeUnless(::isExpired)?.context + } + + // A provider can normalize the load URL between the Home SearchResponse and the + // LoadResponse used by ResultViewModel2. Prefer a channel URL match, then only fall back + // to a single pending context for that provider so two unrelated Home rows cannot mix. + val candidates = pending.entries.filter { it.key.apiName == apiName } + val matching = candidates.firstOrNull { (_, entry) -> + !isExpired(entry) && entry.context.channels.any { channel -> + channel.url.trimEnd('/') == url.trimEnd('/') || + channel.name.equals(channelName, ignoreCase = true) + } + } ?: candidates.singleOrNull { (_, entry) -> !isExpired(entry) } + + if (matching != null && pending.remove(matching.key, matching.value)) { + return matching.value.context + } + return null + } + + fun remove(url: String, apiName: String): ZappingContext? { + return pending.remove(Key(url, apiName))?.context + } + + fun clear() { + pending.clear() + } + + private fun isExpired(entry: Entry): Boolean { + return System.currentTimeMillis() - entry.createdAtMs > MAX_PENDING_AGE_MS + } +} 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 0e6f1a3677d..108003a3eee 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 @@ -127,8 +127,10 @@ class PlayerView @JvmOverloads constructor( fun nextMirror() {} fun onDownload(event: DownloadEvent) {} fun playerError(exception: Throwable) {} - /** Called after [PlayerView] finishes its own player-attached setup (MediaSession, ExoPlayer view). */ - fun playerUpdated(player: Any?) {} + /** Called after [PlayerView] finishes its own player-attached setup (MediaSession, ExoPlayer view). */ + fun playerUpdated(player: Any?) {} + /** Called when the current player renders its first video frame. */ + fun playerFirstFrameRendered(player: Any?) {} /** Called on a short single-tap on empty player area (no swipe, no double-tap). */ fun onSingleTap() {} /** Called when the hold-for-speedup gesture starts (show=true) or ends (show=false). */ @@ -750,7 +752,8 @@ class PlayerView @JvmOverloads constructor( } callbacks?.playerDimensionsLoaded(event.width, event.height) } - is PlayerAttachedEvent -> playerUpdated(event.player) + is PlayerAttachedEvent -> playerUpdated(event.player) + is FirstFrameRenderedEvent -> callbacks?.playerFirstFrameRendered(event.player) is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged() is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp) is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingChannelAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingChannelAdapter.kt new file mode 100644 index 00000000000..da33a609182 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingChannelAdapter.kt @@ -0,0 +1,196 @@ +package com.lagradost.cloudstream3.ui.player + +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import androidx.cardview.widget.CardView +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import com.lagradost.cloudstream3.utils.UIHelper.toPx +import kotlin.math.roundToInt + +/** A focusable 16:9 channel-card list shared by touch devices and TV remotes. */ +class ZappingChannelAdapter( + private val onChannelClick: (Int, View) -> Unit, +) : RecyclerView.Adapter() { + private var channels: List = emptyList() + private var selectedIndex: Int = RecyclerView.NO_POSITION + + fun submit(channels: List, selectedIndex: Int) { + this.channels = channels + this.selectedIndex = selectedIndex.coerceIn(0, (channels.size - 1).coerceAtLeast(0)) + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChannelViewHolder { + val context = parent.context + val item = CarouselItemContainer(context).apply { + layoutParams = RecyclerView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + topMargin = 1.toPx + bottomMargin = 1.toPx + } + clipChildren = false + clipToPadding = false + } + val card = AspectRatioCardView(context).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.END, + ) + radius = 12.toPx.toFloat() + cardElevation = 2.toPx.toFloat() + useCompatPadding = true + isFocusable = true + isFocusableInTouchMode = true + isClickable = true + contentDescription = context.getString(R.string.player_channel_list) + } + val content = FrameLayout(context) + val poster = ImageView(context).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + scaleType = ImageView.ScaleType.CENTER_CROP + setBackgroundColor(context.colorFromAttribute(R.attr.boxItemBackground)) + } + val scrim = View(context).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 72.toPx, + Gravity.BOTTOM, + ) + background = GradientDrawable( + GradientDrawable.Orientation.TOP_BOTTOM, + intArrayOf(Color.TRANSPARENT, 0xE6000000.toInt()), + ) + } + val title = TextView(context).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM, + ).apply { + marginStart = 8.toPx + marginEnd = 8.toPx + bottomMargin = 6.toPx + } + textSize = 12f + maxLines = 1 + ellipsize = android.text.TextUtils.TruncateAt.END + setTextColor(context.colorFromAttribute(R.attr.textColor)) + } + content.addView(poster) + content.addView(scrim) + content.addView(title) + card.addView(content) + item.addView(card) + return ChannelViewHolder(item, card, poster, title) + } + + override fun onBindViewHolder(holder: ChannelViewHolder, position: Int) { + val channel = channels[position] + val isActive = position == selectedIndex + holder.poster.loadImage(channel.posterUrl) + holder.title.text = channel.name + holder.title.setTypeface(null, if (isActive) Typeface.BOLD else Typeface.NORMAL) + holder.title.setTextColor(holder.title.context.colorFromAttribute(R.attr.textColor)) + holder.card.setCardBackgroundColor( + holder.card.context.colorFromAttribute(R.attr.primaryBlackBackground) + ) + holder.card.animate().cancel() + applyVisualState(holder, isActive, holder.card.hasFocus(), animate = false) + holder.card.contentDescription = channel.name + holder.card.setOnClickListener { onChannelClick(position, holder.card) } + holder.card.setOnFocusChangeListener { view, hasFocus -> + applyVisualState(holder, position == selectedIndex, hasFocus, animate = true) + if (hasFocus) { + centerFocusedItem(holder.itemView) + } + } + } + + private fun applyVisualState( + holder: ChannelViewHolder, + isActive: Boolean, + isFocused: Boolean, + animate: Boolean, + ) { + val card = holder.card + val highlighted = isActive || isFocused + val targetScale = if (highlighted) 1.15f else 1f + card.foreground = null + card.cardElevation = 2.toPx.toFloat() + if (animate) { + card.animate() + .scaleX(targetScale) + .scaleY(targetScale) + .setDuration(150L) + .start() + } else { + card.scaleX = targetScale + card.scaleY = targetScale + } + } + + private fun centerFocusedItem(view: View) { + val recycler = view.parent?.parent as? RecyclerView ?: return + recycler.post { + val itemCenter = view.top + view.height / 2 + val viewportCenter = recycler.height / 2 + recycler.smoothScrollBy(0, itemCenter - viewportCenter) + } + } + + override fun getItemCount(): Int = channels.size + + class ChannelViewHolder( + val itemViewContainer: CarouselItemContainer, + val card: AspectRatioCardView, + val poster: ImageView, + val title: TextView, + ) : RecyclerView.ViewHolder(itemViewContainer) + + class CarouselItemContainer(context: android.content.Context) : FrameLayout(context) { + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val availableWidth = MeasureSpec.getSize(widthMeasureSpec) + val cardWidth = (availableWidth * 0.70f).roundToInt() + val cardHeight = (cardWidth * 9f / 16f).roundToInt() + val card = getChildAt(0) + card?.measure( + MeasureSpec.makeMeasureSpec(cardWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(cardHeight, MeasureSpec.EXACTLY), + ) + setMeasuredDimension(availableWidth, cardHeight) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + val card = getChildAt(0) ?: return + val cardLeft = width - card.measuredWidth + card.layout(cardLeft, 0, width, card.measuredHeight) + } + } + + class AspectRatioCardView(context: android.content.Context) : CardView(context) { + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = (width * 9f / 16f).roundToInt() + super.onMeasure( + widthMeasureSpec, + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), + ) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingContext.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingContext.kt new file mode 100644 index 00000000000..cf436c74b1e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingContext.kt @@ -0,0 +1,28 @@ +package com.lagradost.cloudstream3.ui.player + +/** + * Transient navigation state for live-channel zapping. + * + * This intentionally stays independent from Android Bundle serialization. The player keeps the + * context in memory for the lifetime of the playback session, just like VideoGenerator instances. + */ +data class ZappingContext( + val channels: List, + val currentIndex: Int, +) { + init { + require(channels.isNotEmpty()) { "ZappingContext requires at least one channel" } + require(currentIndex in channels.indices) { "currentIndex must point to a channel" } + } + + fun previousIndex(): Int = (currentIndex - 1 + channels.size) % channels.size + + fun nextIndex(): Int = (currentIndex + 1) % channels.size +} + +data class ZappingChannel( + val name: String, + val url: String, + val apiName: String, + val posterUrl: String? = null, +) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingPlayerLauncher.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingPlayerLauncher.kt new file mode 100644 index 00000000000..ff434fea0b4 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingPlayerLauncher.kt @@ -0,0 +1,49 @@ +package com.lagradost.cloudstream3.ui.player + +import android.os.Bundle + +/** + * Creates a normal GeneratorPlayer session and attaches optional live-zapping state to the exact + * same UUID. Keeping this as a small wrapper means existing movie/episode call sites remain + * untouched and live entry points can opt in explicitly. + */ +object ZappingPlayerLauncher { + fun newInstance( + generator: VideoGenerator<*>, + index: Int, + syncData: HashMap? = null, + zappingContext: ZappingContext? = null, + ): Bundle { + val bundle = GeneratorPlayer.newInstance(generator, index, syncData) + val uuid = bundle.getString("uuid") + + if (uuid != null && zappingContext != null) { + ZappingSession(uuid, zappingContext).register() + } + + return bundle + } + + /** + * Launches a player for a result that may have originated from a live Home category. + * Pending state is consumed only when the player is actually created, so backing out of the + * result page does not create a player session and the full channel list never enters a Bundle. + */ + fun newInstanceFromPending( + generator: VideoGenerator<*>, + index: Int, + syncData: HashMap? = null, + url: String, + apiName: String, + channelName: String? = null, + ): Bundle { + val context = PendingZappingStore.consume(url, apiName, channelName) + return newInstance(generator, index, syncData, context) + } + + fun session(bundle: Bundle?): ZappingSession? { + val uuid = bundle?.getString("uuid") ?: return null + val context = ZappingSessionStore.get(uuid) ?: return null + return ZappingSession(uuid, context) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSession.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSession.kt new file mode 100644 index 00000000000..840935fe8b4 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSession.kt @@ -0,0 +1,31 @@ +package com.lagradost.cloudstream3.ui.player + +/** + * Identifies a live-zapping session without putting the full channel list in a Bundle. + */ +data class ZappingSession( + val uuid: String, + val context: ZappingContext, +) { + fun register() { + ZappingSessionStore.put(uuid, context) + } + + fun current(): ZappingContext? = ZappingSessionStore.get(uuid) + + fun select(index: Int): ZappingContext? = ZappingSessionStore.updateIndex(uuid, index) + + fun selectPrevious(): ZappingContext? { + val state = current() ?: return null + return ZappingSessionStore.updateIndex(uuid, state.previousIndex()) + } + + fun selectNext(): ZappingContext? { + val state = current() ?: return null + return ZappingSessionStore.updateIndex(uuid, state.nextIndex()) + } + + fun close() { + ZappingSessionStore.remove(uuid) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSessionStore.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSessionStore.kt new file mode 100644 index 00000000000..985ccc39fcd --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSessionStore.kt @@ -0,0 +1,28 @@ +package com.lagradost.cloudstream3.ui.player + +import java.util.concurrent.ConcurrentHashMap + +/** + * In-memory zapping state keyed by the same playback UUID used by GeneratorPlayer. + * + * Keeping this outside Bundles avoids serializing an entire live-channel category and mirrors the + * lifetime semantics of GeneratorPlayer's transient VideoGenerator storage. + */ +object ZappingSessionStore { + private val sessions = ConcurrentHashMap() + + fun put(uuid: String, context: ZappingContext) { + sessions[uuid] = context + } + + fun get(uuid: String): ZappingContext? = sessions[uuid] + + fun remove(uuid: String): ZappingContext? = sessions.remove(uuid) + + fun updateIndex(uuid: String, index: Int): ZappingContext? { + return sessions.computeIfPresent(uuid) { _, current -> + require(index in current.channels.indices) { "index must point to a channel" } + current.copy(currentIndex = index) + } + } +} 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 c519e0de230..a101c282083 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 @@ -77,6 +77,7 @@ import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator +import com.lagradost.cloudstream3.ui.player.ZappingPlayerLauncher import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull @@ -1559,11 +1560,21 @@ class ResultViewModel2 : ViewModel() { callback = {}, subtitleCallback = {}) } else { + val response = currentResponse activity?.navigate( R.id.global_to_navigation_player, - GeneratorPlayer.newInstance( - generator, index,list - ) + if (response?.type == TvType.Live) { + ZappingPlayerLauncher.newInstanceFromPending( + generator = generator, + index = index, + syncData = list, + url = response.url, + apiName = response.apiName, + channelName = response.name, + ) + } else { + GeneratorPlayer.newInstance(generator, index, list) + } ) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayerScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayerScreen.kt index 176eb1202e5..a3ce0d57bc4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayerScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayerScreen.kt @@ -141,6 +141,12 @@ object SettingsPlayerScreen : SearchableSettings { subtitle = stringResource(R.string.autoplay_next_settings_des), icon = painterResource(R.drawable.skip_next_24px), ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.zappingEnabled, + title = stringResource(R.string.zapping_settings), + subtitle = stringResource(R.string.zapping_settings_des), + icon = painterResource(R.drawable.autorenew_24px), + ), Preference.PreferenceItem.SwitchPreference( preference = settings.player.startPaused, title = stringResource(R.string.start_paused_settings), @@ -348,4 +354,4 @@ object SettingsPlayerScreen : SearchableSettings { ) } -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/player_custom_layout.xml b/app/src/main/res/layout/player_custom_layout.xml index 5ccc3ff09ac..dd6e73cafdd 100644 --- a/app/src/main/res/layout/player_custom_layout.xml +++ b/app/src/main/res/layout/player_custom_layout.xml @@ -927,14 +927,24 @@ android:layout_height="40dp" android:nextFocusLeft="@id/player_sources_btt" - android:nextFocusRight="@id/player_skip_op" + android:nextFocusRight="@id/player_channel_list_btt" android:text="@string/tracks" app:icon="@drawable/ic_baseline_equalizer_24" /> + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/player_custom_layout_tv.xml b/app/src/main/res/layout/player_custom_layout_tv.xml index c6445d80fa5..9c6c1d41643 100644 --- a/app/src/main/res/layout/player_custom_layout_tv.xml +++ b/app/src/main/res/layout/player_custom_layout_tv.xml @@ -1120,10 +1120,20 @@ android:id="@+id/player_tracks_btt" style="@style/VideoButtonTV" android:nextFocusLeft="@id/player_sources_btt" - android:nextFocusRight="@id/player_skip_op" + android:nextFocusRight="@id/player_channel_list_btt" android:nextFocusUp="@id/player_pause_play" android:text="@string/tracks" app:icon="@drawable/ic_baseline_equalizer_24" /> + + @@ -1190,4 +1200,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/values-b+tr/strings.xml b/app/src/main/res/values-b+tr/strings.xml index c7e535c8d8e..1170413e087 100644 --- a/app/src/main/res/values-b+tr/strings.xml +++ b/app/src/main/res/values-b+tr/strings.xml @@ -128,6 +128,7 @@ Sol ve sağ taraftan yukarı kaydırarak ekran parlaklığı ve sesi ayarla Sonraki bölümü otomatik oynat Mevcut bölüm bittiğinde sonraki bölüme başla + Kanal listesi Çift dokunarak atla İki kez dokunarak duraklat Atlanacak süre (Saniye) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66eb3533c4c..1fd015c53eb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -158,6 +158,9 @@ Slide up or down on the left or right side to change brightness or volume Autoplay next episode Start the next episode when the current one ends + Zapping + Use DPAD up/down to switch between Live TV channels in the same Home category + Channel List Start videos paused Reduce stress when selecting sources Double tap to seek @@ -372,6 +375,7 @@ Security Accounts Player features + zapping_enabled_key Subtitles Layout Defaults diff --git a/app/src/test/java/com/lagradost/cloudstream3/PendingZappingStoreTest.kt b/app/src/test/java/com/lagradost/cloudstream3/PendingZappingStoreTest.kt new file mode 100644 index 00000000000..1e38db0d017 --- /dev/null +++ b/app/src/test/java/com/lagradost/cloudstream3/PendingZappingStoreTest.kt @@ -0,0 +1,32 @@ +package com.lagradost.cloudstream3 + +import com.lagradost.cloudstream3.ui.player.PendingZappingStore +import com.lagradost.cloudstream3.ui.player.ZappingChannel +import com.lagradost.cloudstream3.ui.player.ZappingContext +import org.junit.Assert.assertEquals +import org.junit.Test + +class PendingZappingStoreTest { + @Test + fun `consumes a single provider context when load url is normalized`() { + PendingZappingStore.clear() + val context = ZappingContext( + channels = listOf( + ZappingChannel("Mor Spor 1", "https://example.test/channel/1/", "TestProvider"), + ZappingChannel("Mor Spor 2", "https://example.test/channel/2/", "TestProvider"), + ), + currentIndex = 0, + ) + + PendingZappingStore.put("https://example.test/channel/1/", "TestProvider", context) + + assertEquals( + context, + PendingZappingStore.consume( + "https://example.test/normalized-live-url", + "TestProvider", + "Mor Spor 1" + ) + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt index c0f784fc13f..a3b21c06e62 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt @@ -165,6 +165,7 @@ class PlayerPreferences(preferences: PreferenceStore) { val speedEnabled = preferences.getBoolean("playback_speed_enabled_key", false) val tiktokEnabled = preferences.getBoolean("speedup_key", false) val autoPlayEnabled = preferences.getBoolean("autoplay_next_key", true) + val zappingEnabled = preferences.getBoolean("zapping_enabled_key", true) val startPaused = preferences.getBoolean("start_paused_key", false) val skipOpEnabled = preferences.getBoolean("enable_skip_op_from_database", true) val autoRotateEnabled = preferences.getBoolean("auto_rotate_video_key", true) @@ -200,4 +201,4 @@ class GeneralPreferences(preferences: PreferenceStore) { /** This should honesty be refactored to a single setting */ val downloadPath = preferences.getString("download_path_key", "") val downloadPathVisual = preferences.getString("download_path_key_visual", "") -} \ No newline at end of file +} From 62b293330b85447210c90c9f7a3ea2431a736e1c Mon Sep 17 00:00:00 2001 From: wiojelt <121122059+Viollje@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:42:46 +0300 Subject: [PATCH 2/7] refactor: integrate live zapping with player architecture --- .../ui/home/HomeParentItemAdapter.kt | 80 ++-- .../cloudstream3/ui/player/CS3IPlayer.kt | 1 - .../ui/player/FullScreenPlayer.kt | 13 +- .../cloudstream3/ui/player/GeneratorPlayer.kt | 400 +----------------- .../cloudstream3/ui/player/IPlayer.kt | 6 - .../ui/player/LiveZappingGenerator.kt | 91 ++++ .../ui/player/PendingZappingStore.kt | 68 --- .../cloudstream3/ui/player/PlayerView.kt | 3 - .../ui/player/ZappingChannelAdapter.kt | 196 --------- .../cloudstream3/ui/player/ZappingContext.kt | 28 -- .../ui/player/ZappingPlayerLauncher.kt | 49 --- .../cloudstream3/ui/player/ZappingSession.kt | 31 -- .../ui/player/ZappingSessionStore.kt | 28 -- .../ui/result/ResultViewModel2.kt | 24 +- .../main/res/layout/player_custom_layout.xml | 14 +- .../res/layout/player_custom_layout_tv.xml | 12 +- app/src/main/res/values-b+tr/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - .../cloudstream3/PendingZappingStoreTest.kt | 32 -- 19 files changed, 187 insertions(+), 891 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/LiveZappingGenerator.kt delete mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/PendingZappingStore.kt delete mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingChannelAdapter.kt delete mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingContext.kt delete mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingPlayerLauncher.kt delete mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSession.kt delete mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSessionStore.kt delete mode 100644 app/src/test/java/com/lagradost/cloudstream3/PendingZappingStoreTest.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt index f4a790ff496..2024c7cb51f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt @@ -17,9 +17,7 @@ import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.newSharedPool -import com.lagradost.cloudstream3.ui.player.PendingZappingStore -import com.lagradost.cloudstream3.ui.player.ZappingChannel -import com.lagradost.cloudstream3.ui.player.ZappingContext +import com.lagradost.cloudstream3.ui.player.LiveZappingGenerator import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD @@ -47,17 +45,22 @@ open class ParentItemAdapter( id, diffCallback = BaseDiffCallback( itemSame = { a, b -> a.list.name == b.list.name }, - contentSame = { a, b -> a.list.list == b.list.list } - ) + contentSame = { a, b -> + a.list.list == b.list.list + }) ) { companion object { - val sharedPool = newSharedPool { setMaxRecycledViews(CONTENT, 4) } + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 4) } } data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState(binding) { override fun save(): Bundle = Bundle().apply { val recyclerView = (binding as? HomepageParentBinding)?.homeChildRecyclerview - putParcelable("value", recyclerView?.layoutManager?.onSaveInstanceState()) + putParcelable( + "value", + recyclerView?.layoutManager?.onSaveInstanceState() + ) (recyclerView?.adapter as? BaseAdapter<*, *>)?.save(recyclerView) } @@ -68,11 +71,18 @@ open class ParentItemAdapter( } } - override fun submitList(list: Collection?, commitCallback: Runnable?) { + override fun submitList( + list: Collection?, + commitCallback: Runnable? + ) { super.submitList(list?.sortedBy { it.list.list.isEmpty() }, commitCallback) } - override fun onUpdateContent(holder: ViewHolderState, item: HomeViewModel.ExpandableHomepageList, position: Int) { + override fun onUpdateContent( + holder: ViewHolderState, + item: HomeViewModel.ExpandableHomepageList, + position: Int + ) { val binding = holder.view if (binding !is HomepageParentBinding) return (binding.homeChildRecyclerview.adapter as? HomeChildItemAdapter)?.submitList(item.list.list) @@ -85,22 +95,17 @@ open class ParentItemAdapter( it.url == callback.card.url && it.apiName == callback.card.apiName } if (currentIndex >= 0 && channels.isNotEmpty()) { - PendingZappingStore.put( - callback.card.url, - callback.card.apiName, - ZappingContext( - channels = channels.map { - ZappingChannel(it.name, it.url, it.apiName, it.posterUrl) - }, - currentIndex = currentIndex, - ) - ) + LiveZappingGenerator.remember(callback.card, channels) } } clickCallback(callback) } - override fun onBindContent(holder: ViewHolderState, item: HomeViewModel.ExpandableHomepageList, position: Int) { + override fun onBindContent( + holder: ViewHolderState, + item: HomeViewModel.ExpandableHomepageList, + position: Int + ) { val startFocus = R.id.nav_rail_view val endFocus = FOCUS_SELF val binding = holder.view @@ -131,18 +136,37 @@ open class ParentItemAdapter( } } - homeChildRecyclerview.setLinearListLayout(isHorizontal = true, nextLeft = startFocus, nextRight = endFocus) + homeChildRecyclerview.setLinearListLayout( + isHorizontal = true, + nextLeft = startFocus, + nextRight = endFocus, + ) homeChildMoreInfo.text = info.name - homeChildRecyclerview.addOnScrollListener(object : RecyclerView.OnScrollListener() { + homeChildRecyclerview.addOnScrollListener(object : + RecyclerView.OnScrollListener() { var expandCount = 0 val name = item.list.name - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + + override fun onScrollStateChanged( + recyclerView: RecyclerView, + newState: Int + ) { super.onScrollStateChanged(recyclerView, newState) + val adapter = recyclerView.adapter if (adapter !is HomeChildItemAdapter) return + val count = adapter.itemCount val hasNext = adapter.hasNext + /*println( + "scolling ${recyclerView.isRecyclerScrollable()} ${ + recyclerView.canScrollHorizontally( + 1 + ) + }" + )*/ + //!recyclerView.canScrollHorizontally(1) if (!recyclerView.isRecyclerScrollable() && hasNext && expandCount != count) { expandCount = count expandCallback?.invoke(name) @@ -150,7 +174,12 @@ open class ParentItemAdapter( } }) - if (isLayout(PHONE)) homeChildMoreInfo.setOnClickListener { moreInfoClickCallback.invoke(item) } + //(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged() + if (isLayout(PHONE)) { + homeChildMoreInfo.setOnClickListener { + moreInfoClickCallback.invoke(item) + } + } } } @@ -160,13 +189,16 @@ open class ParentItemAdapter( isLayout(EMULATOR) -> R.layout.homepage_parent_emulator else -> R.layout.homepage_parent } + val inflater = LayoutInflater.from(parent.context) val binding = try { HomepageParentBinding.bind(inflater.inflate(layoutResId, parent, false)) } catch (t: Throwable) { logError(t) + // just in case someone forgot we don't want to crash HomepageParentBinding.inflate(inflater) } + return ParentItemHolder(binding) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 9ca5eaecc03..9c5685c682b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -1639,7 +1639,6 @@ class CS3IPlayer : IPlayer { override fun onRenderedFirstFrame() { super.onRenderedFirstFrame() - event(FirstFrameRenderedEvent(exoPlayer)) onRenderFirst() updatedTime(source = PlayerEventSource.Player) } 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 fab3dfdcf40..7950d46559f 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 @@ -450,9 +450,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( setupKeyEventListener() playerHostView?.verifyVolume() activity?.attachBackPressedCallback("FullScreenPlayer") { - if (handlePlayerBackPressed()) { - return@attachBackPressedCallback - } else if (isShowingEpisodeOverlay) { + if (isShowingEpisodeOverlay) { // isShowingEpisodeOverlay pauses, so this makes it easier to unpause if (isLayout(TV or EMULATOR)) { playerPausePlay?.requestFocus() @@ -856,9 +854,6 @@ open class FullScreenPlayer : AbstractPlayerFragment( */ protected open fun handleLiveChannelKey(keyCode: Int): Boolean = false - /** Gives specialised players a chance to close an in-player panel before leaving playback. */ - protected open fun handlePlayerBackPressed(): Boolean = false - override fun playerStatusChanged() { super.playerStatusChanged() scheduleMetadataVisibility() @@ -982,12 +977,12 @@ open class FullScreenPlayer : AbstractPlayerFragment( KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_UP -> { + if (isShowing || isShowingEpisodeOverlay || isDialogOpen()) { + return null + } if (handleLiveChannelKey(keyCode)) { return true } - if (isShowing || isShowingEpisodeOverlay) { - return null - } onClickChange() } 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 be81d59b153..00e74c3089d 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 @@ -8,22 +8,16 @@ import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.graphics.Bitmap -import android.graphics.Color import android.graphics.Typeface -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.GradientDrawable import android.os.Build import android.os.Bundle import android.text.Spanned import android.util.Log -import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.animation.DecelerateInterpolator import android.widget.AbsListView import android.widget.ArrayAdapter -import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView @@ -40,7 +34,6 @@ import androidx.core.text.toSpanned import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import androidx.media3.common.Format.NO_VALUE import androidx.media3.common.MimeTypes @@ -51,15 +44,13 @@ import androidx.media3.ui.PlayerNotificationManager import androidx.media3.ui.PlayerNotificationManager.EXTRA_INSTANCE_ID 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 androidx.recyclerview.widget.LinearLayoutManager import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.LiveStreamLoadResponse import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId @@ -85,7 +76,6 @@ import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup -import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType @@ -102,7 +92,6 @@ 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.buildResultEpisode import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR @@ -122,7 +111,6 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName @@ -143,11 +131,9 @@ import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp import com.lagradost.safefile.SafeFile import kotlinx.coroutines.Job -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.io.Serializable import java.lang.ref.WeakReference import java.util.Calendar @@ -200,20 +186,6 @@ class GeneratorPlayer : FullScreenPlayer() { private var isPlayerActive: AtomicBoolean = AtomicBoolean(false) private var isNextEpisode: Boolean = false // this is used to reset the watch time - - private var zappingSession: ZappingSession? = null - private val zappingSwitchInProgress = AtomicBoolean(false) - private var zappingLoadJob: Job? = null - private var zappingOverlayGeneration = 0 - private var zappingTransitionActive = false - private var zappingTransitionPlayer: Any? = null - private var zappingTransitionBitmap: Bitmap? = null - private var zappingChannelList: LinearLayout? = null - private var zappingChannelRecycler: RecyclerView? = null - private var zappingChannelOverlay: FrameLayout? = null - private var zappingChannelOverlayPoster: ImageView? = null - private var zappingChannelAdapter: ZappingChannelAdapter? = null - private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none private val allMeta: List? get() = viewModel.state.generatorState?.allMeta?.filterIsInstance() @@ -466,10 +438,6 @@ class GeneratorPlayer : FullScreenPlayer() { override fun playerUpdated(player: Any?) { super.playerUpdated(player) - if (zappingTransitionActive && player != null) { - zappingTransitionPlayer = player - } - // Cancel the notification when released if (player == null) { cachedPlayerNotificationManager?.setPlayer(null) @@ -1748,359 +1716,37 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onDestroy() { ResultFragment.updateUI() currentVerifyLink?.cancel() - zappingLoadJob?.cancel() - zappingOverlayGeneration++ - zappingTransitionActive = false - zappingTransitionPlayer = null - clearZappingTransitionBitmap() - if (activity?.isChangingConfigurations != true) { - zappingSession?.close() - } super.onDestroy() } - override fun onResume() { - super.onResume() - val enabled = isZappingEnabled() && zappingSession?.current() != null - playerBinding?.playerChannelListBtt?.isVisible = enabled - if (!enabled) toggleZappingList(false) - } - private fun isZappingEnabled(): Boolean { - return context?.let { ctx -> - PreferenceManager.getDefaultSharedPreferences(ctx) - .getBoolean(getString(R.string.zapping_enabled_key), true) - } == true - } - - private fun zappingOverlayParent(): FrameLayout? { - val rootView: View = playerBinding?.root ?: return null - if (rootView is FrameLayout) return rootView - - // Keep this defensive for alternate player controller layouts. The generated stable - // layouts currently use FrameLayout, but the zapping controls must not disappear merely - // because a future controller root changes its concrete class. - val parent = rootView as? ViewGroup ?: return null - return FrameLayout(rootView.context).also { overlay -> - parent.addView( - overlay, - ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - ) - } - } - - override fun playerFirstFrameRendered(player: Any?) { - super.playerFirstFrameRendered(player) - if (zappingTransitionActive && zappingTransitionPlayer === player) { - completeZappingTransition() - } - } - - private fun setupZappingUi() { - val session = zappingSession ?: return - if (!isZappingEnabled()) return - val root = zappingOverlayParent() ?: return - if (zappingChannelList != null) return - val button = playerBinding?.playerChannelListBtt ?: return - button.isVisible = true - button.setOnClickListener { toggleZappingList(zappingChannelList?.isVisible != true) } - - val panel = LinearLayout(root.context).apply { - orientation = LinearLayout.VERTICAL - background = GradientDrawable( - GradientDrawable.Orientation.LEFT_RIGHT, - intArrayOf(Color.TRANSPARENT, 0x66000000), - ) - elevation = 12.toPx.toFloat() - visibility = View.GONE - } - val recycler = RecyclerView(root.context).apply { - layoutManager = LinearLayoutManager(root.context) - overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS - descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS - clipChildren = false - clipToPadding = false - } - val adapter = ZappingChannelAdapter { index, card -> - switchToLiveChannel(index, card) - } - recycler.adapter = adapter - panel.addView(recycler, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) - root.addView( - panel, - FrameLayout.LayoutParams(if (isLayout(TV or EMULATOR)) 300.toPx else 260.toPx, ViewGroup.LayoutParams.MATCH_PARENT).apply { - gravity = Gravity.TOP or Gravity.END - } - ) - zappingChannelList = panel - zappingChannelRecycler = recycler - zappingChannelAdapter = adapter - - val channelOverlay = FrameLayout(root.context).apply { - visibility = View.GONE - clipChildren = false - clipToPadding = false - } - val overlayPoster = ImageView(root.context).apply { - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - scaleType = ImageView.ScaleType.CENTER_CROP - setBackgroundColor(root.context.colorFromAttribute(R.attr.boxItemBackground)) - } - channelOverlay.addView(overlayPoster) - root.addView( - channelOverlay, - FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - ) - zappingChannelOverlay = channelOverlay - zappingChannelOverlayPoster = overlayPoster - adapter.submit(session.current()?.channels.orEmpty(), session.current()?.currentIndex ?: 0) - } - - private fun toggleZappingList(show: Boolean) { - if (show && !isZappingEnabled()) return - val panel = zappingChannelList ?: return - panel.isVisible = show - if (show) { - playerHostView?.cancelAutoHide() - val state = zappingSession?.current() ?: return - zappingChannelAdapter?.submit(state.channels, state.currentIndex) - zappingChannelRecycler?.post { - val recycler = zappingChannelRecycler ?: return@post - val layoutManager = recycler.layoutManager as? LinearLayoutManager ?: return@post - val itemHeight = (recycler.width * 0.70f * 9f / 16f).toInt() + 2.toPx - val offset = ((recycler.height - itemHeight) / 2).coerceAtLeast(0) - layoutManager.scrollToPositionWithOffset(state.currentIndex, offset) - recycler.post { - recycler.findViewHolderForAdapterPosition(state.currentIndex)?.itemView?.requestFocus() - } - } - } - } - - private fun beginZappingTransition(channel: ZappingChannel, sourceCard: View): Boolean { - if (!isZappingEnabled()) return false - val root = zappingOverlayParent() ?: return false - val overlay = zappingChannelOverlay ?: return false - val poster = zappingChannelOverlayPoster ?: return false - if (root.width <= 0 || root.height <= 0 || sourceCard.width <= 0 || sourceCard.height <= 0) { - return false - } - - val sourceLocation = IntArray(2) - val rootLocation = IntArray(2) - sourceCard.getLocationOnScreen(sourceLocation) - root.getLocationOnScreen(rootLocation) - - val sourceScaleX = sourceCard.scaleX.coerceAtLeast(1f) - val sourceScaleY = sourceCard.scaleY.coerceAtLeast(1f) - val sourceWidth = sourceCard.width * sourceScaleX - val sourceHeight = sourceCard.height * sourceScaleY - val sourceLeft = (sourceLocation[0] - rootLocation[0]).toFloat() - - (sourceWidth - sourceCard.width) / 2f - val sourceTop = (sourceLocation[1] - rootLocation[1]).toFloat() - - (sourceHeight - sourceCard.height) / 2f - val generation = ++zappingOverlayGeneration - - overlay.animate().cancel() - poster.animate().cancel() - val transitionBitmap = createSoftwareTransitionBitmap(sourceCard) ?: return false - zappingTransitionActive = true - zappingTransitionPlayer = null - poster.setImageDrawable(null) - clearZappingTransitionBitmap() - zappingTransitionBitmap = transitionBitmap - poster.setImageBitmap(transitionBitmap) - poster.pivotX = 0f - poster.pivotY = 0f - poster.translationX = sourceLeft - poster.translationY = sourceTop - poster.scaleX = sourceWidth / root.width.toFloat() - poster.scaleY = sourceHeight / root.height.toFloat() - poster.alpha = 1f - overlay.isVisible = true - binding?.playerView?.alpha = 0f - - zappingChannelList?.animate()?.cancel() - zappingChannelList?.animate()?.alpha(0f)?.setDuration(160L)?.setInterpolator( - DecelerateInterpolator() - )?.withEndAction { - if (generation == zappingOverlayGeneration) { - zappingChannelList?.isVisible = false - zappingChannelList?.alpha = 1f - } - }?.start() - - poster.animate() - .translationX(0f) - .translationY(0f) - .scaleX(1f) - .scaleY(1f) - .alpha(0.08f) - .setDuration(850L) - .setInterpolator(DecelerateInterpolator()) - .withEndAction { - if (generation == zappingOverlayGeneration && zappingTransitionActive) { - poster.alpha = 0.08f - } - } - .start() - return true - } - - /** - * ImageLoader may provide a hardware BitmapDrawable. Copy only the selected - * poster to ARGB_8888 before assigning it to the transition ImageView so no - * software Canvas ever tries to draw a hardware bitmap. - */ - private fun createSoftwareTransitionBitmap(sourceCard: View): Bitmap? { - return try { - val card = sourceCard as? ViewGroup ?: return null - val content = card.getChildAt(0) as? ViewGroup ?: return null - val poster = content.getChildAt(0) as? ImageView ?: return null - val bitmap = (poster.drawable as? BitmapDrawable)?.bitmap ?: return null - if (bitmap.isRecycled) return null - bitmap.copy(Bitmap.Config.ARGB_8888, false) - } catch (error: Throwable) { - logError(error) - null - } - } - - private fun clearZappingTransitionBitmap() { - val bitmap = zappingTransitionBitmap - zappingTransitionBitmap = null - if (bitmap != null && !bitmap.isRecycled) { - bitmap.recycle() - } - } - - private fun completeZappingTransition() { - if (!zappingTransitionActive) return - val overlay = zappingChannelOverlay ?: return - val poster = zappingChannelOverlayPoster ?: return - val generation = zappingOverlayGeneration - zappingTransitionPlayer = null - binding?.playerView?.animate() - ?.alpha(1f) - ?.setDuration(280L) - ?.setInterpolator(DecelerateInterpolator()) - ?.start() - poster.animate() - .alpha(0f) - .setDuration(300L) - .setInterpolator(DecelerateInterpolator()) - .withEndAction { - if (generation != zappingOverlayGeneration) return@withEndAction - overlay.isVisible = false - poster.alpha = 1f - poster.setImageDrawable(null) - clearZappingTransitionBitmap() - zappingTransitionActive = false - } - .start() + return context?.let { com.lagradost.cloudstream4.AppSettings(it).player.zappingEnabled.get() } == true } - private fun cancelZappingTransition() { - zappingOverlayGeneration++ - zappingTransitionActive = false - zappingTransitionPlayer = null - zappingChannelOverlay?.animate()?.cancel() - zappingChannelOverlayPoster?.animate()?.cancel() - zappingChannelOverlay?.isVisible = false - zappingChannelOverlayPoster?.alpha = 1f - zappingChannelOverlayPoster?.setImageDrawable(null) - clearZappingTransitionBitmap() - binding?.playerView?.animate()?.cancel() - binding?.playerView?.alpha = 1f + private fun isLiveZapping(): Boolean { + return isZappingEnabled() && + viewModel.generator is LiveZappingGenerator } - override fun handlePlayerBackPressed(): Boolean { - if (zappingChannelList?.isVisible == true) { - toggleZappingList(false) - playerBinding?.playerChannelListBtt?.requestFocus() - return true - } - return false - } - - private fun switchToLiveChannel(targetIndex: Int, sourceCard: View? = null): Boolean { - if (!isZappingEnabled()) return false - val session = zappingSession ?: return false - val state = session.current() ?: return false - if (targetIndex !in state.channels.indices) return false - if (targetIndex == state.currentIndex) { - toggleZappingList(false) - return true - } - if (!zappingSwitchInProgress.compareAndSet(false, true)) return true - - val target = state.channels[targetIndex] - if (sourceCard == null || !beginZappingTransition(target, sourceCard)) { - toggleZappingList(false) - } - zappingLoadJob?.cancel() - zappingLoadJob = viewLifecycleOwner.lifecycleScope.launch { - var playbackLoadStarted = false - try { - val api = getApiFromNameNull(target.apiName) - ?: error("Provider not found: ${target.apiName}") - val response = withContext(Dispatchers.IO) { - APIRepository(api).load(target.url) - } - val load = (response as? Resource.Success)?.value as? LiveStreamLoadResponse - ?: error("Channel did not return a live response: ${target.name}") - val episode = buildResultEpisode( - headerName = load.name, - name = load.name, - poster = target.posterUrl, - episode = 0, - data = load.dataUrl, - apiName = load.apiName, - id = "${target.apiName}:${target.url}".hashCode(), - index = 0, - tvType = load.type, - parentId = "${target.apiName}:${target.url}".hashCode(), - ) - - releasePlayer() - viewModel.attachGenerator(RepoLinkGenerator(listOf(episode), page = load), 0) - session.select(targetIndex) - zappingChannelAdapter?.submit(state.channels, targetIndex) - viewModel.loadLinks() - playbackLoadStarted = true - } catch (error: Throwable) { - logError(error) - cancelZappingTransition() - showToast(activity, error.message ?: getString(R.string.unexpected_error), Toast.LENGTH_SHORT) - } finally { - if (!playbackLoadStarted) zappingSwitchInProgress.set(false) - } - } + private fun switchToLiveChannel(targetIndex: Int): Boolean { + if (!isLiveZapping()) return false + val generator = viewModel.generator as? LiveZappingGenerator ?: return false + if (generator.videos.isEmpty()) return false + val wrappedIndex = ((targetIndex % generator.videos.size) + generator.videos.size) % generator.videos.size + if (wrappedIndex == viewModel.episodeIndex) return true + isNextEpisode = true + releasePlayer() + viewModel.loadThisEpisode(wrappedIndex) return true } override fun handleLiveChannelKey(keyCode: Int): Boolean { - if (keyCode != android.view.KeyEvent.KEYCODE_DPAD_UP && - keyCode != android.view.KeyEvent.KEYCODE_DPAD_DOWN - ) return false - if (isShowing || isDialogOpen()) return false - if (!isZappingEnabled() || zappingChannelList?.isVisible == true) return false - - val session = zappingSession ?: return false - val context = session.current() ?: return false - - val targetIndex = if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_UP) { - context.previousIndex() - } else { - context.nextIndex() + if (isShowing || isDialogOpen() || !isLiveZapping()) return false + return when (keyCode) { + android.view.KeyEvent.KEYCODE_DPAD_UP -> switchToLiveChannel(viewModel.episodeIndex - 1) + android.view.KeyEvent.KEYCODE_DPAD_DOWN -> switchToLiveChannel(viewModel.episodeIndex + 1) + else -> false } - return switchToLiveChannel(targetIndex) } var maxEpisodeSet: Int? = null @@ -2629,10 +2275,6 @@ class GeneratorPlayer : FullScreenPlayer() { super.onBindingCreated(binding, savedInstanceState) - val sessionBundle = savedInstanceState?.takeIf { it.getString("uuid") != null } ?: arguments - zappingSession = ZappingPlayerLauncher.session(sessionBundle) - setupZappingUi() - // Avoid showing no links found if (generator == null || index == null) { exitPlayer() @@ -2725,10 +2367,6 @@ class GeneratorPlayer : FullScreenPlayer() { observe(viewModel.loadingLinks) { (loading, instance) -> if (instance != viewModel.state.instance) return@observe // Outdated observe - if (zappingSwitchInProgress.get() && loading !is Resource.Loading) { - zappingSwitchInProgress.set(false) - } - when (loading) { is Resource.Loading -> { releasePlayer() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index cb822c8646d..0342372667f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -119,12 +119,6 @@ data class PlayerAttachedEvent( override val source: PlayerEventSource = PlayerEventSource.Player ) : PlayerEvent() -/** Event emitted when the current player has rendered its first video frame. */ -data class FirstFrameRenderedEvent( - val player: Any?, - override val source: PlayerEventSource = PlayerEventSource.Player, -) : PlayerEvent() - /** Event from player to inform that subtitles have updated in some way */ data class SubtitlesUpdatedEvent( override val source: PlayerEventSource = PlayerEventSource.Player diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/LiveZappingGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LiveZappingGenerator.kt new file mode 100644 index 00000000000..6ef5ad60896 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LiveZappingGenerator.kt @@ -0,0 +1,91 @@ +package com.lagradost.cloudstream3.ui.player + +import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.LiveStreamLoadResponse +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.ui.APIRepository +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.result.buildResultEpisode +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.ExtractorLink +import java.util.concurrent.ConcurrentHashMap + +private data class LiveZappingChannel( + val name: String, + val url: String, + val apiName: String, + val poster: String?, +) + +private fun liveEpisode(index: Int, channel: LiveZappingChannel): ResultEpisode = buildResultEpisode( + headerName = channel.name, + name = channel.name, + poster = channel.poster, + episode = index, + data = channel.url, + apiName = channel.apiName, + id = "${channel.apiName}:${channel.url}".hashCode(), + index = index, + tvType = TvType.Live, + parentId = "${channel.apiName}:${channel.url}".hashCode(), +) + +/** + * A normal player generator whose entries are the live channels from one Home row. + * The player can therefore use its existing episode list and episode index handling. + */ +class LiveZappingGenerator private constructor( + channels: List, +) : VideoGenerator(channels.mapIndexed(::liveEpisode)) { + companion object { + private val pending = ConcurrentHashMap>() + + private fun key(url: String, apiName: String) = "$apiName\n$url" + + fun remember(response: SearchResponse, channels: List) { + pending[key(response.url, response.apiName)] = channels.map { + LiveZappingChannel(it.name, it.url, it.apiName, it.posterUrl) + } + } + + fun take(url: String, apiName: String, name: String?): Pair? { + val channels = pending.remove(key(url, apiName)) ?: return null + if (channels.isEmpty()) return null + val index = channels.indexOfFirst { it.url == url && it.apiName == apiName && (name == null || it.name == name) } + .takeIf { it >= 0 } + ?: channels.indexOfFirst { it.url == url && it.apiName == apiName } + if (index < 0) return null + return LiveZappingGenerator(channels) to index + } + + fun discard(url: String, apiName: String) { + pending.remove(key(url, apiName)) + } + } + + override val hasCache: Boolean = false + override val canSkipLoading: Boolean = false + override fun getId(index: Int): Int? = videos.getOrNull(index)?.id + + override suspend fun generateLinks( + clearCache: Boolean, + sourceTypes: Set, + callback: (Pair) -> Unit, + subtitleCallback: (SubtitleData) -> Unit, + offset: Int, + isCasting: Boolean, + ): Boolean { + val episode = videos.getOrNull(offset) ?: return false + val api = getApiFromNameNull(episode.apiName) ?: return false + val loaded = APIRepository(api).load(episode.data) + val live = (loaded as? Resource.Success)?.value as? LiveStreamLoadResponse ?: return false + return APIRepository(api).loadLinks( + live.dataUrl, + isCasting, + subtitleCallback = { subtitleCallback(PlayerSubtitleHelper.getSubtitleData(it)) }, + callback = { link -> callback(link to null) }, + ) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PendingZappingStore.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PendingZappingStore.kt deleted file mode 100644 index a4ea42224dc..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PendingZappingStore.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -import java.util.concurrent.ConcurrentHashMap - -/** - * Short-lived bridge between a live card opened from Home and the player session created later. - * - * The channel list stays in memory instead of being serialized into ResultFragment arguments. - * Entries are consumed once the matching live result reaches the player launch path. - */ -object PendingZappingStore { - private const val MAX_PENDING_AGE_MS = 5 * 60 * 1000L - - private data class Key( - val url: String, - val apiName: String, - ) - - private data class Entry( - val context: ZappingContext, - val createdAtMs: Long, - ) - - private val pending = ConcurrentHashMap() - - fun put(url: String, apiName: String, context: ZappingContext) { - pending[Key(url, apiName)] = Entry(context, System.currentTimeMillis()) - } - - fun peek(url: String, apiName: String): ZappingContext? { - return pending[Key(url, apiName)]?.takeUnless(::isExpired)?.context - } - - fun consume(url: String, apiName: String, channelName: String? = null): ZappingContext? { - val exactKey = Key(url, apiName) - pending.remove(exactKey)?.let { entry -> - return entry.takeUnless(::isExpired)?.context - } - - // A provider can normalize the load URL between the Home SearchResponse and the - // LoadResponse used by ResultViewModel2. Prefer a channel URL match, then only fall back - // to a single pending context for that provider so two unrelated Home rows cannot mix. - val candidates = pending.entries.filter { it.key.apiName == apiName } - val matching = candidates.firstOrNull { (_, entry) -> - !isExpired(entry) && entry.context.channels.any { channel -> - channel.url.trimEnd('/') == url.trimEnd('/') || - channel.name.equals(channelName, ignoreCase = true) - } - } ?: candidates.singleOrNull { (_, entry) -> !isExpired(entry) } - - if (matching != null && pending.remove(matching.key, matching.value)) { - return matching.value.context - } - return null - } - - fun remove(url: String, apiName: String): ZappingContext? { - return pending.remove(Key(url, apiName))?.context - } - - fun clear() { - pending.clear() - } - - private fun isExpired(entry: Entry): Boolean { - return System.currentTimeMillis() - entry.createdAtMs > MAX_PENDING_AGE_MS - } -} 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 108003a3eee..582b764913c 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 @@ -129,8 +129,6 @@ class PlayerView @JvmOverloads constructor( fun playerError(exception: Throwable) {} /** Called after [PlayerView] finishes its own player-attached setup (MediaSession, ExoPlayer view). */ fun playerUpdated(player: Any?) {} - /** Called when the current player renders its first video frame. */ - fun playerFirstFrameRendered(player: Any?) {} /** Called on a short single-tap on empty player area (no swipe, no double-tap). */ fun onSingleTap() {} /** Called when the hold-for-speedup gesture starts (show=true) or ends (show=false). */ @@ -753,7 +751,6 @@ class PlayerView @JvmOverloads constructor( callbacks?.playerDimensionsLoaded(event.width, event.height) } is PlayerAttachedEvent -> playerUpdated(event.player) - is FirstFrameRenderedEvent -> callbacks?.playerFirstFrameRendered(event.player) is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged() is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp) is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingChannelAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingChannelAdapter.kt deleted file mode 100644 index da33a609182..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingChannelAdapter.kt +++ /dev/null @@ -1,196 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -import android.graphics.Color -import android.graphics.Typeface -import android.graphics.drawable.GradientDrawable -import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.ImageView -import android.widget.TextView -import androidx.cardview.widget.CardView -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.UIHelper.toPx -import kotlin.math.roundToInt - -/** A focusable 16:9 channel-card list shared by touch devices and TV remotes. */ -class ZappingChannelAdapter( - private val onChannelClick: (Int, View) -> Unit, -) : RecyclerView.Adapter() { - private var channels: List = emptyList() - private var selectedIndex: Int = RecyclerView.NO_POSITION - - fun submit(channels: List, selectedIndex: Int) { - this.channels = channels - this.selectedIndex = selectedIndex.coerceIn(0, (channels.size - 1).coerceAtLeast(0)) - notifyDataSetChanged() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChannelViewHolder { - val context = parent.context - val item = CarouselItemContainer(context).apply { - layoutParams = RecyclerView.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { - topMargin = 1.toPx - bottomMargin = 1.toPx - } - clipChildren = false - clipToPadding = false - } - val card = AspectRatioCardView(context).apply { - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - Gravity.END, - ) - radius = 12.toPx.toFloat() - cardElevation = 2.toPx.toFloat() - useCompatPadding = true - isFocusable = true - isFocusableInTouchMode = true - isClickable = true - contentDescription = context.getString(R.string.player_channel_list) - } - val content = FrameLayout(context) - val poster = ImageView(context).apply { - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - scaleType = ImageView.ScaleType.CENTER_CROP - setBackgroundColor(context.colorFromAttribute(R.attr.boxItemBackground)) - } - val scrim = View(context).apply { - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - 72.toPx, - Gravity.BOTTOM, - ) - background = GradientDrawable( - GradientDrawable.Orientation.TOP_BOTTOM, - intArrayOf(Color.TRANSPARENT, 0xE6000000.toInt()), - ) - } - val title = TextView(context).apply { - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - Gravity.BOTTOM, - ).apply { - marginStart = 8.toPx - marginEnd = 8.toPx - bottomMargin = 6.toPx - } - textSize = 12f - maxLines = 1 - ellipsize = android.text.TextUtils.TruncateAt.END - setTextColor(context.colorFromAttribute(R.attr.textColor)) - } - content.addView(poster) - content.addView(scrim) - content.addView(title) - card.addView(content) - item.addView(card) - return ChannelViewHolder(item, card, poster, title) - } - - override fun onBindViewHolder(holder: ChannelViewHolder, position: Int) { - val channel = channels[position] - val isActive = position == selectedIndex - holder.poster.loadImage(channel.posterUrl) - holder.title.text = channel.name - holder.title.setTypeface(null, if (isActive) Typeface.BOLD else Typeface.NORMAL) - holder.title.setTextColor(holder.title.context.colorFromAttribute(R.attr.textColor)) - holder.card.setCardBackgroundColor( - holder.card.context.colorFromAttribute(R.attr.primaryBlackBackground) - ) - holder.card.animate().cancel() - applyVisualState(holder, isActive, holder.card.hasFocus(), animate = false) - holder.card.contentDescription = channel.name - holder.card.setOnClickListener { onChannelClick(position, holder.card) } - holder.card.setOnFocusChangeListener { view, hasFocus -> - applyVisualState(holder, position == selectedIndex, hasFocus, animate = true) - if (hasFocus) { - centerFocusedItem(holder.itemView) - } - } - } - - private fun applyVisualState( - holder: ChannelViewHolder, - isActive: Boolean, - isFocused: Boolean, - animate: Boolean, - ) { - val card = holder.card - val highlighted = isActive || isFocused - val targetScale = if (highlighted) 1.15f else 1f - card.foreground = null - card.cardElevation = 2.toPx.toFloat() - if (animate) { - card.animate() - .scaleX(targetScale) - .scaleY(targetScale) - .setDuration(150L) - .start() - } else { - card.scaleX = targetScale - card.scaleY = targetScale - } - } - - private fun centerFocusedItem(view: View) { - val recycler = view.parent?.parent as? RecyclerView ?: return - recycler.post { - val itemCenter = view.top + view.height / 2 - val viewportCenter = recycler.height / 2 - recycler.smoothScrollBy(0, itemCenter - viewportCenter) - } - } - - override fun getItemCount(): Int = channels.size - - class ChannelViewHolder( - val itemViewContainer: CarouselItemContainer, - val card: AspectRatioCardView, - val poster: ImageView, - val title: TextView, - ) : RecyclerView.ViewHolder(itemViewContainer) - - class CarouselItemContainer(context: android.content.Context) : FrameLayout(context) { - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val availableWidth = MeasureSpec.getSize(widthMeasureSpec) - val cardWidth = (availableWidth * 0.70f).roundToInt() - val cardHeight = (cardWidth * 9f / 16f).roundToInt() - val card = getChildAt(0) - card?.measure( - MeasureSpec.makeMeasureSpec(cardWidth, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(cardHeight, MeasureSpec.EXACTLY), - ) - setMeasuredDimension(availableWidth, cardHeight) - } - - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - val card = getChildAt(0) ?: return - val cardLeft = width - card.measuredWidth - card.layout(cardLeft, 0, width, card.measuredHeight) - } - } - - class AspectRatioCardView(context: android.content.Context) : CardView(context) { - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val width = MeasureSpec.getSize(widthMeasureSpec) - val height = (width * 9f / 16f).roundToInt() - super.onMeasure( - widthMeasureSpec, - MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), - ) - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingContext.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingContext.kt deleted file mode 100644 index cf436c74b1e..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingContext.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -/** - * Transient navigation state for live-channel zapping. - * - * This intentionally stays independent from Android Bundle serialization. The player keeps the - * context in memory for the lifetime of the playback session, just like VideoGenerator instances. - */ -data class ZappingContext( - val channels: List, - val currentIndex: Int, -) { - init { - require(channels.isNotEmpty()) { "ZappingContext requires at least one channel" } - require(currentIndex in channels.indices) { "currentIndex must point to a channel" } - } - - fun previousIndex(): Int = (currentIndex - 1 + channels.size) % channels.size - - fun nextIndex(): Int = (currentIndex + 1) % channels.size -} - -data class ZappingChannel( - val name: String, - val url: String, - val apiName: String, - val posterUrl: String? = null, -) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingPlayerLauncher.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingPlayerLauncher.kt deleted file mode 100644 index ff434fea0b4..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingPlayerLauncher.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -import android.os.Bundle - -/** - * Creates a normal GeneratorPlayer session and attaches optional live-zapping state to the exact - * same UUID. Keeping this as a small wrapper means existing movie/episode call sites remain - * untouched and live entry points can opt in explicitly. - */ -object ZappingPlayerLauncher { - fun newInstance( - generator: VideoGenerator<*>, - index: Int, - syncData: HashMap? = null, - zappingContext: ZappingContext? = null, - ): Bundle { - val bundle = GeneratorPlayer.newInstance(generator, index, syncData) - val uuid = bundle.getString("uuid") - - if (uuid != null && zappingContext != null) { - ZappingSession(uuid, zappingContext).register() - } - - return bundle - } - - /** - * Launches a player for a result that may have originated from a live Home category. - * Pending state is consumed only when the player is actually created, so backing out of the - * result page does not create a player session and the full channel list never enters a Bundle. - */ - fun newInstanceFromPending( - generator: VideoGenerator<*>, - index: Int, - syncData: HashMap? = null, - url: String, - apiName: String, - channelName: String? = null, - ): Bundle { - val context = PendingZappingStore.consume(url, apiName, channelName) - return newInstance(generator, index, syncData, context) - } - - fun session(bundle: Bundle?): ZappingSession? { - val uuid = bundle?.getString("uuid") ?: return null - val context = ZappingSessionStore.get(uuid) ?: return null - return ZappingSession(uuid, context) - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSession.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSession.kt deleted file mode 100644 index 840935fe8b4..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSession.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -/** - * Identifies a live-zapping session without putting the full channel list in a Bundle. - */ -data class ZappingSession( - val uuid: String, - val context: ZappingContext, -) { - fun register() { - ZappingSessionStore.put(uuid, context) - } - - fun current(): ZappingContext? = ZappingSessionStore.get(uuid) - - fun select(index: Int): ZappingContext? = ZappingSessionStore.updateIndex(uuid, index) - - fun selectPrevious(): ZappingContext? { - val state = current() ?: return null - return ZappingSessionStore.updateIndex(uuid, state.previousIndex()) - } - - fun selectNext(): ZappingContext? { - val state = current() ?: return null - return ZappingSessionStore.updateIndex(uuid, state.nextIndex()) - } - - fun close() { - ZappingSessionStore.remove(uuid) - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSessionStore.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSessionStore.kt deleted file mode 100644 index 985ccc39fcd..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ZappingSessionStore.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -import java.util.concurrent.ConcurrentHashMap - -/** - * In-memory zapping state keyed by the same playback UUID used by GeneratorPlayer. - * - * Keeping this outside Bundles avoids serializing an entire live-channel category and mirrors the - * lifetime semantics of GeneratorPlayer's transient VideoGenerator storage. - */ -object ZappingSessionStore { - private val sessions = ConcurrentHashMap() - - fun put(uuid: String, context: ZappingContext) { - sessions[uuid] = context - } - - fun get(uuid: String): ZappingContext? = sessions[uuid] - - fun remove(uuid: String): ZappingContext? = sessions.remove(uuid) - - fun updateIndex(uuid: String, index: Int): ZappingContext? { - return sessions.computeIfPresent(uuid) { _, current -> - require(index in current.channels.indices) { "index must point to a channel" } - current.copy(currentIndex = index) - } - } -} 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 a101c282083..f54c134061e 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 @@ -77,7 +77,7 @@ import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator -import com.lagradost.cloudstream3.ui.player.ZappingPlayerLauncher +import com.lagradost.cloudstream3.ui.player.LiveZappingGenerator import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull @@ -1563,16 +1563,20 @@ class ResultViewModel2 : ViewModel() { val response = currentResponse activity?.navigate( R.id.global_to_navigation_player, - if (response?.type == TvType.Live) { - ZappingPlayerLauncher.newInstanceFromPending( - generator = generator, - index = index, - syncData = list, - url = response.url, - apiName = response.apiName, - channelName = response.name, - ) + if (response?.type == TvType.Live && + context?.let { com.lagradost.cloudstream4.AppSettings(it).player.zappingEnabled.get() } == true + ) { + LiveZappingGenerator.take( + response.url, + response.apiName, + response.name, + )?.let { (liveGenerator, liveIndex) -> + GeneratorPlayer.newInstance(liveGenerator, liveIndex, list) + } ?: GeneratorPlayer.newInstance(generator, index, list) } else { + if (response?.type == TvType.Live) { + response.let { LiveZappingGenerator.discard(it.url, it.apiName) } + } GeneratorPlayer.newInstance(generator, index, list) } ) diff --git a/app/src/main/res/layout/player_custom_layout.xml b/app/src/main/res/layout/player_custom_layout.xml index dd6e73cafdd..717fb6baf95 100644 --- a/app/src/main/res/layout/player_custom_layout.xml +++ b/app/src/main/res/layout/player_custom_layout.xml @@ -927,24 +927,14 @@ android:layout_height="40dp" android:nextFocusLeft="@id/player_sources_btt" - android:nextFocusRight="@id/player_channel_list_btt" + android:nextFocusRight="@id/player_skip_op" android:text="@string/tracks" app:icon="@drawable/ic_baseline_equalizer_24" /> - - - - diff --git a/app/src/main/res/values-b+tr/strings.xml b/app/src/main/res/values-b+tr/strings.xml index 1170413e087..c7e535c8d8e 100644 --- a/app/src/main/res/values-b+tr/strings.xml +++ b/app/src/main/res/values-b+tr/strings.xml @@ -128,7 +128,6 @@ Sol ve sağ taraftan yukarı kaydırarak ekran parlaklığı ve sesi ayarla Sonraki bölümü otomatik oynat Mevcut bölüm bittiğinde sonraki bölüme başla - Kanal listesi Çift dokunarak atla İki kez dokunarak duraklat Atlanacak süre (Saniye) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1fd015c53eb..8c6367eb2b7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -160,7 +160,6 @@ Start the next episode when the current one ends Zapping Use DPAD up/down to switch between Live TV channels in the same Home category - Channel List Start videos paused Reduce stress when selecting sources Double tap to seek diff --git a/app/src/test/java/com/lagradost/cloudstream3/PendingZappingStoreTest.kt b/app/src/test/java/com/lagradost/cloudstream3/PendingZappingStoreTest.kt deleted file mode 100644 index 1e38db0d017..00000000000 --- a/app/src/test/java/com/lagradost/cloudstream3/PendingZappingStoreTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.lagradost.cloudstream3 - -import com.lagradost.cloudstream3.ui.player.PendingZappingStore -import com.lagradost.cloudstream3.ui.player.ZappingChannel -import com.lagradost.cloudstream3.ui.player.ZappingContext -import org.junit.Assert.assertEquals -import org.junit.Test - -class PendingZappingStoreTest { - @Test - fun `consumes a single provider context when load url is normalized`() { - PendingZappingStore.clear() - val context = ZappingContext( - channels = listOf( - ZappingChannel("Mor Spor 1", "https://example.test/channel/1/", "TestProvider"), - ZappingChannel("Mor Spor 2", "https://example.test/channel/2/", "TestProvider"), - ), - currentIndex = 0, - ) - - PendingZappingStore.put("https://example.test/channel/1/", "TestProvider", context) - - assertEquals( - context, - PendingZappingStore.consume( - "https://example.test/normalized-live-url", - "TestProvider", - "Mor Spor 1" - ) - ) - } -} From 425032a6194c9df43c22afb7039f395b54bc9b6e Mon Sep 17 00:00:00 2001 From: wiojelt <121122059+Viollje@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:54:06 +0300 Subject: [PATCH 3/7] fix: preserve live player dpad navigation --- .../lagradost/cloudstream3/ui/player/FullScreenPlayer.kt | 6 ++++++ .../com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) 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 7950d46559f..7041f0dc276 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 @@ -854,6 +854,9 @@ open class FullScreenPlayer : AbstractPlayerFragment( */ protected open fun handleLiveChannelKey(keyCode: Int): Boolean = false + /** Live players may leave DPAD navigation to the focused view when zapping is inactive. */ + protected open fun shouldPreserveLiveDpadNavigation(): Boolean = false + override fun playerStatusChanged() { super.playerStatusChanged() scheduleMetadataVisibility() @@ -983,6 +986,9 @@ open class FullScreenPlayer : AbstractPlayerFragment( if (handleLiveChannelKey(keyCode)) { return true } + if (shouldPreserveLiveDpadNavigation()) { + return null + } onClickChange() } 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 00e74c3089d..39d9ba7667b 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 @@ -1725,7 +1725,11 @@ class GeneratorPlayer : FullScreenPlayer() { private fun isLiveZapping(): Boolean { return isZappingEnabled() && - viewModel.generator is LiveZappingGenerator + (viewModel.generator as? LiveZappingGenerator)?.videos?.size?.let { it > 1 } == true + } + + override fun shouldPreserveLiveDpadNavigation(): Boolean { + return (currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true && !isLiveZapping() } private fun switchToLiveChannel(targetIndex: Int): Boolean { From 1a1b4e6c641d3a205159ac503a48084696cce236 Mon Sep 17 00:00:00 2001 From: wiojelt <121122059+Viollje@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:01:16 +0300 Subject: [PATCH 4/7] fix: keep live playback running in channel popup --- .../cloudstream3/ui/player/FullScreenPlayer.kt | 11 +++++++++-- .../cloudstream3/ui/player/GeneratorPlayer.kt | 4 ++++ 2 files changed, 13 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 7041f0dc276..10361d0a001 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 @@ -857,6 +857,9 @@ open class FullScreenPlayer : AbstractPlayerFragment( /** Live players may leave DPAD navigation to the focused view when zapping is inactive. */ protected open fun shouldPreserveLiveDpadNavigation(): Boolean = false + /** Episode overlays pause regular video, but live channel lists may stay non-blocking. */ + protected open fun shouldPauseForEpisodeOverlay(): Boolean = true + override fun playerStatusChanged() { super.playerStatusChanged() scheduleMetadataVisibility() @@ -1352,12 +1355,16 @@ open class FullScreenPlayer : AbstractPlayerFragment( private fun toggleEpisodesOverlay(show: Boolean) { if (show && !isShowingEpisodeOverlay) { previousPlayStatus = player.getIsPlaying() - player.handleEvent(CSPlayerEvent.Pause) + if (shouldPauseForEpisodeOverlay()) { + player.handleEvent(CSPlayerEvent.Pause) + } showEpisodesOverlay() isShowingEpisodeOverlay = true animateEpisodesOverlay(true) } else if (isShowingEpisodeOverlay) { - if (previousPlayStatus) player.handleEvent(CSPlayerEvent.Play) + if (previousPlayStatus && shouldPauseForEpisodeOverlay()) { + player.handleEvent(CSPlayerEvent.Play) + } 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 39d9ba7667b..cb07ecb46a5 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 @@ -1732,6 +1732,10 @@ class GeneratorPlayer : FullScreenPlayer() { return (currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true && !isLiveZapping() } + override fun shouldPauseForEpisodeOverlay(): Boolean { + return !((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true && isLiveZapping()) + } + private fun switchToLiveChannel(targetIndex: Int): Boolean { if (!isLiveZapping()) return false val generator = viewModel.generator as? LiveZappingGenerator ?: return false From 0ffdf9dc26b545d48b130360694f776db6d1649f Mon Sep 17 00:00:00 2001 From: wiojelt <121122059+Viollje@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:11:26 +0300 Subject: [PATCH 5/7] fix: distinguish live channel selector --- .../lagradost/cloudstream3/ui/player/FullScreenPlayer.kt | 8 ++++++++ .../lagradost/cloudstream3/ui/player/GeneratorPlayer.kt | 4 ++++ app/src/main/res/values-b+tr/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 14 insertions(+) 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 10361d0a001..6fb7ed16196 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 @@ -773,6 +773,11 @@ open class FullScreenPlayer : AbstractPlayerFragment( val showPlayerEpisodes = !isGone && isThereEpisodes() playerEpisodesButtonRoot.isVisible = showPlayerEpisodes playerEpisodesButton.isVisible = showPlayerEpisodes + if (isLiveChannelSelector()) { + playerEpisodesButton.setImageResource(R.drawable.baseline_list_alt_24) + playerEpisodesButton.contentDescription = getString(R.string.player_channel_list) + playerEpisodesButtonText.setText(R.string.player_channel_list) + } playerVideoTitleHolder.isGone = togglePlayerTitleGone || playerVideoTitle.text.isBlank() playerVideoTitleRez.isGone = isGone || playerVideoTitleRez.text.isBlank() playerEpisodeFiller.isGone = isGone @@ -860,6 +865,9 @@ open class FullScreenPlayer : AbstractPlayerFragment( /** Episode overlays pause regular video, but live channel lists may stay non-blocking. */ protected open fun shouldPauseForEpisodeOverlay(): Boolean = true + /** Live zapping may label the existing episode selector as a channel list. */ + protected open fun isLiveChannelSelector(): Boolean = false + override fun playerStatusChanged() { super.playerStatusChanged() scheduleMetadataVisibility() 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 cb07ecb46a5..917cbbc0f0a 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 @@ -1736,6 +1736,10 @@ class GeneratorPlayer : FullScreenPlayer() { return !((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true && isLiveZapping()) } + override fun isLiveChannelSelector(): Boolean { + return (currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true && isLiveZapping() + } + private fun switchToLiveChannel(targetIndex: Int): Boolean { if (!isLiveZapping()) return false val generator = viewModel.generator as? LiveZappingGenerator ?: return false diff --git a/app/src/main/res/values-b+tr/strings.xml b/app/src/main/res/values-b+tr/strings.xml index c7e535c8d8e..d825c4f084f 100644 --- a/app/src/main/res/values-b+tr/strings.xml +++ b/app/src/main/res/values-b+tr/strings.xml @@ -174,6 +174,7 @@ Sezon yok Bölüm Bölüm + Kanal listesi %1$d-%2$d %1$d %2$s S diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c6367eb2b7..77ed3994150 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -230,6 +230,7 @@ No Season Episode Episodes + Channel List %1$d-%2$d %1$d %2$s Upcoming in %s From feb605bb6a24c9d672e97a4d9b5cd598d8071834 Mon Sep 17 00:00:00 2001 From: wiojelt <121122059+Viollje@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:25:15 +0300 Subject: [PATCH 6/7] fix: preserve player navigation during live zapping --- .../cloudstream3/ui/player/FullScreenPlayer.kt | 9 +++++---- .../cloudstream3/ui/player/GeneratorPlayer.kt | 17 +++++++++++++---- 2 files changed, 18 insertions(+), 8 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 6fb7ed16196..216a0091f0d 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 @@ -857,7 +857,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( * Gives specialised players a chance to consume TV channel-style DPAD input while the * controls are hidden. Normal player navigation remains unchanged when this returns false. */ - protected open fun handleLiveChannelKey(keyCode: Int): Boolean = false + protected open fun handleLiveChannelKey(event: KeyEvent): Boolean = false /** Live players may leave DPAD navigation to the focused view when zapping is inactive. */ protected open fun shouldPreserveLiveDpadNavigation(): Boolean = false @@ -900,7 +900,8 @@ open class FullScreenPlayer : AbstractPlayerFragment( playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() } - private fun handleKeyDownEvent(keyCode: Int): Boolean? { + private fun handleKeyDownEvent(event: KeyEvent): Boolean? { + val keyCode = event.keyCode // adb shell input keyevent [INT] when (keyCode) { KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { @@ -994,7 +995,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( if (isShowing || isShowingEpisodeOverlay || isDialogOpen()) { return null } - if (handleLiveChannelKey(keyCode)) { + if (handleLiveChannelKey(event)) { return true } if (shouldPreserveLiveDpadNavigation()) { @@ -1054,7 +1055,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( val keyCode = event.keyCode if (event.action == KeyEvent.ACTION_DOWN) { - val value = handleKeyDownEvent(keyCode) + val value = handleKeyDownEvent(event) if (value != null) { return value } 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 917cbbc0f0a..e2942071b79 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 @@ -13,6 +13,8 @@ import android.os.Build import android.os.Bundle import android.text.Spanned import android.util.Log +import android.view.InputDevice +import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -1752,11 +1754,18 @@ class GeneratorPlayer : FullScreenPlayer() { return true } - override fun handleLiveChannelKey(keyCode: Int): Boolean { + override fun handleLiveChannelKey(event: KeyEvent): Boolean { + if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return false if (isShowing || isDialogOpen() || !isLiveZapping()) return false - return when (keyCode) { - android.view.KeyEvent.KEYCODE_DPAD_UP -> switchToLiveChannel(viewModel.episodeIndex - 1) - android.view.KeyEvent.KEYCODE_DPAD_DOWN -> switchToLiveChannel(viewModel.episodeIndex + 1) + if (event.isFromSource(InputDevice.SOURCE_KEYBOARD)) return false + if (!event.isFromSource(InputDevice.SOURCE_DPAD) && + !event.isFromSource(InputDevice.SOURCE_GAMEPAD) && + !event.isFromSource(InputDevice.SOURCE_JOYSTICK) + ) return false + + return when (event.keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> switchToLiveChannel(viewModel.episodeIndex - 1) + KeyEvent.KEYCODE_DPAD_DOWN -> switchToLiveChannel(viewModel.episodeIndex + 1) else -> false } } From dd49d5f052dd856cf93051143aca48232b2e860e Mon Sep 17 00:00:00 2001 From: wiojelt <121122059+Viollje@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:14:37 +0300 Subject: [PATCH 7/7] fix: add channel list to TV focus navigation --- .../cloudstream3/ui/player/FullScreenPlayer.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 216a0091f0d..fd7dc5adebb 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 @@ -773,6 +773,19 @@ open class FullScreenPlayer : AbstractPlayerFragment( val showPlayerEpisodes = !isGone && isThereEpisodes() playerEpisodesButtonRoot.isVisible = showPlayerEpisodes playerEpisodesButton.isVisible = showPlayerEpisodes + if (isLayout(TV or EMULATOR)) { + val channelSelectorFocus = isLiveChannelSelector() + playerGoForward.nextFocusRightId = if (channelSelectorFocus) { + playerEpisodesButton.id + } else { + downloadHeaderToggle.id + } + playerEpisodesButton.nextFocusLeftId = if (channelSelectorFocus) { + playerGoForward.id + } else { + downloadHeaderToggle.id + } + } if (isLiveChannelSelector()) { playerEpisodesButton.setImageResource(R.drawable.baseline_list_alt_24) playerEpisodesButton.contentDescription = getString(R.string.player_channel_list)