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..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 @@ -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 @@ -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, item: HomeViewModel.ExpandableHomepageList, @@ -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 { @@ -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) @@ -190,4 +206,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/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index f38a9ab99fd..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,24 @@ 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) + playerEpisodesButtonText.setText(R.string.player_channel_list) + } playerVideoTitleHolder.isGone = togglePlayerTitleGone || playerVideoTitle.text.isBlank() playerVideoTitleRez.isGone = isGone || playerVideoTitleRez.text.isBlank() playerEpisodeFiller.isGone = isGone @@ -848,6 +866,21 @@ 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(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() @@ -880,7 +913,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 -> { @@ -971,7 +1005,13 @@ open class FullScreenPlayer : AbstractPlayerFragment( 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() @@ -1028,7 +1068,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 } @@ -1337,12 +1377,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 e4616c93bcf..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 @@ -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 @@ -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? get() = viewModel.state.generatorState?.allMeta?.filterIsInstance() @@ -1720,6 +1721,55 @@ class GeneratorPlayer : FullScreenPlayer() { super.onDestroy() } + private fun isZappingEnabled(): Boolean { + 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) { 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/PlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt index 0e6f1a3677d..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 @@ -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). */ @@ -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) 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..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,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 @@ -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) + } ) } } 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..717fb6baf95 100644 --- a/app/src/main/res/layout/player_custom_layout.xml +++ b/app/src/main/res/layout/player_custom_layout.xml @@ -1167,4 +1167,4 @@ - \ 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..9961a18d649 100644 --- a/app/src/main/res/layout/player_custom_layout_tv.xml +++ b/app/src/main/res/layout/player_custom_layout_tv.xml @@ -1190,4 +1190,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..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 66eb3533c4c..77ed3994150 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -158,6 +158,8 @@ 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 Start videos paused Reduce stress when selecting sources Double tap to seek @@ -228,6 +230,7 @@ No Season Episode Episodes + Channel List %1$d-%2$d %1$d %2$s Upcoming in %s @@ -372,6 +375,7 @@ Security Accounts Player features + zapping_enabled_key Subtitles Layout Defaults 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 +}