From ac863b338dc28e80ddf5753d942bf0fab0ab9fd4 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:26:25 -0600 Subject: [PATCH 01/15] Major rework to player inheritance to use BaseFragment This practically rebuilds the entire player, aimed to remove the FullScreenPlayer inheritance from ResultFragmentPhone, use BaseFragment for ResultFragmentPhone and the player, and make the player easier to maintain and expand in the future, as well overall cleanup to code readability and adding documentation to methods in the player. --- .../ui/player/AbstractPlayerFragment.kt | 761 +------- .../cloudstream3/ui/player/CS3IPlayer.kt | 34 +- .../ui/player/FullScreenPlayer.kt | 1624 ++--------------- .../cloudstream3/ui/player/GeneratorPlayer.kt | 74 +- .../ui/player/PlayerGestureHelper.kt | 1136 ++++++++++++ .../cloudstream3/ui/player/PlayerView.kt | 788 ++++++++ .../ui/result/ResultFragmentPhone.kt | 148 +- .../ui/result/ResultTrailerPlayer.kt | 161 +- 8 files changed, 2353 insertions(+), 2373 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index 1e6d827e63f..d8d11b7f6b1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -1,64 +1,16 @@ package com.lagradost.cloudstream3.ui.player -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.graphics.drawable.AnimatedImageDrawable -import android.graphics.drawable.AnimatedVectorDrawable -import android.media.metrics.PlaybackErrorEvent -import android.os.Build import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup -import android.view.WindowManager -import android.widget.FrameLayout import android.widget.ImageView -import android.widget.ProgressBar -import android.widget.Toast -import androidx.annotation.LayoutRes import androidx.annotation.OptIn import androidx.annotation.StringRes -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.media3.common.PlaybackException import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession -import androidx.media3.ui.AspectRatioFrameLayout -import androidx.media3.ui.DefaultTimeBar -import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView -import androidx.media3.ui.TimeBar -import androidx.preference.PreferenceManager -import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat -import com.github.rubensousa.previewseekbar.PreviewBar -import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar -import com.lagradost.cloudstream3.CommonActivity.isInPIPMode -import com.lagradost.cloudstream3.CommonActivity.keyEventListener -import com.lagradost.cloudstream3.CommonActivity.playerEventListener -import com.lagradost.cloudstream3.CommonActivity.screenWidth -import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.ErrorLoadingException +import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.ui.settings.Globals.PHONE -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment -import com.lagradost.cloudstream3.utils.AppContextUtils -import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus -import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.UIHelper -import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI -import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp -import java.net.SocketTimeoutException +import com.lagradost.cloudstream3.ui.BaseFragment enum class PlayerResize(@StringRes val nameRes: Int) { Fit(R.string.resize_fit), @@ -79,676 +31,117 @@ const val NEXT_WATCH_EPISODE_PERCENTAGE = 90 const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80 @OptIn(UnstableApi::class) -abstract class AbstractPlayerFragment( - var player: IPlayer = CS3IPlayer() -) : Fragment() { - var resizeMode: Int = 0 - var subView: SubtitleView? = null - protected open var hasPipModeSupport = true +abstract class AbstractPlayerFragment( + bindingCreator: BindingCreator +) : BaseFragment(bindingCreator), PlayerView.Callbacks { - var playerPausePlayHolderHolder: FrameLayout? = null - var playerPausePlay: ImageView? = null - var playerBuffering: ProgressBar? = null - var playerView: PlayerView? = null - var piphide: FrameLayout? = null - var subtitleHolder: FrameLayout? = null - var currentPlayerStatus = CSPlayerLoading.IsBuffering + // Stored pre-initialization so subclasses can set them before onBindingCreated. + private var _player: IPlayer = CS3IPlayer() - @LayoutRes - protected open var layout: Int = R.layout.fragment_player + /** The shared [PlayerView] host that owns all player state and view references. */ + protected lateinit var playerHostView: PlayerView - open fun nextEpisode() { - throw NotImplementedError() - } - - open fun prevEpisode() { - throw NotImplementedError() - } - - open fun playerPositionChanged(position: Long, duration: Long) { - throw NotImplementedError() - } - - open fun playerStatusChanged() {} - - open fun playerDimensionsLoaded(width: Int, height: Int) { - throw NotImplementedError() - } - - open fun subtitlesChanged() { - throw NotImplementedError() - } - - open fun embeddedSubtitlesFetched(subtitles: List) { - throw NotImplementedError() - } - - open fun onTracksInfoChanged() { - throw NotImplementedError() - } - - open fun onTimestamp(timestamp: VideoSkipStamp?) { - - } - - open fun onTimestampSkipped(timestamp: VideoSkipStamp) { - - } - - open fun exitedPipMode() { - throw NotImplementedError() - } - - private fun keepScreenOn(on: Boolean) { - if (on) { - activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + var player: IPlayer + get() = if (::playerHostView.isInitialized) playerHostView.player else _player + set(value) { + _player = value + if (::playerHostView.isInitialized) playerHostView.player = value } - } - - private fun updateIsPlaying( - wasPlaying: CSPlayerLoading, - isPlaying: CSPlayerLoading - ) { - val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying - val isBuffering = CSPlayerLoading.IsBuffering == isPlaying - currentPlayerStatus = isPlaying - - keepScreenOn(isPlayingRightNow || isBuffering) - - if (isBuffering) { - playerPausePlayHolderHolder?.isVisible = false - playerBuffering?.isVisible = true - } else { - playerPausePlayHolderHolder?.isVisible = true - playerBuffering?.isVisible = false - - if (isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)) { - playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24) - } else if (wasPlaying != isPlaying) { - playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play) - val drawable = playerPausePlay?.drawable - var startedAnimation = false - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { - if (drawable is AnimatedImageDrawable) { - drawable.start() - startedAnimation = true - } - } + var subView: SubtitleView? + get() = if (::playerHostView.isInitialized) playerHostView.subView else null + set(value) { if (::playerHostView.isInitialized) playerHostView.subView = value } - if (drawable is AnimatedVectorDrawable) { - drawable.start() - startedAnimation = true - } + protected open var hasPipModeSupport: Boolean + get() = if (::playerHostView.isInitialized) playerHostView.hasPipModeSupport else true + set(value) { if (::playerHostView.isInitialized) playerHostView.hasPipModeSupport = value } - if (drawable is AnimatedVectorDrawableCompat) { - drawable.start() - startedAnimation = true - } + var playerPausePlay: ImageView? + get() = if (::playerHostView.isInitialized) playerHostView.playerPausePlay else null + set(value) { if (::playerHostView.isInitialized) playerHostView.playerPausePlay = value } - // somehow the phone is wacked - if (!startedAnimation) { - playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) - } - } else { - playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) - } - } + /** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */ + var playerView: androidx.media3.ui.PlayerView? + get() = if (::playerHostView.isInitialized) playerHostView.exoPlayerView else null + set(value) { if (::playerHostView.isInitialized) playerHostView.exoPlayerView = value } - PlayerPipHelper.updatePIPModeActions( - activity, - isPlaying, - hasPipModeSupport, - player.getAspectRatio() - ) - } + var currentPlayerStatus: CSPlayerLoading + get() = if (::playerHostView.isInitialized) playerHostView.currentPlayerStatus else CSPlayerLoading.IsBuffering + set(value) { if (::playerHostView.isInitialized) playerHostView.currentPlayerStatus = value } - private var pipReceiver: BroadcastReceiver? = null - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode) - try { - isInPIPMode = isInPictureInPictureMode - if (isInPictureInPictureMode) { - // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. - piphide?.isVisible = false - pipReceiver = object : BroadcastReceiver() { - override fun onReceive( - context: Context, - intent: Intent, - ) { - if (ACTION_MEDIA_CONTROL != intent.action) { - return - } - player.handleEvent( - CSPlayerEvent.entries[intent.getIntExtra( - EXTRA_CONTROL_TYPE, - 0 - )], source = PlayerEventSource.UI - ) - } - } - - val filter = IntentFilter() - filter.addAction(ACTION_MEDIA_CONTROL) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED) - } else { - @SuppressLint("UnspecifiedRegisterReceiverFlag") - activity?.registerReceiver(pipReceiver, filter) - } - - val isPlaying = player.getIsPlaying() - val isPlayingValue = - if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused - updateIsPlaying(isPlayingValue, isPlayingValue) - } else { - // Restore the full-screen UI. - piphide?.isVisible = true - exitedPipMode() - pipReceiver?.let { - // Prevents java.lang.IllegalArgumentException: Receiver not registered - safe { - activity?.unregisterReceiver(it) - } - } - activity?.hideSystemUI() - this.view?.let { UIHelper.hideKeyboard(it) } - } - } catch (e: Exception) { - logError(e) - } - } + protected var mMediaSession: MediaSession? + get() = if (::playerHostView.isInitialized) playerHostView.mMediaSession else null + set(value) { if (::playerHostView.isInitialized) playerHostView.mMediaSession = value } - open fun hasNextMirror(): Boolean { - throw NotImplementedError() - } + // No-op callbacks (nextEpisode, prevEpisode, etc.) are intentionally left as + // open so subclasses can override only what they need. The ones below throw + // to make it obvious when an implementation is missing. - open fun nextMirror() { - throw NotImplementedError() - } + override fun nextEpisode() { throw NotImplementedError() } + override fun prevEpisode() { throw NotImplementedError() } + override fun playerPositionChanged(position: Long, duration: Long) { throw NotImplementedError() } + override fun playerDimensionsLoaded(width: Int, height: Int) { throw NotImplementedError() } + override fun subtitlesChanged() { throw NotImplementedError() } + override fun embeddedSubtitlesFetched(subtitles: List) { throw NotImplementedError() } + override fun onTracksInfoChanged() { throw NotImplementedError() } + override fun exitedPipMode() { throw NotImplementedError() } + override fun hasNextMirror(): Boolean { throw NotImplementedError() } + override fun nextMirror() { throw NotImplementedError() } - private fun requestAudioFocus() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) - } + /** Delegates to [PlayerView.playerError] by default; override to customise. */ + override fun playerError(exception: Throwable) { + if (::playerHostView.isInitialized) playerHostView.playerError(exception) } - open fun playerError(exception: Throwable) { - fun showToast(message: String, gotoNext: Boolean = false) { - if (gotoNext && hasNextMirror()) { - showToast( - message, - Toast.LENGTH_SHORT - ) - nextMirror() - } else { - showToast( - context?.getString(R.string.no_links_found_toast) + "\n" + message, - Toast.LENGTH_LONG - ) - activity?.popCurrentPage() - } - } + /** Player fragments don't need system-bar padding adjustment by default. */ + override fun fixLayout(view: View) = Unit + override fun onBindingCreated(binding: T, savedInstanceState: Bundle?) { val ctx = context ?: return - when (exception) { - is PlaybackException -> { - val msg = exception.message ?: "" - val errorName = exception.errorCodeName - when (val code = exception.errorCode) { - PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, - PlaybackException.ERROR_CODE_IO_NO_PERMISSION, - PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, - PlaybackException.ERROR_CODE_IO_UNSPECIFIED, - PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED -> { - showToast( - "${ctx.getString(R.string.source_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - PlaybackException.ERROR_CODE_REMOTE_ERROR, - PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, - PlaybackException.ERROR_CODE_TIMEOUT, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, - PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> { - showToast( - "${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, - PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, - PlaybackException.ERROR_CODE_DECODING_FAILED, - PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, - PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, - PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> { - showToast( - "${ctx.getString(R.string.render_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, - PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> { - showToast( - "${ctx.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, - PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> { - showToast( - "${ctx.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - else -> { - showToast( - "${ctx.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg", - gotoNext = false - ) - } - } - } - - is InvalidFileException -> { - showToast( - "${ctx.getString(R.string.source_error)}\n${exception.message}", - gotoNext = true - ) - } - - is SocketTimeoutException -> { - /** - * Ensures this is run on the UI thread to prevent issues - * caused by SocketTimeoutException in torrents. Running - * on another thread can break player interactions or - * prevent switching to the next source. - */ - activity?.runOnUiThread { - showToast( - "${ctx.getString(R.string.remote_error)}\n${exception.message}", - gotoNext = true - ) - } - } - - is ErrorLoadingException -> { - exception.message?.let { - showToast( - it, - gotoNext = true - ) - } ?: showToast( - exception.toString(), - gotoNext = true - ) - } - - else -> { - exception.message?.let { - showToast( - it, - gotoNext = false - ) - } ?: showToast( - exception.toString(), - gotoNext = false - ) - } - } - } - - private fun onSubStyleChanged(style: SaveCaptionStyle) { - player.updateSubtitleStyle(style) - // Forcefully update the subtitle encoding in case the edge size is changed - player.seekTime(-1) + playerHostView = PlayerView(ctx) + playerHostView.player = _player + playerHostView.hasPipModeSupport = hasPipModeSupport + playerHostView.callbacks = this + playerHostView.bindViews(binding.root) + playerHostView.initialize() } - - @SuppressLint("UnsafeOptInUsageError") - open fun playerUpdated(player: Any?) { - if (player is ExoPlayer) { - context?.let { ctx -> - mMediaSession?.release() - mMediaSession = MediaSession.Builder(ctx, player) - // Ensure unique ID for concurrent players - .setId(System.currentTimeMillis().toString()) - .build() - } - - // Necessary for multiple combined videos - @Suppress("DEPRECATION") - playerView?.setShowMultiWindowTimeBar(true) - playerView?.player = player - playerView?.performClick() - } - } - - protected var mMediaSession: MediaSession? = null - - // this can be used in the future for players other than exoplayer - //private val mMediaSessionCallback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() { - // override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { - // val keyEvent = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as KeyEvent? - // if (keyEvent != null) { - // if (keyEvent.action == KeyEvent.ACTION_DOWN) { // NO DOUBLE SKIP - // val consumed = when (keyEvent.keyCode) { - // KeyEvent.KEYCODE_MEDIA_PAUSE -> callOnPause() - // KeyEvent.KEYCODE_MEDIA_PLAY -> callOnPlay() - // KeyEvent.KEYCODE_MEDIA_STOP -> callOnStop() - // KeyEvent.KEYCODE_MEDIA_NEXT -> callOnNext() - // else -> false - // } - // if (consumed) return true - // } - // } - // - // return super.onMediaButtonEvent(mediaButtonEvent) - // } - //} - - open fun onDownload(event: DownloadEvent) = Unit - - /** This receives the events from the player, if you want to append functionality you do it here, - * do note that this only receives events for UI changes, - * and returning early WONT stop it from changing in eg the player time or pause status */ - open fun mainCallback(event: PlayerEvent) { - // we don't want to spam DownloadEvent - if (event !is DownloadEvent) { - Log.i(TAG, "Handle event: $event") - } - when (event) { - is DownloadEvent -> { - onDownload(event) - } - - is ResizedEvent -> { - playerDimensionsLoaded(event.width, event.height) - } - - is PlayerAttachedEvent -> { - playerUpdated(event.player) - } - - is SubtitlesUpdatedEvent -> { - subtitlesChanged() - } - - is TimestampSkippedEvent -> { - onTimestampSkipped(event.timestamp) - } - - is TimestampInvokedEvent -> { - onTimestamp(event.timestamp) - } - - is TracksChangedEvent -> { - onTracksInfoChanged() - } - - is EmbeddedSubtitlesFetchedEvent -> { - embeddedSubtitlesFetched(event.tracks) - } - - is ErrorEvent -> { - playerError(event.error) - } - - is RequestAudioFocusEvent -> { - requestAudioFocus() - } - - is EpisodeSeekEvent -> { - when (event.offset) { - -1 -> prevEpisode() - 1 -> nextEpisode() - else -> {} - } - } - - is StatusEvent -> { - updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) - playerStatusChanged() - } - - is PositionEvent -> { - playerPositionChanged(position = event.toMs, duration = event.durationMs) - } - - is VideoEndedEvent -> { - context?.let { ctx -> - // Only play next episode if autoplay is on (default) - if (PreferenceManager.getDefaultSharedPreferences(ctx) - ?.getBoolean( - ctx.getString(R.string.autoplay_next_key), - true - ) == true - ) { - player.handleEvent( - CSPlayerEvent.NextEpisode, - source = PlayerEventSource.Player - ) - } - } - } - - is PauseEvent -> Unit - is PlayEvent -> Unit - } - } - - @SuppressLint("SetTextI18n", "UnsafeOptInUsageError") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - resizeMode = DataStoreHelper.resizeMode - resize(resizeMode, false) - - player.releaseCallbacks() - player.initCallbacks( - eventHandler = ::mainCallback, - requestedListeningPercentages = listOf( - SKIP_OP_VIDEO_PERCENTAGE, - PRELOAD_NEXT_EPISODE_PERCENTAGE, - NEXT_WATCH_EPISODE_PERCENTAGE, - UPDATE_SYNC_PROGRESS_PERCENTAGE, - ), - ) - - val player = player - if (player is CS3IPlayer) { - // preview bar - val progressBar: PreviewTimeBar? = playerView?.findViewById(R.id.exo_progress) - val previewImageView: ImageView? = playerView?.findViewById(R.id.previewImageView) - val previewFrameLayout: FrameLayout? = playerView?.findViewById(R.id.previewFrameLayout) - if (progressBar != null && previewImageView != null && previewFrameLayout != null) { - var resume = false - progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener { - override fun onScrubStart(previewBar: PreviewBar?) { - val hasPreview = player.hasPreview() - progressBar.isPreviewEnabled = hasPreview - resume = player.getIsPlaying() - if (resume) player.handleEvent( - CSPlayerEvent.Pause, - PlayerEventSource.Player - ) - - // No clashing UI - if (hasPreview) { - subView?.isVisible = false - } - } - - override fun onScrubMove( - previewBar: PreviewBar?, - progress: Int, - fromUser: Boolean - ) { - } - - override fun onScrubStop(previewBar: PreviewBar?) { - if (resume) player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) - // Delay to prevent the small flicker of subtitle before seeking - subView?.postDelayed({ - // If we are not scrubbing then show subtitles again - if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) { - subView?.isVisible = true - } - }, 200) - } - }) - progressBar.attachPreviewView(previewFrameLayout) - progressBar.setPreviewLoader { currentPosition, max -> - val bitmap = player.getPreview(currentPosition.toFloat().div(max.toFloat())) - previewImageView.isGone = bitmap == null - previewImageView.setImageBitmap(bitmap) - } - } - - subView = playerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles) - player.initSubtitles(subView, subtitleHolder, CustomDecoder.style) - (player.imageGenerator as? PreviewGenerator)?.params = ImageParams.new16by9(screenWidth) - - /*previewImageView?.doOnLayout { - (player.imageGenerator as? PreviewGenerator)?.params = ImageParams( - it.measuredWidth, - it.measuredHeight - ) - }*/ - /** this might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player - * and once by the UI even if it should only be registered once by the UI */ - playerView?.findViewById(R.id.exo_progress) - ?.addListener(object : TimeBar.OnScrubListener { - override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit - override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit - override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { - if (canceled) return - val playerDuration = player.getDuration() ?: return - val playerPosition = player.getPosition() ?: return - mainCallback( - PositionEvent( - source = PlayerEventSource.UI, - durationMs = playerDuration, - fromMs = playerPosition, - toMs = position - ) - ) - } - }) - - SubtitlesFragment.applyStyleEvent += ::onSubStyleChanged - - try { - context?.let { ctx -> - val settingsManager = PreferenceManager.getDefaultSharedPreferences( - ctx - ) - - val currentPrefCacheSize = - settingsManager.getInt(getString(R.string.video_buffer_size_key), 0) - val currentPrefDiskSize = - settingsManager.getInt(getString(R.string.video_buffer_disk_key), 0) - val currentPrefBufferSec = - settingsManager.getInt(getString(R.string.video_buffer_length_key), 0) - - player.cacheSize = currentPrefCacheSize * 1024L * 1024L - player.simpleCacheSize = currentPrefDiskSize * 1024L * 1024L - player.videoBufferMs = currentPrefBufferSec * 1000L - } - } catch (e: Exception) { - logError(e) - } + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode) + if (::playerHostView.isInitialized) { + playerHostView.onPictureInPictureModeChanged(isInPictureInPictureMode, activity) } - - /*context?.let { ctx -> - player.loadPlayer( - ctx, - false, - ExtractorLink( - "idk", - "bunny", - "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", - "", - Qualities.P720.value, - false - ), - ) - }*/ } override fun onDestroy() { - player.release() - player.releaseCallbacks() - player = CS3IPlayer() - - playerEventListener = null - keyEventListener = null - - PlayerPipHelper.updatePIPModeActions(activity, CSPlayerLoading.IsPaused, false, null) - - mMediaSession?.release() - mMediaSession = null - playerView?.player = null - SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged - - keepScreenOn(false) + if (::playerHostView.isInitialized) { + playerHostView.release() + } super.onDestroy() } - fun nextResize() { - resizeMode = (resizeMode + 1) % PlayerResize.entries.size - resize(resizeMode, true) - } - - fun resize(resize: Int, showToast: Boolean) { - resize(PlayerResize.entries[resize], showToast) - } - - @SuppressLint("UnsafeOptInUsageError") - open fun resize(resize: PlayerResize, showToast: Boolean) { - DataStoreHelper.resizeMode = resize.ordinal - val type = when (resize) { - PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL - PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT - PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM - } - playerView?.resizeMode = type - - if (showToast) - showToast(resize.nameRes, Toast.LENGTH_SHORT) + override fun onPause() { + if (::playerHostView.isInitialized) playerHostView.releaseKeyEventListener() + super.onPause() } override fun onStop() { - player.onStop() + if (::playerHostView.isInitialized) playerHostView.onStop() super.onStop() } override fun onResume() { context?.let { ctx -> - player.onResume(ctx) + if (::playerHostView.isInitialized) playerHostView.onResume(ctx) } - super.onResume() } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val root = inflater.inflate(layout, container, false) - playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) - playerPausePlay = root.findViewById(R.id.player_pause_play) - playerBuffering = root.findViewById(R.id.player_buffering) - playerView = root.findViewById(R.id.player_view) - piphide = root.findViewById(R.id.piphide) - subtitleHolder = root.findViewById(R.id.subtitle_holder) - return root + fun nextResize() { + if (::playerHostView.isInitialized) playerHostView.nextResize() + } + + open fun resize(resize: PlayerResize, showToast: Boolean) { + if (::playerHostView.isInitialized) playerHostView.resize(resize, showToast) } -} \ No newline at end of file +} 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 8a643cc69b8..b9ad585b1c3 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 @@ -247,23 +247,6 @@ class CS3IPlayer : IPlayer { } } - // I know, this is not a perfect solution, however it works for fixing subs - private fun reloadSubs() { - exoPlayer?.applicationLooper?.let { - try { - Handler(it).post { - try { - seekTime(1L, source = PlayerEventSource.Player) - } catch (e: Exception) { - logError(e) - } - } - } catch (e: Exception) { - logError(e) - } - } - } - fun String.stripTrackId(): String { return this.replace(Regex("""^\d+:"""), "") } @@ -432,9 +415,9 @@ class CS3IPlayer : IPlayer { * Gets all supported formats in a list * */ private fun List.getFormats(): List> { - return this.map { + return this.flatMap { it.getFormats() - }.flatten() + } } private fun Tracks.Group.getFormats(): List> { @@ -476,12 +459,12 @@ class CS3IPlayer : IPlayer { ) } + private var currentAudioTrack: AudioTrack? = null override fun getVideoTracks(): CurrentTracks { val allTrackGroups = exoPlayer?.currentTracks?.groups ?: emptyList() val videoTracks = allTrackGroups.filter { it.type == TRACK_TYPE_VIDEO } .getFormats() .map { it.first.toVideoTrack() } - var currentAudioTrack: AudioTrack? = null val audioTracks = allTrackGroups.filter { it.type == TRACK_TYPE_AUDIO } .flatMap { group -> group.getFormats().map { (format, formatIndex) -> @@ -1094,7 +1077,7 @@ class CS3IPlayer : IPlayer { .setFallbackMinPlaybackSpeed(0.97f) .build() ) - .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> + .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, _, metadataRendererOutput -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val current = settingsManager.getInt( context.getString(R.string.software_decoding_key), @@ -1128,7 +1111,7 @@ class CS3IPlayer : IPlayer { // Custom TextOutput to apply cue styling and rules to all subtitles val customTextOutput = TextOutput { cue -> // Do not remove filterNotNull as Java typesystem is fucked - val (bitmapCues, textCues) = cue.cues.filterNotNull() + val (bitmapCues, textCues) = cue.cues.toList() .partition { it.bitmap != null } val styledBitmapCues = bitmapCues.map { bitmapCue -> @@ -1335,7 +1318,7 @@ class CS3IPlayer : IPlayer { } else { try { val source = ConcatenatingMediaSource2.Builder() - mediaItemSlices.map { item -> + mediaItemSlices.forEach { item -> source.add( // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 ClippingMediaSource( @@ -1349,7 +1332,7 @@ class CS3IPlayer : IPlayer { @Suppress("DEPRECATION") val source = ConcatenatingMediaSource() // FIXME figure out why ConcatenatingMediaSource2 seems to fail with Torrents only - mediaItemSlices.map { item -> + mediaItemSlices.forEach { item -> source.addMediaSource( // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 ClippingMediaSource( @@ -1804,7 +1787,7 @@ class CS3IPlayer : IPlayer { defaultSet ) ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } - } catch (e: Throwable) { + } catch (_: Throwable) { null } ?: default @@ -2005,4 +1988,3 @@ class CS3IPlayer : IPlayer { } } - 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 8699202b9b0..d26759e7877 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 @@ -1,7 +1,6 @@ package com.lagradost.cloudstream3.ui.player import android.animation.ObjectAnimator -import android.animation.ValueAnimator import android.annotation.SuppressLint import android.app.Activity import android.app.Dialog @@ -11,20 +10,12 @@ import android.content.pm.ActivityInfo import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Color -import android.graphics.Matrix -import android.media.AudioManager -import android.media.audiofx.LoudnessEnhancer import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.provider.Settings import android.text.Editable -import android.text.format.DateUtils import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent -import android.view.ScaleGestureDetector import android.view.Surface import android.view.View import android.view.ViewGroup @@ -33,41 +24,32 @@ import android.view.WindowManager import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AlphaAnimation -import android.view.animation.Animation -import android.view.animation.AnimationUtils import android.view.animation.DecelerateInterpolator import android.widget.LinearLayout import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat import androidx.core.graphics.blue import androidx.core.graphics.green import androidx.core.graphics.red import androidx.core.view.children import androidx.core.view.isGone -import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.ui.AspectRatioFrameLayout import androidx.preference.PreferenceManager import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.button.MaterialButton -import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity.keyEventListener +import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding import com.lagradost.cloudstream3.CommonActivity.playerEventListener -import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation -import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding import com.lagradost.cloudstream3.databinding.SpeedDialogBinding import com.lagradost.cloudstream3.databinding.SubtitleOffsetBinding import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.player.GeneratorPlayer.Companion.subsProvidersIsActive import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR @@ -75,6 +57,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData +import com.lagradost.cloudstream3.utils.AppContextUtils.shouldShowPlayerMetadata import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.DataStoreHelper @@ -87,62 +70,29 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.UserPreferenceDelegate -import com.lagradost.cloudstream3.utils.Vector2 import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt -import kotlin.math.abs -import kotlin.math.absoluteValue -import kotlin.math.ceil -import kotlin.math.max -import kotlin.math.min -import kotlin.math.round import kotlin.math.roundToInt -import com.lagradost.cloudstream3.utils.AppContextUtils.shouldShowPlayerMetadata - - -// You can zoom out more than 100%, but it will zoom back into 100% -const val MINIMUM_ZOOM = 0.95f - -// How sensitive the auto zoom is to center at the min zoom -const val ZOOM_SNAP_SENSITIVITY = 0.07f - -// Maximum zoom to avoid getting lost -const val MAXIMUM_ZOOM = 4.0f -const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking -const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage -const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // in percentage -const val VERTICAL_MULTIPLIER = 2.0f -const val HORIZONTAL_MULTIPLIER = 2.0f -const val DOUBLE_TAB_MAXIMUM_HOLD_TIME = 200L -const val DOUBLE_TAB_MINIMUM_TIME_BETWEEN = 200L // this also affects the UI show response time -const val DOUBLE_TAB_PAUSE_PERCENTAGE = 0.15 // in both directions private const val SUBTITLE_DELAY_BUNDLE_KEY = "subtitle_delay" // All the UI Logic for the player @OptIn(UnstableApi::class) -open class FullScreenPlayer : AbstractPlayerFragment() { - private var isVerticalOrientation: Boolean = false +open class FullScreenPlayer : AbstractPlayerFragment( + BindingCreator.Bind(FragmentPlayerBinding::bind) +) { + override fun pickLayout(): Int = R.layout.fragment_player protected open var lockRotation = true protected open var isFullScreenPlayer = true protected var playerBinding: PlayerCustomLayoutBinding? = null - protected var brightnessOverlay: View? = null - - private var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) // state of player UI protected var isShowing = false - private var uiShowingBeforeGesture = false protected var isLocked = false protected var timestampShowState = false private var metadataVisibilityToken = 0 protected var hasEpisodes = false private set - // protected val hasEpisodes - // get() = episodes.isNotEmpty() - - // options for player /** * Default profile 1 @@ -151,23 +101,13 @@ open class FullScreenPlayer : AbstractPlayerFragment() { **/ protected var currentQualityProfile = 1 - // protected var currentPrefQuality = -// Qualities.P2160.value // preferred maximum quality, used for ppl w bad internet or on cell - protected var extraBrightnessEnabled = false - protected var fastForwardTime = 10000L protected var androidTVInterfaceOffSeekTime = 10000L protected var androidTVInterfaceOnSeekTime = 30000L - protected var swipeHorizontalEnabled = false - protected var swipeVerticalEnabled = false protected var playBackSpeedEnabled = false protected var playerResizeEnabled = false - protected var doubleTapEnabled = false - protected var doubleTapPauseEnabled = true protected var playerRotateEnabled = false protected var rotatedManually = false - protected var autoPlayerRotateEnabled = false private var hideControlsNames = false - protected var speedupEnabled = false protected var subtitleDelay set(value) = try { player.setSubtitleOffset(-value) @@ -181,65 +121,15 @@ open class FullScreenPlayer : AbstractPlayerFragment() { 0L } - // private var useSystemBrightness = false - protected var useTrueSystemBrightness = true private val fullscreenNotch = true // TODO SETTING private var statusBarHeight: Int? = null private var navigationBarHeight: Int? = null - private val brightnessIcons = listOf( - R.drawable.sun_1, - R.drawable.sun_2, - R.drawable.sun_3, - R.drawable.sun_4, - R.drawable.sun_5, - R.drawable.sun_6, - R.drawable.sun_7, - // R.drawable.ic_baseline_brightness_1_24, - // R.drawable.ic_baseline_brightness_2_24, - // R.drawable.ic_baseline_brightness_3_24, - // R.drawable.ic_baseline_brightness_4_24, - // R.drawable.ic_baseline_brightness_5_24, - // R.drawable.ic_baseline_brightness_6_24, - // R.drawable.ic_baseline_brightness_7_24, - ) - - private val volumeIcons = listOf( - R.drawable.ic_baseline_volume_mute_24, - R.drawable.ic_baseline_volume_down_24, - R.drawable.ic_baseline_volume_up_24, - ) - private var isShowingEpisodeOverlay: Boolean = false private var previousPlayStatus: Boolean = false - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null - playerBinding = PlayerCustomLayoutBinding.bind(root.findViewById(R.id.player_holder)) - - // Inject the overlay from a separate XML into the PlayerView content frame - safe { - val pv = root.findViewById(R.id.player_view) - val packageName = context?.packageName ?: return@safe - val contentId = resources.getIdentifier("exo_content_frame", "id", packageName) - val contentFrame = pv?.findViewById(contentId) - if (contentFrame != null) { - brightnessOverlay = contentFrame.findViewById(R.id.extra_brightness_overlay) - brightnessOverlay = LayoutInflater.from(context).inflate( - R.layout.extra_brightness_overlay, - contentFrame, - false - ) - contentFrame.addView(brightnessOverlay) - requestUpdateBrightnessOverlayOnNextLayout() - } - } - return root - } + + override fun fixLayout(view: View) = Unit /** * Wet code but this can not be made into a function as it is a setter. @@ -342,108 +232,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - @SuppressLint("UnsafeOptInUsageError") - override fun playerUpdated(player: Any?) { - super.playerUpdated(player) - } - override fun onDestroyView() { - // Clean up brightness overlay if created - safe { - // remove overlay if present - brightnessOverlay?.let { overlay -> - val oParent = overlay.parent as? ViewGroup - oParent?.removeView(overlay) - } - } - brightnessOverlay = null + if (::playerHostView.isInitialized) playerHostView.releaseOverlayLayoutListener() playerBinding = null super.onDestroyView() } - /** - * Resize/position the brightness overlay to exactly match the visible video surface. - * This copies the video surface size, scale and translation so the overlay won't cover - * letterbox/pillarbox areas when zooming or panning. - */ - private fun updateBrightnessOverlayBounds() { - val overlay = brightnessOverlay ?: return - val pv = playerView ?: return - val video = pv.videoSurfaceView ?: return - - // Compute accurate transformed bounding box of the video view after scale+translation - val vw = video.width.toFloat() - val vh = video.height.toFloat() - val sx = video.scaleX - val sy = video.scaleY - if (vw > 0f && vh > 0f) { - // pivot defaults to center if not set - val pivotX = if (video.pivotX != 0f) video.pivotX else vw * 0.5f - val pivotY = if (video.pivotY != 0f) video.pivotY else vh * 0.5f - // Use view position (includes translation) as base; avoid double-counting translation - val tx = video.x - val ty = video.y - - // transform function for a local point (lx,ly) - fun transform(lx: Float, ly: Float): Pair { - val gx = tx + pivotX + (lx - pivotX) * sx - val gy = ty + pivotY + (ly - pivotY) * sy - return Pair(gx, gy) - } - - val p0 = transform(0f, 0f) - val p1 = transform(vw, 0f) - val p2 = transform(0f, vh) - val p3 = transform(vw, vh) - - val minX = min(min(p0.first, p1.first), min(p2.first, p3.first)) - val maxX = max(max(p0.first, p1.first), max(p2.first, p3.first)) - val minY = min(min(p0.second, p1.second), min(p2.second, p3.second)) - val maxY = max(max(p0.second, p1.second), max(p2.second, p3.second)) - - val newW = ceil(maxX - minX).toInt().coerceAtLeast(0) - val newH = ceil(maxY - minY).toInt().coerceAtLeast(0) - - val lp = overlay.layoutParams - if (lp == null) { - overlay.layoutParams = ViewGroup.LayoutParams(newW, newH) - } else { - if (lp.width != newW || lp.height != newH) { - lp.width = newW - lp.height = newH - overlay.layoutParams = lp - } - } - - overlay.scaleX = 1.0f - overlay.scaleY = 1.0f - overlay.x = minX - overlay.y = minY - } - } - - /** - * Ensure the overlay is updated once the next layout pass completes. - * Adds a one-time global layout listener (PiP/resizing/rotation frames). - */ - private fun requestUpdateBrightnessOverlayOnNextLayout() { - val pv = playerView ?: return - safe { - val obs = pv.viewTreeObserver - val listener = object : android.view.ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - safe { - updateBrightnessOverlayBounds() - } - if (obs.isAlive) { - obs.removeOnGlobalLayoutListener(this) - } - } - } - if (obs.isAlive) obs.addOnGlobalLayoutListener(listener) - } - } - open fun showMirrorsDialogue() { throw NotImplementedError() } @@ -468,43 +262,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { return false } - /** - * [isValidTouch] should be called on a [View] spanning across the screen for reliable results. - * - * Android has supported gesture navigation properly since API-30. We get the absolute screen dimens using - * [WindowManager.getCurrentWindowMetrics] and remove the stable insets - * {[WindowInsets.getInsetsIgnoringVisibility]} to get a safe perimeter. - * This approach supports any and all types of necessary system insets. - * - * @return false if the touch is on the status bar or navigation bar - * */ - private fun View.isValidTouch(rawX: Float, rawY: Float): Boolean { - // NOTE: screenWidth is without the navbar width when 3button nav is turned on. - if (Build.VERSION.SDK_INT >= 30) { - // real = absolute dimen without any default deductions like navbar width - val windowMetrics = - (context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager)?.currentWindowMetrics - val realScreenHeight = - windowMetrics?.let { windowMetrics.bounds.bottom - windowMetrics.bounds.top } - ?: screenHeightWithOrientation - val realScreenWidth = - windowMetrics?.let { windowMetrics.bounds.right - windowMetrics.bounds.left } - ?: screenWidthWithOrientation - - val insets = - rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) - val isOutsideHeight = rawY < insets.top || rawY > (realScreenHeight - insets.bottom) - val isOutsideWidth = if (windowMetrics == null) { - rawX < screenWidthWithOrientation - } else rawX < insets.left || rawX > realScreenWidth - insets.right - - return !(isOutsideWidth || isOutsideHeight) - } else { - val statusHeight = statusBarHeight ?: 0 - return rawY > statusHeight && rawX < screenWidthWithOrientation - } - } - override fun exitedPipMode() { animateLayoutChanges() } @@ -601,25 +358,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } if (!isLocked) { - playerFfwdHolder.alpha = 1f - playerRewHolder.alpha = 1f - // player_pause_play_holder?.alpha = 1f + if (::playerHostView.isInitialized) playerHostView.gestureHelper.animateCenterControls(fadeTo) shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) - playerFfwdHolder.startAnimation(fadeAnimation) - playerRewHolder.startAnimation(fadeAnimation) - playerPausePlay.startAnimation(fadeAnimation) downloadBothHeader.startAnimation(fadeAnimation) - - /*if (isBuffering) { - player_pause_play?.isVisible = false - player_pause_play_holder?.isVisible = false - } else { - player_pause_play?.isVisible = true - player_pause_play_holder?.startAnimation(fadeAnimation) - player_pause_play?.startAnimation(fadeAnimation) - }*/ - // player_buffering?.startAnimation(fadeAnimation) } bottomPlayerBar.startAnimation(fadeAnimation) @@ -647,7 +389,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - else -> dynamicOrientation() + else -> playerHostView.dynamicOrientation() } activity.requestedOrientation = orientation } @@ -661,14 +403,14 @@ open class FullScreenPlayer : AbstractPlayerFragment() { Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - else -> dynamicOrientation() + else -> playerHostView.dynamicOrientation() } activity.requestedOrientation = orientation } - open fun lockOrientation(activity: Activity) { - @Suppress("DEPRECATION") + private fun lockOrientation(activity: Activity) { val display = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) + @Suppress("DEPRECATION") (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay else activity.display!! val rotation = display.rotation @@ -689,7 +431,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - else -> orientation = dynamicOrientation() + else -> orientation = playerHostView.dynamicOrientation() } activity.requestedOrientation = orientation } @@ -701,10 +443,9 @@ open class FullScreenPlayer : AbstractPlayerFragment() { lockOrientation(this) } else { if (ignoreDynamicOrientation || rotatedManually) { - // restore when lock is disabled restoreOrientationWithSensor(this) } else { - this.requestedOrientation = dynamicOrientation() + this.requestedOrientation = playerHostView.dynamicOrientation() } } } @@ -724,7 +465,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } protected fun exitFullscreen() { - resetZoomToDefault() + if (::playerHostView.isInitialized) playerHostView.gestureHelper.resetZoomToDefault() // if (lockRotation) activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER @@ -739,13 +480,25 @@ open class FullScreenPlayer : AbstractPlayerFragment() { activity?.showSystemUI() } - private fun resetZoomToDefault() { - if (zoomMatrix != null) resize(PlayerResize.Fit, false) + private fun setupKeyEventListener() { + keyEventListener = { eventNav -> + val (event, hasNavigated) = eventNav + when { + event == null -> false + event.action == KeyEvent.ACTION_DOWN && + (event.keyCode == KeyEvent.KEYCODE_VOLUME_UP || + event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) -> + playerHostView.handleVolumeKey(event.keyCode) + player.isActive() -> handleKeyEvent(event, hasNavigated) + else -> false + } + } } override fun onResume() { enterFullscreen() - verifyVolume() + setupKeyEventListener() + playerHostView.verifyVolume() activity?.attachBackPressedCallback("FullScreenPlayer") { if (isShowingEpisodeOverlay) { // isShowingEpisodeOverlay pauses, so this makes it easier to unpause @@ -761,7 +514,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { activity?.popCurrentPage("FullScreenPlayer") } } - requestUpdateBrightnessOverlayOnNextLayout() + playerHostView.requestUpdateBrightnessOverlayOnNextLayout() super.onResume() } @@ -816,22 +569,9 @@ open class FullScreenPlayer : AbstractPlayerFragment() { var currentOffset = subtitleDelay binding.apply { - var subtitleAdapter: SubtitleOffsetItemAdapter? = null - subtitleOffsetInput.doOnTextChanged { text, _, _, _ -> text?.toString()?.toLongOrNull()?.let { time -> currentOffset = time - - // Scroll to the first active subtitle - val playerPosition = player.getPosition() ?: 0 - val totalPosition = playerPosition - currentOffset - subtitleAdapter?.updateTime(totalPosition) - - subtitleAdapter?.getLatestActiveItem(totalPosition) - ?.let { subtitlePos -> - subtitleOffsetRecyclerview.scrollToPosition(subtitlePos) - } - val str = when { time > 0L -> { txt(R.string.subtitle_offset_extra_hint_later_format, time) @@ -857,7 +597,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { noSubtitlesLoadedNotice.isVisible = subtitles.isEmpty() val initialSubtitlePosition = (player.getPosition() ?: 0) - currentOffset - subtitleAdapter = + val subtitleAdapter = SubtitleOffsetItemAdapter(initialSubtitlePosition) { subtitleCue -> val playerPosition = player.getPosition() ?: 0 subtitleOffsetInput.text = Editable.Factory.getInstance() @@ -964,7 +704,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { updateSpeedDialogBinding(binding) } - binding.speedBar.addOnChangeListener { slider, value, fromUser -> + binding.speedBar.addOnChangeListener { _, value, fromUser -> if (fromUser) { setPlayBackSpeed(value) updateSpeedDialogBinding(binding) @@ -997,82 +737,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { //} } - fun resetRewindText() { - playerBinding?.exoRewText?.text = - getString(R.string.rew_text_regular_format).format(fastForwardTime / 1000) - } - - fun resetFastForwardText() { - playerBinding?.exoFfwdText?.text = - getString(R.string.ffw_text_regular_format).format(fastForwardTime / 1000) - } - - private fun rewind() { - try { - playerBinding?.apply { - playerCenterMenu.isGone = false - playerRewHolder.alpha = 1f - - val rotateLeft = AnimationUtils.loadAnimation(context, R.anim.rotate_left) - playerRew.startAnimation(rotateLeft) - - val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left) - goLeft.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} - - override fun onAnimationRepeat(animation: Animation?) {} - - override fun onAnimationEnd(animation: Animation?) { - exoRewText.post { - resetRewindText() - playerCenterMenu.isGone = !isShowing - playerRewHolder.alpha = if (isShowing) 1f else 0f - } - } - }) - exoRewText.startAnimation(goLeft) - exoRewText.text = - getString(R.string.rew_text_format).format(fastForwardTime / 1000) - } - player.seekTime(-fastForwardTime) - } catch (e: Exception) { - logError(e) - } - } - - private fun fastForward() { - try { - playerBinding?.apply { - playerCenterMenu.isGone = false - playerFfwdHolder.alpha = 1f - - val rotateRight = AnimationUtils.loadAnimation(context, R.anim.rotate_right) - playerFfwd.startAnimation(rotateRight) - - val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) - goRight.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} - - override fun onAnimationRepeat(animation: Animation?) {} - - override fun onAnimationEnd(animation: Animation?) { - exoFfwdText.post { - resetFastForwardText() - playerCenterMenu.isGone = !isShowing - playerFfwdHolder.alpha = if (isShowing) 1f else 0f - } - } - }) - exoFfwdText.startAnimation(goRight) - exoFfwdText.text = - getString(R.string.ffw_text_format).format(fastForwardTime / 1000) - } - player.seekTime(fastForwardTime) - } catch (e: Exception) { - logError(e) - } - } - private fun onClickChange() { isShowing = !isShowing if (isShowing) { @@ -1091,6 +755,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } isLocked = !isLocked + if (::playerHostView.isInitialized) playerHostView.isLocked = isLocked updateOrientation(true) // set true to ignore auto rotate to stay in current orientation if (isLocked && isShowing) { @@ -1102,6 +767,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } val fadeTo = if (isLocked) 0f else 1f + if (::playerHostView.isInitialized) playerHostView.gestureHelper.animateCenterControls(fadeTo) playerBinding?.apply { val fadeAnimation = AlphaAnimation(playerVideoTitleHolder.alpha, fadeTo).apply { duration = 100 @@ -1109,11 +775,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } updateUIVisibility() - // MENUS - // centerMenu.startAnimation(fadeAnimation) - playerPausePlay.startAnimation(fadeAnimation) - playerFfwdHolder.startAnimation(fadeAnimation) - playerRewHolder.startAnimation(fadeAnimation) downloadBothHeader.startAnimation(fadeAnimation) if (hasEpisodes) @@ -1136,7 +797,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { updateLockUI() } - open fun updateUIVisibility() { + private fun updateUIVisibility() { val isGone = isLocked || !isShowing var togglePlayerTitleGone = isGone context?.let { @@ -1172,27 +833,27 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private fun updateLockUI() { playerBinding?.apply { playerLock.setIconResource(if (isLocked) R.drawable.video_locked else R.drawable.video_unlocked) - if (layout == R.layout.fragment_player) { - val color = if (isLocked) context?.colorFromAttribute(R.attr.colorPrimary) - else Color.WHITE - if (color != null) { - playerLock.setTextColor(color) - playerLock.iconTint = ColorStateList.valueOf(color) - playerLock.rippleColor = - ColorStateList.valueOf(Color.argb(50, color.red, color.green, color.blue)) - } + val color = if (isLocked) context?.colorFromAttribute(R.attr.colorPrimary) + else Color.WHITE + if (color != null) { + playerLock.setTextColor(color) + playerLock.iconTint = ColorStateList.valueOf(color) + playerLock.rippleColor = + ColorStateList.valueOf(Color.argb(50, color.red, color.green, color.blue)) } } } - private var currentTapIndex = 0 protected fun autoHide() { metadataVisibilityToken++ - currentTapIndex++ - delayHide() + if (::playerHostView.isInitialized) playerHostView.scheduleAutoHide() scheduleMetadataVisibility() } + override fun onAutoHideUI() { + if (player.getIsPlaying()) onClickChange() + } + protected fun hidePlayerUI() { if (isShowing) { isShowing = false @@ -1200,861 +861,82 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - override fun playerStatusChanged() { - super.playerStatusChanged() - scheduleMetadataVisibility() - delayHide() - } - - private fun delayHide() { - val index = currentTapIndex - playerBinding?.playerHolder?.postDelayed({ - if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) { - onClickChange() - } - }, 3000) - } + /** PlayerView.Callbacks touch overrides */ - // this is used because you don't want to hide UI when double tap seeking - private var currentDoubleTapIndex = 0 - private fun toggleShowDelayed() { - if (doubleTapEnabled || doubleTapPauseEnabled) { - val index = currentDoubleTapIndex - playerBinding?.playerHolder?.postDelayed({ - if (index == currentDoubleTapIndex) { - onClickChange() - } - }, DOUBLE_TAB_MINIMUM_TIME_BETWEEN) - } else { - onClickChange() + override fun isValidTouch(rawX: Float, rawY: Float): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val insets = playerBinding?.playerHolder?.rootWindowInsets + ?.getInsets(WindowInsets.Type.systemBars()) ?: return true + return rawY > insets.top && rawX < (screenWidthWithOrientation - insets.right) } + return rawY > (statusBarHeight ?: 0) } - private var isCurrentTouchValid = false - private var currentTouchStart: Vector2? = null - private var currentTouchLast: Vector2? = null - private var currentTouchAction: TouchAction? = null - private var currentLastTouchAction: TouchAction? = null - private var currentTouchStartPlayerTime: Long? = - null // the time in the player when you first click - private var currentTouchStartTime: Long? = null // the system time when you first click - private var currentLastTouchEndTime: Long = 0 // the system time when you released your finger - private var currentClickCount: Int = - 0 // amount of times you have double clicked, will reset when other action is taken - - // requested volume and brightness is used to make swiping smoother - // to make it not jump between values, - // this value is within the range [0,2] where 1+ is loudness - private var currentRequestedVolume: Float = 0.0f - - // from [0.0f, 1.0f] where 1.0f is max extra brightness, used only to track extra brightness - private var currentExtraBrightness: Float = 0.0f - - // this value is within the range [0,2] where 1+ is extra brightness - private var currentRequestedBrightness: Float = 1.0f - - enum class TouchAction { - Brightness, - Volume, - Time, - } + override fun isUiShowing(): Boolean = isShowing - companion object { - /** - * Gets the translationXY + scale form a matrix with no rotation. - * - * @return (translationX, translationY, scale) - * */ - fun matrixToTranslationAndScale(matrix: Matrix): Triple { - val points = floatArrayOf(0.0f, 0.0f, 1.0f, 1.0f) - matrix.mapPoints(points) - - // A linear matrix will map (0,0) to the translation - val translationX = points[0] - val translationY = points[1] - - // The unit vectors (1,0) and (0,1) will map to the scale if you remove the translation - // As this assumes a uniform scaling, only a single vector is needed - val scaleX = points[2] - translationX - val scaleY = points[3] - translationY - - // The matrix should have the same scaleX and scaleY - if (BuildConfig.DEBUG) { - assert((scaleX - scaleY).absoluteValue < 0.1f) { - "$scaleY != $scaleX" - } - } - - return Triple(translationX, translationY, scaleX) - } - - private fun forceLetters(inp: Long, letters: Int = 2): String { - val added: Int = letters - inp.toString().length - return if (added > 0) { - "0".repeat(added) + inp.toString() - } else { - inp.toString() - } - } - - private fun convertTimeToString(sec: Long): String { - val rsec = sec % 60L - val min = ceil((sec - rsec) / 60.0).toInt() - val rmin = min % 60L - val h = ceil((min - rmin) / 60.0).toLong() - // int rh = h;// h % 24; - return (if (h > 0) forceLetters(h) + ":" else "") + (if (rmin >= 0 || h >= 0) forceLetters( - rmin - ) + ":" else "") + forceLetters( - rsec - ) - } + override fun onSingleTap() { + onClickChange() } - private fun calculateNewTime( - startTime: Long?, - touchStart: Vector2?, - touchEnd: Vector2? - ): Long? { - if (touchStart == null || touchEnd == null || startTime == null) return null - val diffX = - (touchEnd.x - touchStart.x) * HORIZONTAL_MULTIPLIER / screenWidthWithOrientation.toFloat() - val duration = player.getDuration() ?: return null - return max( - min( - startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), - duration - ), 0 - ) + override fun onTouchDown() { + playerBinding?.playerIntroPlay?.isGone = true + if (isShowingEpisodeOverlay) toggleEpisodesOverlay(show = false) } - /** - * Returns screen brightness in <0.0f, 1.0f> range - */ - private fun getBrightness(): Float? { - return if (useTrueSystemBrightness) { - try { - Settings.System.getInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS - ) / 255f - } catch (e: Exception) { - // because true system brightness requires - // permission, this is a lazy way to check - // as it will throw an error if we do not have it - useTrueSystemBrightness = false - return getBrightness() - } - } else { - try { - activity?.window?.attributes?.screenBrightness - } catch (e: Exception) { - logError(e) - null - } + @SuppressLint("SetTextI18n") + override fun onSeekPreviewText(text: String?) { + playerBinding?.playerTimeText?.apply { + isVisible = text != null + if (text != null) this.text = text } } - /** - * Sets the screen brightness in the range <0.0f, 1.0f>. Values outside this range - * will be clamped to the minimum (0.0f) or maximum (1.0f). - * - * @param brightness desired brightness (values outside the range will be clamped) - */ - private fun setBrightness(brightness: Float) { - if (useTrueSystemBrightness) { - try { - Settings.System.putInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS_MODE, - Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL - ) - - Settings.System.putInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS, - min(1, (brightness.coerceIn(0.0f, 1.0f) * 255).toInt()) - ) - } catch (e: Exception) { - useTrueSystemBrightness = false - setBrightness(brightness) - } - } else { - try { - val lp = activity?.window?.attributes - // use 0.004f instead of 0, because on some devices setting too small value - // causes system to override it and in turn system makes the screen apply system brightness level instead - // which can be too bright, and it is very hard to fine tune very low brightness, because of it. - // Without this clamp, it can jump from almost 0% to 100% brightness when this threshold is crossed. - lp?.screenBrightness = brightness.coerceIn(0.004f, 1.0f) - // Log.i("Brightness", "clamped brightness: ${lp?.screenBrightness}") - activity?.window?.attributes = lp - } catch (e: Exception) { - logError(e) - } - } + override fun onHidePlayerUI() { + hidePlayerUI() } - private var isVolumeLocked: Boolean = false - private var hasShownVolumeToast: Boolean = false - - private var isBrightnessLocked: Boolean = false - private var hasShownBrightnessToast: Boolean = false - - private var progressBarLeftHideRunnable: Runnable? = null - private var progressBarRightHideRunnable: Runnable? = null - - // Verifies that the currentRequestedVolume matches the system volume - // if not, then it removes changes currentRequestedVolume and removes the loudnessEnhancer - // if the real volume is less than 100% - // - // This is here to make returning to the player less jarring, if we change the volume outside - // the app. Note that this will make it a bit wierd when using loudness in PiP, then returning - // however that is the cost of correctness. - private fun verifyVolume() { - (activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { audioManager -> - val currentVolumeStep = - audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val maxVolumeStep = - audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - - // if we can set the volume directly then do it - if (currentVolumeStep < maxVolumeStep || currentRequestedVolume <= 1.0f) { - currentRequestedVolume = - currentVolumeStep.toFloat() / maxVolumeStep.toFloat() - - loudnessEnhancer?.release() - loudnessEnhancer = null - } + override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { + if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { + isShowing = true + animateLayoutChanges() } + autoHide() } - val holdhandler = Handler(Looper.getMainLooper()) - var hasTriggeredSpeedUp = false - val holdRunnable = Runnable { - if (isShowing) { - onClickChange() - } - player.setPlaybackSpeed(2.0f) - showOrHideSpeedUp(true) - hasTriggeredSpeedUp = true + override fun playerStatusChanged() { + super.playerStatusChanged() + scheduleMetadataVisibility() } - private fun showOrHideSpeedUp(show: Boolean) { - playerBinding?.playerSpeedupButton?.let { button -> - button.clearAnimation() - button.alpha = if (show) 0f else 1f - button.isVisible = show - button.animate() - .alpha(if (show) 1f else 0f) - .setDuration(200L) - .start() - } + // When the hold-speedup gesture fires, hide controls so the video is unobstructed. + // The speedup button show/hide and speed change are handled by PlayerView. + override fun onHoldSpeedUp(show: Boolean) { + if (show && isShowing) onClickChange() } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) // If we rotate the device we need to recalculate the zoom - val matrix = zoomMatrix - val animation = matrixAnimation + val gh = if (::playerHostView.isInitialized) playerHostView.gestureHelper else return + val matrix = gh.zoomMatrix + val animation = gh.matrixAnimation if ((animation == null || !animation.isRunning) && matrix != null) { - // Ignore if we have no zoom or mid animation + // Ignore if we have no zoom or mid-animation playerView?.post { - applyZoomMatrix(matrix, true) - requestUpdateBrightnessOverlayOnNextLayout() + gh.applyZoomMatrix(matrix, true) + playerHostView.requestUpdateBrightnessOverlayOnNextLayout() } } } - private var scaleGestureDetector: ScaleGestureDetector? = null - private var lastPan: Vector2? = null - - /** - * Gets the non-null zoom matrix, - * this is different from `zoomMatrix ?: Matrix()` - * because it allows used to start zooming at different resizeModes. - * - * The main issue is that RESIZE_MODE_FIT = 100% zoom, but if you are in RESIZE_MODE_ZOOM - * 100% will make the zoom snap to less zoomed in then you already are. - * */ - fun currentZoomMatrix(): Matrix { - val current = zoomMatrix - if (current != null) { - // Already assigned - return current - } - - val playerView = playerView - val videoView = playerView?.videoSurfaceView - - if (playerView == null || videoView == null || playerView.resizeMode != AspectRatioFrameLayout.RESIZE_MODE_ZOOM) { - // This is a fit or fill resize mode so start at 100% zoom - return Matrix() - } - - val videoWidth = videoView.width.toFloat() - val videoHeight = videoView.height.toFloat() - val playerWidth = screenWidthWithOrientation - val playerHeight = screenHeightWithOrientation - - // Sanity check - if (videoWidth <= 1.0f || videoHeight <= 1.0f || playerWidth <= 1.0f || playerHeight <= 1.0f) { - // Something is wrong with the video, return the default 100% zoom - return Matrix() - } - - val initAspect = - (playerHeight * videoWidth) / (playerWidth * videoHeight) - val aspect = max(initAspect, 1.0f / initAspect) - - // Return the matrix with the correct zoom, as it is already zoomed in - return Matrix().apply { postScale(aspect, aspect) } - } - - /** A Matrix encoding the translation and scale of the current zoom */ - private var zoomMatrix: Matrix? = null - - /** A Matrix encoding the translation and scale of the desired zoom, - * aka after you release the zoom */ - private var desiredMatrix: Matrix? = null - - /** The animation of zooming to the desiredMatrix */ - private var matrixAnimation: ValueAnimator? = null - - @SuppressLint("UnsafeOptInUsageError") override fun resize(resize: PlayerResize, showToast: Boolean) { - // Clear all zoom stuff if we resize - matrixAnimation?.cancel() - matrixAnimation = null - zoomMatrix = null - desiredMatrix = null - playerView?.videoSurfaceView?.apply { - scaleX = 1.0f - scaleY = 1.0f - translationX = 0.0f - translationY = 0.0f - } - + // Clear all zoom state before applying the new resize mode + if (::playerHostView.isInitialized) playerHostView.clearZoomState() super.resize(resize, showToast) - requestUpdateBrightnessOverlayOnNextLayout() - } - - /** - * Applies a new zoom matrix to the screen. Matrix should only contain a scale + translation. - * - * @param newMatrix The new zoom matrix - * @param animation If this zoom is part of an animation, - * as then it will not auto zoom after we are done - */ - @OptIn(UnstableApi::class) - fun applyZoomMatrix(newMatrix: Matrix, animation: Boolean) { - if (!animation) { - matrixAnimation?.cancel() - matrixAnimation = null - } - val (translationX, translationY, scale) = matrixToTranslationAndScale(newMatrix) - - playerView?.let { player -> - if (player.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT) { - player.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM - } - - val videoView = player.videoSurfaceView ?: return@let - - val videoWidth = videoView.width.toFloat() - val videoHeight = videoView.height.toFloat() - val playerWidth = screenWidthWithOrientation - val playerHeight = screenHeightWithOrientation - - // Sanity check - if (videoWidth <= 1.0f || videoHeight <= 1.0f || playerWidth <= 1.0f || playerHeight <= 1.0f || scale <= 0.01f) { - return - } - - // Calculate the scaled aspect ratio as the view height is not real, check the debugger - // and you will see videoView.height > screen.heigh - val initAspect = - (playerHeight * videoWidth) / (playerWidth * videoHeight) - val aspect = min(initAspect, 1.0f / initAspect) - val scaledAspect = scale * aspect - - // Calculate clamp, this is very weird because we need to use aspect here as videoHeight > playerHeight - val maxTransX = max(0.0f, videoWidth * scaledAspect - playerWidth) * 0.5f - val maxTransY = max(0.0f, videoHeight * scaledAspect - playerHeight) * 0.5f - - // Correct the translation to clamp within the viewing area - val expectedTranslationX = translationX.coerceIn(-maxTransX, maxTransX) - val expectedTranslationY = translationY.coerceIn(-maxTransY, maxTransY) - - // Set the transform to the correct x and y - newMatrix.postTranslate( - expectedTranslationX - translationX, - expectedTranslationY - translationY - ) - zoomMatrix = newMatrix - - if (!animation) { - // If we are not in an animation, set up the values for the animation - if ((scaledAspect - 1.0f).absoluteValue < ZOOM_SNAP_SENSITIVITY) { - // We are within the correct scaling, so center and fit it - playerBinding?.videoOutline?.isVisible = true - val desired = Matrix() - desired.setScale(1.0f / aspect, 1.0f / aspect) - desiredMatrix = desired - } else if (scale < 1.0f) { - // We have zoomed too far, zoom to 100% - playerBinding?.videoOutline?.isVisible = false - desiredMatrix = Matrix() - } else { - // Keep the same scaling after zoom - playerBinding?.videoOutline?.isVisible = false - desiredMatrix = null - } - } - - // Finally set the actual scale + translation - videoView.scaleX = scaledAspect - videoView.scaleY = scaledAspect - videoView.translationX = expectedTranslationX - videoView.translationY = expectedTranslationY - updateBrightnessOverlayBounds() - } + if (::playerHostView.isInitialized) playerHostView.requestUpdateBrightnessOverlayOnNextLayout() } - fun createScaleGestureDetector(context: Context) { - scaleGestureDetector = ScaleGestureDetector( - context, - object : ScaleGestureDetector.SimpleOnScaleGestureListener() { - override fun onScale(detector: ScaleGestureDetector): Boolean { - val matrix = currentZoomMatrix() - val (_, _, scale) = matrixToTranslationAndScale(matrix) - // Clamp scale of the zoom, do it here as it is easier then doing it within applyZoomMatrix - val newScale = (scale * detector.scaleFactor).coerceIn( - MINIMUM_ZOOM, - MAXIMUM_ZOOM - ) - // How much we should scale it with to prevent inf scaling - val actualScaleFactor = newScale / scale - - // Scale around the focus point, this is more natural than just zoom - val pivotX = detector.focusX - screenWidthWithOrientation.toFloat() * 0.5f - val pivotY = detector.focusY - screenHeightWithOrientation.toFloat() * 0.5f - matrix.postScale( - actualScaleFactor, - actualScaleFactor, - pivotX, - pivotY - ) - applyZoomMatrix(matrix, false) - return true - } - }) - } - - @SuppressLint("SetTextI18n") - private fun handleMotionEvent(view: View?, event: MotionEvent?): Boolean { - if (event == null || view == null) return false - val currentTouch = Vector2(event.x, event.y) - val startTouch = currentTouchStart - - playerBinding?.playerIntroPlay?.isGone = true - - // Handle pan with two fingers - if ((event.pointerCount == 2 || lastPan != null) && !isLocked && isFullScreenPlayer && !hasTriggeredSpeedUp && currentTouchAction == null) { - holdhandler.removeCallbacks(holdRunnable) // remove 2x speed - - // Gesture detectors for zoom & pan - if (scaleGestureDetector == null) { - createScaleGestureDetector(view.context) - } - - isCurrentTouchValid = false // Prevent other touches - scaleGestureDetector?.onTouchEvent(event) - - when (event.actionMasked) { - MotionEvent.ACTION_POINTER_DOWN -> { - // Hide UI - if (isShowing) { - onClickChange() - } - } - - MotionEvent.ACTION_MOVE -> { - val newPan = Vector2( - (event.getX(0) + event.getX(1)) / 2f, - (event.getY(0) + event.getY(1)) / 2f - ) - val oldPan = lastPan - if (oldPan != null) { - val matrix = currentZoomMatrix() - // Delta move - matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) - applyZoomMatrix(matrix, false) - updateBrightnessOverlayBounds() - } - lastPan = newPan - } - - MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_UP -> { - // Reset touch - lastPan = null - currentTouchStart = null - currentLastTouchAction = null - currentTouchAction = null - currentTouchStartPlayerTime = null - currentTouchLast = null - currentTouchStartTime = null - - // Reset views - playerBinding?.videoOutline?.isVisible = false - matrixAnimation?.cancel() - matrixAnimation = null - - // After we have zoomed in, snap to - matrixAnimation = ValueAnimator.ofFloat(0.0f, 1.0f).apply { - startDelay = 0 - duration = 200 - - val startMatrix = currentZoomMatrix() - val endMatrix = desiredMatrix ?: return@apply - - val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) - val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) - - addUpdateListener { animation -> - val value = animation.animatedValue as Float // ValueAnimator.ofFloat - - // Linear interpolation of scale and translation between startMatrix and endMatrix - val valueInv = 1.0f - value - val x = startX * valueInv + endX * value - val y = startY * valueInv + endY * value - val s = startScale * valueInv + endScale * value - val m = Matrix() - m.setScale(s, s) - m.postTranslate(x, y) - applyZoomMatrix(m, true) - } - start() - } - } - } - return true - } - - playerBinding?.apply { - when (event.action) { - MotionEvent.ACTION_DOWN -> { - // validates if the touch is inside of the player area - isCurrentTouchValid = view.isValidTouch(currentTouch.x, currentTouch.y) - if (isCurrentTouchValid && isShowingEpisodeOverlay) { - toggleEpisodesOverlay(show = false) - } else if (isCurrentTouchValid) { - if (speedupEnabled) { - hasTriggeredSpeedUp = false - if (player.getIsPlaying() && !isLocked && isFullScreenPlayer) { - holdhandler.postDelayed(holdRunnable, 500) - } - } - isVolumeLocked = currentRequestedVolume < 1.0f - if (currentRequestedVolume <= 1.0f) { - hasShownVolumeToast = false - } - - isBrightnessLocked = currentRequestedBrightness < 1.0f - if (currentRequestedBrightness <= 1.0f) { - hasShownBrightnessToast = false - } - - currentTouchStartTime = System.currentTimeMillis() - currentTouchStart = currentTouch - currentTouchLast = currentTouch - currentTouchStartPlayerTime = player.getPosition() - - getBrightness()?.let { - currentRequestedBrightness = it + currentExtraBrightness - } - verifyVolume() - } - } - - MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { - holdhandler.removeCallbacks(holdRunnable) - if (hasTriggeredSpeedUp) { - player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) - showOrHideSpeedUp(false) - } - if (isCurrentTouchValid && !isLocked && isFullScreenPlayer) { - // seek time - if (swipeHorizontalEnabled && currentTouchAction == TouchAction.Time) { - val startTime = currentTouchStartPlayerTime - if (startTime != null) { - calculateNewTime( - startTime, - startTouch, - currentTouch - )?.let { seekTo -> - if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { - player.seekTo(seekTo, PlayerEventSource.UI) - } - } - } - } - } - - // see if click is eligible for seek 10s - val holdTime = currentTouchStartTime?.minus(System.currentTimeMillis()) - if (isCurrentTouchValid // is valid - && currentTouchAction == null // no other action like swiping is taking place - && currentLastTouchAction == null // last action was none, this prevents mis input random seek - && holdTime != null - && holdTime < DOUBLE_TAB_MAXIMUM_HOLD_TIME // it is a click not a long hold - ) { - if (!isLocked - && (System.currentTimeMillis() - currentLastTouchEndTime) < DOUBLE_TAB_MINIMUM_TIME_BETWEEN // the time since the last action is short - ) { - currentClickCount++ - - if (currentClickCount >= 1) { // have double clicked - currentDoubleTapIndex++ - if (doubleTapPauseEnabled && isFullScreenPlayer) { // you can pause if your tap is in the middle of the screen - when { - currentTouch.x < screenWidthWithOrientation / 2 - (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidthWithOrientation) -> { - if (doubleTapEnabled) - rewind() - } - - currentTouch.x > screenWidthWithOrientation / 2 + (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidthWithOrientation) -> { - if (doubleTapEnabled) - fastForward() - } - - else -> { - player.handleEvent( - CSPlayerEvent.PlayPauseToggle, - PlayerEventSource.UI - ) - } - } - } else if (doubleTapEnabled && isFullScreenPlayer) { - if (currentTouch.x < screenWidthWithOrientation / 2) { - rewind() - } else { - fastForward() - } - } - } - } else { - // is a valid click but not fast enough for seek - currentClickCount = 0 - if (!hasTriggeredSpeedUp) { - toggleShowDelayed() - } - // onClickChange() - } - } else { - currentClickCount = 0 - } - - // If we hid the UI for a gesture and playback is paused, show it again - if (!player.getIsPlaying()) { - val didGesture = - currentTouchAction != null || currentLastTouchAction != null - if (didGesture && uiShowingBeforeGesture && !isShowing) { - isShowing = true - animateLayoutChanges() - } - } - - // call auto hide as it wont hide when you have your finger down - autoHide() - - // reset variables - isCurrentTouchValid = false - currentTouchStart = null - currentLastTouchAction = currentTouchAction - currentTouchAction = null - currentTouchStartPlayerTime = null - currentTouchLast = null - currentTouchStartTime = null - uiShowingBeforeGesture = false - - // resets UI - playerTimeText.isVisible = false - - currentLastTouchEndTime = System.currentTimeMillis() - } - - MotionEvent.ACTION_MOVE -> { - // if current touch is valid - - if (hasTriggeredSpeedUp) { - return true - } - if (startTouch != null && isCurrentTouchValid && !isLocked && isFullScreenPlayer) { - // action is unassigned and can therefore be assigned - - if (currentTouchAction == null) { - val diffFromStart = startTouch - currentTouch - if (swipeVerticalEnabled) { - if (abs(diffFromStart.y * 100 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) { - // left = Brightness, right = Volume, but the UI is reversed to show the UI better - uiShowingBeforeGesture = isShowing - currentTouchAction = - if (startTouch.x < screenWidthWithOrientation / 2) { - // hide the UI if you hold brightness to show screen better, better UX - hidePlayerUI() - TouchAction.Brightness - } else { - // hide the UI if you hold volume to show screen better, better UX - hidePlayerUI() - TouchAction.Volume - } - } - } - if (swipeHorizontalEnabled) { - if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) { - currentTouchAction = TouchAction.Time - } - } - } - - // display action - val lastTouch = currentTouchLast - if (lastTouch != null) { - val diffFromLast = lastTouch - currentTouch - val verticalAddition = - diffFromLast.y * VERTICAL_MULTIPLIER / screenHeightWithOrientation.toFloat() - - // update UI - playerTimeText.isVisible = false - - when (currentTouchAction) { - TouchAction.Time -> { - holdhandler.removeCallbacks(holdRunnable) - // this simply updates UI as the seek logic happens on release - // startTime is rounded to make the UI sync in a nice way - val startTime = - currentTouchStartPlayerTime?.div(1000L)?.times(1000L) - if (startTime != null) { - calculateNewTime( - startTime, - startTouch, - currentTouch - )?.let { newMs -> - val skipMs = newMs - startTime - playerTimeText.apply { - text = - "${convertTimeToString(newMs / 1000)} [${ - (if (abs(skipMs) < 1000) "" else (if (skipMs > 0) "+" else "-")) - }${convertTimeToString(abs(skipMs / 1000))}]" - isVisible = true - } - } - } - } - - TouchAction.Brightness -> { - holdhandler.removeCallbacks(holdRunnable) - playerBinding?.playerProgressbarRightHolder?.apply { - if (!isVisible || alpha < 1f) { - alpha = 1f - isVisible = true - } - - progressBarRightHideRunnable?.let { removeCallbacks(it) } - progressBarRightHideRunnable = Runnable { - // Fade out the progress bar - animate().cancel() - animate() - .alpha(0f) - .setDuration(300) - .withEndAction { isVisible = false } - .start() - } - // Show the progress bar for 1.5 seconds - postDelayed(progressBarRightHideRunnable, 1500) - } - - val lastRequested = currentRequestedBrightness - val nextBrightness = if (extraBrightnessEnabled) { - currentRequestedBrightness + verticalAddition - } else { - (currentRequestedBrightness + verticalAddition).coerceIn( - 0.0f, - 1.0f - ) - } - // Log.e("Brightness", "Current: $currentRequestedBrightness, Next: $nextBrightness") - // show toast - if (extraBrightnessEnabled && nextBrightness > 1.0f && isBrightnessLocked && !hasShownBrightnessToast) { - showToast(R.string.slide_up_again_to_exceed_100) - hasShownBrightnessToast = true - } - currentRequestedBrightness = nextBrightness - - // this is to not spam request it, just in case it fucks over someone - if (lastRequested != currentRequestedBrightness) - setBrightness(currentRequestedBrightness) - - val level1ProgressBar = playerProgressbarRightLevel1 - - // max is set high to make it smooth - level1ProgressBar.max = 100_000 - level1ProgressBar.progress = - max( - 2_000, - (min( - 1.0f, - currentRequestedBrightness - ) * 100_000f).toInt() - ) - - if (extraBrightnessEnabled && !isBrightnessLocked) { - val level2ProgressBar = playerProgressbarRightLevel2 - - currentExtraBrightness = if (currentRequestedBrightness > 1.0f) min(2.0f, currentRequestedBrightness) - 1.0f else 0.0f - level2ProgressBar.max = 100_000 - level2ProgressBar.progress = - (currentExtraBrightness * 100_000f).toInt().coerceIn(2_000, 100_000) - level2ProgressBar.isVisible = currentRequestedBrightness > 1.0f - brightnessOverlay?.let { - it.alpha = currentExtraBrightness - } - } - - // Log.i("Brightness", "current: $currentRequestedBrightness, ce: $currentExtraBrightness L1: ${level1ProgressBar.progress}, L2: ${level2ProgressBar.progress}") - playerProgressbarRightIcon.setImageResource( - brightnessIcons[min( // clamp the value in case of extra brightness - brightnessIcons.size - 1, - max( - 0, - round(currentRequestedBrightness * (brightnessIcons.size - 1)).toInt() - ) - )] - ) - } - - TouchAction.Volume -> { - holdhandler.removeCallbacks(holdRunnable) - handleVolumeAdjustment( - verticalAddition, - false - ) - } - - else -> Unit - } - } - } - } - } - } - currentTouchLast = currentTouch - return true - } - - @SuppressLint("GestureBackNavigation") private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { if (hasNavigated) { autoHide() @@ -2107,29 +989,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP -> { - if (isLayout(PHONE or EMULATOR) && isFullScreenPlayer) { - /** - * Some TVs do not support volume boosting, and overriding - * the volume buttons can be inconvenient for TV users. - * Since boosting volume is mainly useful on phones and emulators, - * we limit this feature to those devices. - */ - verifyVolume() - if (currentRequestedVolume <= 1.0f) { - hasShownVolumeToast = false - } - isVolumeLocked = currentRequestedVolume < 1.0f - handleVolumeAdjustment( - // +- 5% - if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) { - 0.05f - } else { - -0.05f - }, - true - ) - return true - } + // Handled entirely by PlayerView.handleVolumeKey (checks PHONE/EMULATOR + isFullScreen). + if (playerHostView.handleVolumeKey(keyCode)) return true } } } @@ -2163,138 +1024,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { return false } - private var loudnessEnhancer: LoudnessEnhancer? = null - - private fun handleVolumeAdjustment( - delta: Float, - fromButton: Boolean, - ) { - val audioManager = - activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return - val currentVolumeStep = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val maxVolumeStep = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - - val currentVolume = currentRequestedVolume - val isCurrentVolumeLocked = isVolumeLocked - - val nextVolume = - (currentVolume + delta).coerceIn(0.0f, if (isCurrentVolumeLocked) 1.0f else 2.0f) - - val nextVolumeStep = - (nextVolume * maxVolumeStep.toFloat()).roundToInt().coerceIn(0, maxVolumeStep) - - // show toast - if (fromButton) { - // for button related request we only show a toast when we exceeded the volume - if (currentVolume <= 1.0f && nextVolume > 1.0f && !hasShownVolumeToast) { - showToast(R.string.volume_exceeded_100) - hasShownVolumeToast = true - } - } else { - val nextRequestedVolume = currentVolume + delta - - // for swipes, we show toast that we need to swipe again - if (nextRequestedVolume > 1.0 && isCurrentVolumeLocked && !hasShownVolumeToast) { - showToast(R.string.slide_up_again_to_exceed_100) - hasShownVolumeToast = true - } - } - - // set the current volume step - if (nextVolumeStep != currentVolumeStep) { - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, nextVolumeStep, 0) - } - - var hasBoostError = false - - // Apply loudness enhancer for volumes > 100%, removes it if less - if (nextVolume > 1.0f) { - val boostFactor = ((nextVolume - 1.0f) * 1000).toInt() - val currentEnhancer = loudnessEnhancer - - if (currentEnhancer != null) { - currentEnhancer.setTargetGain(boostFactor) - } else { - val audioSessionId = (playerView?.player as? ExoPlayer)?.audioSessionId - if (audioSessionId != null && audioSessionId != AudioManager.ERROR) { - try { - loudnessEnhancer = LoudnessEnhancer(audioSessionId).apply { - setTargetGain(boostFactor) - enabled = true - } - } catch (t: Throwable) { - logError(t) - hasBoostError = true - } - } - } - } else { - loudnessEnhancer?.release() - loudnessEnhancer = null - } - - currentRequestedVolume = nextVolume - - // Update the progress bar - playerBinding?.apply { - val level1ProgressBar = playerProgressbarLeftLevel1 - val level2ProgressBar = playerProgressbarLeftLevel2 - - // Change color to show that LoudnessEnhancer broke - // this is not a real fix, but solves the crash issue - if (nextVolume > 1.0f) { - level2ProgressBar.progressTintList = ColorStateList.valueOf( - ContextCompat.getColor( - level2ProgressBar.context, if (hasBoostError) { - R.color.colorPrimaryRed - } else { - R.color.colorPrimaryOrange - } - ) - ) - } - - level1ProgressBar.max = 100_000 - level1ProgressBar.progress = - (nextVolume * 100_000f).toInt().coerceIn(2_000, 100_000) - - level2ProgressBar.max = 100_000 - level2ProgressBar.progress = - if (nextVolume > 1.0f) ((nextVolume - 1.0) * 100_000f).toInt() - .coerceIn(2_000, 100_000) else 0 - level2ProgressBar.isVisible = nextVolume > 1.0f - - // Calculate the clamped index for the volume icon based on the requested volume - val iconIndex = (nextVolume * (volumeIcons.lastIndex)) - .roundToInt() - .coerceIn(0, volumeIcons.lastIndex) - - // Update icon - playerProgressbarLeftIcon.setImageResource(volumeIcons[iconIndex]) - } - - // alpha fade - playerBinding?.playerProgressbarLeftHolder?.apply { - if (!isVisible || alpha < 1f) { - alpha = 1f - isVisible = true - } - - progressBarLeftHideRunnable?.let { removeCallbacks(it) } - progressBarLeftHideRunnable = Runnable { - // Fade out the progress bar - animate().cancel() - animate() - .alpha(0f) - .setDuration(300) - .withEndAction { isVisible = false } - .start() - } - // Show the progress bar for 1.5 seconds - postDelayed(progressBarLeftHideRunnable, 1500) - } - } - protected fun uiReset() { metadataVisibilityToken++ playerBinding?.playerMetadataScrim?.let { @@ -2315,8 +1044,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { updateLockUI() updateUIVisibility() animateLayoutChanges() - resetFastForwardText() - resetRewindText() + if (::playerHostView.isInitialized) { + playerHostView.gestureHelper.resetFastForwardText() + playerHostView.gestureHelper.resetRewindText() + } } override fun onSaveInstanceState(outState: Bundle) { @@ -2325,9 +1056,22 @@ open class FullScreenPlayer : AbstractPlayerFragment() { super.onSaveInstanceState(outState) } - @SuppressLint("ClickableViewAccessibility") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + @SuppressLint("ClickableViewAccessibility", "DiscouragedApi") + override fun onBindingCreated(binding: FragmentPlayerBinding, savedInstanceState: Bundle?) { + // Set up playerBinding before super initializes the player + // (brightness overlay is now injected by PlayerView.initialize()) + playerBinding = PlayerCustomLayoutBinding.bind(binding.root.findViewById(R.id.player_holder)) + + super.onBindingCreated(binding, savedInstanceState) + + // This player is always full-screen; tell PlayerView so volume-key handling is active. + playerHostView.isFullScreen = true + + // Wire up the snap-hint outline view and schedule brightness overlay bounds update + playerHostView.videoOutline = playerBinding?.videoOutline + playerHostView.requestUpdateBrightnessOverlayOnNextLayout() + + val view = binding.root // init variables setPlayBackSpeed(DataStoreHelper.playBackSpeed) savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let { @@ -2410,24 +1154,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } // handle tv controls directly based on player state - keyEventListener = { eventNav -> - // Don't hook player keys if player isn't active - if (player.isActive()) { - val (event, hasNavigated) = eventNav - if (event != null) - handleKeyEvent(event, hasNavigated) - else false - } else false - } + setupKeyEventListener() try { context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - fastForwardTime = - settingsManager.getInt(ctx.getString(R.string.double_tap_seek_time_key), 10) - .toLong() * 1000L - androidTVInterfaceOffSeekTime = settingsManager.getInt( ctx.getString(R.string.android_tv_interface_off_seek_key), @@ -2444,13 +1176,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { navigationBarHeight = ctx.getNavigationBarHeight() statusBarHeight = ctx.getStatusBarHeight() - swipeHorizontalEnabled = - settingsManager.getBoolean(ctx.getString(R.string.swipe_enabled_key), true) - swipeVerticalEnabled = - settingsManager.getBoolean( - ctx.getString(R.string.swipe_vertical_enabled_key), - true - ) playBackSpeedEnabled = settingsManager.getBoolean( ctx.getString(R.string.playback_speed_enabled_key), false @@ -2459,42 +1184,16 @@ open class FullScreenPlayer : AbstractPlayerFragment() { ctx.getString(R.string.rotate_video_key), false ) - autoPlayerRotateEnabled = settingsManager.getBoolean( - ctx.getString(R.string.auto_rotate_video_key), - true - ) playerResizeEnabled = settingsManager.getBoolean( ctx.getString(R.string.player_resize_enabled_key), true ) - doubleTapEnabled = - settingsManager.getBoolean( - ctx.getString(R.string.double_tap_enabled_key), - false - ) - - doubleTapPauseEnabled = - settingsManager.getBoolean( - ctx.getString(R.string.double_tap_pause_enabled_key), - false - ) - hideControlsNames = settingsManager.getBoolean( ctx.getString(R.string.hide_player_control_names_key), false ) - speedupEnabled = settingsManager.getBoolean( - ctx.getString(R.string.speedup_key), - false - ) - - extraBrightnessEnabled = settingsManager.getBoolean( - ctx.getString(R.string.extra_brightness_key), - false - ) - val profiles = QualityDataHelper.getProfiles() val type = if (ctx.isUsingMobileData()) QualityDataHelper.QualityProfileType.Data @@ -2503,13 +1202,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { currentQualityProfile = profiles.firstOrNull { it.types.contains(type) }?.id ?: profiles.firstOrNull()?.id ?: currentQualityProfile - -// currentPrefQuality = settingsManager.getInt( -// ctx.getString(if (ctx.isUsingMobileData()) R.string.quality_pref_mobile_data_key else R.string.quality_pref_key), -// currentPrefQuality -// ) - // useSystemBrightness = - // settingsManager.getBoolean(ctx.getString(R.string.use_system_brightness_key), false) } playerBinding?.apply { playerSpeedBtt.isVisible = playBackSpeedEnabled @@ -2559,14 +1251,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - exoDuration.setOnClickListener { - setRemainingTimeCounter(true) - } - - timeLeft.setOnClickListener { - setRemainingTimeCounter(false) - } - skipChapterButton.setOnClickListener { player.handleEvent(CSPlayerEvent.SkipCurrentChapter) } @@ -2616,16 +1300,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { showSubtitleOffsetDialog() } - playerRew.setOnClickListener { - autoHide() - rewind() - } - - playerFfwd.setOnClickListener { - autoHide() - fastForward() - } - playerGoBack.setOnClickListener { activity?.popCurrentPage("FullScreenPlayer") } @@ -2638,11 +1312,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { showTracksDialogue() } - // it is !not! a bug that you cant touch the right side, it does not register inputs on navbar or status bar - playerHolder.setOnTouchListener { callView, event -> - return@setOnTouchListener handleMotionEvent(callView, event) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { playerControlsScroll.setOnScrollChangeListener { _, _, _, _, _ -> autoHide() @@ -2654,12 +1323,9 @@ open class FullScreenPlayer : AbstractPlayerFragment() { exoProgress.setOnTouchListener { _, event -> // this makes the bar not disappear when sliding when (event.action) { - MotionEvent.ACTION_DOWN -> { - currentTapIndex++ - } - + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { - currentTapIndex++ + if (::playerHostView.isInitialized) playerHostView.cancelAutoHide() } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_BUTTON_RELEASE -> { @@ -2672,11 +1338,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { toggleEpisodesOverlay(show = true) } } - // cs3 is peak media center - setRemainingTimeCounter(durationMode || isLayout(TV)) - playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ -> - updateRemainingTime() - } // init UI try { uiReset() @@ -2685,7 +1346,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - @SuppressLint("SourceLockedOrientationActivity") private fun toggleRotate() { activity?.let { toggleOrientationWithSensor(it) @@ -2710,59 +1370,11 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } override fun playerDimensionsLoaded(width: Int, height: Int) { - // On TV, don't rotate for portrait videos; display with pillarbox (black bars on sides) - if (isLayout(TV or EMULATOR)) { - isVerticalOrientation = false - return - } - isVerticalOrientation = height > width + // PlayerView already set isVerticalOrientation; skip rotation on TV (pillarbox instead). + if (isLayout(TV or EMULATOR)) return updateOrientation() } - private fun updateRemainingTime() { - val duration = player.getDuration() - val position = player.getPosition() - - if (playerBinding?.exoProgress?.isAtLiveEdge() == true) { - // Hide using a parentView instead? - playerBinding?.timeLeft?.alpha = 0f - playerBinding?.exoDuration?.alpha = 0f - playerBinding?.timeLive?.isVisible = true - } else { - playerBinding?.timeLeft?.alpha = 1f - playerBinding?.exoDuration?.alpha = 1f - playerBinding?.timeLive?.isVisible = false - } - - if (duration != null && duration > 1 && position != null) { - val remainingTimeSeconds = (duration - position + 500) / 1000 - val formattedTime = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}" - playerBinding?.timeLeft?.text = formattedTime - } - } - - private fun setRemainingTimeCounter(showRemaining: Boolean) { - durationMode = showRemaining - playerBinding?.exoDuration?.isInvisible = showRemaining - playerBinding?.timeLeft?.isVisible = showRemaining - } - - private fun dynamicOrientation(): Int { - // TV should always remain in landscape mode - if (isLayout(TV or EMULATOR)) { - return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } - return if (autoPlayerRotateEnabled) { - if (isVerticalOrientation) { - ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - } else { - ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } - } else { - ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE // default orientation - } - } - private fun toggleEpisodesOverlay(show: Boolean) { if (show && !isShowingEpisodeOverlay) { previousPlayStatus = player.getIsPlaying() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 16b03e4f61c..4794423a414 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 @@ -174,7 +174,6 @@ class GeneratorPlayer : FullScreenPlayer() { private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none - private var binding: FragmentPlayerBinding? = null private var allMeta: List? = null private fun startLoading() { player.release() @@ -348,16 +347,13 @@ class GeneratorPlayer : FullScreenPlayer() { } // retry several times with a preview in case the preview generator is slow - for (i in 0..10) { + repeat(10) { val preview = this@GeneratorPlayer.player.getPreview(0.5f) - if (preview == null) { - delay(1000L) - continue + if (preview != null) { + callback.onBitmap(preview) + return@repeat } - callback.onBitmap( - preview - ) - break + delay(1000L) } } @@ -373,6 +369,7 @@ class GeneratorPlayer : FullScreenPlayer() { return mutableMapOf( STOP_ACTION to NotificationCompat.Action( R.drawable.baseline_stop_24, + @SuppressLint("PrivateResource") context.getString(androidx.media3.ui.R.string.exo_controls_stop_description), createBroadcastIntent(STOP_ACTION, context, instanceId) ) @@ -633,7 +630,6 @@ class GeneratorPlayer : FullScreenPlayer() { imageViewEnd.setImageDrawable(drawableEnd) } - @SuppressLint("SetTextI18n") override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view = convertView ?: LayoutInflater.from(context).inflate(layout, null) @@ -649,6 +645,7 @@ class GeneratorPlayer : FullScreenPlayer() { item?.let { fromTagToLanguageName(it.lang) ?: it.lang } ?: "" val providerSuffix = if (isSingleProvider || item == null) "" else " · ${item.source}" + @SuppressLint("SetTextI18n") secondaryTextView?.text = language + providerSuffix setHearingImpairedIcon(drawableEnd, position) @@ -1515,7 +1512,6 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun playerError(exception: Throwable) { val currentUrl = currentSelectedLink?.let { it.first?.url ?: it.second?.uri?.toString() } ?: "unknown" @@ -1841,8 +1837,6 @@ class GeneratorPlayer : FullScreenPlayer() { return "" } - - @SuppressLint("SetTextI18n") fun setTitle() { var playerVideoTitle = getPlayerVideoTitle() @@ -1864,7 +1858,6 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.offlinePin?.isVisible = lastUsedGenerator is DownloadFileGenerator } - @SuppressLint("SetTextI18n") fun setPlayerDimen(widthHeight: Pair?) { val resolution = widthHeight?.let { "${it.first}x${it.second}" } val name = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name @@ -1976,29 +1969,8 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - // this is used instead of layout-television to follow the settings and some TV devices are not classified as TV for some reason - layout = - if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player - - viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] - sync = ViewModelProvider(this)[SyncViewModel::class.java] - - viewModel.attachGenerator(lastUsedGenerator) - unwrapBundle(savedInstanceState) - unwrapBundle(arguments) - - val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null - binding = FragmentPlayerBinding.bind(root) - return root - } - - override fun onDestroyView() { - binding = null - super.onDestroyView() - } + override fun pickLayout(): Int = + if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player var skipAnimator: ValueAnimator? = null var skipIndex = 0 @@ -2120,17 +2092,14 @@ class GeneratorPlayer : FullScreenPlayer() { } // update overlay season title - var lastTopIndex = -1 + val lastTopIndex = -1 playerEpisodeList.addOnScrollListener(object : RecyclerView.OnScrollListener() { - @SuppressLint("SetTextI18n", "DefaultLocale") override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return val topIndex = layoutManager.findFirstCompletelyVisibleItemPosition() if (topIndex != RecyclerView.NO_POSITION && topIndex != lastTopIndex) { - lastTopIndex = topIndex val topItem = episodes.getOrNull(topIndex) - topItem?.let { playerEpisodeOverlayTitle.setText( ResultViewModel2.seasonToTxt( @@ -2148,9 +2117,15 @@ class GeneratorPlayer : FullScreenPlayer() { } } - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onBindingCreated(binding: FragmentPlayerBinding, savedInstanceState: Bundle?) { + viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] + sync = ViewModelProvider(this)[SyncViewModel::class.java] + viewModel.attachGenerator(lastUsedGenerator) + unwrapBundle(savedInstanceState) + unwrapBundle(arguments) + + super.onBindingCreated(binding, savedInstanceState) + var langFilterList = listOf() var filterSubByLang = false @@ -2184,11 +2159,11 @@ class GeneratorPlayer : FullScreenPlayer() { viewModel.loadLinks() } - binding?.overlayLoadingSkipButton?.setOnClickListener { + binding.overlayLoadingSkipButton.setOnClickListener { startPlayer() } - binding?.playerLoadingGoBack?.setOnClickListener { + binding.playerLoadingGoBack.setOnClickListener { exitFullscreen() player.release() activity?.popCurrentPage() @@ -2232,14 +2207,15 @@ class GeneratorPlayer : FullScreenPlayer() { observe(viewModel.currentLinks) { currentLinks = it val turnVisible = it.isNotEmpty() && lastUsedGenerator?.canSkipLoading == true - val wasGone = binding?.overlayLoadingSkipButton?.isGone == true + val wasGone = binding.overlayLoadingSkipButton.isGone - binding?.overlayLoadingSkipButton?.apply { + binding.overlayLoadingSkipButton.apply { isVisible = turnVisible val value = viewModel.currentLinks.value if (value.isNullOrEmpty()) { setText(R.string.skip_loading) } else { + @SuppressLint("SetTextI18n") text = "${context.getString(R.string.skip_loading)} (${value.size})" } } @@ -2255,7 +2231,7 @@ class GeneratorPlayer : FullScreenPlayer() { } if (turnVisible && wasGone) { - binding?.overlayLoadingSkipButton?.requestFocus() + binding.overlayLoadingSkipButton.requestFocus() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt new file mode 100644 index 00000000000..5575425add1 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -0,0 +1,1136 @@ +package com.lagradost.cloudstream3.ui.player + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.provider.Settings +import android.content.res.ColorStateList +import android.graphics.Matrix +import android.media.AudioManager +import android.media.audiofx.LoudnessEnhancer +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.View +import android.view.ViewGroup +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.annotation.OptIn +import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CommonActivity.keyEventListener +import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation +import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.Vector2 +import kotlin.math.abs +import kotlin.math.absoluteValue +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round +import kotlin.math.roundToInt + +/** + * Handles all gesture, volume, brightness, speed-up, zoom, and hardware-key-event input for a + * [PlayerView]. Keeps these separate from the player-view setup and lifecycle + * code in [PlayerView] itself. + * + * Instantiated and owned by [PlayerView]; accessed from host fragments via the delegate + * properties [PlayerView] exposes. + */ +@OptIn(UnstableApi::class) +class PlayerGestureHelper(private val playerView: PlayerView) { + + private val context: Context get() = playerView.context + + /** Set true by the host when the player occupies the full screen. + * Controls whether hardware volume-key overrides are active (phones/emulators only). */ + var isFullScreen: Boolean = false + + /** Volume state */ + var currentRequestedVolume: Float = 0.0f + var isVolumeLocked: Boolean = false + var hasShownVolumeToast: Boolean = false + private var loudnessEnhancer: LoudnessEnhancer? = null + private var progressBarLeftHideRunnable: Runnable? = null + + /** Brightness state */ + var currentRequestedBrightness: Float = 1.0f + var currentExtraBrightness: Float = 0.0f + var isBrightnessLocked: Boolean = false + var hasShownBrightnessToast: Boolean = false + /** When true, read/write system brightness via [Settings.System.SCREEN_BRIGHTNESS]. + * Automatically falls back to window-attribute brightness if the permission is missing. */ + var useTrueSystemBrightness: Boolean = true + /** White overlay inflated into exo_content_frame; alpha encodes extra brightness (0–1). */ + var brightnessOverlay: View? = null + private var progressBarRightHideRunnable: Runnable? = null + + /** Gesture settings (read from prefs in initialize) */ + var swipeVerticalEnabled: Boolean = true + var swipeHorizontalEnabled: Boolean = false + var extraBrightnessEnabled: Boolean = false + var speedupEnabled: Boolean = false + + /** Hold / speed-up */ + val holdHandler = Handler(Looper.getMainLooper()) + var hasTriggeredSpeedUp = false + val holdRunnable = Runnable { + playerView.player.setPlaybackSpeed(2.0f) + showOrHideSpeedUp(true) + playerView.callbacks?.onHoldSpeedUp(true) + hasTriggeredSpeedUp = true + } + + enum class TouchAction { Brightness, Volume, Time } + + /** Mirrors the host's lock state; suppresses gesture interactions when true. */ + var isLocked: Boolean = false + + /** Touch tracking */ + var isCurrentTouchValid = false + private set + private var currentTouchStart: Vector2? = null + private var currentTouchLast: Vector2? = null + /** Current in-progress swipe action, null when no swipe is active. */ + var currentTouchAction: TouchAction? = null + /** Action from the previous touch sequence; guards against mis-detected double-taps after swipes. */ + var currentLastTouchAction: TouchAction? = null + private var currentTouchStartPlayerTime: Long? = null + private var currentTouchStartTime: Long? = null + /** Whether the player UI was visible when the current swipe gesture began. */ + var uiShowingBeforeGesture: Boolean = false + + /** Icons */ + private val brightnessIcons = listOf( + R.drawable.sun_1, R.drawable.sun_2, R.drawable.sun_3, + R.drawable.sun_4, R.drawable.sun_5, R.drawable.sun_6, R.drawable.sun_7, + ) + private val volumeIcons = listOf( + R.drawable.ic_baseline_volume_mute_24, + R.drawable.ic_baseline_volume_down_24, + R.drawable.ic_baseline_volume_up_24, + ) + + /** Double-tap / tap state */ + + /** Whether double-tapping left/right seeks backward/forward. */ + var doubleTapEnabled: Boolean = false + + /** Whether double-tapping the centre of the screen pauses (left/right still seeks if [doubleTapEnabled]). */ + var doubleTapPauseEnabled: Boolean = false + + /** Seek distance (ms) for each double-tap seek. Read from prefs in [initialize]. */ + var fastForwardTime: Long = 10_000L + + /** Monotonically-incremented token; cancels any pending single-tap runnable when a double-tap arrives. */ + private var doubleTapToken = 0 + + /** Number of consecutive taps in the current double-tap window. */ + private var tapCount = 0 + + /** System time of the most-recent touch end. Updated by callers at the end of every ACTION_UP. */ + var lastTouchEndTime: Long = 0L + + /** Zoom state */ + + /** Optional view for showing the snap-hint outline during zoom (set by FullScreenPlayer). */ + var videoOutline: View? = null + + /** Current zoom+pan matrix, or null when no zoom is active. */ + var zoomMatrix: Matrix? = null + + /** The matrix the zoom will animate to after the user lifts fingers. */ + var desiredMatrix: Matrix? = null + + /** Running snap-back animation, or null. */ + var matrixAnimation: ValueAnimator? = null + + private var scaleGestureDetector: ScaleGestureDetector? = null + + /** Midpoint of the two-finger pan, null when no pan is active. */ + var lastPan: Vector2? = null + + private var overlayLayoutListener: View.OnLayoutChangeListener? = null + + /** Called from [PlayerView.initialize] after views are bound. */ + fun initialize() { + try { + val sm = PreferenceManager.getDefaultSharedPreferences(context) + swipeVerticalEnabled = sm.getBoolean(context.getString(R.string.swipe_vertical_enabled_key), true) + swipeHorizontalEnabled = sm.getBoolean(context.getString(R.string.swipe_enabled_key), true) + extraBrightnessEnabled = sm.getBoolean(context.getString(R.string.extra_brightness_key), false) + speedupEnabled = sm.getBoolean(context.getString(R.string.speedup_key), false) + doubleTapEnabled = sm.getBoolean(context.getString(R.string.double_tap_enabled_key), false) + doubleTapPauseEnabled = sm.getBoolean(context.getString(R.string.double_tap_pause_enabled_key), false) + fastForwardTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10).toLong() * 1000L + } catch (_: Exception) { } + + // Inject the brightness overlay into the ExoPlayer content frame so it sits + // directly on top of the video surface. Alpha is set by handleBrightnessAdjustment. + safe { + val pkg = context.packageName + val contentId = context.resources.getIdentifier("exo_content_frame", "id", pkg) + val contentFrame = playerView.exoPlayerView?.findViewById(contentId) + if (contentFrame != null) { + brightnessOverlay?.let { (it.parent as? ViewGroup)?.removeView(it) } + brightnessOverlay = LayoutInflater.from(context) + .inflate(R.layout.extra_brightness_overlay, contentFrame, false) + contentFrame.addView(brightnessOverlay) + } + } + + setupTouchGestures() + } + + /** Called from [PlayerView.release]. */ + fun release() { + safe { brightnessOverlay?.let { (it.parent as? ViewGroup)?.removeView(it) } } + brightnessOverlay = null + loudnessEnhancer?.release() + loudnessEnhancer = null + holdHandler.removeCallbacksAndMessages(null) + clearZoomState() + releaseOverlayLayoutListener() + } + + /** Key-event listener */ + + /** + * Registers the basic volume-key listener on [keyEventListener]. + * Called from [PlayerView.initialize] and from the host fragment's onResume. + */ + fun setupKeyEventListener() { + keyEventListener = { (event, _) -> + if (event != null && event.action == KeyEvent.ACTION_DOWN) + handleVolumeKey(event.keyCode) + else false + } + } + + /** Nulls [keyEventListener]. Called from the host fragment's onPause. */ + fun releaseKeyEventListener() { + keyEventListener = null + } + + /** Speed-up */ + + fun showOrHideSpeedUp(show: Boolean) { + playerView.playerSpeedupButton?.let { btn -> + btn.clearAnimation() + btn.alpha = if (show) 0f else 1f + btn.isVisible = show + btn.animate() + .alpha(if (show) 1f else 0f) + .setDuration(200L) + .withEndAction { if (!show) btn.isVisible = false } + .start() + } + } + + /** Volume helpers */ + + /** Syncs [currentRequestedVolume] with the current system stream volume. */ + fun verifyVolume() { + ((context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { am -> + val cur = am.getStreamVolume(AudioManager.STREAM_MUSIC) + val max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + if (cur < max || currentRequestedVolume <= 1.0f) { + currentRequestedVolume = cur.toFloat() / max.toFloat() + loudnessEnhancer?.release() + loudnessEnhancer = null + } + } + } + + /** + * Handles a hardware volume key press. + * Only active on phones/emulators when [isFullScreen] is true. + * + * @return true if the key was consumed (suppresses the system volume UI). + */ + fun handleVolumeKey(keyCode: Int): Boolean { + if (!isLayout(PHONE or EMULATOR) || !isFullScreen) return false + if (keyCode != KeyEvent.KEYCODE_VOLUME_UP && keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) return false + verifyVolume() + if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false + isVolumeLocked = currentRequestedVolume < 1.0f + handleVolumeAdjustment(if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) 0.05f else -0.05f, fromButton = true) + return true + } + + fun handleVolumeAdjustment(delta: Float, fromButton: Boolean) { + val am = (context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return + val curStep = am.getStreamVolume(AudioManager.STREAM_MUSIC) + val maxStep = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + + val cur = currentRequestedVolume + val locked = isVolumeLocked + val next = (cur + delta).coerceIn(0.0f, if (locked) 1.0f else 2.0f) + val nextStep = (next * maxStep.toFloat()).roundToInt().coerceIn(0, maxStep) + + if (fromButton) { + if (cur <= 1.0f && next > 1.0f && !hasShownVolumeToast) { + showToast(R.string.volume_exceeded_100) + hasShownVolumeToast = true + } + } else { + val raw = cur + delta + if (raw > 1.0 && locked && !hasShownVolumeToast) { + showToast(R.string.slide_up_again_to_exceed_100) + hasShownVolumeToast = true + } + } + + if (nextStep != curStep) am.setStreamVolume(AudioManager.STREAM_MUSIC, nextStep, 0) + + var hasBoostError = false + if (next > 1.0f) { + val boost = ((next - 1.0f) * 1000).toInt() + val existing = loudnessEnhancer + if (existing != null) { + existing.setTargetGain(boost) + } else { + val sessionId = (playerView.exoPlayerView?.player as? ExoPlayer)?.audioSessionId + if (sessionId != null && sessionId != AudioManager.ERROR) { + try { + loudnessEnhancer = LoudnessEnhancer(sessionId).apply { + setTargetGain(boost); enabled = true + } + } catch (t: Throwable) { logError(t); hasBoostError = true } + } + } + } else { + loudnessEnhancer?.release(); loudnessEnhancer = null + } + + currentRequestedVolume = next + + val leftHolder = playerView.playerProgressbarLeftHolder ?: return + val level1 = playerView.playerProgressbarLeftLevel1 ?: return + val level2 = playerView.playerProgressbarLeftLevel2 ?: return + val icon = playerView.playerProgressbarLeftIcon ?: return + + if (next > 1.0f) { + level2.progressTintList = ColorStateList.valueOf( + ContextCompat.getColor(context, if (hasBoostError) R.color.colorPrimaryRed else R.color.colorPrimaryOrange) + ) + } + level1.max = 100_000 + level1.progress = (next * 100_000f).toInt().coerceIn(2_000, 100_000) + level2.max = 100_000 + level2.progress = if (next > 1.0f) ((next - 1.0) * 100_000f).toInt().coerceIn(2_000, 100_000) else 0 + level2.isVisible = next > 1.0f + val iconIdx = (next * volumeIcons.lastIndex).roundToInt().coerceIn(0, volumeIcons.lastIndex) + icon.setImageResource(volumeIcons[iconIdx]) + + if (!leftHolder.isVisible || leftHolder.alpha < 1f) { + leftHolder.animate().cancel(); leftHolder.alpha = 1f; leftHolder.isVisible = true + } + progressBarLeftHideRunnable?.let { leftHolder.removeCallbacks(it) } + progressBarLeftHideRunnable = Runnable { + leftHolder.animate().cancel() + leftHolder.animate().alpha(0f).setDuration(300).withEndAction { leftHolder.isVisible = false }.start() + } + leftHolder.postDelayed(progressBarLeftHideRunnable, 1500) + } + + /** Brightness helpers */ + + /** + * Reads from [Settings.System.SCREEN_BRIGHTNESS], falling back to the window + * attribute if the permission is absent. + */ + fun getBrightness(): Float? { + return if (useTrueSystemBrightness) { + try { + Settings.System.getInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS + ) / 255f + } catch (_: Exception) { + // Permission not granted — fall back to window-attribute mode permanently. + useTrueSystemBrightness = false + getBrightness() + } + } else { + try { + (context as? Activity)?.window?.attributes?.screenBrightness?.takeIf { it >= 0f } + } catch (e: Exception) { + logError(e) + null + } + } + } + + /** + * Sets [Settings.System.SCREEN_BRIGHTNESS], falling back to the window + * attribute if the permission is absent. + */ + fun setBrightness(brightness: Float) { + if (useTrueSystemBrightness) { + try { + Settings.System.putInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS_MODE, + Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL + ) + Settings.System.putInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS, + min(1, (brightness.coerceIn(0.0f, 1.0f) * 255).toInt()) + ) + } catch (_: Exception) { + useTrueSystemBrightness = false + setBrightness(brightness) + } + } else { + try { + val lp = (context as? Activity)?.window?.attributes ?: return + // Use 0.004f instead of 0: on some devices a value too close to 0 causes the + // system to override with its own brightness, making fine-tuning impossible. + lp.screenBrightness = brightness.coerceIn(0.004f, 1.0f) + (context as? Activity)?.window?.attributes = lp + } catch (e: Exception) { + logError(e) + } + } + } + + fun handleBrightnessAdjustment(verticalAddition: Float) { + val lastBrightness = currentRequestedBrightness + val raw = currentRequestedBrightness + verticalAddition + val next = raw.coerceIn(0.0f, if (extraBrightnessEnabled && !isBrightnessLocked) 2.0f else 1.0f) + + if (extraBrightnessEnabled && isBrightnessLocked && raw > 1.0f && !hasShownBrightnessToast) { + showToast(R.string.slide_up_again_to_exceed_100) + hasShownBrightnessToast = true + } + + currentRequestedBrightness = next + if (lastBrightness != currentRequestedBrightness) setBrightness(currentRequestedBrightness) + + currentExtraBrightness = if (extraBrightnessEnabled && next > 1.0f) min(2.0f, next) - 1.0f else 0.0f + brightnessOverlay?.alpha = currentExtraBrightness + playerView.callbacks?.onBrightnessExtra(currentExtraBrightness) + + val rightHolder = playerView.playerProgressbarRightHolder ?: return + val level1 = playerView.playerProgressbarRightLevel1 ?: return + val level2 = playerView.playerProgressbarRightLevel2 ?: return + val icon = playerView.playerProgressbarRightIcon ?: return + + level1.max = 100_000 + level1.progress = max(2_000, (min(1.0f, next) * 100_000f).toInt()) + + if (extraBrightnessEnabled) { + level2.max = 100_000 + level2.progress = (currentExtraBrightness * 100_000f).toInt().coerceIn(2_000, 100_000) + level2.isVisible = next > 1.0f + } + + icon.setImageResource( + brightnessIcons[min(brightnessIcons.lastIndex, max(0, round(next * brightnessIcons.lastIndex).toInt()))] + ) + + if (!rightHolder.isVisible || rightHolder.alpha < 1f) { + rightHolder.animate().cancel(); rightHolder.alpha = 1f; rightHolder.isVisible = true + } + progressBarRightHideRunnable?.let { rightHolder.removeCallbacks(it) } + progressBarRightHideRunnable = Runnable { + rightHolder.animate().cancel() + rightHolder.animate().alpha(0f).setDuration(300).withEndAction { rightHolder.isVisible = false }.start() + } + rightHolder.postDelayed(progressBarRightHideRunnable, 1500) + } + + /** Zoom helpers */ + + /** + * Returns the current zoom matrix, accounting for RESIZE_MODE_ZOOM which already has + * an implicit zoom applied. + */ + fun currentZoomMatrix(): Matrix { + val current = zoomMatrix + if (current != null) return current + + val exoView = playerView.exoPlayerView + val videoView = exoView?.videoSurfaceView + + if (exoView == null || videoView == null || + exoView.resizeMode != AspectRatioFrameLayout.RESIZE_MODE_ZOOM) { + return Matrix() + } + + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() + val playerHeight = screenHeightWithOrientation.toFloat() + + if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f) { + return Matrix() + } + + val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = max(initAspect, 1f / initAspect) + return Matrix().apply { postScale(aspect, aspect) } + } + + /** Applies [newMatrix] (scale + translation only) to the video surface view. */ + fun applyZoomMatrix(newMatrix: Matrix, animation: Boolean) { + val exoView = playerView.exoPlayerView ?: return + if (!animation) { + matrixAnimation?.cancel() + matrixAnimation = null + } + val (translationX, translationY, scale) = matrixToTranslationAndScale(newMatrix) + + if (exoView.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT) { + exoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + + val videoView = exoView.videoSurfaceView ?: return + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() + val playerHeight = screenHeightWithOrientation.toFloat() + + if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f || scale <= 0.01f) return + + val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = min(initAspect, 1f / initAspect) + val scaledAspect = scale * aspect + + val maxTransX = max(0f, videoWidth * scaledAspect - playerWidth) * 0.5f + val maxTransY = max(0f, videoHeight * scaledAspect - playerHeight) * 0.5f + + val expectedTranslationX = translationX.coerceIn(-maxTransX, maxTransX) + val expectedTranslationY = translationY.coerceIn(-maxTransY, maxTransY) + + newMatrix.postTranslate( + expectedTranslationX - translationX, + expectedTranslationY - translationY + ) + zoomMatrix = newMatrix + + if (!animation) { + if ((scaledAspect - 1f).absoluteValue < ZOOM_SNAP_SENSITIVITY) { + videoOutline?.isVisible = true + val desired = Matrix() + desired.setScale(1f / aspect, 1f / aspect) + desiredMatrix = desired + } else if (scale < 1f) { + videoOutline?.isVisible = false + desiredMatrix = Matrix() + } else { + videoOutline?.isVisible = false + desiredMatrix = null + } + } + + videoView.scaleX = scaledAspect + videoView.scaleY = scaledAspect + videoView.translationX = expectedTranslationX + videoView.translationY = expectedTranslationY + updateBrightnessOverlayBounds() + } + + /** + * Clears all zoom state and resets the video surface view to 1:1 scale. + * Does NOT change the ExoPlayer resize mode - call [PlayerView.resize] separately. + */ + fun clearZoomState() { + matrixAnimation?.cancel() + matrixAnimation = null + zoomMatrix = null + desiredMatrix = null + scaleGestureDetector = null + lastPan = null + playerView.exoPlayerView?.videoSurfaceView?.apply { + scaleX = 1f + scaleY = 1f + translationX = 0f + translationY = 0f + } + } + + /** + * Resets zoom to fit mode if any zoom is currently active. + * Calls [PlayerView.resize] to update the ExoPlayer resize mode. + */ + fun resetZoomToDefault() { + if (zoomMatrix != null) { + clearZoomState() + playerView.resize(PlayerResize.Fit, false) + } + } + + private fun createScaleGestureDetector(ctx: Context) { + scaleGestureDetector = ScaleGestureDetector( + ctx, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + val matrix = currentZoomMatrix() + val (_, _, scale) = matrixToTranslationAndScale(matrix) + val newScale = (scale * detector.scaleFactor).coerceIn(MINIMUM_ZOOM, MAXIMUM_ZOOM) + val actualScaleFactor = newScale / scale + val pivotX = detector.focusX - screenWidthWithOrientation.toFloat() * 0.5f + val pivotY = detector.focusY - screenHeightWithOrientation.toFloat() * 0.5f + matrix.postScale(actualScaleFactor, actualScaleFactor, pivotX, pivotY) + applyZoomMatrix(matrix, false) + return true + } + } + ) + } + + /** + * Processes a two-finger zoom/pan gesture event. + * Handles scale detection, panning, and the snap-back animation after finger lift. + * + * @param event The motion event (should have pointerCount >= 2 or [lastPan] != null). + * @param ctx Context used to create the [ScaleGestureDetector] on first call. + * @param onFirstPointerDown Called on [MotionEvent.ACTION_POINTER_DOWN] (e.g. hide player UI). + * @param onGestureEnd Called when the gesture ends (e.g. reset caller touch state). + * @return Always true (event consumed). + */ + fun handleZoomPanGesture( + event: MotionEvent, + ctx: Context, + onFirstPointerDown: () -> Unit = {}, + onGestureEnd: () -> Unit = {} + ): Boolean { + if (scaleGestureDetector == null) createScaleGestureDetector(ctx) + scaleGestureDetector?.onTouchEvent(event) + + when (event.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + onFirstPointerDown() + } + + MotionEvent.ACTION_MOVE -> { + if (event.pointerCount >= 2) { + val newPan = Vector2( + (event.getX(0) + event.getX(1)) / 2f, + (event.getY(0) + event.getY(1)) / 2f + ) + val oldPan = lastPan + if (oldPan != null) { + val matrix = currentZoomMatrix() + matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) + applyZoomMatrix(matrix, false) + } + lastPan = newPan + } + } + + MotionEvent.ACTION_CANCEL, + MotionEvent.ACTION_POINTER_UP, + MotionEvent.ACTION_UP -> { + lastPan = null + videoOutline?.isVisible = false + matrixAnimation?.cancel() + matrixAnimation = null + + // Snap to desired matrix after zoom gesture ends + matrixAnimation = ValueAnimator.ofFloat(0f, 1f).apply { + startDelay = 0 + duration = 200 + val startMatrix = currentZoomMatrix() + val endMatrix = desiredMatrix ?: return@apply + val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) + val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) + addUpdateListener { anim -> + val v = anim.animatedValue as Float + val vInv = 1f - v + val m = Matrix() + m.setScale(startScale * vInv + endScale * v, startScale * vInv + endScale * v) + m.postTranslate(startX * vInv + endX * v, startY * vInv + endY * v) + applyZoomMatrix(m, true) + } + start() + } + + onGestureEnd() + } + } + return true + } + + /** + * Resizes and repositions [brightnessOverlay] to exactly match the visible video surface, + * accounting for zoom scale and translation. + */ + fun updateBrightnessOverlayBounds() { + val overlay = brightnessOverlay ?: return + val pv = playerView.exoPlayerView ?: return + val video = pv.videoSurfaceView ?: return + + val vw = video.width.toFloat() + val vh = video.height.toFloat() + val sx = video.scaleX + val sy = video.scaleY + if (vw <= 0f || vh <= 0f) return + + val pivotX = if (video.pivotX != 0f) video.pivotX else vw * 0.5f + val pivotY = if (video.pivotY != 0f) video.pivotY else vh * 0.5f + val tx = video.x + val ty = video.y + + fun transform(lx: Float, ly: Float): Pair { + val gx = tx + pivotX + (lx - pivotX) * sx + val gy = ty + pivotY + (ly - pivotY) * sy + return Pair(gx, gy) + } + + val p0 = transform(0f, 0f); val p1 = transform(vw, 0f) + val p2 = transform(0f, vh); val p3 = transform(vw, vh) + + val minX = min(min(p0.first, p1.first), min(p2.first, p3.first)) + val maxX = max(max(p0.first, p1.first), max(p2.first, p3.first)) + val minY = min(min(p0.second, p1.second), min(p2.second, p3.second)) + val maxY = max(max(p0.second, p1.second), max(p2.second, p3.second)) + + val newW = ceil(maxX - minX).toInt().coerceAtLeast(0) + val newH = ceil(maxY - minY).toInt().coerceAtLeast(0) + + val lp = overlay.layoutParams + if (lp == null) { + overlay.layoutParams = ViewGroup.LayoutParams(newW, newH) + } else if (lp.width != newW || lp.height != newH) { + lp.width = newW; lp.height = newH + overlay.layoutParams = lp + } + + overlay.scaleX = 1f; overlay.scaleY = 1f + overlay.x = minX; overlay.y = minY + } + + /** + * Attaches a persistent layout-change listener to the ExoPlayer view so + * [updateBrightnessOverlayBounds] is called on every layout pass (orientation change, + * aspect-ratio change, zoom, PiP transition, etc.). + */ + fun requestUpdateBrightnessOverlayOnNextLayout() { + val exoView = playerView.exoPlayerView ?: return + overlayLayoutListener?.let { exoView.removeOnLayoutChangeListener(it) } + val listener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + safe { updateBrightnessOverlayBounds() } + } + overlayLayoutListener = listener + exoView.addOnLayoutChangeListener(listener) + } + + /** Removes the overlay layout listener registered by [requestUpdateBrightnessOverlayOnNextLayout]. */ + fun releaseOverlayLayoutListener() { + overlayLayoutListener?.let { playerView.exoPlayerView?.removeOnLayoutChangeListener(it) } + overlayLayoutListener = null + } + + /** Rewind / fast-forward animations */ + + /** Resets the rewind button label to the standard "–Xs" format. */ + fun resetRewindText() { + playerView.exoRewText?.text = context.getString(R.string.rew_text_regular_format) + .format(fastForwardTime / 1000) + } + + /** Resets the fast-forward button label to the standard "+Xs" format. */ + fun resetFastForwardText() { + playerView.exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format) + .format(fastForwardTime / 1000) + } + + /** + * Fades playerRewHolder, playerFfwdHolder, and playerPausePlay to [fadeTo] (0f or 1f). + * Always resets the holder alphas to 1f first so any stale fillAfter state is cleared. + * Called from host fragments' show/hide control animations so both GeneratorPlayer and trailer share + * the same fade logic. + */ + fun animateCenterControls(fadeTo: Float) { + val from = if (fadeTo > 0.5f) 0f else 1f + fun makeAnim() = AlphaAnimation(from, fadeTo).apply { duration = 100; fillAfter = true } + // Each view needs its own Animation instance; sharing one causes fillAfter to + // not hold reliably across all views once any of them restarts the animation. + playerView.playerRewHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } + playerView.playerFfwdHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } + playerView.playerPausePlay?.startAnimation(makeAnim()) + } + + /** Plays the rewind animation and seeks back by [fastForwardTime]. */ + fun rewind() { + try { + val rewHolder = playerView.playerRewHolder ?: return + val rew = playerView.playerRew + val rewText = playerView.exoRewText + val wasShowing = playerView.callbacks?.isUiShowing() ?: false + + // Only expose the parent chain when controls are currently hidden. + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true + if (!wasShowing) { + playerView.playerCenterMenu?.isGone = false + playerView.playerVideoHolder?.isVisible = true + } + // Always clear any stale fillAfter alpha so the button is visible during animation. + rewHolder.alpha = 1f + + rew?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_left)) + val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left) + goLeft.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + override fun onAnimationRepeat(animation: Animation?) {} + override fun onAnimationEnd(animation: Animation?) { + rewText?.post { + resetRewindText() + // Restore parent chain only if we changed it and controls are still hidden. + if (!wasShowing && !(playerView.callbacks?.isUiShowing() ?: false)) { + playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible + rewHolder.alpha = 0f + } + } + } + }) + rewText?.startAnimation(goLeft) + rewText?.text = context.getString(R.string.rew_text_format).format(fastForwardTime / 1000) + playerView.player.seekTime(-fastForwardTime) + } catch (e: Exception) { logError(e) } + } + + /** Plays the fast-forward animation and seeks forward by [fastForwardTime]. */ + fun fastForward() { + try { + val ffwdHolder = playerView.playerFfwdHolder ?: return + val ffwd = playerView.playerFfwd + val ffwdText = playerView.exoFfwdText + val wasShowing = playerView.callbacks?.isUiShowing() ?: false + + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true + if (!wasShowing) { + playerView.playerCenterMenu?.isGone = false + playerView.playerVideoHolder?.isVisible = true + } + // Always clear any stale fillAfter alpha so the button is visible during animation. + ffwdHolder.alpha = 1f + + ffwd?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_right)) + val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) + goRight.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + override fun onAnimationRepeat(animation: Animation?) {} + override fun onAnimationEnd(animation: Animation?) { + ffwdText?.post { + resetFastForwardText() + if (!wasShowing && !(playerView.callbacks?.isUiShowing() ?: false)) { + playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible + ffwdHolder.alpha = 0f + } + } + } + }) + ffwdText?.startAnimation(goRight) + ffwdText?.text = context.getString(R.string.ffw_text_format).format(fastForwardTime / 1000) + playerView.player.seekTime(fastForwardTime) + } catch (e: Exception) { logError(e) } + } + + /** Double-tap detection */ + + /** + * Call when a valid tap is detected (short hold, minimal movement, valid touch area). + * Routes to double-tap seeking/pausing or schedules a delayed single-tap callback. + * + * Updates [lastTouchEndTime] when a confirmed tap (single or double) is recorded. + * + * @param x X coordinate of the tap in the view's coordinate space. + * @param viewWidth Width of the view (used to compute left/center/right zones). + * @param isLocked Whether player controls are locked (suppresses double-tap seek). + * @param onSingleTap Invoked when it is determined to be a single tap; may be deferred. + * @return true if a double-tap action was performed. + */ + fun onTapDetected(x: Float, viewWidth: Int, isLocked: Boolean, onSingleTap: () -> Unit): Boolean { + val anyDoubleTap = doubleTapEnabled || doubleTapPauseEnabled + if (!anyDoubleTap) { + onSingleTap() + return false + } + + val timeSinceLast = System.currentTimeMillis() - lastTouchEndTime + return if (!isLocked && timeSinceLast < DOUBLE_TAP_MINIMUM_TIME_BETWEEN) { + /** Double-tap */ + tapCount++ + doubleTapToken++ // cancel any pending single-tap runnable + if (doubleTapPauseEnabled) { + when { + x < viewWidth / 2f - (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { + if (doubleTapEnabled) rewind() + } + x > viewWidth / 2f + (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { + if (doubleTapEnabled) fastForward() + } + else -> { + playerView.player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) + } + } + } else if (doubleTapEnabled) { + if (x < viewWidth / 2f) rewind() else fastForward() + } + true + } else { + /** Single tap (first tap, or too slow for double-tap) */ + tapCount = 0 + val token = ++doubleTapToken + playerView.playerHolder?.postDelayed({ + if (token == doubleTapToken) { + onSingleTap() + } + }, DOUBLE_TAP_MINIMUM_TIME_BETWEEN) + false + } + } + + /** Seek time helpers */ + + private fun calculateNewTime(startTime: Long?, touchStart: Vector2?, touchEnd: Vector2?): Long? { + if (touchStart == null || touchEnd == null || startTime == null) return null + val diffX = (touchEnd.x - touchStart.x) * HORIZONTAL_MULTIPLIER / screenWidthWithOrientation.toFloat() + val duration = playerView.player.getDuration() ?: return null + return max(min(startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), duration), 0) + } + + private fun forceLetters(inp: Long, letters: Int = 2): String { + val added = letters - inp.toString().length + return if (added > 0) "0".repeat(added) + inp.toString() else inp.toString() + } + + private fun convertTimeToString(sec: Long): String { + val rsec = sec % 60L + val min = ceil((sec - rsec) / 60.0).toInt() + val rmin = min % 60L + val h = ceil((min - rmin) / 60.0).toLong() + return (if (h > 0) forceLetters(h) + ":" else "") + + (if (rmin >= 0 || h >= 0) forceLetters(rmin) + ":" else "") + + forceLetters(rsec) + } + + /** Touch gestures */ + + @SuppressLint("ClickableViewAccessibility") + fun setupTouchGestures() { + val holder = playerView.playerHolder ?: return + holder.setOnTouchListener { v, event -> handleGesture(v, event) } + } + + private fun handleGesture(view: View, event: MotionEvent): Boolean { + val currentTouch = Vector2(event.x, event.y) + val startTouch = currentTouchStart + + /** Two-finger zoom/pan (fullscreen, unlocked) */ + if ((event.pointerCount >= 2 || lastPan != null) && isFullScreen && !isLocked + && !hasTriggeredSpeedUp && currentTouchAction == null) { + holdHandler.removeCallbacks(holdRunnable) + isCurrentTouchValid = false + return handleZoomPanGesture( + event = event, + ctx = view.context, + onFirstPointerDown = { + uiShowingBeforeGesture = playerView.callbacks?.isUiShowing() ?: false + playerView.callbacks?.onHidePlayerUI() + }, + onGestureEnd = { + currentTouchStart = null + currentLastTouchAction = null + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + } + ) + } + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + isCurrentTouchValid = playerView.callbacks?.isValidTouch(event.rawX, event.rawY) ?: true + if (isCurrentTouchValid) { + playerView.callbacks?.onTouchDown() + hasTriggeredSpeedUp = false + if (speedupEnabled && playerView.player.getIsPlaying() && !isLocked) { + holdHandler.postDelayed(holdRunnable, 500) + } + isVolumeLocked = currentRequestedVolume < 1.0f + if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false + isBrightnessLocked = currentRequestedBrightness < 1.0f + if (currentRequestedBrightness <= 1.0f) hasShownBrightnessToast = false + currentTouchStartTime = System.currentTimeMillis() + currentTouchStart = currentTouch + currentTouchLast = currentTouch + currentTouchStartPlayerTime = playerView.player.getPosition() + getBrightness()?.let { currentRequestedBrightness = it + currentExtraBrightness } + verifyVolume() + } + return true + } + + MotionEvent.ACTION_MOVE -> { + if (hasTriggeredSpeedUp) return true + if (!isCurrentTouchValid) return true + + if (currentTouchAction == null && startTouch != null) { + val diffFromStart = startTouch - currentTouch + if (swipeVerticalEnabled) { + if (abs(diffFromStart.y * 100 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) { + holdHandler.removeCallbacks(holdRunnable) + uiShowingBeforeGesture = playerView.callbacks?.isUiShowing() ?: false + playerView.callbacks?.onHidePlayerUI() + currentTouchAction = if ((startTouch.x) >= view.width / 2f) + TouchAction.Volume else TouchAction.Brightness + } + } + if (swipeHorizontalEnabled && !isLocked) { + if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) { + holdHandler.removeCallbacks(holdRunnable) + currentTouchAction = TouchAction.Time + } + } + } + + val lastTouch = currentTouchLast + if (lastTouch != null) { + val diffFromLast = lastTouch - currentTouch + val verticalAddition = diffFromLast.y * VERTICAL_MULTIPLIER / view.height.toFloat() + when (currentTouchAction) { + TouchAction.Time -> { + val startTime = currentTouchStartPlayerTime?.div(1000L)?.times(1000L) + if (startTime != null) { + calculateNewTime(startTime, startTouch, currentTouch)?.let { newMs -> + val skipMs = newMs - startTime + playerView.callbacks?.onSeekPreviewText( + "${convertTimeToString(newMs / 1000)} [${ + if (abs(skipMs) < 1000) "" else if (skipMs > 0) "+" else "-" + }${convertTimeToString(abs(skipMs / 1000))}]" + ) + } + } + } + TouchAction.Brightness -> handleBrightnessAdjustment(verticalAddition) + TouchAction.Volume -> handleVolumeAdjustment(verticalAddition, false) + null -> Unit + } + if (currentTouchAction != TouchAction.Time) { + playerView.callbacks?.onSeekPreviewText(null) + } + } + currentTouchLast = currentTouch + return true + } + + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + holdHandler.removeCallbacks(holdRunnable) + if (hasTriggeredSpeedUp) { + playerView.player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) + showOrHideSpeedUp(false) + playerView.callbacks?.onHoldSpeedUp(false) + hasTriggeredSpeedUp = false + } + + if (isCurrentTouchValid) { + // Horizontal seek on release + if (swipeHorizontalEnabled && currentTouchAction == TouchAction.Time && !isLocked) { + val startTime = currentTouchStartPlayerTime + if (startTime != null) { + calculateNewTime(startTime, startTouch, currentTouch)?.let { seekTo -> + if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { + playerView.player.seekTo(seekTo, PlayerEventSource.UI) + } + } + } + } + // Tap detection: only fire if the finger was held briefly (not a long-press). + val holdTime = currentTouchStartTime?.let { System.currentTimeMillis() - it } + if (currentTouchAction == null && currentLastTouchAction == null + && !hasTriggeredSpeedUp + && (holdTime == null || holdTime < DOUBLE_TAP_MAXIMUM_HOLD_TIME)) { + onTapDetected( + x = currentTouch.x, + viewWidth = view.width, + isLocked = isLocked, + onSingleTap = { playerView.callbacks?.onSingleTap() } + ) + } + } + + playerView.callbacks?.onSeekPreviewText(null) + val hadSwipe = currentTouchAction != null || currentLastTouchAction != null + playerView.callbacks?.onGestureEnd(hadSwipe, uiShowingBeforeGesture) + + lastTouchEndTime = System.currentTimeMillis() + isCurrentTouchValid = false + currentTouchStart = null + currentLastTouchAction = currentTouchAction + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + uiShowingBeforeGesture = false + return true + } + } + return false + } + + companion object { + /** Swipe-seek constants */ + const val MINIMUM_SEEK_TIME = 7000L + const val MINIMUM_VERTICAL_SWIPE = 2.0f // % of screen height + const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // % of screen height + const val VERTICAL_MULTIPLIER = 2.0f + const val HORIZONTAL_MULTIPLIER = 2.0f + + /** Double-tap constants */ + /** Maximum finger-hold time (ms) for a tap to qualify as a double-tap seek. */ + const val DOUBLE_TAP_MAXIMUM_HOLD_TIME = 200L + /** Time window (ms) between taps to count as a double-tap. + * Also determines how long a single-tap is delayed before firing. */ + const val DOUBLE_TAP_MINIMUM_TIME_BETWEEN = 200L + /** Fraction of view width on each side that counts as "left" / "right" seek zone. */ + const val DOUBLE_TAP_PAUSE_PERCENTAGE = 0.15 + + /** Zoom constants */ + /** Minimum zoom; allows zooming out past 100% but snaps back. */ + const val MINIMUM_ZOOM = 0.95f + /** Sensitivity for the auto-snap to 100% at the minimum zoom boundary. */ + const val ZOOM_SNAP_SENSITIVITY = 0.07f + /** Maximum zoom to prevent the user from getting lost. */ + const val MAXIMUM_ZOOM = 4.0f + + /** Extracts translation and uniform scale from a matrix with no rotation. */ + fun matrixToTranslationAndScale(matrix: Matrix): Triple { + val points = floatArrayOf(0f, 0f, 1f, 1f) + matrix.mapPoints(points) + val translationX = points[0] + val translationY = points[1] + val scale = points[2] - translationX + return Triple(translationX, translationY, scale) + } + } +} 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 new file mode 100644 index 00000000000..1dba796729a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt @@ -0,0 +1,788 @@ +package com.lagradost.cloudstream3.ui.player + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.drawable.AnimatedImageDrawable +import android.graphics.drawable.AnimatedVectorDrawable +import android.media.metrics.PlaybackErrorEvent +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.util.Log +import android.view.View +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.RelativeLayout +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.OptIn +import androidx.core.view.isGone +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.FragmentActivity +import androidx.media3.common.PlaybackException +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.SubtitleView +import androidx.media3.ui.TimeBar +import androidx.preference.PreferenceManager +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat +import com.github.rubensousa.previewseekbar.PreviewBar +import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar +import com.lagradost.cloudstream3.CommonActivity.isInPIPMode +import com.lagradost.cloudstream3.CommonActivity.playerEventListener +import com.lagradost.cloudstream3.CommonActivity.screenWidth +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.player.live.LivePreviewTimeBar +import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.AppContextUtils +import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard +import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI +import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import android.content.pm.ActivityInfo +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.UserPreferenceDelegate +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import java.net.SocketTimeoutException +import android.text.format.DateUtils +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE + +/** + * Shared player view - manages ExoPlayer setup, view binding, lifecycle, and event + * dispatching. Gesture/volume/brightness/key-event input is handled by [gestureHelper] + * ([PlayerGestureHelper]), which is exposed via delegate properties for easier access. + */ +@OptIn(UnstableApi::class) +class PlayerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + + /** All gesture, volume, brightness and key-event logic lives here. */ + val gestureHelper = PlayerGestureHelper(this) + + /** Delegate properties (forwarded to gestureHelper for external callers to have easier access) */ + var isFullScreen: Boolean + get() = gestureHelper.isFullScreen + set(value) { gestureHelper.isFullScreen = value } + + var isLocked: Boolean + get() = gestureHelper.isLocked + set(value) { gestureHelper.isLocked = value } + + var videoOutline: View? + get() = gestureHelper.videoOutline + set(value) { gestureHelper.videoOutline = value } + + /** Delegate methods */ + fun handleVolumeKey(keyCode: Int) = gestureHelper.handleVolumeKey(keyCode) + fun verifyVolume() = gestureHelper.verifyVolume() + fun setupKeyEventListener() = gestureHelper.setupKeyEventListener() + fun releaseKeyEventListener() = gestureHelper.releaseKeyEventListener() + fun clearZoomState() = gestureHelper.clearZoomState() + fun requestUpdateBrightnessOverlayOnNextLayout() = gestureHelper.requestUpdateBrightnessOverlayOnNextLayout() + fun releaseOverlayLayoutListener() = gestureHelper.releaseOverlayLayoutListener() + + /** Callbacks */ + + /** Host-fragment-level callbacks invoked by [mainCallback]. */ + interface Callbacks { + fun nextEpisode() {} + fun prevEpisode() {} + fun playerPositionChanged(position: Long, duration: Long) {} + fun playerStatusChanged() {} + fun playerDimensionsLoaded(width: Int, height: Int) {} + fun subtitlesChanged() {} + fun embeddedSubtitlesFetched(subtitles: List) {} + fun onTracksInfoChanged() {} + fun onTimestamp(timestamp: VideoSkipStamp?) {} + fun onTimestampSkipped(timestamp: VideoSkipStamp) {} + fun exitedPipMode() {} + fun hasNextMirror(): Boolean = false + 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 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). */ + fun onHoldSpeedUp(show: Boolean) {} + /** Called during brightness swipe with the current extra-brightness alpha (0–1). */ + fun onBrightnessExtra(alpha: Float) {} + + /** Touch event callbacks */ + + /** Called to validate a touch position; return false to discard nav-bar / status-bar touches. */ + fun isValidTouch(rawX: Float, rawY: Float): Boolean = true + /** Returns whether the player UI (controls overlay) is currently visible. */ + fun isUiShowing(): Boolean = false + /** Called on a valid ACTION_DOWN; use for e.g. dismissing an episode overlay. */ + fun onTouchDown() {} + /** Called with seek-preview text during a horizontal-swipe, or null to clear it. */ + fun onSeekPreviewText(text: String?) {} + /** Called when a swipe gesture begins; hide the player UI if desired. */ + fun onHidePlayerUI() {} + /** + * Called at the end of each touch sequence. + * @param hadSwipe true if a swipe (brightness/volume/time) was in progress. + * @param wasUiShowing true if the UI was visible when the swipe began. + */ + fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) {} + /** + * Called when the auto-hide timer fires: UI is showing, no touch is active. + * Implement to hide the player controls. + */ + fun onAutoHideUI() {} + } + + var callbacks: Callbacks? = null + + /** Player state */ + + var player: IPlayer = CS3IPlayer() + var resizeMode: Int = 0 + var hasPipModeSupport: Boolean = true + var currentPlayerStatus: CSPlayerLoading = CSPlayerLoading.IsBuffering + var mMediaSession: MediaSession? = null + private var pipReceiver: BroadcastReceiver? = null + + /** Auto-hide */ + private var autoHideToken = 0 + private val autoHideHandler = Handler(Looper.getMainLooper()) + + /** View references (populated by bindViews) */ + + var subView: SubtitleView? = null + var playerPausePlayHolderHolder: FrameLayout? = null + var playerPausePlay: ImageView? = null + var playerBuffering: ProgressBar? = null + /** The Media3/ExoPlayer [androidx.media3.ui.PlayerView] widget. */ + var exoPlayerView: androidx.media3.ui.PlayerView? = null + var piphide: FrameLayout? = null + var subtitleHolder: FrameLayout? = null + internal var playerRew: View? = null + internal var playerFfwd: View? = null + internal var exoRewText: TextView? = null + internal var exoFfwdText: TextView? = null + internal var playerCenterMenu: View? = null + internal var playerRewHolder: View? = null + internal var playerFfwdHolder: View? = null + internal var playerVideoHolder: View? = null + private var skipChapterButton: View? = null + var playerProgressbarLeftHolder: RelativeLayout? = null + var playerProgressbarLeftIcon: ImageView? = null + var playerProgressbarLeftLevel1: ProgressBar? = null + var playerProgressbarLeftLevel2: ProgressBar? = null + var playerProgressbarRightHolder: RelativeLayout? = null + var playerProgressbarRightIcon: ImageView? = null + var playerProgressbarRightLevel1: ProgressBar? = null + var playerProgressbarRightLevel2: ProgressBar? = null + /** Accessed by [PlayerGestureHelper.showOrHideSpeedUp]. */ + internal var playerSpeedupButton: View? = null + var playerHolder: FrameLayout? = null + private var exoDuration: TextView? = null + private var timeLeft: TextView? = null + private var exoPosition: TextView? = null + private var timeLive: View? = null + private var exoProgress: LivePreviewTimeBar? = null + + /** Seek delta used by the basic rew/ffwd click listeners. Read from settings in [initialize]. */ + var seekTime: Long = 10_000L + + /** True when the current video is taller than it is wide. Set by [mainCallback] on [ResizedEvent]. */ + var isVerticalOrientation: Boolean = false + + /** When true, [dynamicOrientation] returns portrait for portrait videos. Read from settings in [initialize]. */ + var autoPlayerRotateEnabled: Boolean = false + + var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) + + // Kept so SubtitlesFragment can unsubscribe the exact same reference. + private val subStyleListener: (SaveCaptionStyle) -> Unit = ::onSubStyleChanged + + /** View discovery */ + + /** + * Discovers player-related views from [root]. IDs absent in compact layouts simply + * remain null — all usage is null-safe. + */ + fun bindViews(root: View) { + playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) + playerPausePlay = root.findViewById(R.id.player_pause_play) + playerBuffering = root.findViewById(R.id.player_buffering) + exoPlayerView = root.findViewById(R.id.player_view) + piphide = root.findViewById(R.id.piphide) + subtitleHolder = root.findViewById(R.id.subtitle_holder) + playerRew = root.findViewById(R.id.player_rew) + playerFfwd = root.findViewById(R.id.player_ffwd) + exoRewText = root.findViewById(R.id.exo_rew_text) + exoFfwdText = root.findViewById(R.id.exo_ffwd_text) + playerCenterMenu = root.findViewById(R.id.player_center_menu) + playerRewHolder = root.findViewById(R.id.player_rew_holder) + playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder) + playerVideoHolder = root.findViewById(R.id.player_video_holder) + skipChapterButton = root.findViewById(R.id.skip_chapter_button) + playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder) + playerProgressbarLeftIcon = root.findViewById(R.id.player_progressbar_left_icon) + playerProgressbarLeftLevel1 = root.findViewById(R.id.player_progressbar_left_level1) + playerProgressbarLeftLevel2 = root.findViewById(R.id.player_progressbar_left_level2) + playerProgressbarRightHolder = root.findViewById(R.id.player_progressbar_right_holder) + playerProgressbarRightIcon = root.findViewById(R.id.player_progressbar_right_icon) + playerProgressbarRightLevel1 = root.findViewById(R.id.player_progressbar_right_level1) + playerProgressbarRightLevel2 = root.findViewById(R.id.player_progressbar_right_level2) + playerSpeedupButton = root.findViewById(R.id.player_speedup_button) + playerHolder = root.findViewById(R.id.player_holder) + timeLeft = root.findViewById(R.id.time_left) + timeLive = root.findViewById(R.id.time_live) + exoDuration = playerHolder?.findViewById(androidx.media3.ui.R.id.exo_duration) + ?: root.findViewById(androidx.media3.ui.R.id.exo_duration) + exoPosition = playerHolder?.findViewById(androidx.media3.ui.R.id.exo_position) + ?: root.findViewById(androidx.media3.ui.R.id.exo_position) + } + + /** + * Called once after [bindViews]. Sets up the preview seek-bar, subtitle style listener, + * player callbacks and basic controls; then delegates gesture/input setup to [gestureHelper]. + */ + fun initialize() { + resizeMode = DataStoreHelper.resizeMode + resize(resizeMode, false) + + player.releaseCallbacks() + player.initCallbacks( + eventHandler = ::mainCallback, + requestedListeningPercentages = listOf( + SKIP_OP_VIDEO_PERCENTAGE, + PRELOAD_NEXT_EPISODE_PERCENTAGE, + NEXT_WATCH_EPISODE_PERCENTAGE, + UPDATE_SYNC_PROGRESS_PERCENTAGE, + ), + ) + + if (player is CS3IPlayer) { + val progressBar: PreviewTimeBar? = exoPlayerView?.findViewById(R.id.exo_progress) + exoProgress = progressBar as? LivePreviewTimeBar + val previewImageView: ImageView? = exoPlayerView?.findViewById(R.id.previewImageView) + val previewFrameLayout: FrameLayout? = + exoPlayerView?.findViewById(R.id.previewFrameLayout) + + if (progressBar != null && previewImageView != null && previewFrameLayout != null) { + var resume = false + progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener { + override fun onScrubStart(previewBar: PreviewBar?) { + val cs3 = player as? CS3IPlayer ?: return + val hasPreview = cs3.hasPreview() + progressBar.isPreviewEnabled = hasPreview + resume = cs3.getIsPlaying() + if (resume) cs3.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) + if (hasPreview) subView?.isVisible = false + } + + override fun onScrubMove(previewBar: PreviewBar?, progress: Int, fromUser: Boolean) {} + + override fun onScrubStop(previewBar: PreviewBar?) { + val cs3 = player as? CS3IPlayer ?: return + if (resume) cs3.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) + subView?.postDelayed({ + if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) { + subView?.isVisible = true + } + }, 200) + } + }) + progressBar.attachPreviewView(previewFrameLayout) + progressBar.setPreviewLoader { currentPosition, max -> + val cs3 = player as? CS3IPlayer ?: return@setPreviewLoader + val bitmap = cs3.getPreview(currentPosition.toFloat().div(max.toFloat())) + previewImageView.isGone = bitmap == null + previewImageView.setImageBitmap(bitmap) + } + } + + subView = exoPlayerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles) + (player as? CS3IPlayer)?.initSubtitles(subView, subtitleHolder, CustomDecoder.style) + (player as? CS3IPlayer)?.let { + (it.imageGenerator as? PreviewGenerator)?.params = + ImageParams.new16by9(screenWidth) + } + + exoPlayerView?.findViewById(R.id.exo_progress) + ?.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit + override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + if (canceled) return + val playerDuration = player.getDuration() ?: return + val playerPosition = player.getPosition() ?: return + mainCallback( + PositionEvent( + source = PlayerEventSource.UI, + durationMs = playerDuration, + fromMs = playerPosition, + toMs = position + ) + ) + } + }) + + // Read seek time and rotation settings. + try { + val sm = PreferenceManager.getDefaultSharedPreferences(context) + seekTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10) + .toLong() * 1000L + autoPlayerRotateEnabled = sm.getBoolean( + context.getString(R.string.auto_rotate_video_key), true + ) + } catch (_: Exception) { } + + val seekSecs = (seekTime / 1000).toInt() + exoRewText?.text = context.getString(R.string.rew_text_regular_format).format(seekSecs) + exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format).format(seekSecs) + + playerPausePlay?.setOnClickListener { + if (currentPlayerStatus == CSPlayerLoading.IsEnded) { + player.handleEvent(CSPlayerEvent.Restart, PlayerEventSource.UI) + } else { + player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) + } + } + playerRew?.setOnClickListener { + scheduleAutoHide() + gestureHelper.rewind() + } + playerFfwd?.setOnClickListener { + scheduleAutoHide() + gestureHelper.fastForward() + } + skipChapterButton?.setOnClickListener { player.handleEvent(CSPlayerEvent.SkipCurrentChapter) } + + SubtitlesFragment.applyStyleEvent += subStyleListener + + try { + val ctx = context + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + val cs3 = player as? CS3IPlayer ?: return + cs3.cacheSize = + settingsManager.getInt(context.getString(R.string.video_buffer_size_key), 0) * 1024L * 1024L + cs3.simpleCacheSize = + settingsManager.getInt(context.getString(R.string.video_buffer_disk_key), 0) * 1024L * 1024L + cs3.videoBufferMs = + settingsManager.getInt(context.getString(R.string.video_buffer_length_key), 0) * 1000L + } catch (e: Exception) { + logError(e) + } + + // Duration toggle click listeners + exoDuration?.setOnClickListener { setRemainingTimeCounter(true) } + timeLeft?.setOnClickListener { setRemainingTimeCounter(false) } + // Keep remaining-time text in sync with playback position + exoPosition?.doOnTextChanged { _, _, _, _ -> updateRemainingTime() } + + // Delegate gesture/input setup (settings, brightness overlay, touch gestures, key listener) + gestureHelper.initialize() + setupKeyEventListener() + + // Apply duration-mode display (remaining time vs elapsed); TV always shows remaining + setRemainingTimeCounter(durationMode || isLayout(TV)) + } + } + + /** Lifecycle delegation */ + + fun onStop() { + player.onStop() + } + + fun onResume(ctx: Context) { + player.onResume(ctx) + } + + /** Releases all player resources. */ + fun release() { + player.release() + player.releaseCallbacks() + player = CS3IPlayer() + + playerEventListener = null + // keyEventListener is deregistered in onPause so that the incoming player's + // onResume can register its own listener without racing against release(). + + PlayerPipHelper.updatePIPModeActions( + context as? Activity, + CSPlayerLoading.IsPaused, + false, + null + ) + + mMediaSession?.release() + mMediaSession = null + exoPlayerView?.player = null + + SubtitlesFragment.applyStyleEvent -= subStyleListener + + gestureHelper.release() + autoHideHandler.removeCallbacksAndMessages(null) + + keepScreenOn(false) + } + + fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + activity: Activity? + ) { + try { + isInPIPMode = isInPictureInPictureMode + if (isInPictureInPictureMode) { + piphide?.isVisible = false + pipReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (ACTION_MEDIA_CONTROL != intent.action) return + player.handleEvent( + CSPlayerEvent.entries[intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)], + source = PlayerEventSource.UI + ) + } + } + val filter = IntentFilter().apply { addAction(ACTION_MEDIA_CONTROL) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + @SuppressLint("UnspecifiedRegisterReceiverFlag") + activity?.registerReceiver(pipReceiver, filter) + } + val isPlaying = player.getIsPlaying() + val status = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + updateIsPlaying(status, status) + } else { + piphide?.isVisible = true + callbacks?.exitedPipMode() + pipReceiver?.let { safe { activity?.unregisterReceiver(it) } } + activity?.hideSystemUI() + hideKeyboard(this) + } + } catch (e: Exception) { + logError(e) + } + } + + /** Player UI helpers */ + + private fun keepScreenOn(on: Boolean) { + val window = (context as? Activity)?.window ?: return + if (on) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + fun updateIsPlaying(wasPlaying: CSPlayerLoading, isPlaying: CSPlayerLoading) { + val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying + val isBuffering = CSPlayerLoading.IsBuffering == isPlaying + currentPlayerStatus = isPlaying + + keepScreenOn(isPlayingRightNow || isBuffering) + + if (isBuffering) { + playerPausePlayHolderHolder?.isVisible = false + playerBuffering?.isVisible = true + } else { + playerPausePlayHolderHolder?.isVisible = true + playerBuffering?.isVisible = false + + if (isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)) { + playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24) + } else if (wasPlaying != isPlaying) { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play + ) + val drawable = playerPausePlay?.drawable + var startedAnimation = false + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + if (drawable is AnimatedImageDrawable) { drawable.start(); startedAnimation = true } + } + if (drawable is AnimatedVectorDrawable) { drawable.start(); startedAnimation = true } + if (drawable is AnimatedVectorDrawableCompat) { drawable.start(); startedAnimation = true } + if (!startedAnimation) { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play + ) + } + } else { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play + ) + } + } + + PlayerPipHelper.updatePIPModeActions( + context as? Activity, + isPlaying, + hasPipModeSupport, + player.getAspectRatio() + ) + } + + private fun requestAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + (context as? Activity)?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) + } + } + + private fun playerUpdated(player: Any?) { + if (player is ExoPlayer) { + mMediaSession?.release() + mMediaSession = MediaSession.Builder(context, player) + .setId(System.currentTimeMillis().toString()) + .build() + + @Suppress("DEPRECATION") + exoPlayerView?.setShowMultiWindowTimeBar(true) + exoPlayerView?.player = player + exoPlayerView?.performClick() + } + callbacks?.playerUpdated(player) + } + + private fun onSubStyleChanged(style: SaveCaptionStyle) { + player.updateSubtitleStyle(style) + player.seekTime(-1) + } + + /** Error handling */ + + fun playerError(exception: Throwable) { + fun showErrorToast(message: String, gotoNext: Boolean = false) { + if (gotoNext && callbacks?.hasNextMirror() == true) { + showToast(message, Toast.LENGTH_SHORT) + callbacks?.nextMirror() + } else { + showToast( + context.getString(R.string.no_links_found_toast) + "\n" + message, + Toast.LENGTH_LONG + ) + (context as? FragmentActivity)?.popCurrentPage() + } + } + + when (exception) { + is PlaybackException -> { + val msg = exception.message ?: "" + val errorName = exception.errorCodeName + when (val code = exception.errorCode) { + PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, + PlaybackException.ERROR_CODE_IO_NO_PERMISSION, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED -> + showErrorToast("${context.getString(R.string.source_error)}\n$errorName ($code)\n$msg", gotoNext = true) + + PlaybackException.ERROR_CODE_REMOTE_ERROR, + PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, + PlaybackException.ERROR_CODE_TIMEOUT, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, + PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> + showErrorToast("${context.getString(R.string.remote_error)}\n$errorName ($code)\n$msg", gotoNext = true) + + PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, + PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, + PlaybackException.ERROR_CODE_DECODING_FAILED, + PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, + PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> + showErrorToast("${context.getString(R.string.render_error)}\n$errorName ($code)\n$msg", gotoNext = true) + + PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> + showErrorToast("${context.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg", gotoNext = true) + + PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> + showErrorToast("${context.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg", gotoNext = true) + + else -> + showErrorToast("${context.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg", gotoNext = false) + } + } + + is InvalidFileException -> + showErrorToast("${context.getString(R.string.source_error)}\n${exception.message}", gotoNext = true) + + is SocketTimeoutException -> + (context as? Activity)?.runOnUiThread { + showErrorToast("${context.getString(R.string.remote_error)}\n${exception.message}", gotoNext = true) + } + + is ErrorLoadingException -> + exception.message?.let { showErrorToast(it, gotoNext = true) } + ?: showErrorToast(exception.toString(), gotoNext = true) + + else -> + exception.message?.let { showErrorToast(it, gotoNext = false) } + ?: showErrorToast(exception.toString(), gotoNext = false) + } + } + + /** Resize */ + + fun nextResize() { + resizeMode = (resizeMode + 1) % PlayerResize.entries.size + resize(resizeMode, true) + } + + fun resize(resize: Int, showToast: Boolean) { + resize(PlayerResize.entries[resize], showToast) + } + + fun resize(resize: PlayerResize, showToast: Boolean) { + DataStoreHelper.resizeMode = resize.ordinal + val type = when (resize) { + PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL + PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT + PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + exoPlayerView?.resizeMode = type + if (showToast) showToast(resize.nameRes, Toast.LENGTH_SHORT) + } + + /** Orientation */ + + /** + * Returns the desired [ActivityInfo] orientation constant based on [isVerticalOrientation] + * and [autoPlayerRotateEnabled]. TV/emulator always returns sensor-landscape. + * Host fragments call this from [Callbacks.playerDimensionsLoaded] to apply rotation. + */ + fun dynamicOrientation(): Int { + if (isLayout(TV or EMULATOR)) return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + return if (autoPlayerRotateEnabled && isVerticalOrientation) + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + else + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + + /** Event dispatch */ + + fun mainCallback(event: PlayerEvent) { + if (event !is DownloadEvent) Log.i(TAG, "Handle event: $event") + when (event) { + is DownloadEvent -> callbacks?.onDownload(event) + is ResizedEvent -> { + // TV never rotates; otherwise track whether the video is portrait. + isVerticalOrientation = !isLayout(TV or EMULATOR) && event.height > event.width + callbacks?.playerDimensionsLoaded(event.width, event.height) + } + is PlayerAttachedEvent -> playerUpdated(event.player) + is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged() + is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp) + is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp) + is TracksChangedEvent -> callbacks?.onTracksInfoChanged() + is EmbeddedSubtitlesFetchedEvent -> callbacks?.embeddedSubtitlesFetched(event.tracks) + is ErrorEvent -> { + val cb = callbacks + if (cb != null) cb.playerError(event.error) + else playerError(event.error) + } + is RequestAudioFocusEvent -> requestAudioFocus() + is EpisodeSeekEvent -> when (event.offset) { + -1 -> callbacks?.prevEpisode() + 1 -> callbacks?.nextEpisode() + } + is StatusEvent -> { + updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) + scheduleAutoHide() + callbacks?.playerStatusChanged() + } + is PositionEvent -> callbacks?.playerPositionChanged( + position = event.toMs, + duration = event.durationMs + ) + is VideoEndedEvent -> { + val ctx = context + if (PreferenceManager.getDefaultSharedPreferences(ctx) + ?.getBoolean(ctx.getString(R.string.autoplay_next_key), true) == true + ) { + player.handleEvent(CSPlayerEvent.NextEpisode, source = PlayerEventSource.Player) + } + } + is PauseEvent -> Unit + is PlayEvent -> Unit + } + } + + /** Duration display */ + + fun setRemainingTimeCounter(showRemaining: Boolean) { + durationMode = showRemaining + exoDuration?.isInvisible = showRemaining + timeLeft?.isVisible = showRemaining + if (showRemaining) updateRemainingTime() + } + + fun updateRemainingTime() { + val duration = player.getDuration() + val position = player.getPosition() + + if (exoProgress?.isAtLiveEdge() == true) { + timeLeft?.alpha = 0f + exoDuration?.alpha = 0f + timeLive?.isVisible = true + } else { + timeLeft?.alpha = 1f + exoDuration?.alpha = 1f + timeLive?.isVisible = false + } + + if (duration != null && duration > 1 && position != null) { + val remainingTimeSeconds = (duration - position + 500) / 1000 + @SuppressLint("SetTextI18n") + timeLeft?.text = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}" + } + } + + /** Auto-hide */ + + /** + * Schedules a delayed auto-hide of the player UI after [delayMs] ms. + * Any previously pending hide is canceled first. + * The hide fires only when no touch is active and [Callbacks.isUiShowing] is true; + * the actual hide action is delegated to [Callbacks.onAutoHideUI]. + */ + fun scheduleAutoHide(delayMs: Long = 3000L) { + val token = ++autoHideToken + autoHideHandler.removeCallbacksAndMessages(null) + autoHideHandler.postDelayed({ + if (token != autoHideToken) return@postDelayed + if (gestureHelper.isCurrentTouchValid) return@postDelayed + if (callbacks?.isUiShowing() != true) return@postDelayed + callbacks?.onAutoHideUI() + }, delayMs) + } + + /** Cancels any pending auto-hide scheduled by [scheduleAutoHide]. */ + fun cancelAutoHide() { + autoHideToken++ + autoHideHandler.removeCallbacksAndMessages(null) + } + + companion object { + private const val TAG = "PlayerView" + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index c9da385f63b..dd7919d9597 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -4,12 +4,10 @@ import android.annotation.SuppressLint import android.app.Dialog import android.content.Intent import android.content.res.ColorStateList -import android.content.res.Configuration import android.graphics.Rect import android.os.Build import android.os.Bundle import android.text.Editable -import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.AlphaAnimation @@ -56,8 +54,14 @@ import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup +import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.player.CS3IPlayer import com.lagradost.cloudstream3.ui.player.CSPlayerEvent -import com.lagradost.cloudstream3.ui.player.FullScreenPlayer +import com.lagradost.cloudstream3.ui.player.IPlayer +import com.lagradost.cloudstream3.ui.player.PlayerView as PlayerHostView +import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI +import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo @@ -99,7 +103,9 @@ import com.lagradost.cloudstream3.utils.txt import java.net.URLEncoder import kotlin.math.roundToInt -open class ResultFragmentPhone : FullScreenPlayer() { +open class ResultFragmentPhone : BaseFragment( + BindingCreator.Inflate(FragmentResultSwipeBinding::inflate) +), PlayerHostView.Callbacks { private val gestureRegionsListener = object : PanelsChildGestureRegionObserver.GestureRegionsListener { override fun onGestureRegionsUpdate(gestureRegions: List) { @@ -110,31 +116,49 @@ open class ResultFragmentPhone : FullScreenPlayer() { protected lateinit var viewModel: ResultViewModel2 protected lateinit var syncModel: SyncViewModel - protected var binding: FragmentResultSwipeBinding? = null protected var resultBinding: FragmentResultBinding? = null protected var recommendationBinding: ResultRecommendationsBinding? = null protected var syncBinding: ResultSyncBinding? = null - override var layout = R.layout.fragment_result_swipe + var player: IPlayer = CS3IPlayer() + protected open var hasPipModeSupport: Boolean = false + protected open var isFullScreenPlayer: Boolean = true + protected open var lockRotation: Boolean = true + protected var playerBinding: PlayerCustomLayoutBinding? = null + protected var isShowing: Boolean = false - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] - syncModel = ViewModelProvider(this)[SyncViewModel::class.java] - updateUIEvent += ::updateUI + protected lateinit var playerHostView: PlayerHostView - val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null - FragmentResultSwipeBinding.bind(root).let { bind -> - resultBinding = bind.fragmentResult - recommendationBinding = bind.resultRecommendations - syncBinding = bind.resultSync - binding = bind + protected open fun enterFullscreen() { + activity?.hideSystemUI() + if (lockRotation) { + activity?.requestedOrientation = + android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE } + } + + protected open fun exitFullscreen() { + activity?.showSystemUI() + activity?.requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_USER + } - return root + open fun updateUIVisibility() {} + + protected fun uiReset() { + isShowing = false + updateUIVisibility() + } + + open fun showMirrorsDialogue() {} + open fun showTracksDialogue() {} + open fun openOnlineSubPicker( + context: android.content.Context, + loadResponse: LoadResponse?, + dismissCallback: () -> Unit + ) {} + + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } override fun onCreate(savedInstanceState: Bundle?) { @@ -158,7 +182,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun playerError(exception: Throwable) { if (player.getIsPlaying()) { // because we don't want random toasts in player - super.playerError(exception) + if (::playerHostView.isInitialized) playerHostView.playerError(exception) } else { nextMirror() } @@ -258,7 +282,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { } updateUIEvent -= ::updateUI - binding = null + if (::playerHostView.isInitialized) playerHostView.release() + playerBinding = null resultBinding?.resultScroll?.setOnClickListener(null) resultBinding = null syncBinding = null @@ -282,7 +307,6 @@ open class ResultFragmentPhone : FullScreenPlayer() { var selectSeason: String? = null var selectEpisodeRange: String? = null - var selectSort: EpisodeSortType? = null private fun setUrl(url: String?) { if (url == null) { @@ -325,6 +349,12 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + context?.let { ctx -> + if (::playerHostView.isInitialized) { + playerHostView.onResume(ctx) + playerHostView.setupKeyEventListener() + } + } super.onResume() PanelsChildGestureRegionObserver.Provider.get() .addGestureRegionsUpdateListener(gestureRegionsListener) @@ -332,30 +362,44 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun onStop() { afterPluginsLoadedEvent -= ::reloadViewModel + if (::playerHostView.isInitialized) playerHostView.onStop() super.onStop() } + @Suppress("UNUSED_PARAMETER") private fun updateUI(id: Int?) { syncModel.updateUserData() viewModel.reloadEpisodes() } - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - view?.let { fixSystemBarsPadding(it) } - } + override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { + // Set up sub-binding references + viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] + syncModel = ViewModelProvider(this)[SyncViewModel::class.java] + updateUIEvent += ::updateUI - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + resultBinding = binding.fragmentResult + recommendationBinding = binding.resultRecommendations + syncBinding = binding.resultSync + + // Set up trailer player + val ctx = context ?: return + playerHostView = PlayerHostView(ctx) + playerHostView.player = player + playerHostView.hasPipModeSupport = hasPipModeSupport + playerHostView.callbacks = this + playerHostView.bindViews(binding.root) + playerBinding = binding.root.findViewById(R.id.player_holder)?.let { + PlayerCustomLayoutBinding.bind(it) + } + playerHostView.initialize() // ===== setup ===== - fixSystemBarsPadding(view) val storedData = getStoredData() ?: return activity?.window?.decorView?.clearFocus() activity?.loadCache() context?.updateHasTrailers() - hideKeyboard() + hideKeyboard(binding.root) if (storedData.restart || !viewModel.hasLoaded()) viewModel.load( activity, @@ -373,7 +417,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { // This may not be 100% reliable, and may delay for small period // before resultCastItems will be scrollable again, but this does work // most of the time. - binding?.resultOverlappingPanels?.registerEndPanelStateListeners( + binding.resultOverlappingPanels.registerEndPanelStateListeners( object : OverlappingPanelsLayout.PanelStateListener { override fun onPanelStateChange(panelState: PanelState) { PanelsChildGestureRegionObserver.Provider.get().apply { @@ -385,8 +429,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { // ===== ===== ===== - binding?.resultSearch?.isGone = storedData.name.isBlank() - binding?.resultSearch?.setOnClickListener { + binding.resultSearch.isGone = storedData.name.isBlank() + binding.resultSearch.setOnClickListener { QuickSearchFragment.pushSearch(activity, storedData.name) } @@ -415,7 +459,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { focused: View? ): Boolean { // Make the cast always focus the first visible item when focused - // from somewhere else. Otherwise it jumps to the last item. + // from somewhere else. Otherwise, it jumps to the last item. return if (parent.focusedChild == null) { scrollToPosition(this.findFirstCompletelyVisibleItemPosition()) true @@ -468,9 +512,9 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultScroll.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY -> val dy = scrollY - oldScrollY if (dy > 0) { //check for scroll down - binding?.resultBookmarkFab?.shrink() + binding.resultBookmarkFab.shrink() } else if (dy < -5) { - binding?.resultBookmarkFab?.extend() + binding.resultBookmarkFab.extend() } if (!isFullScreenPlayer && player.getIsPlaying()) { if (scrollY > (resultBinding?.fragmentTrailer?.playerBackground?.height @@ -482,7 +526,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { }) } - binding?.apply { + binding.apply { resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultBack.setOnClickListener { @@ -675,7 +719,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } observeNullable(viewModel.subscribeStatus) { isSubscribed -> - binding?.resultSubscribe?.isVisible = isSubscribed != null + binding.resultSubscribe.isVisible = isSubscribed != null if (isSubscribed == null) return@observeNullable val drawable = if (isSubscribed) { @@ -684,11 +728,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { R.drawable.baseline_notifications_none_24 } - binding?.resultSubscribe?.setImageResource(drawable) + binding.resultSubscribe.setImageResource(drawable) } observeNullable(viewModel.favoriteStatus) { isFavorite -> - binding?.resultFavorite?.isVisible = isFavorite != null + binding.resultFavorite.isVisible = isFavorite != null if (isFavorite == null) return@observeNullable val drawable = if (isFavorite) { @@ -697,7 +741,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { R.drawable.ic_baseline_favorite_border_24 } - binding?.resultFavorite?.setImageResource(drawable) + binding.resultFavorite.setImageResource(drawable) } observeNullable(viewModel.episodes) { episodes -> @@ -932,7 +976,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { syncModel.addFromUrl(d.url) } - binding?.apply { + binding.apply { resultSearch.isGone = d.title.isBlank() resultSearch.setOnClickListener { QuickSearchFragment.pushSearch(activity, d.title) @@ -967,10 +1011,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { } (data as? Resource.Failure)?.let { data -> + @SuppressLint("SetTextI18n") resultErrorText.text = storedData.url.plus("\n") + data.errorString } - binding?.resultBookmarkFab?.isVisible = data is Resource.Success + binding.resultBookmarkFab.isVisible = data is Resource.Success resultFinishLoading.isVisible = data is Resource.Success resultLoading.isVisible = data is Resource.Loading @@ -1018,7 +1063,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } observe(viewModel.trailers) { trailers -> - setTrailers(trailers.flatMap { it.mirros }) // I dont care about subtitles yet! + setTrailers(trailers.flatMap { it.mirros }) // I don't care about subtitles yet! } observe(syncModel.synced) { list -> @@ -1027,8 +1072,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { val newList = list.filter { it.isSynced && it.hasAccount } - binding?.resultMiniSync?.isVisible = newList.isNotEmpty() - //(binding?.resultMiniSync?.adapter as? ImageAdapter)?.submitList(newList.mapNotNull { it.icon }) + binding.resultMiniSync.isVisible = newList.isNotEmpty() } @@ -1123,7 +1167,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } } - binding?.resultOverlappingPanels?.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) + binding.resultOverlappingPanels.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) } observe(viewModel.recommendations) { recommendations -> setRecommendations(recommendations, null) @@ -1184,7 +1228,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } observe(viewModel.watchStatus) { watchType -> - binding?.resultBookmarkFab?.apply { + binding.resultBookmarkFab.apply { setText(watchType.stringRes) if (watchType == WatchType.NONE) { context?.colorFromAttribute(R.attr.white) @@ -1239,6 +1283,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { viewModel.skipLoading() } isVisible = true + @SuppressLint("SetTextI18n") text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})" } } @@ -1359,6 +1404,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } override fun onPause() { + if (::playerHostView.isInitialized) playerHostView.releaseKeyEventListener() super.onPause() PanelsChildGestureRegionObserver.Provider.get() .addGestureRegionsUpdateListener(gestureRegionsListener) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt index 969fa6d95c3..3319cf92b56 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.ui.result import android.animation.ValueAnimator +import android.annotation.SuppressLint import android.content.Context import android.content.res.Configuration import android.os.Build @@ -15,8 +16,11 @@ import com.lagradost.cloudstream3.CommonActivity.screenHeight import com.lagradost.cloudstream3.CommonActivity.screenWidth import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding import com.lagradost.cloudstream3.ui.player.CSPlayerEvent +import com.lagradost.cloudstream3.ui.player.CSPlayerLoading import com.lagradost.cloudstream3.ui.player.PlayerEventSource +import com.lagradost.cloudstream3.ui.player.PlayerView import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback @@ -29,17 +33,39 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { override var hasPipModeSupport = false companion object { - const val TAG = "RESULT_TRAILER" + const val TAG = "ResultTrailerPlayer" } private var playerWidthHeight: Pair? = null + private var introVisible = true - override fun nextEpisode() {} + // Single-tap on empty player area: toggle controls. + override fun onSingleTap() { + if (!introVisible) { + if (isShowing) uiReset() else showControls() + } + } - override fun prevEpisode() {} + private fun showControls() { + if (introVisible) return + isShowing = true + updateUIVisibility() + playerHostView.scheduleAutoHide() + } - override fun playerPositionChanged(position: Long, duration : Long) {} + override fun isUiShowing(): Boolean = isShowing + + override fun onAutoHideUI() { + uiReset() + } + + override fun onHidePlayerUI() { + uiReset() + } + override fun nextEpisode() {} + override fun prevEpisode() {} + override fun playerPositionChanged(position: Long, duration: Long) {} override fun nextMirror() {} override fun onConfigurationChanged(newConfig: Configuration) { @@ -49,33 +75,28 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { } private fun fixPlayerSize() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - binding?.apply { - if (isFullScreenPlayer) { - // Remove listener + binding?.apply { + if (isFullScreenPlayer) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { ViewCompat.setOnApplyWindowInsetsListener(root, null) - root.overlay.clear() // Clear the cutout overlay - root.setPadding(0, 0, 0, 0) // Reset padding for full screen - } else { - // Reapply padding when not in full screen - fixSystemBarsPadding(root) + root.overlay.clear() + } + root.setPadding(0, 0, 0, 0) + } else { + fixSystemBarsPadding(root) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { ViewCompat.requestApplyInsets(root) } } } playerWidthHeight?.let { (w, h) -> - if(w <= 0 || h <= 0) return@let + if (w <= 0 || h <= 0) return@let val orientation = context?.resources?.configuration?.orientation ?: return - val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { - screenWidth - } else { - screenHeight - } + val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) screenWidth else screenHeight - //result_trailer_loading?.isVisible = false resultBinding?.resultSmallscreenHolder?.isVisible = !isFullScreenPlayer binding?.resultFullscreenHolder?.isVisible = isFullScreenPlayer @@ -83,35 +104,30 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { resultBinding?.fragmentTrailer?.playerBackground?.apply { isVisible = true - layoutParams = - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to - ) + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to + ) } playerBinding?.playerIntroPlay?.apply { - layoutParams = - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - resultBinding?.resultTopHolder?.measuredHeight - ?: FrameLayout.LayoutParams.MATCH_PARENT - ) + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + resultBinding?.resultTopHolder?.measuredHeight ?: FrameLayout.LayoutParams.MATCH_PARENT + ) } if (playerBinding?.playerIntroPlay?.isGone == true) { resultBinding?.resultTopHolder?.apply { - val anim = ValueAnimator.ofInt( measuredHeight, if (isFullScreenPlayer) ViewGroup.LayoutParams.MATCH_PARENT else to ) - anim.addUpdateListener { valueAnimator -> - val `val` = valueAnimator.animatedValue as Int - val layoutParams: ViewGroup.LayoutParams = - layoutParams - layoutParams.height = `val` - setLayoutParams(layoutParams) + anim.addUpdateListener { va -> + val v = va.animatedValue as Int + val lp: ViewGroup.LayoutParams = layoutParams + lp.height = v + setLayoutParams(lp) } anim.duration = 200 anim.start() @@ -120,9 +136,14 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { } } - override fun playerDimensionsLoaded(width: Int, height : Int) { + override fun playerDimensionsLoaded(width: Int, height: Int) { playerWidthHeight = width to height fixPlayerSize() + // Apply auto-rotation when fullscreen (lockRotation = true). + // PlayerView already set isVerticalOrientation before this callback fires. + if (lockRotation) { + activity?.requestedOrientation = playerHostView.dynamicOrientation() + } } override fun showMirrorsDialogue() {} @@ -132,20 +153,29 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { context: Context, loadResponse: LoadResponse?, dismissCallback: () -> Unit - ) { - } + ) {} override fun subtitlesChanged() {} - override fun embeddedSubtitlesFetched(subtitles: List) {} override fun onTracksInfoChanged() {} - override fun exitedPipMode() {} + + @SuppressLint("SetTextI18n") + override fun onSeekPreviewText(text: String?) { + playerBinding?.playerTimeText?.apply { + isVisible = text != null + if (text != null) this.text = text + } + } + private fun updateFullscreen(fullscreen: Boolean) { isFullScreenPlayer = fullscreen lockRotation = fullscreen + playerHostView.isFullScreen = fullscreen - playerBinding?.playerFullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24) + playerBinding?.playerFullscreen?.setImageResource( + if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24 + ) if (fullscreen) { enterFullscreen() binding?.apply { @@ -153,12 +183,10 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { resultFullscreenHolder.isVisible = true resultMainHolder.isVisible = false } - resultBinding?.fragmentTrailer?.playerBackground?.let { view -> (view.parent as ViewGroup?)?.removeView(view) binding?.resultFullscreenHolder?.addView(view) } - } else { binding?.apply { resultTopBar.isVisible = true @@ -175,30 +203,49 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { uiReset() if (isFullScreenPlayer) { - activity?.attachBackPressedCallback("ResultTrailerPlayer") { - updateFullscreen(false) - } - } else activity?.detachBackPressedCallback("ResultTrailerPlayer") + activity?.attachBackPressedCallback("ResultTrailerPlayer") { updateFullscreen(false) } + } else { + activity?.detachBackPressedCallback("ResultTrailerPlayer") + } } override fun updateUIVisibility() { super.updateUIVisibility() - playerBinding?.playerGoBackHolder?.isVisible = false + playerBinding?.apply { + playerGoBackHolder.isVisible = false + val controlsVisible = isShowing && !introVisible + playerTopHolder.isVisible = controlsVisible + playerVideoHolder.isVisible = controlsVisible + shadowOverlay.isVisible = controlsVisible + playerPausePlayHolderHolder.isVisible = + controlsVisible && playerHostView.currentPlayerStatus != CSPlayerLoading.IsBuffering + } + // Fade center controls in/out; also resets stale fillAfter alpha from seek animations. + playerHostView.gestureHelper.animateCenterControls(if (isShowing && !introVisible) 1f else 0f) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - playerBinding?.playerFullscreen?.setOnClickListener { - updateFullscreen(!isFullScreenPlayer) + override fun playerStatusChanged() { + if (introVisible) { + playerBinding?.playerPausePlayHolderHolder?.isVisible = false } + } + + override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + playerHostView.videoOutline = playerBinding?.videoOutline + playerHostView.requestUpdateBrightnessOverlayOnNextLayout() + + playerBinding?.playerFullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) } updateFullscreen(isFullScreenPlayer) uiReset() playerBinding?.playerIntroPlay?.setOnClickListener { playerBinding?.playerIntroPlay?.isGone = true + introVisible = false player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) - updateUIVisibility() fixPlayerSize() + showControls() } } -} \ No newline at end of file +} From ab64c50ecea234fca77647dd6c1b9c8b7a4c3f90 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:45:18 -0600 Subject: [PATCH 02/15] Some fixes --- .../com/lagradost/cloudstream3/ui/player/PlayerView.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 1dba796729a..920b364b0a3 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 @@ -188,7 +188,6 @@ class PlayerView @JvmOverloads constructor( internal var playerRewHolder: View? = null internal var playerFfwdHolder: View? = null internal var playerVideoHolder: View? = null - private var skipChapterButton: View? = null var playerProgressbarLeftHolder: RelativeLayout? = null var playerProgressbarLeftIcon: ImageView? = null var playerProgressbarLeftLevel1: ProgressBar? = null @@ -223,8 +222,8 @@ class PlayerView @JvmOverloads constructor( /** View discovery */ /** - * Discovers player-related views from [root]. IDs absent in compact layouts simply - * remain null — all usage is null-safe. + * Discovers player-related views from [root]. IDs absent in compact layouts (e.g. trailer) simply + * remain null, all usage is null-safe. */ fun bindViews(root: View) { playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) @@ -241,7 +240,6 @@ class PlayerView @JvmOverloads constructor( playerRewHolder = root.findViewById(R.id.player_rew_holder) playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder) playerVideoHolder = root.findViewById(R.id.player_video_holder) - skipChapterButton = root.findViewById(R.id.skip_chapter_button) playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder) playerProgressbarLeftIcon = root.findViewById(R.id.player_progressbar_left_icon) playerProgressbarLeftLevel1 = root.findViewById(R.id.player_progressbar_left_level1) @@ -374,7 +372,6 @@ class PlayerView @JvmOverloads constructor( scheduleAutoHide() gestureHelper.fastForward() } - skipChapterButton?.setOnClickListener { player.handleEvent(CSPlayerEvent.SkipCurrentChapter) } SubtitlesFragment.applyStyleEvent += subStyleListener From 536cb65af0a7a61adb0c4b1d9fb91427128a325b Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 18 Apr 2026 13:59:10 -0600 Subject: [PATCH 03/15] Some fixes --- .../ui/player/AbstractPlayerFragment.kt | 2 +- .../ui/player/FullScreenPlayer.kt | 11 ++-- .../ui/player/PlayerGestureHelper.kt | 17 +++--- .../cloudstream3/ui/player/PlayerView.kt | 58 ++++++++++--------- .../ui/result/ResultTrailerPlayer.kt | 21 ++----- 5 files changed, 53 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index d8d11b7f6b1..47a012355f7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -88,7 +88,7 @@ abstract class AbstractPlayerFragment( override fun hasNextMirror(): Boolean { throw NotImplementedError() } override fun nextMirror() { throw NotImplementedError() } - /** Delegates to [PlayerView.playerError] by default; override to customise. */ + /** Delegates to [PlayerView.playerError] by default; override to customize. */ override fun playerError(exception: Throwable) { if (::playerHostView.isInitialized) playerHostView.playerError(exception) } 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 d26759e7877..132d9dc0ffb 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 @@ -41,11 +41,11 @@ import androidx.preference.PreferenceManager import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.CommonActivity.keyEventListener -import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding import com.lagradost.cloudstream3.CommonActivity.playerEventListener import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding import com.lagradost.cloudstream3.databinding.SpeedDialogBinding import com.lagradost.cloudstream3.databinding.SubtitleOffsetBinding @@ -872,7 +872,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( return rawY > (statusBarHeight ?: 0) } - override fun isUiShowing(): Boolean = isShowing + override fun isUIShowing(): Boolean = isShowing override fun onSingleTap() { onClickChange() @@ -931,8 +931,6 @@ open class FullScreenPlayer : AbstractPlayerFragment( } override fun resize(resize: PlayerResize, showToast: Boolean) { - // Clear all zoom state before applying the new resize mode - if (::playerHostView.isInitialized) playerHostView.clearZoomState() super.resize(resize, showToast) if (::playerHostView.isInitialized) playerHostView.requestUpdateBrightnessOverlayOnNextLayout() } @@ -1056,7 +1054,6 @@ open class FullScreenPlayer : AbstractPlayerFragment( super.onSaveInstanceState(outState) } - @SuppressLint("ClickableViewAccessibility", "DiscouragedApi") override fun onBindingCreated(binding: FragmentPlayerBinding, savedInstanceState: Bundle?) { // Set up playerBinding before super initializes the player // (brightness overlay is now injected by PlayerView.initialize()) @@ -1320,6 +1317,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( exoProgress.registerPlayerView(playerView) + @SuppressLint("ClickableViewAccessibility") exoProgress.setOnTouchListener { _, event -> // this makes the bar not disappear when sliding when (event.action) { @@ -1372,6 +1370,9 @@ open class FullScreenPlayer : AbstractPlayerFragment( override fun playerDimensionsLoaded(width: Int, height: Int) { // PlayerView already set isVerticalOrientation; skip rotation on TV (pillarbox instead). if (isLayout(TV or EMULATOR)) return + // Skip zero-size events emitted when the player transitions to STATE_IDLE, + // acting on them would reset auto-detected orientation to landscape. + if (width <= 0 || height <= 0) return updateOrientation() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt index 5575425add1..f00937d612d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -4,13 +4,13 @@ import android.animation.ValueAnimator import android.annotation.SuppressLint import android.app.Activity import android.content.Context -import android.provider.Settings import android.content.res.ColorStateList import android.graphics.Matrix import android.media.AudioManager import android.media.audiofx.LoudnessEnhancer import android.os.Handler import android.os.Looper +import android.provider.Settings import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent @@ -188,6 +188,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { // directly on top of the video surface. Alpha is set by handleBrightnessAdjustment. safe { val pkg = context.packageName + @SuppressLint("DiscouragedApi") val contentId = context.resources.getIdentifier("exo_content_frame", "id", pkg) val contentFrame = playerView.exoPlayerView?.findViewById(contentId) if (contentFrame != null) { @@ -780,7 +781,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { val rewHolder = playerView.playerRewHolder ?: return val rew = playerView.playerRew val rewText = playerView.exoRewText - val wasShowing = playerView.callbacks?.isUiShowing() ?: false + val wasShowing = playerView.callbacks?.isUIShowing() ?: false // Only expose the parent chain when controls are currently hidden. val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false @@ -801,7 +802,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { rewText?.post { resetRewindText() // Restore parent chain only if we changed it and controls are still hidden. - if (!wasShowing && !(playerView.callbacks?.isUiShowing() ?: false)) { + if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { playerView.playerCenterMenu?.isGone = prevCenterMenuGone playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible rewHolder.alpha = 0f @@ -821,7 +822,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { val ffwdHolder = playerView.playerFfwdHolder ?: return val ffwd = playerView.playerFfwd val ffwdText = playerView.exoFfwdText - val wasShowing = playerView.callbacks?.isUiShowing() ?: false + val wasShowing = playerView.callbacks?.isUIShowing() ?: false val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true @@ -840,7 +841,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { override fun onAnimationEnd(animation: Animation?) { ffwdText?.post { resetFastForwardText() - if (!wasShowing && !(playerView.callbacks?.isUiShowing() ?: false)) { + if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { playerView.playerCenterMenu?.isGone = prevCenterMenuGone playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible ffwdHolder.alpha = 0f @@ -935,9 +936,9 @@ class PlayerGestureHelper(private val playerView: PlayerView) { /** Touch gestures */ - @SuppressLint("ClickableViewAccessibility") fun setupTouchGestures() { val holder = playerView.playerHolder ?: return + @SuppressLint("ClickableViewAccessibility") holder.setOnTouchListener { v, event -> handleGesture(v, event) } } @@ -954,7 +955,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { event = event, ctx = view.context, onFirstPointerDown = { - uiShowingBeforeGesture = playerView.callbacks?.isUiShowing() ?: false + uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false playerView.callbacks?.onHidePlayerUI() }, onGestureEnd = { @@ -1000,7 +1001,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { if (swipeVerticalEnabled) { if (abs(diffFromStart.y * 100 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) { holdHandler.removeCallbacks(holdRunnable) - uiShowingBeforeGesture = playerView.callbacks?.isUiShowing() ?: false + uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false playerView.callbacks?.onHidePlayerUI() currentTouchAction = if ((startTouch.x) >= view.width / 2f) TouchAction.Volume else TouchAction.Brightness 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 920b364b0a3..5e4f89f8d86 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 @@ -6,12 +6,14 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.ActivityInfo import android.graphics.drawable.AnimatedImageDrawable import android.graphics.drawable.AnimatedVectorDrawable import android.media.metrics.PlaybackErrorEvent import android.os.Build import android.os.Handler import android.os.Looper +import android.text.format.DateUtils import android.util.AttributeSet import android.util.Log import android.view.View @@ -48,6 +50,10 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.player.live.LivePreviewTimeBar +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.utils.AppContextUtils @@ -56,15 +62,9 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import android.content.pm.ActivityInfo -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.UserPreferenceDelegate import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp import java.net.SocketTimeoutException -import android.text.format.DateUtils -import com.lagradost.cloudstream3.ui.settings.Globals.PHONE /** * Shared player view - manages ExoPlayer setup, view binding, lifecycle, and event @@ -98,7 +98,6 @@ class PlayerView @JvmOverloads constructor( fun verifyVolume() = gestureHelper.verifyVolume() fun setupKeyEventListener() = gestureHelper.setupKeyEventListener() fun releaseKeyEventListener() = gestureHelper.releaseKeyEventListener() - fun clearZoomState() = gestureHelper.clearZoomState() fun requestUpdateBrightnessOverlayOnNextLayout() = gestureHelper.requestUpdateBrightnessOverlayOnNextLayout() fun releaseOverlayLayoutListener() = gestureHelper.releaseOverlayLayoutListener() @@ -135,7 +134,7 @@ class PlayerView @JvmOverloads constructor( /** Called to validate a touch position; return false to discard nav-bar / status-bar touches. */ fun isValidTouch(rawX: Float, rawY: Float): Boolean = true /** Returns whether the player UI (controls overlay) is currently visible. */ - fun isUiShowing(): Boolean = false + fun isUIShowing(): Boolean = false /** Called on a valid ACTION_DOWN; use for e.g. dismissing an episode overlay. */ fun onTouchDown() {} /** Called with seek-preview text during a horizontal-swipe, or null to clear it. */ @@ -226,20 +225,19 @@ class PlayerView @JvmOverloads constructor( * remain null, all usage is null-safe. */ fun bindViews(root: View) { - playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) - playerPausePlay = root.findViewById(R.id.player_pause_play) - playerBuffering = root.findViewById(R.id.player_buffering) + exoDuration = root.findViewById(androidx.media3.ui.R.id.exo_duration) + exoFfwdText = root.findViewById(R.id.exo_ffwd_text) exoPlayerView = root.findViewById(R.id.player_view) - piphide = root.findViewById(R.id.piphide) - subtitleHolder = root.findViewById(R.id.subtitle_holder) - playerRew = root.findViewById(R.id.player_rew) - playerFfwd = root.findViewById(R.id.player_ffwd) + exoPosition = root.findViewById(R.id.exo_position) exoRewText = root.findViewById(R.id.exo_rew_text) - exoFfwdText = root.findViewById(R.id.exo_ffwd_text) + piphide = root.findViewById(R.id.piphide) + playerBuffering = root.findViewById(R.id.player_buffering) playerCenterMenu = root.findViewById(R.id.player_center_menu) - playerRewHolder = root.findViewById(R.id.player_rew_holder) + playerFfwd = root.findViewById(R.id.player_ffwd) playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder) - playerVideoHolder = root.findViewById(R.id.player_video_holder) + playerHolder = root.findViewById(R.id.player_holder) + playerPausePlay = root.findViewById(R.id.player_pause_play) + playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder) playerProgressbarLeftIcon = root.findViewById(R.id.player_progressbar_left_icon) playerProgressbarLeftLevel1 = root.findViewById(R.id.player_progressbar_left_level1) @@ -248,14 +246,14 @@ class PlayerView @JvmOverloads constructor( playerProgressbarRightIcon = root.findViewById(R.id.player_progressbar_right_icon) playerProgressbarRightLevel1 = root.findViewById(R.id.player_progressbar_right_level1) playerProgressbarRightLevel2 = root.findViewById(R.id.player_progressbar_right_level2) + playerRew = root.findViewById(R.id.player_rew) + playerRewHolder = root.findViewById(R.id.player_rew_holder) playerSpeedupButton = root.findViewById(R.id.player_speedup_button) - playerHolder = root.findViewById(R.id.player_holder) + playerVideoHolder = root.findViewById(R.id.player_video_holder) + skipChapterButton = root.findViewById(R.id.skip_chapter_button) + subtitleHolder = root.findViewById(R.id.subtitle_holder) timeLeft = root.findViewById(R.id.time_left) timeLive = root.findViewById(R.id.time_live) - exoDuration = playerHolder?.findViewById(androidx.media3.ui.R.id.exo_duration) - ?: root.findViewById(androidx.media3.ui.R.id.exo_duration) - exoPosition = playerHolder?.findViewById(androidx.media3.ui.R.id.exo_position) - ?: root.findViewById(androidx.media3.ui.R.id.exo_position) } /** @@ -646,6 +644,8 @@ class PlayerView @JvmOverloads constructor( } fun resize(resize: Int, showToast: Boolean) { + // Clear all zoom state before applying the new resize mode + gestureHelper.clearZoomState() resize(PlayerResize.entries[resize], showToast) } @@ -682,8 +682,12 @@ class PlayerView @JvmOverloads constructor( when (event) { is DownloadEvent -> callbacks?.onDownload(event) is ResizedEvent -> { - // TV never rotates; otherwise track whether the video is portrait. - isVerticalOrientation = !isLayout(TV or EMULATOR) && event.height > event.width + // Skip 0x0 dimensions that the player emits when going to STATE_IDLE + // to avoid incorrectly resetting the auto-detected orientation. + if (event.width > 0 && event.height > 0) { + // TV never rotates; otherwise track whether the video is portrait. + isVerticalOrientation = !isLayout(TV or EMULATOR) && event.height > event.width + } callbacks?.playerDimensionsLoaded(event.width, event.height) } is PlayerAttachedEvent -> playerUpdated(event.player) @@ -759,7 +763,7 @@ class PlayerView @JvmOverloads constructor( /** * Schedules a delayed auto-hide of the player UI after [delayMs] ms. * Any previously pending hide is canceled first. - * The hide fires only when no touch is active and [Callbacks.isUiShowing] is true; + * The hide fires only when no touch is active and [Callbacks.isUIShowing] is true; * the actual hide action is delegated to [Callbacks.onAutoHideUI]. */ fun scheduleAutoHide(delayMs: Long = 3000L) { @@ -768,7 +772,7 @@ class PlayerView @JvmOverloads constructor( autoHideHandler.postDelayed({ if (token != autoHideToken) return@postDelayed if (gestureHelper.isCurrentTouchValid) return@postDelayed - if (callbacks?.isUiShowing() != true) return@postDelayed + if (callbacks?.isUIShowing() != true) return@postDelayed callbacks?.onAutoHideUI() }, delayMs) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt index 3319cf92b56..f97cab6febf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -1,17 +1,15 @@ package com.lagradost.cloudstream3.ui.result import android.animation.ValueAnimator -import android.annotation.SuppressLint import android.content.Context import android.content.res.Configuration import android.os.Build import android.os.Bundle -import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import androidx.core.view.ViewCompat import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.core.view.ViewCompat import com.lagradost.cloudstream3.CommonActivity.screenHeight import com.lagradost.cloudstream3.CommonActivity.screenWidth import com.lagradost.cloudstream3.LoadResponse @@ -20,7 +18,6 @@ import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding import com.lagradost.cloudstream3.ui.player.CSPlayerEvent import com.lagradost.cloudstream3.ui.player.CSPlayerLoading import com.lagradost.cloudstream3.ui.player.PlayerEventSource -import com.lagradost.cloudstream3.ui.player.PlayerView import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback @@ -53,15 +50,10 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { playerHostView.scheduleAutoHide() } - override fun isUiShowing(): Boolean = isShowing + override fun isUIShowing(): Boolean = isShowing - override fun onAutoHideUI() { - uiReset() - } - - override fun onHidePlayerUI() { - uiReset() - } + override fun onAutoHideUI() = uiReset() + override fun onHidePlayerUI() = uiReset() override fun nextEpisode() {} override fun prevEpisode() {} @@ -127,7 +119,7 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { val v = va.animatedValue as Int val lp: ViewGroup.LayoutParams = layoutParams lp.height = v - setLayoutParams(lp) + layoutParams = lp } anim.duration = 200 anim.start() @@ -139,7 +131,7 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { override fun playerDimensionsLoaded(width: Int, height: Int) { playerWidthHeight = width to height fixPlayerSize() - // Apply auto-rotation when fullscreen (lockRotation = true). + // Apply autorotation when fullscreen (lockRotation = true). // PlayerView already set isVerticalOrientation before this callback fires. if (lockRotation) { activity?.requestedOrientation = playerHostView.dynamicOrientation() @@ -160,7 +152,6 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { override fun onTracksInfoChanged() {} override fun exitedPipMode() {} - @SuppressLint("SetTextI18n") override fun onSeekPreviewText(text: String?) { playerBinding?.playerTimeText?.apply { isVisible = text != null From ca0388c0f38f53a3708f73ae5fde46f11b9f94cf Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 18 Apr 2026 14:08:57 -0600 Subject: [PATCH 04/15] Fix issue caused by out of date branch --- .../main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt | 1 - 1 file changed, 1 deletion(-) 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 5e4f89f8d86..e93ce8beb58 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 @@ -250,7 +250,6 @@ class PlayerView @JvmOverloads constructor( playerRewHolder = root.findViewById(R.id.player_rew_holder) playerSpeedupButton = root.findViewById(R.id.player_speedup_button) playerVideoHolder = root.findViewById(R.id.player_video_holder) - skipChapterButton = root.findViewById(R.id.skip_chapter_button) subtitleHolder = root.findViewById(R.id.subtitle_holder) timeLeft = root.findViewById(R.id.time_left) timeLive = root.findViewById(R.id.time_live) From 5e05534a78d55b616964dc165d219a698dc2c04d Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:29:50 -0600 Subject: [PATCH 05/15] Move companion objects to top --- .../ui/player/PlayerGestureHelper.kt | 72 +++++++++---------- .../cloudstream3/ui/player/PlayerView.kt | 8 +-- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt index f00937d612d..6d24af555be 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -59,6 +59,42 @@ import kotlin.math.roundToInt @OptIn(UnstableApi::class) class PlayerGestureHelper(private val playerView: PlayerView) { + companion object { + /** Swipe-seek constants */ + const val MINIMUM_SEEK_TIME = 7000L + const val MINIMUM_VERTICAL_SWIPE = 2.0f // % of screen height + const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // % of screen height + const val VERTICAL_MULTIPLIER = 2.0f + const val HORIZONTAL_MULTIPLIER = 2.0f + + /** Double-tap constants */ + /** Maximum finger-hold time (ms) for a tap to qualify as a double-tap seek. */ + const val DOUBLE_TAP_MAXIMUM_HOLD_TIME = 200L + /** Time window (ms) between taps to count as a double-tap. + * Also determines how long a single-tap is delayed before firing. */ + const val DOUBLE_TAP_MINIMUM_TIME_BETWEEN = 200L + /** Fraction of view width on each side that counts as "left" / "right" seek zone. */ + const val DOUBLE_TAP_PAUSE_PERCENTAGE = 0.15 + + /** Zoom constants */ + /** Minimum zoom; allows zooming out past 100% but snaps back. */ + const val MINIMUM_ZOOM = 0.95f + /** Sensitivity for the auto-snap to 100% at the minimum zoom boundary. */ + const val ZOOM_SNAP_SENSITIVITY = 0.07f + /** Maximum zoom to prevent the user from getting lost. */ + const val MAXIMUM_ZOOM = 4.0f + + /** Extracts translation and uniform scale from a matrix with no rotation. */ + fun matrixToTranslationAndScale(matrix: Matrix): Triple { + val points = floatArrayOf(0f, 0f, 1f, 1f) + matrix.mapPoints(points) + val translationX = points[0] + val translationY = points[1] + val scale = points[2] - translationX + return Triple(translationX, translationY, scale) + } + } + private val context: Context get() = playerView.context /** Set true by the host when the player occupies the full screen. @@ -1098,40 +1134,4 @@ class PlayerGestureHelper(private val playerView: PlayerView) { } return false } - - companion object { - /** Swipe-seek constants */ - const val MINIMUM_SEEK_TIME = 7000L - const val MINIMUM_VERTICAL_SWIPE = 2.0f // % of screen height - const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // % of screen height - const val VERTICAL_MULTIPLIER = 2.0f - const val HORIZONTAL_MULTIPLIER = 2.0f - - /** Double-tap constants */ - /** Maximum finger-hold time (ms) for a tap to qualify as a double-tap seek. */ - const val DOUBLE_TAP_MAXIMUM_HOLD_TIME = 200L - /** Time window (ms) between taps to count as a double-tap. - * Also determines how long a single-tap is delayed before firing. */ - const val DOUBLE_TAP_MINIMUM_TIME_BETWEEN = 200L - /** Fraction of view width on each side that counts as "left" / "right" seek zone. */ - const val DOUBLE_TAP_PAUSE_PERCENTAGE = 0.15 - - /** Zoom constants */ - /** Minimum zoom; allows zooming out past 100% but snaps back. */ - const val MINIMUM_ZOOM = 0.95f - /** Sensitivity for the auto-snap to 100% at the minimum zoom boundary. */ - const val ZOOM_SNAP_SENSITIVITY = 0.07f - /** Maximum zoom to prevent the user from getting lost. */ - const val MAXIMUM_ZOOM = 4.0f - - /** Extracts translation and uniform scale from a matrix with no rotation. */ - fun matrixToTranslationAndScale(matrix: Matrix): Triple { - val points = floatArrayOf(0f, 0f, 1f, 1f) - matrix.mapPoints(points) - val translationX = points[0] - val translationY = points[1] - val scale = points[2] - translationX - return Triple(translationX, translationY, scale) - } - } } 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 e93ce8beb58..73ffb5fe54b 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 @@ -77,6 +77,10 @@ class PlayerView @JvmOverloads constructor( attrs: AttributeSet? = null ) : FrameLayout(context, attrs) { + companion object { + private const val TAG = "PlayerView" + } + /** All gesture, volume, brightness and key-event logic lives here. */ val gestureHelper = PlayerGestureHelper(this) @@ -781,8 +785,4 @@ class PlayerView @JvmOverloads constructor( autoHideToken++ autoHideHandler.removeCallbacksAndMessages(null) } - - companion object { - private const val TAG = "PlayerView" - } } From 23ed1d81023aaa582258d2cdfecb5261a9135250 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:41:57 -0600 Subject: [PATCH 06/15] No lateinit + bugfix --- .../ui/player/AbstractPlayerFragment.kt | 60 ++++++------ .../ui/player/FullScreenPlayer.kt | 93 ++++++------------- .../cloudstream3/ui/player/GeneratorPlayer.kt | 6 +- .../cloudstream3/ui/player/PlayerView.kt | 30 ++++++ .../ui/result/ResultFragmentPhone.kt | 41 +++----- .../ui/result/ResultTrailerPlayer.kt | 20 ++-- 6 files changed, 111 insertions(+), 139 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index 47a012355f7..0a9cff9ee54 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -39,39 +39,39 @@ abstract class AbstractPlayerFragment( private var _player: IPlayer = CS3IPlayer() /** The shared [PlayerView] host that owns all player state and view references. */ - protected lateinit var playerHostView: PlayerView + protected var playerHostView: PlayerView? = null var player: IPlayer - get() = if (::playerHostView.isInitialized) playerHostView.player else _player + get() = playerHostView?.player ?: _player set(value) { _player = value - if (::playerHostView.isInitialized) playerHostView.player = value + playerHostView?.player = value } var subView: SubtitleView? - get() = if (::playerHostView.isInitialized) playerHostView.subView else null - set(value) { if (::playerHostView.isInitialized) playerHostView.subView = value } + get() = playerHostView?.subView + set(value) { playerHostView?.subView = value } protected open var hasPipModeSupport: Boolean - get() = if (::playerHostView.isInitialized) playerHostView.hasPipModeSupport else true - set(value) { if (::playerHostView.isInitialized) playerHostView.hasPipModeSupport = value } + get() = playerHostView?.hasPipModeSupport ?: true + set(value) { playerHostView?.hasPipModeSupport = value } var playerPausePlay: ImageView? - get() = if (::playerHostView.isInitialized) playerHostView.playerPausePlay else null - set(value) { if (::playerHostView.isInitialized) playerHostView.playerPausePlay = value } + get() = playerHostView?.playerPausePlay + set(value) { playerHostView?.playerPausePlay = value } /** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */ var playerView: androidx.media3.ui.PlayerView? - get() = if (::playerHostView.isInitialized) playerHostView.exoPlayerView else null - set(value) { if (::playerHostView.isInitialized) playerHostView.exoPlayerView = value } + get() = playerHostView?.exoPlayerView + set(value) { playerHostView?.exoPlayerView = value } var currentPlayerStatus: CSPlayerLoading - get() = if (::playerHostView.isInitialized) playerHostView.currentPlayerStatus else CSPlayerLoading.IsBuffering - set(value) { if (::playerHostView.isInitialized) playerHostView.currentPlayerStatus = value } + get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering + set(value) { playerHostView?.currentPlayerStatus = value } protected var mMediaSession: MediaSession? - get() = if (::playerHostView.isInitialized) playerHostView.mMediaSession else null - set(value) { if (::playerHostView.isInitialized) playerHostView.mMediaSession = value } + get() = playerHostView?.mMediaSession + set(value) { playerHostView?.mMediaSession = value } // No-op callbacks (nextEpisode, prevEpisode, etc.) are intentionally left as // open so subclasses can override only what they need. The ones below throw @@ -90,7 +90,7 @@ abstract class AbstractPlayerFragment( /** Delegates to [PlayerView.playerError] by default; override to customize. */ override fun playerError(exception: Throwable) { - if (::playerHostView.isInitialized) playerHostView.playerError(exception) + playerHostView?.playerError(exception) } /** Player fragments don't need system-bar padding adjustment by default. */ @@ -99,49 +99,45 @@ abstract class AbstractPlayerFragment( override fun onBindingCreated(binding: T, savedInstanceState: Bundle?) { val ctx = context ?: return playerHostView = PlayerView(ctx) - playerHostView.player = _player - playerHostView.hasPipModeSupport = hasPipModeSupport - playerHostView.callbacks = this - playerHostView.bindViews(binding.root) - playerHostView.initialize() + playerHostView?.player = _player + playerHostView?.hasPipModeSupport = hasPipModeSupport + playerHostView?.callbacks = this + playerHostView?.bindViews(binding.root) + playerHostView?.initialize() } override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) - if (::playerHostView.isInitialized) { - playerHostView.onPictureInPictureModeChanged(isInPictureInPictureMode, activity) - } + playerHostView?.onPictureInPictureModeChanged(isInPictureInPictureMode, activity) } override fun onDestroy() { - if (::playerHostView.isInitialized) { - playerHostView.release() - } + playerHostView?.release() super.onDestroy() } override fun onPause() { - if (::playerHostView.isInitialized) playerHostView.releaseKeyEventListener() + playerHostView?.releaseKeyEventListener() super.onPause() } override fun onStop() { - if (::playerHostView.isInitialized) playerHostView.onStop() + playerHostView?.onStop() super.onStop() } override fun onResume() { context?.let { ctx -> - if (::playerHostView.isInitialized) playerHostView.onResume(ctx) + playerHostView?.onResume(ctx) } super.onResume() } fun nextResize() { - if (::playerHostView.isInitialized) playerHostView.nextResize() + playerHostView?.nextResize() } open fun resize(resize: PlayerResize, showToast: Boolean) { - if (::playerHostView.isInitialized) playerHostView.resize(resize, showToast) + playerHostView?.resize(resize, showToast) } } 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 132d9dc0ffb..39632b524e5 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 @@ -21,7 +21,6 @@ import android.view.View import android.view.ViewGroup import android.view.WindowInsets import android.view.WindowManager -import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AlphaAnimation import android.view.animation.DecelerateInterpolator @@ -83,7 +82,6 @@ open class FullScreenPlayer : AbstractPlayerFragment( ) { override fun pickLayout(): Int = R.layout.fragment_player protected open var lockRotation = true - protected open var isFullScreenPlayer = true protected var playerBinding: PlayerCustomLayoutBinding? = null // state of player UI @@ -121,8 +119,6 @@ open class FullScreenPlayer : AbstractPlayerFragment( 0L } - private val fullscreenNotch = true // TODO SETTING - private var statusBarHeight: Int? = null private var navigationBarHeight: Int? = null @@ -233,7 +229,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( } override fun onDestroyView() { - if (::playerHostView.isInitialized) playerHostView.releaseOverlayLayoutListener() + playerHostView?.releaseOverlayLayoutListener() playerBinding = null super.onDestroyView() } @@ -358,7 +354,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( } if (!isLocked) { - if (::playerHostView.isInitialized) playerHostView.gestureHelper.animateCenterControls(fadeTo) + playerHostView?.gestureHelper?.animateCenterControls(fadeTo) shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) downloadBothHeader.startAnimation(fadeAnimation) @@ -389,7 +385,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - else -> playerHostView.dynamicOrientation() + else -> playerHostView?.dynamicOrientation() ?: return } activity.requestedOrientation = orientation } @@ -403,7 +399,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - else -> playerHostView.dynamicOrientation() + else -> playerHostView?.dynamicOrientation() ?: return } activity.requestedOrientation = orientation } @@ -431,7 +427,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - else -> orientation = playerHostView.dynamicOrientation() + else -> orientation = playerHostView?.dynamicOrientation() ?: return } activity.requestedOrientation = orientation } @@ -445,41 +441,13 @@ open class FullScreenPlayer : AbstractPlayerFragment( if (ignoreDynamicOrientation || rotatedManually) { restoreOrientationWithSensor(this) } else { - this.requestedOrientation = playerHostView.dynamicOrientation() + this.requestedOrientation = playerHostView?.dynamicOrientation() ?: return@apply } } } } } - protected fun enterFullscreen() { - if (isFullScreenPlayer) { - activity?.hideSystemUI() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { - val params = activity?.window?.attributes - params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES - activity?.window?.attributes = params - } - } - updateOrientation() - } - - protected fun exitFullscreen() { - if (::playerHostView.isInitialized) playerHostView.gestureHelper.resetZoomToDefault() - // if (lockRotation) - activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER - - // simply resets brightness and notch settings that might have been overridden - val lp = activity?.window?.attributes - lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - lp?.layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT - } - activity?.window?.attributes = lp - activity?.showSystemUI() - } - private fun setupKeyEventListener() { keyEventListener = { eventNav -> val (event, hasNavigated) = eventNav @@ -488,7 +456,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( event.action == KeyEvent.ACTION_DOWN && (event.keyCode == KeyEvent.KEYCODE_VOLUME_UP || event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) -> - playerHostView.handleVolumeKey(event.keyCode) + playerHostView?.handleVolumeKey(event.keyCode) ?: false player.isActive() -> handleKeyEvent(event, hasNavigated) else -> false } @@ -496,9 +464,9 @@ open class FullScreenPlayer : AbstractPlayerFragment( } override fun onResume() { - enterFullscreen() + playerHostView?.enterFullscreen { updateOrientation() } setupKeyEventListener() - playerHostView.verifyVolume() + playerHostView?.verifyVolume() activity?.attachBackPressedCallback("FullScreenPlayer") { if (isShowingEpisodeOverlay) { // isShowingEpisodeOverlay pauses, so this makes it easier to unpause @@ -514,7 +482,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( activity?.popCurrentPage("FullScreenPlayer") } } - playerHostView.requestUpdateBrightnessOverlayOnNextLayout() + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() super.onResume() } @@ -524,7 +492,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( } override fun onDestroy() { - exitFullscreen() + playerHostView?.exitFullscreen() super.onDestroy() } @@ -638,8 +606,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( dialog.setOnDismissListener { selectSubtitlesDialog = null - if (isFullScreenPlayer) - activity?.hideSystemUI() + activity?.hideSystemUI() } applyBtt.setOnClickListener { selectSubtitlesDialog = null @@ -712,8 +679,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( } val dismiss = DialogInterface.OnDismissListener { - if (isFullScreenPlayer) - activity?.hideSystemUI() + activity?.hideSystemUI() if (isPlaying) { player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) } @@ -743,8 +709,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( playerBinding?.playerIntroPlay?.isGone = true autoHide() } - if (isFullScreenPlayer) - activity?.hideSystemUI() + activity?.hideSystemUI() animateLayoutChanges() if (playerBinding?.playerEpisodeOverlay?.isGone == true) playerBinding?.playerPausePlay?.requestFocus() } @@ -755,7 +720,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( } isLocked = !isLocked - if (::playerHostView.isInitialized) playerHostView.isLocked = isLocked + playerHostView?.isLocked = isLocked updateOrientation(true) // set true to ignore auto rotate to stay in current orientation if (isLocked && isShowing) { @@ -767,7 +732,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( } val fadeTo = if (isLocked) 0f else 1f - if (::playerHostView.isInitialized) playerHostView.gestureHelper.animateCenterControls(fadeTo) + playerHostView?.gestureHelper?.animateCenterControls(fadeTo) playerBinding?.apply { val fadeAnimation = AlphaAnimation(playerVideoTitleHolder.alpha, fadeTo).apply { duration = 100 @@ -846,7 +811,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( protected fun autoHide() { metadataVisibilityToken++ - if (::playerHostView.isInitialized) playerHostView.scheduleAutoHide() + playerHostView?.scheduleAutoHide() scheduleMetadataVisibility() } @@ -918,21 +883,21 @@ open class FullScreenPlayer : AbstractPlayerFragment( super.onConfigurationChanged(newConfig) // If we rotate the device we need to recalculate the zoom - val gh = if (::playerHostView.isInitialized) playerHostView.gestureHelper else return + val gh = playerHostView?.gestureHelper ?: return val matrix = gh.zoomMatrix val animation = gh.matrixAnimation if ((animation == null || !animation.isRunning) && matrix != null) { // Ignore if we have no zoom or mid-animation playerView?.post { gh.applyZoomMatrix(matrix, true) - playerHostView.requestUpdateBrightnessOverlayOnNextLayout() + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() } } } override fun resize(resize: PlayerResize, showToast: Boolean) { super.resize(resize, showToast) - if (::playerHostView.isInitialized) playerHostView.requestUpdateBrightnessOverlayOnNextLayout() + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() } private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { @@ -987,8 +952,8 @@ open class FullScreenPlayer : AbstractPlayerFragment( KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP -> { - // Handled entirely by PlayerView.handleVolumeKey (checks PHONE/EMULATOR + isFullScreen). - if (playerHostView.handleVolumeKey(keyCode)) return true + // Handled entirely by PlayerView.handleVolumeKey (checks PHONE/EMULATOR). + if (playerHostView?.handleVolumeKey(keyCode) == true) return true } } } @@ -1042,10 +1007,8 @@ open class FullScreenPlayer : AbstractPlayerFragment( updateLockUI() updateUIVisibility() animateLayoutChanges() - if (::playerHostView.isInitialized) { - playerHostView.gestureHelper.resetFastForwardText() - playerHostView.gestureHelper.resetRewindText() - } + playerHostView?.gestureHelper?.resetFastForwardText() + playerHostView?.gestureHelper?.resetRewindText() } override fun onSaveInstanceState(outState: Bundle) { @@ -1062,11 +1025,11 @@ open class FullScreenPlayer : AbstractPlayerFragment( super.onBindingCreated(binding, savedInstanceState) // This player is always full-screen; tell PlayerView so volume-key handling is active. - playerHostView.isFullScreen = true + playerHostView?.isFullScreen = true // Wire up the snap-hint outline view and schedule brightness overlay bounds update - playerHostView.videoOutline = playerBinding?.videoOutline - playerHostView.requestUpdateBrightnessOverlayOnNextLayout() + playerHostView?.videoOutline = playerBinding?.videoOutline + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() val view = binding.root // init variables @@ -1323,7 +1286,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( when (event.action) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { - if (::playerHostView.isInitialized) playerHostView.cancelAutoHide() + playerHostView?.cancelAutoHide() } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_BUTTON_RELEASE -> { 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 ac728f35e06..8449bdb5535 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 @@ -383,7 +383,7 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onCustomAction(player: Player, action: String, intent: Intent) { when (action) { STOP_ACTION -> { - exitFullscreen() + playerHostView?.exitFullscreen() this@GeneratorPlayer.player.release() activity?.popCurrentPage() } @@ -534,7 +534,7 @@ class GeneratorPlayer : FullScreenPlayer() { (if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle( currentSubs, settings = true, downloads = true ), - preview = isFullScreenPlayer + preview = true ) } @@ -2169,7 +2169,7 @@ class GeneratorPlayer : FullScreenPlayer() { } binding.playerLoadingGoBack.setOnClickListener { - exitFullscreen() + playerHostView?.exitFullscreen() player.release() activity?.popCurrentPage() } 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 73ffb5fe54b..48801fa1801 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 @@ -18,6 +18,7 @@ import android.util.AttributeSet import android.util.Log import android.view.View import android.view.WindowManager +import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES import android.widget.FrameLayout import android.widget.ImageView import android.widget.ProgressBar @@ -62,6 +63,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI import com.lagradost.cloudstream3.utils.UserPreferenceDelegate import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp import java.net.SocketTimeoutException @@ -407,6 +409,34 @@ class PlayerView @JvmOverloads constructor( /** Lifecycle delegation */ + var fullscreenNotch: Boolean = true // TODO SETTING + + fun enterFullscreen(updateOrientation: () -> Unit = {}) { + val activity = context as? Activity + if (isFullScreen) { + activity?.hideSystemUI() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { + val params = activity?.window?.attributes + params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + activity?.window?.attributes = params + } + } + updateOrientation() + } + + fun exitFullscreen() { + val activity = context as? Activity + gestureHelper.resetZoomToDefault() + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + val lp = activity?.window?.attributes + lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + lp?.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT + } + activity?.window?.attributes = lp + activity?.showSystemUI() + } + fun onStop() { player.onStop() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index dd7919d9597..bc6e284c723 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -60,8 +60,6 @@ import com.lagradost.cloudstream3.ui.player.CS3IPlayer import com.lagradost.cloudstream3.ui.player.CSPlayerEvent import com.lagradost.cloudstream3.ui.player.IPlayer import com.lagradost.cloudstream3.ui.player.PlayerView as PlayerHostView -import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI -import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo @@ -127,20 +125,7 @@ open class ResultFragmentPhone : BaseFragment( protected var playerBinding: PlayerCustomLayoutBinding? = null protected var isShowing: Boolean = false - protected lateinit var playerHostView: PlayerHostView - - protected open fun enterFullscreen() { - activity?.hideSystemUI() - if (lockRotation) { - activity?.requestedOrientation = - android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } - } - - protected open fun exitFullscreen() { - activity?.showSystemUI() - activity?.requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_USER - } + protected var playerHostView: PlayerHostView? = null open fun updateUIVisibility() {} @@ -182,7 +167,7 @@ open class ResultFragmentPhone : BaseFragment( override fun playerError(exception: Throwable) { if (player.getIsPlaying()) { // because we don't want random toasts in player - if (::playerHostView.isInitialized) playerHostView.playerError(exception) + playerHostView?.playerError(exception) } else { nextMirror() } @@ -282,7 +267,7 @@ open class ResultFragmentPhone : BaseFragment( } updateUIEvent -= ::updateUI - if (::playerHostView.isInitialized) playerHostView.release() + playerHostView?.release() playerBinding = null resultBinding?.resultScroll?.setOnClickListener(null) resultBinding = null @@ -350,10 +335,8 @@ open class ResultFragmentPhone : BaseFragment( afterPluginsLoadedEvent += ::reloadViewModel activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) context?.let { ctx -> - if (::playerHostView.isInitialized) { - playerHostView.onResume(ctx) - playerHostView.setupKeyEventListener() - } + playerHostView?.onResume(ctx) + playerHostView?.setupKeyEventListener() } super.onResume() PanelsChildGestureRegionObserver.Provider.get() @@ -362,7 +345,7 @@ open class ResultFragmentPhone : BaseFragment( override fun onStop() { afterPluginsLoadedEvent -= ::reloadViewModel - if (::playerHostView.isInitialized) playerHostView.onStop() + playerHostView?.onStop() super.onStop() } @@ -385,14 +368,14 @@ open class ResultFragmentPhone : BaseFragment( // Set up trailer player val ctx = context ?: return playerHostView = PlayerHostView(ctx) - playerHostView.player = player - playerHostView.hasPipModeSupport = hasPipModeSupport - playerHostView.callbacks = this - playerHostView.bindViews(binding.root) + playerHostView?.player = player + playerHostView?.hasPipModeSupport = hasPipModeSupport + playerHostView?.callbacks = this + playerHostView?.bindViews(binding.root) playerBinding = binding.root.findViewById(R.id.player_holder)?.let { PlayerCustomLayoutBinding.bind(it) } - playerHostView.initialize() + playerHostView?.initialize() // ===== setup ===== val storedData = getStoredData() ?: return @@ -1404,7 +1387,7 @@ open class ResultFragmentPhone : BaseFragment( } override fun onPause() { - if (::playerHostView.isInitialized) playerHostView.releaseKeyEventListener() + playerHostView?.releaseKeyEventListener() super.onPause() PanelsChildGestureRegionObserver.Provider.get() .addGestureRegionsUpdateListener(gestureRegionsListener) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt index f97cab6febf..bc5582c5520 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -23,7 +23,7 @@ import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPres import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding -open class ResultTrailerPlayer : ResultFragmentPhone() { +class ResultTrailerPlayer : ResultFragmentPhone() { override var lockRotation = false override var isFullScreenPlayer = false @@ -47,7 +47,7 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { if (introVisible) return isShowing = true updateUIVisibility() - playerHostView.scheduleAutoHide() + playerHostView?.scheduleAutoHide() } override fun isUIShowing(): Boolean = isShowing @@ -134,7 +134,7 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { // Apply autorotation when fullscreen (lockRotation = true). // PlayerView already set isVerticalOrientation before this callback fires. if (lockRotation) { - activity?.requestedOrientation = playerHostView.dynamicOrientation() + activity?.requestedOrientation = playerHostView?.dynamicOrientation() ?: return } } @@ -162,13 +162,13 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { private fun updateFullscreen(fullscreen: Boolean) { isFullScreenPlayer = fullscreen lockRotation = fullscreen - playerHostView.isFullScreen = fullscreen + playerHostView?.isFullScreen = fullscreen playerBinding?.playerFullscreen?.setImageResource( if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24 ) if (fullscreen) { - enterFullscreen() + playerHostView?.enterFullscreen() binding?.apply { resultTopBar.isVisible = false resultFullscreenHolder.isVisible = true @@ -188,7 +188,7 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { resultBinding?.resultSmallscreenHolder?.addView(view) } } - exitFullscreen() + playerHostView?.exitFullscreen() } fixPlayerSize() uiReset() @@ -209,10 +209,10 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { playerVideoHolder.isVisible = controlsVisible shadowOverlay.isVisible = controlsVisible playerPausePlayHolderHolder.isVisible = - controlsVisible && playerHostView.currentPlayerStatus != CSPlayerLoading.IsBuffering + controlsVisible && playerHostView?.currentPlayerStatus != CSPlayerLoading.IsBuffering } // Fade center controls in/out; also resets stale fillAfter alpha from seek animations. - playerHostView.gestureHelper.animateCenterControls(if (isShowing && !introVisible) 1f else 0f) + playerHostView?.gestureHelper?.animateCenterControls(if (isShowing && !introVisible) 1f else 0f) } override fun playerStatusChanged() { @@ -224,8 +224,8 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) - playerHostView.videoOutline = playerBinding?.videoOutline - playerHostView.requestUpdateBrightnessOverlayOnNextLayout() + playerHostView?.videoOutline = playerBinding?.videoOutline + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() playerBinding?.playerFullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) } updateFullscreen(isFullScreenPlayer) From dd2a881f0c5cda1dd0f316505dbff8dbb5a75e06 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:46:09 -0600 Subject: [PATCH 07/15] Restore lastTopIndex --- .../com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 8449bdb5535..45cb1ac53b5 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 @@ -2097,13 +2097,15 @@ class GeneratorPlayer : FullScreenPlayer() { } // update overlay season title - val lastTopIndex = -1 + var lastTopIndex = -1 playerEpisodeList.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return val topIndex = layoutManager.findFirstCompletelyVisibleItemPosition() if (topIndex != RecyclerView.NO_POSITION && topIndex != lastTopIndex) { + @Suppress("AssignedValueIsNeverRead") + lastTopIndex = topIndex val topItem = episodes.getOrNull(topIndex) topItem?.let { playerEpisodeOverlayTitle.setText( From 70c6950036bb52ce8e96bb213130c3ba0511730e Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:57:17 -0600 Subject: [PATCH 08/15] Fixes --- .../ui/player/FullScreenPlayer.kt | 9 ++---- .../ui/player/PlayerGestureHelper.kt | 29 ++++++++++++++----- .../ui/result/ResultFragmentPhone.kt | 16 +++++----- .../main/res/layout/trailer_custom_layout.xml | 5 ++-- 4 files changed, 34 insertions(+), 25 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 39632b524e5..656e50c529e 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 @@ -67,7 +67,6 @@ import com.lagradost.cloudstream3.utils.UIHelper.getNavigationBarHeight import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt @@ -705,10 +704,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( private fun onClickChange() { isShowing = !isShowing - if (isShowing) { - playerBinding?.playerIntroPlay?.isGone = true - autoHide() - } + if (isShowing) autoHide() activity?.hideSystemUI() animateLayoutChanges() if (playerBinding?.playerEpisodeOverlay?.isGone == true) playerBinding?.playerPausePlay?.requestFocus() @@ -784,7 +780,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( playerEpisodesButtonRoot.isVisible = showPlayerEpisodes playerEpisodesButton.isVisible = showPlayerEpisodes playerVideoTitleHolder.isGone = togglePlayerTitleGone -// player_video_title_rez?.isGone = isGone + playerVideoTitleRez.isGone = isGone playerEpisodeFiller.isGone = isGone playerCenterMenu.isGone = isGone playerLock.isGone = !isShowing @@ -844,7 +840,6 @@ open class FullScreenPlayer : AbstractPlayerFragment( } override fun onTouchDown() { - playerBinding?.playerIntroPlay?.isGone = true if (isShowingEpisodeOverlay) toggleEpisodesOverlay(show = false) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt index 6d24af555be..b2250dbfad9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -171,7 +171,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { /** Whether double-tapping left/right seeks backward/forward. */ var doubleTapEnabled: Boolean = false - /** Whether double-tapping the centre of the screen pauses (left/right still seeks if [doubleTapEnabled]). */ + /** Whether double-tapping the center of the screen pauses (left/right still seeks if [doubleTapEnabled]). */ var doubleTapPauseEnabled: Boolean = false /** Seek distance (ms) for each double-tap seek. Read from prefs in [initialize]. */ @@ -285,7 +285,13 @@ class PlayerGestureHelper(private val playerView: PlayerView) { /** Volume helpers */ - /** Syncs [currentRequestedVolume] with the current system stream volume. */ + /** + * Syncs [currentRequestedVolume] with the current system stream volume. + * + * This is here to make returning to the player less jarring, if we change the volume outside + * the app. Note that this will make it a bit wierd when using loudness in PiP, then returning + * however that is the cost of correctness. + */ fun verifyVolume() { ((context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { am -> val cur = am.getStreamVolume(AudioManager.STREAM_MUSIC) @@ -503,6 +509,12 @@ class PlayerGestureHelper(private val playerView: PlayerView) { /** * Returns the current zoom matrix, accounting for RESIZE_MODE_ZOOM which already has * an implicit zoom applied. + * + * This is different from `zoomMatrix ?: Matrix()` + * because it allows used to start zooming at different resizeModes. + * + * The main issue is that RESIZE_MODE_FIT = 100% zoom, but if you are in RESIZE_MODE_ZOOM + * 100% will make the zoom snap to less zoomed in then you already are. */ fun currentZoomMatrix(): Matrix { val current = zoomMatrix @@ -626,8 +638,11 @@ class PlayerGestureHelper(private val playerView: PlayerView) { override fun onScale(detector: ScaleGestureDetector): Boolean { val matrix = currentZoomMatrix() val (_, _, scale) = matrixToTranslationAndScale(matrix) + // Clamp scale of the zoom, do it here as it is easier than doing it within applyZoomMatrix. val newScale = (scale * detector.scaleFactor).coerceIn(MINIMUM_ZOOM, MAXIMUM_ZOOM) + // This is how much we should scale it with to prevent infinite scaling. val actualScaleFactor = newScale / scale + // Scale around the focus point, this is more natural than just zoom. val pivotX = detector.focusX - screenWidthWithOrientation.toFloat() * 0.5f val pivotY = detector.focusY - screenHeightWithOrientation.toFloat() * 0.5f matrix.postScale(actualScaleFactor, actualScaleFactor, pivotX, pivotY) @@ -651,8 +666,8 @@ class PlayerGestureHelper(private val playerView: PlayerView) { fun handleZoomPanGesture( event: MotionEvent, ctx: Context, - onFirstPointerDown: () -> Unit = {}, - onGestureEnd: () -> Unit = {} + onFirstPointerDown: () -> Unit, + onGestureEnd: () -> Unit ): Boolean { if (scaleGestureDetector == null) createScaleGestureDetector(ctx) scaleGestureDetector?.onTouchEvent(event) @@ -975,7 +990,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { fun setupTouchGestures() { val holder = playerView.playerHolder ?: return @SuppressLint("ClickableViewAccessibility") - holder.setOnTouchListener { v, event -> handleGesture(v, event) } + holder.setOnTouchListener(::handleGesture) } private fun handleGesture(view: View, event: MotionEvent): Boolean { @@ -1069,8 +1084,8 @@ class PlayerGestureHelper(private val playerView: PlayerView) { } } } - TouchAction.Brightness -> handleBrightnessAdjustment(verticalAddition) - TouchAction.Volume -> handleVolumeAdjustment(verticalAddition, false) + TouchAction.Brightness -> if (!isLocked) handleBrightnessAdjustment(verticalAddition) + TouchAction.Volume -> if (!isLocked) handleVolumeAdjustment(verticalAddition, false) null -> Unit } if (currentTouchAction != TouchAction.Time) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index bc6e284c723..d96d78324bc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -43,6 +43,7 @@ import com.lagradost.cloudstream3.databinding.FragmentResultBinding import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding import com.lagradost.cloudstream3.databinding.ResultRecommendationsBinding import com.lagradost.cloudstream3.databinding.ResultSyncBinding +import com.lagradost.cloudstream3.databinding.TrailerCustomLayoutBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe @@ -50,16 +51,15 @@ import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.services.SubscriptionWorkManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup -import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.player.CS3IPlayer import com.lagradost.cloudstream3.ui.player.CSPlayerEvent import com.lagradost.cloudstream3.ui.player.IPlayer -import com.lagradost.cloudstream3.ui.player.PlayerView as PlayerHostView +import com.lagradost.cloudstream3.ui.player.PlayerView import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo @@ -103,7 +103,7 @@ import kotlin.math.roundToInt open class ResultFragmentPhone : BaseFragment( BindingCreator.Inflate(FragmentResultSwipeBinding::inflate) -), PlayerHostView.Callbacks { +), PlayerView.Callbacks { private val gestureRegionsListener = object : PanelsChildGestureRegionObserver.GestureRegionsListener { override fun onGestureRegionsUpdate(gestureRegions: List) { @@ -122,10 +122,10 @@ open class ResultFragmentPhone : BaseFragment( protected open var hasPipModeSupport: Boolean = false protected open var isFullScreenPlayer: Boolean = true protected open var lockRotation: Boolean = true - protected var playerBinding: PlayerCustomLayoutBinding? = null + protected var playerBinding: TrailerCustomLayoutBinding? = null protected var isShowing: Boolean = false - protected var playerHostView: PlayerHostView? = null + protected var playerHostView: PlayerView? = null open fun updateUIVisibility() {} @@ -367,13 +367,13 @@ open class ResultFragmentPhone : BaseFragment( // Set up trailer player val ctx = context ?: return - playerHostView = PlayerHostView(ctx) + playerHostView = PlayerView(ctx) playerHostView?.player = player playerHostView?.hasPipModeSupport = hasPipModeSupport playerHostView?.callbacks = this playerHostView?.bindViews(binding.root) playerBinding = binding.root.findViewById(R.id.player_holder)?.let { - PlayerCustomLayoutBinding.bind(it) + TrailerCustomLayoutBinding.bind(it) } playerHostView?.initialize() diff --git a/app/src/main/res/layout/trailer_custom_layout.xml b/app/src/main/res/layout/trailer_custom_layout.xml index 8b12505c266..76231a2d3a0 100644 --- a/app/src/main/res/layout/trailer_custom_layout.xml +++ b/app/src/main/res/layout/trailer_custom_layout.xml @@ -12,6 +12,7 @@ android:layout_width="640dp" android:layout_height="match_parent" android:background="@drawable/bg_player_metadata_scrim_netflix" + android:visibility="gone" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"> @@ -1129,6 +1130,4 @@ - - - \ No newline at end of file + From a3cfa8ba09d63c3e30924b73bf013df484e96d66 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:51:06 -0600 Subject: [PATCH 09/15] Fixes --- .../ui/player/AbstractPlayerFragment.kt | 20 +-- .../ui/player/FullScreenPlayer.kt | 1 + .../cloudstream3/ui/player/GeneratorPlayer.kt | 13 +- .../ui/player/PlayerGestureHelper.kt | 155 ++++++++++++------ .../cloudstream3/ui/player/PlayerView.kt | 117 ++++++++----- 5 files changed, 200 insertions(+), 106 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index 0a9cff9ee54..90dc26820a9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -77,16 +77,16 @@ abstract class AbstractPlayerFragment( // open so subclasses can override only what they need. The ones below throw // to make it obvious when an implementation is missing. - override fun nextEpisode() { throw NotImplementedError() } - override fun prevEpisode() { throw NotImplementedError() } - override fun playerPositionChanged(position: Long, duration: Long) { throw NotImplementedError() } - override fun playerDimensionsLoaded(width: Int, height: Int) { throw NotImplementedError() } - override fun subtitlesChanged() { throw NotImplementedError() } - override fun embeddedSubtitlesFetched(subtitles: List) { throw NotImplementedError() } - override fun onTracksInfoChanged() { throw NotImplementedError() } - override fun exitedPipMode() { throw NotImplementedError() } - override fun hasNextMirror(): Boolean { throw NotImplementedError() } - override fun nextMirror() { throw NotImplementedError() } + override fun nextEpisode() = throw NotImplementedError() + override fun prevEpisode() = throw NotImplementedError() + override fun playerPositionChanged(position: Long, duration: Long) = throw NotImplementedError() + override fun playerDimensionsLoaded(width: Int, height: Int) = throw NotImplementedError() + override fun subtitlesChanged() = throw NotImplementedError() + override fun embeddedSubtitlesFetched(subtitles: List) = throw NotImplementedError() + override fun onTracksInfoChanged() = throw NotImplementedError() + override fun exitedPipMode() = throw NotImplementedError() + override fun hasNextMirror(): Boolean = throw NotImplementedError() + override fun nextMirror() = throw NotImplementedError() /** Delegates to [PlayerView.playerError] by default; override to customize. */ override fun playerError(exception: Throwable) { 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 656e50c529e..516673e7b70 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 @@ -438,6 +438,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( lockOrientation(this) } else { if (ignoreDynamicOrientation || rotatedManually) { + // Restore when lock is disabled. restoreOrientationWithSensor(this) } else { this.requestedOrientation = playerHostView?.dynamicOrientation() ?: return@apply 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 45cb1ac53b5..3576f487413 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 @@ -1974,6 +1974,11 @@ class GeneratorPlayer : FullScreenPlayer() { } } + /** + * This is used instead of layout-television to follow the + * settings and some TV devices are not classified as TV + * for some reason. + */ override fun pickLayout(): Int = if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player @@ -2138,10 +2143,10 @@ class GeneratorPlayer : FullScreenPlayer() { context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - showName = settingsManager.getBoolean(ctx.getString(R.string.show_name_key), true) - showResolution = settingsManager.getBoolean(ctx.getString(R.string.show_resolution_key), true) - showMediaInfo = settingsManager.getBoolean(ctx.getString(R.string.show_media_info_key), false) - limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_title_limit_key), 0) + showName = settingsManager.getBoolean(ctx.getString(R.string.show_name_key), true) + showResolution = settingsManager.getBoolean(ctx.getString(R.string.show_resolution_key), true) + showMediaInfo = settingsManager.getBoolean(ctx.getString(R.string.show_media_info_key), false) + limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_title_limit_key), 0) updateForcedEncoding(ctx) filterSubByLang = settingsManager.getBoolean(getString(R.string.filter_sub_lang_key), false) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt index b2250dbfad9..ae355f84541 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -61,11 +61,11 @@ class PlayerGestureHelper(private val playerView: PlayerView) { companion object { /** Swipe-seek constants */ - const val MINIMUM_SEEK_TIME = 7000L - const val MINIMUM_VERTICAL_SWIPE = 2.0f // % of screen height - const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // % of screen height - const val VERTICAL_MULTIPLIER = 2.0f - const val HORIZONTAL_MULTIPLIER = 2.0f + const val MINIMUM_SEEK_TIME = 7000L + const val MINIMUM_VERTICAL_SWIPE = 2.0f // % of screen height + const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // % of screen height + const val VERTICAL_MULTIPLIER = 2.0f + const val HORIZONTAL_MULTIPLIER = 2.0f /** Double-tap constants */ /** Maximum finger-hold time (ms) for a tap to qualify as a double-tap seek. */ @@ -150,7 +150,9 @@ class PlayerGestureHelper(private val playerView: PlayerView) { var currentTouchAction: TouchAction? = null /** Action from the previous touch sequence; guards against mis-detected double-taps after swipes. */ var currentLastTouchAction: TouchAction? = null + /** The time in the player when you first click. */ private var currentTouchStartPlayerTime: Long? = null + /** The system time when you first click. */ private var currentTouchStartTime: Long? = null /** Whether the player UI was visible when the current swipe gesture began. */ var uiShowingBeforeGesture: Boolean = false @@ -211,14 +213,15 @@ class PlayerGestureHelper(private val playerView: PlayerView) { fun initialize() { try { val sm = PreferenceManager.getDefaultSharedPreferences(context) - swipeVerticalEnabled = sm.getBoolean(context.getString(R.string.swipe_vertical_enabled_key), true) + swipeVerticalEnabled = sm.getBoolean(context.getString(R.string.swipe_vertical_enabled_key), true) swipeHorizontalEnabled = sm.getBoolean(context.getString(R.string.swipe_enabled_key), true) extraBrightnessEnabled = sm.getBoolean(context.getString(R.string.extra_brightness_key), false) - speedupEnabled = sm.getBoolean(context.getString(R.string.speedup_key), false) - doubleTapEnabled = sm.getBoolean(context.getString(R.string.double_tap_enabled_key), false) - doubleTapPauseEnabled = sm.getBoolean(context.getString(R.string.double_tap_pause_enabled_key), false) - fastForwardTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10).toLong() * 1000L - } catch (_: Exception) { } + speedupEnabled = sm.getBoolean(context.getString(R.string.speedup_key), false) + doubleTapEnabled = sm.getBoolean(context.getString(R.string.double_tap_enabled_key), false) + doubleTapPauseEnabled = sm.getBoolean(context.getString(R.string.double_tap_pause_enabled_key), false) + fastForwardTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10).toLong() * 1000L + } catch (_: Exception) { + } // Inject the brightness overlay into the ExoPlayer content frame so it sits // directly on top of the video surface. Alpha is set by handleBrightnessAdjustment. @@ -228,7 +231,9 @@ class PlayerGestureHelper(private val playerView: PlayerView) { val contentId = context.resources.getIdentifier("exo_content_frame", "id", pkg) val contentFrame = playerView.exoPlayerView?.findViewById(contentId) if (contentFrame != null) { - brightnessOverlay?.let { (it.parent as? ViewGroup)?.removeView(it) } + brightnessOverlay?.let { + (it.parent as? ViewGroup)?.removeView(it) + } brightnessOverlay = LayoutInflater.from(context) .inflate(R.layout.extra_brightness_overlay, contentFrame, false) contentFrame.addView(brightnessOverlay) @@ -240,7 +245,11 @@ class PlayerGestureHelper(private val playerView: PlayerView) { /** Called from [PlayerView.release]. */ fun release() { - safe { brightnessOverlay?.let { (it.parent as? ViewGroup)?.removeView(it) } } + safe { + brightnessOverlay?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + } brightnessOverlay = null loudnessEnhancer?.release() loudnessEnhancer = null @@ -311,11 +320,18 @@ class PlayerGestureHelper(private val playerView: PlayerView) { * @return true if the key was consumed (suppresses the system volume UI). */ fun handleVolumeKey(keyCode: Int): Boolean { + /** + * Some TVs do not support volume boosting, and overriding + * the volume buttons can be inconvenient for TV users. + * Since boosting volume is mainly useful on phones and emulators, + * we limit this feature to those devices. + */ if (!isLayout(PHONE or EMULATOR) || !isFullScreen) return false if (keyCode != KeyEvent.KEYCODE_VOLUME_UP && keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) return false verifyVolume() if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false isVolumeLocked = currentRequestedVolume < 1.0f + // +- 5% handleVolumeAdjustment(if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) 0.05f else -0.05f, fromButton = true) return true } @@ -325,27 +341,32 @@ class PlayerGestureHelper(private val playerView: PlayerView) { val curStep = am.getStreamVolume(AudioManager.STREAM_MUSIC) val maxStep = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - val cur = currentRequestedVolume + val cur = currentRequestedVolume val locked = isVolumeLocked - val next = (cur + delta).coerceIn(0.0f, if (locked) 1.0f else 2.0f) + val next = (cur + delta).coerceIn(0.0f, if (locked) 1.0f else 2.0f) val nextStep = (next * maxStep.toFloat()).roundToInt().coerceIn(0, maxStep) + // Show toast if (fromButton) { + // For button related request we only show a toast when we exceeded the volume. if (cur <= 1.0f && next > 1.0f && !hasShownVolumeToast) { showToast(R.string.volume_exceeded_100) hasShownVolumeToast = true } } else { val raw = cur + delta + // For swipes, we show toast that we need to swipe again. if (raw > 1.0 && locked && !hasShownVolumeToast) { showToast(R.string.slide_up_again_to_exceed_100) hasShownVolumeToast = true } } + // Set the current volume step. if (nextStep != curStep) am.setStreamVolume(AudioManager.STREAM_MUSIC, nextStep, 0) var hasBoostError = false + // Apply loudness enhancer for volumes > 100%, removes it if less. if (next > 1.0f) { val boost = ((next - 1.0f) * 1000).toInt() val existing = loudnessEnhancer @@ -368,20 +389,24 @@ class PlayerGestureHelper(private val playerView: PlayerView) { currentRequestedVolume = next val leftHolder = playerView.playerProgressbarLeftHolder ?: return - val level1 = playerView.playerProgressbarLeftLevel1 ?: return - val level2 = playerView.playerProgressbarLeftLevel2 ?: return - val icon = playerView.playerProgressbarLeftIcon ?: return + val level1 = playerView.playerProgressbarLeftLevel1 ?: return + val level2 = playerView.playerProgressbarLeftLevel2 ?: return + val icon = playerView.playerProgressbarLeftIcon ?: return if (next > 1.0f) { + // Change color to show that LoudnessEnhancer broke + // this is not a real fix, but solves the crash issue. level2.progressTintList = ColorStateList.valueOf( ContextCompat.getColor(context, if (hasBoostError) R.color.colorPrimaryRed else R.color.colorPrimaryOrange) ) } - level1.max = 100_000 + // Max is set high to make it smooth. + level1.max = 100_000 level1.progress = (next * 100_000f).toInt().coerceIn(2_000, 100_000) - level2.max = 100_000 + level2.max = 100_000 level2.progress = if (next > 1.0f) ((next - 1.0) * 100_000f).toInt().coerceIn(2_000, 100_000) else 0 level2.isVisible = next > 1.0f + // Calculate the clamped index for the volume icon based on the requested volume. val iconIdx = (next * volumeIcons.lastIndex).roundToInt().coerceIn(0, volumeIcons.lastIndex) icon.setImageResource(volumeIcons[iconIdx]) @@ -393,6 +418,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { leftHolder.animate().cancel() leftHolder.animate().alpha(0f).setDuration(300).withEndAction { leftHolder.isVisible = false }.start() } + // Show the progress bar for 1.5 seconds. leftHolder.postDelayed(progressBarLeftHideRunnable, 1500) } @@ -410,7 +436,9 @@ class PlayerGestureHelper(private val playerView: PlayerView) { Settings.System.SCREEN_BRIGHTNESS ) / 255f } catch (_: Exception) { - // Permission not granted — fall back to window-attribute mode permanently. + // Because true system brightness requires + // permission, this is a lazy way to check + // as it will throw an error if we do not have it. useTrueSystemBrightness = false getBrightness() } @@ -460,7 +488,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { fun handleBrightnessAdjustment(verticalAddition: Float) { val lastBrightness = currentRequestedBrightness - val raw = currentRequestedBrightness + verticalAddition + val raw = currentRequestedBrightness + verticalAddition val next = raw.coerceIn(0.0f, if (extraBrightnessEnabled && !isBrightnessLocked) 2.0f else 1.0f) if (extraBrightnessEnabled && isBrightnessLocked && raw > 1.0f && !hasShownBrightnessToast) { @@ -476,20 +504,21 @@ class PlayerGestureHelper(private val playerView: PlayerView) { playerView.callbacks?.onBrightnessExtra(currentExtraBrightness) val rightHolder = playerView.playerProgressbarRightHolder ?: return - val level1 = playerView.playerProgressbarRightLevel1 ?: return - val level2 = playerView.playerProgressbarRightLevel2 ?: return - val icon = playerView.playerProgressbarRightIcon ?: return + val level1 = playerView.playerProgressbarRightLevel1 ?: return + val level2 = playerView.playerProgressbarRightLevel2 ?: return + val icon = playerView.playerProgressbarRightIcon ?: return - level1.max = 100_000 + level1.max = 100_000 level1.progress = max(2_000, (min(1.0f, next) * 100_000f).toInt()) if (extraBrightnessEnabled) { - level2.max = 100_000 + level2.max = 100_000 level2.progress = (currentExtraBrightness * 100_000f).toInt().coerceIn(2_000, 100_000) level2.isVisible = next > 1.0f } icon.setImageResource( + // Clamp the value in case of extra brightness. brightnessIcons[min(brightnessIcons.lastIndex, max(0, round(next * brightnessIcons.lastIndex).toInt()))] ) @@ -528,9 +557,9 @@ class PlayerGestureHelper(private val playerView: PlayerView) { return Matrix() } - val videoWidth = videoView.width.toFloat() + val videoWidth = videoView.width.toFloat() val videoHeight = videoView.height.toFloat() - val playerWidth = screenWidthWithOrientation.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() val playerHeight = screenHeightWithOrientation.toFloat() if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f) { @@ -542,7 +571,12 @@ class PlayerGestureHelper(private val playerView: PlayerView) { return Matrix().apply { postScale(aspect, aspect) } } - /** Applies [newMatrix] (scale + translation only) to the video surface view. */ + /** + * Applies [newMatrix] (scale + translation only) to the video surface view. + * + * @param newMatrix The new zoom matrix + * @param animation If this zoom is part of an animation, as then it will not auto zoom after we are done. + */ fun applyZoomMatrix(newMatrix: Matrix, animation: Boolean) { val exoView = playerView.exoPlayerView ?: return if (!animation) { @@ -555,24 +589,30 @@ class PlayerGestureHelper(private val playerView: PlayerView) { exoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM } - val videoView = exoView.videoSurfaceView ?: return - val videoWidth = videoView.width.toFloat() + val videoView = exoView.videoSurfaceView ?: return + val videoWidth = videoView.width.toFloat() val videoHeight = videoView.height.toFloat() - val playerWidth = screenWidthWithOrientation.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() val playerHeight = screenHeightWithOrientation.toFloat() + // Sanity check if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f || scale <= 0.01f) return - val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) - val aspect = min(initAspect, 1f / initAspect) + // Calculate the scaled aspect ratio as the view height is not real, check the debugger + // and you will see videoView.height > screen.height. + val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = min(initAspect, 1f / initAspect) val scaledAspect = scale * aspect + // Calculate clamp, this is very weird because we need to use aspect here as videoHeight > playerHeight. val maxTransX = max(0f, videoWidth * scaledAspect - playerWidth) * 0.5f val maxTransY = max(0f, videoHeight * scaledAspect - playerHeight) * 0.5f + // Correct the translation to clamp within the viewing area. val expectedTranslationX = translationX.coerceIn(-maxTransX, maxTransX) val expectedTranslationY = translationY.coerceIn(-maxTransY, maxTransY) + // Set the transform to the correct x and y. newMatrix.postTranslate( expectedTranslationX - translationX, expectedTranslationY - translationY @@ -580,22 +620,27 @@ class PlayerGestureHelper(private val playerView: PlayerView) { zoomMatrix = newMatrix if (!animation) { + // If we are not in an animation, set up the values for the animation. if ((scaledAspect - 1f).absoluteValue < ZOOM_SNAP_SENSITIVITY) { + // We are within the correct scaling, so center and fit it. videoOutline?.isVisible = true val desired = Matrix() desired.setScale(1f / aspect, 1f / aspect) desiredMatrix = desired } else if (scale < 1f) { + // We have zoomed too far, zoom to 100%. videoOutline?.isVisible = false desiredMatrix = Matrix() } else { + // Keep the same scaling after zoom. videoOutline?.isVisible = false desiredMatrix = null } } - videoView.scaleX = scaledAspect - videoView.scaleY = scaledAspect + // Finally set the actual scale + translation. + videoView.scaleX = scaledAspect + videoView.scaleY = scaledAspect videoView.translationX = expectedTranslationX videoView.translationY = expectedTranslationY updateBrightnessOverlayBounds() @@ -708,9 +753,9 @@ class PlayerGestureHelper(private val playerView: PlayerView) { val startMatrix = currentZoomMatrix() val endMatrix = desiredMatrix ?: return@apply val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) - val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) + val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) addUpdateListener { anim -> - val v = anim.animatedValue as Float + val v = anim.animatedValue as Float val vInv = 1f - v val m = Matrix() m.setScale(startScale * vInv + endScale * v, startScale * vInv + endScale * v) @@ -732,20 +777,24 @@ class PlayerGestureHelper(private val playerView: PlayerView) { */ fun updateBrightnessOverlayBounds() { val overlay = brightnessOverlay ?: return - val pv = playerView.exoPlayerView ?: return - val video = pv.videoSurfaceView ?: return + val pv = playerView.exoPlayerView ?: return + val video = pv.videoSurfaceView ?: return + // Compute accurate transformed bounding box of the video view after scale+translation. val vw = video.width.toFloat() val vh = video.height.toFloat() val sx = video.scaleX val sy = video.scaleY if (vw <= 0f || vh <= 0f) return + // Pivot defaults to center if not set. val pivotX = if (video.pivotX != 0f) video.pivotX else vw * 0.5f val pivotY = if (video.pivotY != 0f) video.pivotY else vh * 0.5f + // Use view position (includes translation) as base; avoid double-counting translation. val tx = video.x val ty = video.y + // Transform function for a local point (lx,ly). fun transform(lx: Float, ly: Float): Pair { val gx = tx + pivotX + (lx - pivotX) * sx val gy = ty + pivotY + (ly - pivotY) * sy @@ -829,13 +878,13 @@ class PlayerGestureHelper(private val playerView: PlayerView) { /** Plays the rewind animation and seeks back by [fastForwardTime]. */ fun rewind() { try { - val rewHolder = playerView.playerRewHolder ?: return - val rew = playerView.playerRew - val rewText = playerView.exoRewText + val rewHolder = playerView.playerRewHolder ?: return + val rew = playerView.playerRew + val rewText = playerView.exoRewText val wasShowing = playerView.callbacks?.isUIShowing() ?: false // Only expose the parent chain when controls are currently hidden. - val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true if (!wasShowing) { playerView.playerCenterMenu?.isGone = false @@ -871,11 +920,11 @@ class PlayerGestureHelper(private val playerView: PlayerView) { fun fastForward() { try { val ffwdHolder = playerView.playerFfwdHolder ?: return - val ffwd = playerView.playerFfwd - val ffwdText = playerView.exoFfwdText + val ffwd = playerView.playerFfwd + val ffwdText = playerView.exoFfwdText val wasShowing = playerView.callbacks?.isUIShowing() ?: false - val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true if (!wasShowing) { playerView.playerCenterMenu?.isGone = false @@ -980,6 +1029,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { val min = ceil((sec - rsec) / 60.0).toInt() val rmin = min % 60L val h = ceil((min - rmin) / 60.0).toLong() + // int rh = h;// h % 24; return (if (h > 0) forceLetters(h) + ":" else "") + (if (rmin >= 0 || h >= 0) forceLetters(rmin) + ":" else "") + forceLetters(rsec) @@ -1000,8 +1050,8 @@ class PlayerGestureHelper(private val playerView: PlayerView) { /** Two-finger zoom/pan (fullscreen, unlocked) */ if ((event.pointerCount >= 2 || lastPan != null) && isFullScreen && !isLocked && !hasTriggeredSpeedUp && currentTouchAction == null) { - holdHandler.removeCallbacks(holdRunnable) - isCurrentTouchValid = false + holdHandler.removeCallbacks(holdRunnable) // Remove 2x speed. + isCurrentTouchValid = false // Prevent other touches return handleZoomPanGesture( event = event, ctx = view.context, @@ -1072,6 +1122,8 @@ class PlayerGestureHelper(private val playerView: PlayerView) { val verticalAddition = diffFromLast.y * VERTICAL_MULTIPLIER / view.height.toFloat() when (currentTouchAction) { TouchAction.Time -> { + // This simply updates UI as the seek logic happens on release + // startTime is rounded to make the UI sync in a nice way. val startTime = currentTouchStartPlayerTime?.div(1000L)?.times(1000L) if (startTime != null) { calculateNewTime(startTime, startTouch, currentTouch)?.let { newMs -> @@ -1085,7 +1137,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { } } TouchAction.Brightness -> if (!isLocked) handleBrightnessAdjustment(verticalAddition) - TouchAction.Volume -> if (!isLocked) handleVolumeAdjustment(verticalAddition, false) + TouchAction.Volume -> if (!isLocked) handleVolumeAdjustment(verticalAddition, false) null -> Unit } if (currentTouchAction != TouchAction.Time) { @@ -1135,6 +1187,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { val hadSwipe = currentTouchAction != null || currentLastTouchAction != null playerView.callbacks?.onGestureEnd(hadSwipe, uiShowingBeforeGesture) + // Reset touch lastTouchEndTime = System.currentTimeMillis() isCurrentTouchValid = false currentTouchStart = 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 48801fa1801..beb247555d9 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 @@ -36,6 +36,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.SubtitleView import androidx.media3.ui.TimeBar import androidx.preference.PreferenceManager @@ -231,34 +232,34 @@ class PlayerView @JvmOverloads constructor( * remain null, all usage is null-safe. */ fun bindViews(root: View) { - exoDuration = root.findViewById(androidx.media3.ui.R.id.exo_duration) - exoFfwdText = root.findViewById(R.id.exo_ffwd_text) - exoPlayerView = root.findViewById(R.id.player_view) - exoPosition = root.findViewById(R.id.exo_position) - exoRewText = root.findViewById(R.id.exo_rew_text) - piphide = root.findViewById(R.id.piphide) - playerBuffering = root.findViewById(R.id.player_buffering) - playerCenterMenu = root.findViewById(R.id.player_center_menu) - playerFfwd = root.findViewById(R.id.player_ffwd) - playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder) - playerHolder = root.findViewById(R.id.player_holder) - playerPausePlay = root.findViewById(R.id.player_pause_play) - playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) - playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder) - playerProgressbarLeftIcon = root.findViewById(R.id.player_progressbar_left_icon) - playerProgressbarLeftLevel1 = root.findViewById(R.id.player_progressbar_left_level1) - playerProgressbarLeftLevel2 = root.findViewById(R.id.player_progressbar_left_level2) + exoDuration = root.findViewById(androidx.media3.ui.R.id.exo_duration) + exoFfwdText = root.findViewById(R.id.exo_ffwd_text) + exoPlayerView = root.findViewById(R.id.player_view) + exoPosition = root.findViewById(R.id.exo_position) + exoRewText = root.findViewById(R.id.exo_rew_text) + piphide = root.findViewById(R.id.piphide) + playerBuffering = root.findViewById(R.id.player_buffering) + playerCenterMenu = root.findViewById(R.id.player_center_menu) + playerFfwd = root.findViewById(R.id.player_ffwd) + playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder) + playerHolde = root.findViewById(R.id.player_holder) + playerPausePlay = root.findViewById(R.id.player_pause_play) + playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) + playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder) + playerProgressbarLeftIcon = root.findViewById(R.id.player_progressbar_left_icon) + playerProgressbarLeftLevel1 = root.findViewById(R.id.player_progressbar_left_level1) + playerProgressbarLeftLevel2 = root.findViewById(R.id.player_progressbar_left_level2) playerProgressbarRightHolder = root.findViewById(R.id.player_progressbar_right_holder) - playerProgressbarRightIcon = root.findViewById(R.id.player_progressbar_right_icon) + playerProgressbarRightIcon = root.findViewById(R.id.player_progressbar_right_icon) playerProgressbarRightLevel1 = root.findViewById(R.id.player_progressbar_right_level1) playerProgressbarRightLevel2 = root.findViewById(R.id.player_progressbar_right_level2) - playerRew = root.findViewById(R.id.player_rew) - playerRewHolder = root.findViewById(R.id.player_rew_holder) - playerSpeedupButton = root.findViewById(R.id.player_speedup_button) - playerVideoHolder = root.findViewById(R.id.player_video_holder) - subtitleHolder = root.findViewById(R.id.subtitle_holder) - timeLeft = root.findViewById(R.id.time_left) - timeLive = root.findViewById(R.id.time_live) + playerRew = root.findViewById(R.id.player_rew) + playerRewHolder = root.findViewById(R.id.player_rew_holder) + playerSpeedupButton = root.findViewById(R.id.player_speedup_button) + playerVideoHolder = root.findViewById(R.id.player_video_holder) + subtitleHolder = root.findViewById(R.id.subtitle_holder) + timeLeft = root.findViewById(R.id.time_left) + timeLive = root.findViewById(R.id.time_live) } /** @@ -281,6 +282,7 @@ class PlayerView @JvmOverloads constructor( ) if (player is CS3IPlayer) { + // Preview bar val progressBar: PreviewTimeBar? = exoPlayerView?.findViewById(R.id.exo_progress) exoProgress = progressBar as? LivePreviewTimeBar val previewImageView: ImageView? = exoPlayerView?.findViewById(R.id.previewImageView) @@ -296,6 +298,7 @@ class PlayerView @JvmOverloads constructor( progressBar.isPreviewEnabled = hasPreview resume = cs3.getIsPlaying() if (resume) cs3.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) + // No clashing UI if (hasPreview) subView?.isVisible = false } @@ -304,7 +307,9 @@ class PlayerView @JvmOverloads constructor( override fun onScrubStop(previewBar: PreviewBar?) { val cs3 = player as? CS3IPlayer ?: return if (resume) cs3.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) + // Delay to prevent the small flicker of subtitle before seeking. subView?.postDelayed({ + // If we are not scrubbing then show subtitles again. if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) { subView?.isVisible = true } @@ -327,7 +332,11 @@ class PlayerView @JvmOverloads constructor( ImageParams.new16by9(screenWidth) } - exoPlayerView?.findViewById(R.id.exo_progress) + /** + * This might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player + * and once by the UI even if it should only be registered once by the UI. + */ + exoPlayerView?.findViewById(R.id.exo_progress) ?.addListener(object : TimeBar.OnScrubListener { override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit @@ -354,10 +363,11 @@ class PlayerView @JvmOverloads constructor( autoPlayerRotateEnabled = sm.getBoolean( context.getString(R.string.auto_rotate_video_key), true ) - } catch (_: Exception) { } + } catch (_: Exception) { + } val seekSecs = (seekTime / 1000).toInt() - exoRewText?.text = context.getString(R.string.rew_text_regular_format).format(seekSecs) + exoRewText?.text = context.getString(R.string.rew_text_regular_format).format(seekSecs) exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format).format(seekSecs) playerPausePlay?.setOnClickListener { @@ -428,6 +438,7 @@ class PlayerView @JvmOverloads constructor( val activity = context as? Activity gestureHelper.resetZoomToDefault() activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + // Simply resets brightness and notch settings that might have been overridden. val lp = activity?.window?.attributes lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -481,6 +492,7 @@ class PlayerView @JvmOverloads constructor( try { isInPIPMode = isInPictureInPictureMode if (isInPictureInPictureMode) { + // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. piphide?.isVisible = false pipReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -502,9 +514,13 @@ class PlayerView @JvmOverloads constructor( val status = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused updateIsPlaying(status, status) } else { + // Restore the full-screen UI. piphide?.isVisible = true callbacks?.exitedPipMode() - pipReceiver?.let { safe { activity?.unregisterReceiver(it) } } + pipReceiver?.let { + // Prevents java.lang.IllegalArgumentException: Receiver not registered + safe { activity?.unregisterReceiver(it) } + } activity?.hideSystemUI() hideKeyboard(this) } @@ -548,6 +564,7 @@ class PlayerView @JvmOverloads constructor( } if (drawable is AnimatedVectorDrawable) { drawable.start(); startedAnimation = true } if (drawable is AnimatedVectorDrawableCompat) { drawable.start(); startedAnimation = true } + // Somehow the phone is wacked if (!startedAnimation) { playerPausePlay?.setImageResource( if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play @@ -578,9 +595,11 @@ class PlayerView @JvmOverloads constructor( if (player is ExoPlayer) { mMediaSession?.release() mMediaSession = MediaSession.Builder(context, player) + // Ensure unique ID for concurrent players. .setId(System.currentTimeMillis().toString()) .build() + // Necessary for multiple combined videos. @Suppress("DEPRECATION") exoPlayerView?.setShowMultiWindowTimeBar(true) exoPlayerView?.player = player @@ -591,6 +610,7 @@ class PlayerView @JvmOverloads constructor( private fun onSubStyleChanged(style: SaveCaptionStyle) { player.updateSubtitleStyle(style) + // Forcefully update the subtitle encoding in case the edge size is changed. player.seekTime(-1) } @@ -654,10 +674,17 @@ class PlayerView @JvmOverloads constructor( is InvalidFileException -> showErrorToast("${context.getString(R.string.source_error)}\n${exception.message}", gotoNext = true) - is SocketTimeoutException -> + is SocketTimeoutException -> { + /** + * Ensures this is run on the UI thread to prevent issues + * caused by SocketTimeoutException in torrents. Running + * on another thread can break player interactions or + * prevent switching to the next source. + */ (context as? Activity)?.runOnUiThread { showErrorToast("${context.getString(R.string.remote_error)}\n${exception.message}", gotoNext = true) } + } is ErrorLoadingException -> exception.message?.let { showErrorToast(it, gotoNext = true) } @@ -686,7 +713,7 @@ class PlayerView @JvmOverloads constructor( DataStoreHelper.resizeMode = resize.ordinal val type = when (resize) { PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL - PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT + PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM } exoPlayerView?.resizeMode = type @@ -710,11 +737,18 @@ class PlayerView @JvmOverloads constructor( /** Event dispatch */ + /** + * This receives the events from the player, if you want to append functionality + * you do it here, do note that this only receives events for UI changes, + * and returning early WON'T stop it from changing in e.g. the player time + * or pause status. + */ fun mainCallback(event: PlayerEvent) { + // We don't want to spam DownloadEvent. if (event !is DownloadEvent) Log.i(TAG, "Handle event: $event") when (event) { - is DownloadEvent -> callbacks?.onDownload(event) - is ResizedEvent -> { + is DownloadEvent -> callbacks?.onDownload(event) + is ResizedEvent -> { // Skip 0x0 dimensions that the player emits when going to STATE_IDLE // to avoid incorrectly resetting the auto-detected orientation. if (event.width > 0 && event.height > 0) { @@ -723,13 +757,13 @@ class PlayerView @JvmOverloads constructor( } callbacks?.playerDimensionsLoaded(event.width, event.height) } - is PlayerAttachedEvent -> playerUpdated(event.player) - is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged() - is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp) - is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp) - is TracksChangedEvent -> callbacks?.onTracksInfoChanged() + is PlayerAttachedEvent -> playerUpdated(event.player) + is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged() + is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp) + is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp) + is TracksChangedEvent -> callbacks?.onTracksInfoChanged() is EmbeddedSubtitlesFetchedEvent -> callbacks?.embeddedSubtitlesFetched(event.tracks) - is ErrorEvent -> { + is ErrorEvent -> { val cb = callbacks if (cb != null) cb.playerError(event.error) else playerError(event.error) @@ -737,7 +771,7 @@ class PlayerView @JvmOverloads constructor( is RequestAudioFocusEvent -> requestAudioFocus() is EpisodeSeekEvent -> when (event.offset) { -1 -> callbacks?.prevEpisode() - 1 -> callbacks?.nextEpisode() + 1 -> callbacks?.nextEpisode() } is StatusEvent -> { updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) @@ -749,6 +783,7 @@ class PlayerView @JvmOverloads constructor( duration = event.durationMs ) is VideoEndedEvent -> { + // Only play next episode if autoplay is on (default). val ctx = context if (PreferenceManager.getDefaultSharedPreferences(ctx) ?.getBoolean(ctx.getString(R.string.autoplay_next_key), true) == true @@ -757,7 +792,7 @@ class PlayerView @JvmOverloads constructor( } } is PauseEvent -> Unit - is PlayEvent -> Unit + is PlayEvent -> Unit } } From 452d15ced1e935a75d4f7eb7ba3d3901c43a4b8c Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:57:59 -0600 Subject: [PATCH 10/15] Fix --- .../ui/player/AbstractPlayerFragment.kt | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index 90dc26820a9..d62009b2410 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -77,16 +77,45 @@ abstract class AbstractPlayerFragment( // open so subclasses can override only what they need. The ones below throw // to make it obvious when an implementation is missing. - override fun nextEpisode() = throw NotImplementedError() - override fun prevEpisode() = throw NotImplementedError() - override fun playerPositionChanged(position: Long, duration: Long) = throw NotImplementedError() - override fun playerDimensionsLoaded(width: Int, height: Int) = throw NotImplementedError() - override fun subtitlesChanged() = throw NotImplementedError() - override fun embeddedSubtitlesFetched(subtitles: List) = throw NotImplementedError() - override fun onTracksInfoChanged() = throw NotImplementedError() - override fun exitedPipMode() = throw NotImplementedError() - override fun hasNextMirror(): Boolean = throw NotImplementedError() - override fun nextMirror() = throw NotImplementedError() + override fun nextEpisode() { + throw NotImplementedError() + } + + override fun prevEpisode() { + throw NotImplementedError() + } + + override fun playerPositionChanged(position: Long, duration: Long) { + throw NotImplementedError() + } + + override fun playerDimensionsLoaded(width: Int, height: Int) { + throw NotImplementedError() + } + + override fun subtitlesChanged() { + throw NotImplementedError() + } + + override fun embeddedSubtitlesFetched(subtitles: List) { + throw NotImplementedError() + } + + override fun onTracksInfoChanged() { + throw NotImplementedError() + } + + override fun exitedPipMode() { + throw NotImplementedError() + } + + override fun hasNextMirror(): Boolean { + throw NotImplementedError() + } + + override fun nextMirror() { + throw NotImplementedError() + } /** Delegates to [PlayerView.playerError] by default; override to customize. */ override fun playerError(exception: Throwable) { From cc197a9b846d89cf17e2745f4039c1e321495219 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:59:24 -0600 Subject: [PATCH 11/15] Fix --- .../java/com/lagradost/cloudstream3/ui/player/PlayerView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 beb247555d9..c9a948586da 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 @@ -242,7 +242,7 @@ class PlayerView @JvmOverloads constructor( playerCenterMenu = root.findViewById(R.id.player_center_menu) playerFfwd = root.findViewById(R.id.player_ffwd) playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder) - playerHolde = root.findViewById(R.id.player_holder) + playerHolder = root.findViewById(R.id.player_holder) playerPausePlay = root.findViewById(R.id.player_pause_play) playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder) From 5e948f5ba3fd0365156bed9109ff76fab858bae2 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:48:05 -0600 Subject: [PATCH 12/15] Fixes --- .../cloudstream3/ui/player/AbstractPlayerFragment.kt | 10 ++-------- .../com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt | 2 +- .../cloudstream3/ui/player/FullScreenPlayer.kt | 2 +- .../cloudstream3/ui/result/ResultTrailerPlayer.kt | 5 ++++- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index d62009b2410..c84079a0d85 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -52,18 +52,13 @@ abstract class AbstractPlayerFragment( get() = playerHostView?.subView set(value) { playerHostView?.subView = value } - protected open var hasPipModeSupport: Boolean - get() = playerHostView?.hasPipModeSupport ?: true - set(value) { playerHostView?.hasPipModeSupport = value } - var playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay set(value) { playerHostView?.playerPausePlay = value } /** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */ - var playerView: androidx.media3.ui.PlayerView? - get() = playerHostView?.exoPlayerView - set(value) { playerHostView?.exoPlayerView = value } + val playerView: androidx.media3.ui.PlayerView? = + playerHostView?.exoPlayerView var currentPlayerStatus: CSPlayerLoading get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering @@ -129,7 +124,6 @@ abstract class AbstractPlayerFragment( val ctx = context ?: return playerHostView = PlayerView(ctx) playerHostView?.player = _player - playerHostView?.hasPipModeSupport = hasPipModeSupport playerHostView?.callbacks = this playerHostView?.bindViews(binding.root) playerHostView?.initialize() 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 11e6285f4a7..1bd9ef87d3e 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 @@ -459,12 +459,12 @@ class CS3IPlayer : IPlayer { ) } - private var currentAudioTrack: AudioTrack? = null override fun getVideoTracks(): CurrentTracks { val allTrackGroups = exoPlayer?.currentTracks?.groups ?: emptyList() val videoTracks = allTrackGroups.filter { it.type == TRACK_TYPE_VIDEO } .getFormats() .map { it.first.toVideoTrack() } + var currentAudioTrack: AudioTrack? = null val audioTracks = allTrackGroups.filter { it.type == TRACK_TYPE_AUDIO } .flatMap { group -> group.getFormats().map { (format, formatIndex) -> 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 516673e7b70..a3964a9d7f2 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 @@ -828,7 +828,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( override fun isValidTouch(rawX: Float, rawY: Float): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val insets = playerBinding?.playerHolder?.rootWindowInsets - ?.getInsets(WindowInsets.Type.systemBars()) ?: return true + ?.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) ?: return true return rawY > insets.top && rawX < (screenWidthWithOrientation - insets.right) } return rawY > (statusBarHeight ?: 0) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt index bc5582c5520..c2054825cf3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -52,7 +52,10 @@ class ResultTrailerPlayer : ResultFragmentPhone() { override fun isUIShowing(): Boolean = isShowing - override fun onAutoHideUI() = uiReset() + override fun onAutoHideUI() { + if (player.getIsPlaying()) uiReset() + } + override fun onHidePlayerUI() = uiReset() override fun nextEpisode() {} From c9bd82064c8b7bd7d86a8dc98dd362961524bb66 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:33:11 -0600 Subject: [PATCH 13/15] Fix --- .../cloudstream3/ui/player/AbstractPlayerFragment.kt | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index c84079a0d85..b7e6da536f6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -48,17 +48,11 @@ abstract class AbstractPlayerFragment( playerHostView?.player = value } - var subView: SubtitleView? - get() = playerHostView?.subView - set(value) { playerHostView?.subView = value } - - var playerPausePlay: ImageView? - get() = playerHostView?.playerPausePlay - set(value) { playerHostView?.playerPausePlay = value } + val subView: SubtitleView? = playerHostView?.subView + val playerPausePlay: ImageView? = playerHostView?.playerPausePlay /** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */ - val playerView: androidx.media3.ui.PlayerView? = - playerHostView?.exoPlayerView + val playerView: androidx.media3.ui.PlayerView? = playerHostView?.exoPlayerView var currentPlayerStatus: CSPlayerLoading get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering From 93d117953e2e00aa354343c406db67871786105d Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:25:44 -0600 Subject: [PATCH 14/15] Fix --- .../cloudstream3/ui/player/AbstractPlayerFragment.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index b7e6da536f6..e5a460b9a02 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -48,11 +48,12 @@ abstract class AbstractPlayerFragment( playerHostView?.player = value } - val subView: SubtitleView? = playerHostView?.subView - val playerPausePlay: ImageView? = playerHostView?.playerPausePlay + val subView: SubtitleView? get() = playerHostView?.subView + val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay /** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */ - val playerView: androidx.media3.ui.PlayerView? = playerHostView?.exoPlayerView + val playerView: androidx.media3.ui.PlayerView? + get() = playerHostView?.exoPlayerView var currentPlayerStatus: CSPlayerLoading get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering From 00692358f0c674d372ff3670b97f635dc7b24918 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:34:39 -0600 Subject: [PATCH 15/15] trailer fix --- .../cloudstream3/ui/result/ResultTrailerPlayer.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt index c2054825cf3..f79f32fe160 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -6,12 +6,14 @@ import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.view.ViewGroup +import android.view.WindowInsets import android.widget.FrameLayout import androidx.core.view.ViewCompat import androidx.core.view.isGone import androidx.core.view.isVisible import com.lagradost.cloudstream3.CommonActivity.screenHeight import com.lagradost.cloudstream3.CommonActivity.screenWidth +import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding @@ -22,6 +24,7 @@ import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight class ResultTrailerPlayer : ResultFragmentPhone() { @@ -50,6 +53,15 @@ class ResultTrailerPlayer : ResultFragmentPhone() { playerHostView?.scheduleAutoHide() } + override fun isValidTouch(rawX: Float, rawY: Float): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val insets = playerBinding?.playerHolder?.rootWindowInsets + ?.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) ?: return true + return rawY > insets.top && rawX < (screenWidthWithOrientation - insets.right) + } + return rawY > (context?.getStatusBarHeight() ?: 0) + } + override fun isUIShowing(): Boolean = isShowing override fun onAutoHideUI() {