Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@ 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.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
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.setRecycledViewPool
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
Expand Down Expand Up @@ -85,6 +88,19 @@ open class ParentItemAdapter(
(binding.homeChildRecyclerview.adapter as? HomeChildItemAdapter)?.submitList(item.list.list)
}

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()) {
LiveZappingGenerator.remember(callback.card, channels)
}
}
clickCallback(callback)
}

override fun onBindContent(
holder: ViewHolderState<Bundle>,
item: HomeViewModel.ExpandableHomepageList,
Expand All @@ -101,7 +117,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 {
Expand All @@ -113,7 +129,7 @@ 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)
Expand Down Expand Up @@ -190,4 +206,4 @@ open class ParentItemAdapter(
@Suppress("DEPRECATION")
inline fun <reified T> Bundle.getSafeParcelable(key: String): T? =
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getParcelable(key)
else getParcelable(key, T::class.java)
else getParcelable(key, T::class.java)
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,24 @@ open class FullScreenPlayer : AbstractPlayerFragment<FragmentPlayerBinding>(
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)
playerEpisodesButtonText.setText(R.string.player_channel_list)
}
playerVideoTitleHolder.isGone = togglePlayerTitleGone || playerVideoTitle.text.isBlank()
playerVideoTitleRez.isGone = isGone || playerVideoTitleRez.text.isBlank()
playerEpisodeFiller.isGone = isGone
Expand Down Expand Up @@ -848,6 +866,21 @@ open class FullScreenPlayer : AbstractPlayerFragment<FragmentPlayerBinding>(
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(event: KeyEvent): Boolean = false

/** 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

/** Live zapping may label the existing episode selector as a channel list. */
protected open fun isLiveChannelSelector(): Boolean = false

override fun playerStatusChanged() {
super.playerStatusChanged()
scheduleMetadataVisibility()
Expand Down Expand Up @@ -880,7 +913,8 @@ open class FullScreenPlayer : AbstractPlayerFragment<FragmentPlayerBinding>(
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 -> {
Expand Down Expand Up @@ -971,7 +1005,13 @@ open class FullScreenPlayer : AbstractPlayerFragment<FragmentPlayerBinding>(

KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_UP -> {
if (isShowing || isShowingEpisodeOverlay) {
if (isShowing || isShowingEpisodeOverlay || isDialogOpen()) {
return null
}
if (handleLiveChannelKey(event)) {
return true
}
if (shouldPreserveLiveDpadNavigation()) {
return null
}
onClickChange()
Expand Down Expand Up @@ -1028,7 +1068,7 @@ open class FullScreenPlayer : AbstractPlayerFragment<FragmentPlayerBinding>(
val keyCode = event.keyCode

if (event.action == KeyEvent.ACTION_DOWN) {
val value = handleKeyDownEvent(keyCode)
val value = handleKeyDownEvent(event)
if (value != null) {
return value
}
Expand Down Expand Up @@ -1337,12 +1377,16 @@ open class FullScreenPlayer : AbstractPlayerFragment<FragmentPlayerBinding>(
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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,8 +46,8 @@ 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 androidx.recyclerview.widget.LinearLayoutManager
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.CloudStreamApp
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
Expand Down Expand Up @@ -186,7 +188,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 preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none
private val allMeta: List<ResultEpisode>?
get() = viewModel.state.generatorState?.allMeta?.filterIsInstance<ResultEpisode>()
Expand Down Expand Up @@ -1720,6 +1721,55 @@ class GeneratorPlayer : FullScreenPlayer() {
super.onDestroy()
}

private fun isZappingEnabled(): Boolean {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use AppSettings(context).player.zappingEnabled instead

return context?.let { com.lagradost.cloudstream4.AppSettings(it).player.zappingEnabled.get() } == true
}

private fun isLiveZapping(): Boolean {
return isZappingEnabled() &&
(viewModel.generator as? LiveZappingGenerator)?.videos?.size?.let { it > 1 } == true
}

override fun shouldPreserveLiveDpadNavigation(): Boolean {
return (currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true && !isLiveZapping()
}

override fun shouldPauseForEpisodeOverlay(): Boolean {
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
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(event: KeyEvent): Boolean {
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return false
if (isShowing || isDialogOpen() || !isLiveZapping()) return false
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
}
}

var maxEpisodeSet: Int? = null
var hasRequestedStamps: Boolean = false
override fun playerPositionChanged(position: Long, duration: Long) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<LiveZappingChannel>,
) : VideoGenerator<ResultEpisode>(channels.mapIndexed(::liveEpisode)) {
companion object {
private val pending = ConcurrentHashMap<String, List<LiveZappingChannel>>()

private fun key(url: String, apiName: String) = "$apiName\n$url"

fun remember(response: SearchResponse, channels: List<SearchResponse>) {
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<LiveZappingGenerator, Int>? {
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<ExtractorLinkType>,
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> 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) },
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ 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 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). */
Expand Down Expand Up @@ -750,7 +750,7 @@ class PlayerView @JvmOverloads constructor(
}
callbacks?.playerDimensionsLoaded(event.width, event.height)
}
is PlayerAttachedEvent -> playerUpdated(event.player)
is PlayerAttachedEvent -> playerUpdated(event.player)
is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged()
is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp)
is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.LiveZappingGenerator
import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
Expand Down Expand Up @@ -1559,11 +1560,25 @@ 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 &&
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)
}
)
}
}
Expand Down
Loading
Loading