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 1e6d827e63..e5a460b9a0 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,131 @@ 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 - - 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 - - @LayoutRes - protected open var layout: Int = R.layout.fragment_player - - open fun nextEpisode() { - throw NotImplementedError() - } +abstract class AbstractPlayerFragment( + bindingCreator: BindingCreator +) : BaseFragment(bindingCreator), PlayerView.Callbacks { - open fun prevEpisode() { - throw NotImplementedError() - } + // Stored pre-initialization so subclasses can set them before onBindingCreated. + private var _player: IPlayer = CS3IPlayer() - open fun playerPositionChanged(position: Long, duration: Long) { - throw NotImplementedError() - } + /** The shared [PlayerView] host that owns all player state and view references. */ + protected var playerHostView: PlayerView? = null - open fun playerStatusChanged() {} + var player: IPlayer + get() = playerHostView?.player ?: _player + set(value) { + _player = value + playerHostView?.player = value + } - open fun playerDimensionsLoaded(width: Int, height: Int) { - throw NotImplementedError() - } + val subView: SubtitleView? get() = playerHostView?.subView + val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay - open fun subtitlesChanged() { - throw NotImplementedError() - } + /** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */ + val playerView: androidx.media3.ui.PlayerView? + get() = playerHostView?.exoPlayerView - open fun embeddedSubtitlesFetched(subtitles: List) { - throw NotImplementedError() - } + var currentPlayerStatus: CSPlayerLoading + get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering + set(value) { playerHostView?.currentPlayerStatus = value } - open fun onTracksInfoChanged() { - throw NotImplementedError() - } + protected var mMediaSession: MediaSession? + get() = playerHostView?.mMediaSession + set(value) { playerHostView?.mMediaSession = value } - open fun onTimestamp(timestamp: VideoSkipStamp?) { + // 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. + override fun nextEpisode() { + throw NotImplementedError() } - open fun onTimestampSkipped(timestamp: VideoSkipStamp) { - + override fun prevEpisode() { + throw NotImplementedError() } - open fun exitedPipMode() { + override fun playerPositionChanged(position: Long, duration: Long) { 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) - } + override fun playerDimensionsLoaded(width: Int, height: Int) { + throw NotImplementedError() } - 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 - } - } - - 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) - } - } else { - playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) - } - } - - PlayerPipHelper.updatePIPModeActions( - activity, - isPlaying, - hasPipModeSupport, - player.getAspectRatio() - ) + override fun subtitlesChanged() { + throw NotImplementedError() } - 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) - } + override fun embeddedSubtitlesFetched(subtitles: List) { + throw NotImplementedError() } - open fun hasNextMirror(): Boolean { + override fun onTracksInfoChanged() { throw NotImplementedError() } - open fun nextMirror() { + override fun exitedPipMode() { throw NotImplementedError() } - private fun requestAudioFocus() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) - } + override fun hasNextMirror(): Boolean { + throw NotImplementedError() } - 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() - } - } - - 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 - ) - } - } + override fun nextMirror() { + throw NotImplementedError() } - private fun onSubStyleChanged(style: SaveCaptionStyle) { - player.updateSubtitleStyle(style) - // Forcefully update the subtitle encoding in case the edge size is changed - player.seekTime(-1) + /** Delegates to [PlayerView.playerError] by default; override to customize. */ + override fun playerError(exception: Throwable) { + playerHostView?.playerError(exception) } + /** Player fragments don't need system-bar padding adjustment by default. */ + override fun fixLayout(view: View) = Unit - @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 - } + override fun onBindingCreated(binding: T, savedInstanceState: Bundle?) { + val ctx = context ?: return + playerHostView = PlayerView(ctx) + playerHostView?.player = _player + playerHostView?.callbacks = this + playerHostView?.bindViews(binding.root) + playerHostView?.initialize() } - @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) - } - } - - /*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 onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode) + playerHostView?.onPictureInPictureModeChanged(isInPictureInPictureMode, activity) } 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) + 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() { + playerHostView?.releaseKeyEventListener() + super.onPause() } override fun onStop() { - player.onStop() + playerHostView?.onStop() super.onStop() } override fun onResume() { context?.let { ctx -> - player.onResume(ctx) + 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() { + playerHostView?.nextResize() } -} \ No newline at end of file + + open fun resize(resize: PlayerResize, showToast: Boolean) { + playerHostView?.resize(resize, showToast) + } +} 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 4323c98fde..1bd9ef87d3 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> { @@ -1110,7 +1093,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), @@ -1144,7 +1127,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 -> @@ -1351,7 +1334,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( @@ -1365,7 +1348,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( @@ -1837,7 +1820,7 @@ class CS3IPlayer : IPlayer { defaultSet ) ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } - } catch (e: Throwable) { + } catch (_: Throwable) { null } ?: default @@ -2038,4 +2021,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 8699202b9b..1ca4c72574 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,63 +10,43 @@ 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 -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.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.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.FragmentPlayerBinding 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,74 +54,38 @@ 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 import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding -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.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 +94,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 +114,10 @@ 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 +220,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 + 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 +250,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 +346,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } if (!isLocked) { - playerFfwdHolder.alpha = 1f - playerRewHolder.alpha = 1f - // player_pause_play_holder?.alpha = 1f + 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 +377,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - else -> dynamicOrientation() + else -> playerHostView?.dynamicOrientation() ?: return } activity.requestedOrientation = orientation } @@ -661,14 +391,14 @@ open class FullScreenPlayer : AbstractPlayerFragment() { Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - else -> dynamicOrientation() + else -> playerHostView?.dynamicOrientation() ?: return } 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 +419,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - else -> orientation = dynamicOrientation() + else -> orientation = playerHostView?.dynamicOrientation() ?: return } activity.requestedOrientation = orientation } @@ -701,51 +431,35 @@ open class FullScreenPlayer : AbstractPlayerFragment() { lockOrientation(this) } else { if (ignoreDynamicOrientation || rotatedManually) { - // restore when lock is disabled + // Restore when lock is disabled. restoreOrientationWithSensor(this) } else { - this.requestedOrientation = 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 + 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) ?: false + player.isActive() -> handleKeyEvent(event, hasNavigated) + else -> false } } - updateOrientation() - } - - protected fun exitFullscreen() { - 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 resetZoomToDefault() { - if (zoomMatrix != null) resize(PlayerResize.Fit, false) } override fun onResume() { - enterFullscreen() - verifyVolume() + playerHostView?.enterFullscreen { updateOrientation() } + setupKeyEventListener() + playerHostView?.verifyVolume() activity?.attachBackPressedCallback("FullScreenPlayer") { if (isShowingEpisodeOverlay) { // isShowingEpisodeOverlay pauses, so this makes it easier to unpause @@ -761,7 +475,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { activity?.popCurrentPage("FullScreenPlayer") } } - requestUpdateBrightnessOverlayOnNextLayout() + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() super.onResume() } @@ -771,7 +485,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } override fun onDestroy() { - exitFullscreen() + playerHostView?.exitFullscreen() super.onDestroy() } @@ -816,22 +530,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 +558,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() @@ -898,8 +599,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { dialog.setOnDismissListener { selectSubtitlesDialog = null - if (isFullScreenPlayer) - activity?.hideSystemUI() + activity?.hideSystemUI() } applyBtt.setOnClickListener { selectSubtitlesDialog = null @@ -920,7 +620,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - @SuppressLint("SetTextI18n") fun updateSpeedDialogBinding(binding: SpeedDialogBinding) { val speed = player.getPlaybackSpeed() @@ -964,7 +663,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { updateSpeedDialogBinding(binding) } - binding.speedBar.addOnChangeListener { slider, value, fromUser -> + binding.speedBar.addOnChangeListener { _, value, fromUser -> if (fromUser) { setPlayBackSpeed(value) updateSpeedDialogBinding(binding) @@ -972,8 +671,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } val dismiss = DialogInterface.OnDismissListener { - if (isFullScreenPlayer) - activity?.hideSystemUI() + activity?.hideSystemUI() if (isPlaying) { player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) } @@ -997,90 +695,10 @@ 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) { - playerBinding?.playerIntroPlay?.isGone = true - autoHide() - } - if (isFullScreenPlayer) - activity?.hideSystemUI() + if (isShowing) autoHide() + activity?.hideSystemUI() animateLayoutChanges() if (playerBinding?.playerEpisodeOverlay?.isGone == true) playerBinding?.playerPausePlay?.requestFocus() } @@ -1091,6 +709,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } isLocked = !isLocked + playerHostView?.isLocked = isLocked updateOrientation(true) // set true to ignore auto rotate to stay in current orientation if (isLocked && isShowing) { @@ -1102,6 +721,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } val fadeTo = if (isLocked) 0f else 1f + playerHostView?.gestureHelper?.animateCenterControls(fadeTo) playerBinding?.apply { val fadeAnimation = AlphaAnimation(playerVideoTitleHolder.alpha, fadeTo).apply { duration = 100 @@ -1109,11 +729,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 +751,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { updateLockUI() } - open fun updateUIVisibility() { + private fun updateUIVisibility() { val isGone = isLocked || !isShowing var togglePlayerTitleGone = isGone context?.let { @@ -1158,7 +773,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 @@ -1172,27 +787,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() + playerHostView?.scheduleAutoHide() scheduleMetadataVisibility() } + override fun onAutoHideUI() { + if (player.getIsPlaying()) onClickChange() + } + protected fun hidePlayerUI() { if (isShowing) { isShowing = false @@ -1200,861 +815,70 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - override fun playerStatusChanged() { - super.playerStatusChanged() - scheduleMetadataVisibility() - delayHide() - } + /** PlayerView.Callbacks touch overrides */ - private fun delayHide() { - val index = currentTapIndex - playerBinding?.playerHolder?.postDelayed({ - if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) { - onClickChange() - } - }, 3000) - } + override fun isUIShowing(): Boolean = isShowing - // 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 onSingleTap() { + onClickChange() } - 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 onTouchDown() { + if (isShowingEpisodeOverlay) toggleEpisodesOverlay(show = false) } - 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 - ) + @SuppressLint("SetTextI18n") + override fun onSeekPreviewText(text: String?) { + playerBinding?.playerTimeText?.apply { + isVisible = text != null + if (text != null) this.text = text } } - 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 onHidePlayerUI() { + hidePlayerUI() } - /** - * 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 - } - } - } - - /** - * 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) - } - } - } - - 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 = 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 + // 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 - } - 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() - } - } - - 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 + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() } - @SuppressLint("GestureBackNavigation") private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { if (hasNavigated) { autoHide() @@ -2107,29 +931,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). + if (playerHostView?.handleVolumeKey(keyCode) == true) return true } } } @@ -2163,138 +966,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 +986,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { updateLockUI() updateUIVisibility() animateLayoutChanges() - resetFastForwardText() - resetRewindText() + playerHostView?.gestureHelper?.resetFastForwardText() + playerHostView?.gestureHelper?.resetRewindText() } override fun onSaveInstanceState(outState: Bundle) { @@ -2325,9 +996,21 @@ open class FullScreenPlayer : AbstractPlayerFragment() { super.onSaveInstanceState(outState) } - @SuppressLint("ClickableViewAccessibility") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + 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 +1093,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), @@ -2441,16 +1112,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { ) .toLong() * 1000L - 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 +1120,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 +1138,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 +1187,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - exoDuration.setOnClickListener { - setRemainingTimeCounter(true) - } - - timeLeft.setOnClickListener { - setRemainingTimeCounter(false) - } - skipChapterButton.setOnClickListener { player.handleEvent(CSPlayerEvent.SkipCurrentChapter) } @@ -2616,16 +1236,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { showSubtitleOffsetDialog() } - playerRew.setOnClickListener { - autoHide() - rewind() - } - - playerFfwd.setOnClickListener { - autoHide() - fastForward() - } - playerGoBack.setOnClickListener { activity?.popCurrentPage("FullScreenPlayer") } @@ -2638,11 +1248,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() @@ -2651,15 +1256,13 @@ open class FullScreenPlayer : AbstractPlayerFragment() { exoProgress.registerPlayerView(playerView) + @SuppressLint("ClickableViewAccessibility") 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++ + playerHostView?.cancelAutoHide() } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_BUTTON_RELEASE -> { @@ -2672,11 +1275,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 +1283,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - @SuppressLint("SourceLockedOrientationActivity") private fun toggleRotate() { activity?.let { toggleOrientationWithSensor(it) @@ -2710,59 +1307,14 @@ 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 + // 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() } - 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 c3d8306e67..3576f48741 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) ) @@ -386,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() } @@ -537,7 +534,7 @@ class GeneratorPlayer : FullScreenPlayer() { (if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle( currentSubs, settings = true, downloads = true ), - preview = isFullScreenPlayer + preview = true ) } @@ -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) @@ -1520,7 +1517,6 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun playerError(exception: Throwable) { val currentUrl = currentSelectedLink?.let { it.first?.url ?: it.second?.uri?.toString() } ?: "unknown" @@ -1846,8 +1842,6 @@ class GeneratorPlayer : FullScreenPlayer() { return "" } - - @SuppressLint("SetTextI18n") fun setTitle() { var playerVideoTitle = getPlayerVideoTitle() @@ -1869,7 +1863,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 @@ -1981,29 +1974,13 @@ 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() - } + /** + * 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 var skipAnimator: ValueAnimator? = null var skipIndex = 0 @@ -2127,15 +2104,14 @@ class GeneratorPlayer : FullScreenPlayer() { // update overlay season title var 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) { + @Suppress("AssignedValueIsNeverRead") lastTopIndex = topIndex val topItem = episodes.getOrNull(topIndex) - topItem?.let { playerEpisodeOverlayTitle.setText( ResultViewModel2.seasonToTxt( @@ -2153,18 +2129,24 @@ 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 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) @@ -2189,12 +2171,12 @@ class GeneratorPlayer : FullScreenPlayer() { viewModel.loadLinks() } - binding?.overlayLoadingSkipButton?.setOnClickListener { + binding.overlayLoadingSkipButton.setOnClickListener { startPlayer() } - binding?.playerLoadingGoBack?.setOnClickListener { - exitFullscreen() + binding.playerLoadingGoBack.setOnClickListener { + playerHostView?.exitFullscreen() player.release() activity?.popCurrentPage() } @@ -2237,14 +2219,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})" } } @@ -2260,7 +2243,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 0000000000..4d2b9237e3 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -0,0 +1,1220 @@ +package com.lagradost.cloudstream3.ui.player + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Matrix +import android.media.AudioManager +import android.media.audiofx.LoudnessEnhancer +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +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.WindowInsets +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.UIHelper.getStatusBarHeight +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) { + + 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. + * 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 + /** 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 + + /** 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 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]. */ + 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 + @SuppressLint("DiscouragedApi") + 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. + * + * 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) + 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 { + /** + * 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 + } + + 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) + + // 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 + 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) { + // 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) + ) + } + // 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.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]) + + 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() + } + // Show the progress bar for 1.5 seconds. + 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) { + // 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() + } + } 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( + // Clamp the value in case of extra brightness. + 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. + * + * 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) 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. + * + * @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) { + 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() + + // Sanity check + if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f || 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.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 + ) + 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 + } + } + + // Finally set the actual scale + translation. + 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) + // 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) + 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 + + // 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 + 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() + // int rh = h;// h % 24; + return (if (h > 0) forceLetters(h) + ":" else "") + + (if (rmin >= 0 || h >= 0) forceLetters(rmin) + ":" else "") + + forceLetters(rsec) + } + + /** Touch gestures */ + + fun setupTouchGestures() { + val holder = playerView.playerHolder ?: return + @SuppressLint("ClickableViewAccessibility") + holder.setOnTouchListener(::handleGesture) + } + + private fun isValidTouch(rawX: Float, rawY: Float): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val holder = playerView.playerHolder ?: return true + val insets = holder.rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) + val validHeight = rawY > insets.top && rawY < screenHeightWithOrientation - insets.bottom + val validWidth = rawX > insets.left && rawX < screenWidthWithOrientation - insets.right + return validHeight && validWidth + } + + return rawY > (context.getStatusBarHeight() ?: 0) && rawX < screenWidthWithOrientation + } + + 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) // Remove 2x speed. + isCurrentTouchValid = false // Prevent other touches + 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 = isValidTouch(event.rawX, event.rawY) + 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 -> { + // 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 + playerView.callbacks?.onSeekPreviewText( + "${convertTimeToString(newMs / 1000)} [${ + if (abs(skipMs) < 1000) "" else if (skipMs > 0) "+" else "-" + }${convertTimeToString(abs(skipMs / 1000))}]" + ) + } + } + } + TouchAction.Brightness -> if (!isLocked) handleBrightnessAdjustment(verticalAddition) + TouchAction.Volume -> if (!isLocked) 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) + + // Reset touch + lastTouchEndTime = System.currentTimeMillis() + isCurrentTouchValid = false + currentTouchStart = null + currentLastTouchAction = currentTouchAction + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + uiShowingBeforeGesture = false + return true + } + } + return false + } +} 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 0000000000..d8e7e579da --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt @@ -0,0 +1,851 @@ +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.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 +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 +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.DefaultTimeBar +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.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 +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 com.lagradost.cloudstream3.utils.UIHelper.showSystemUI +import com.lagradost.cloudstream3.utils.UserPreferenceDelegate +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import java.net.SocketTimeoutException + +/** + * 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) { + + companion object { + private const val TAG = "PlayerView" + } + + /** 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 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 */ + + /** 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 + 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 (e.g. trailer) simply + * 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) + 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) + 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) + } + + /** + * 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) { + // Preview bar + 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) + // No clashing UI + 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) + // 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 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) + } + + /** + * 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 + 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() + } + + 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 */ + + 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 + // 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() + } + + 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) { + // 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().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 { + // Restore the full-screen UI. + piphide?.isVisible = true + callbacks?.exitedPipMode() + pipReceiver?.let { + // Prevents java.lang.IllegalArgumentException: Receiver not registered + 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 } + // 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 + ) + } + } + + 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) + // Ensure unique ID for concurrent players. + .setId(System.currentTimeMillis().toString()) + .build() + + // Necessary for multiple combined videos. + @Suppress("DEPRECATION") + exoPlayerView?.setShowMultiWindowTimeBar(true) + exoPlayerView?.player = player + exoPlayerView?.performClick() + } + callbacks?.playerUpdated(player) + } + + private fun onSubStyleChanged(style: SaveCaptionStyle) { + player.updateSubtitleStyle(style) + // Forcefully update the subtitle encoding in case the edge size is changed. + 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 -> { + /** + * 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) } + ?: 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) { + // Clear all zoom state before applying the new resize mode + gestureHelper.clearZoomState() + 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 */ + + /** + * 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 -> { + // 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) + 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 -> { + // 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 + ) { + 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) + } +} 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 c9da385f63..d96d78324b 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 @@ -45,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 @@ -52,12 +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.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 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 +101,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) +), PlayerView.Callbacks { private val gestureRegionsListener = object : PanelsChildGestureRegionObserver.GestureRegionsListener { override fun onGestureRegionsUpdate(gestureRegions: List) { @@ -110,31 +114,36 @@ 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: TrailerCustomLayoutBinding? = 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 var playerHostView: PlayerView? = null - 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 - } + open fun updateUIVisibility() {} - return root + 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 +167,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) + playerHostView?.playerError(exception) } else { nextMirror() } @@ -258,7 +267,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { } updateUIEvent -= ::updateUI - binding = null + playerHostView?.release() + playerBinding = null resultBinding?.resultScroll?.setOnClickListener(null) resultBinding = null syncBinding = null @@ -282,7 +292,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 +334,10 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + context?.let { ctx -> + playerHostView?.onResume(ctx) + playerHostView?.setupKeyEventListener() + } super.onResume() PanelsChildGestureRegionObserver.Provider.get() .addGestureRegionsUpdateListener(gestureRegionsListener) @@ -332,30 +345,44 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun onStop() { afterPluginsLoadedEvent -= ::reloadViewModel + 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 = PlayerView(ctx) + playerHostView?.player = player + playerHostView?.hasPipModeSupport = hasPipModeSupport + playerHostView?.callbacks = this + playerHostView?.bindViews(binding.root) + playerBinding = binding.root.findViewById(R.id.player_holder)?.let { + TrailerCustomLayoutBinding.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 +400,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 +412,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 +442,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 +495,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 +509,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { }) } - binding?.apply { + binding.apply { resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultBack.setOnClickListener { @@ -675,7 +702,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 +711,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 +724,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 +959,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 +994,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 +1046,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 +1055,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 +1150,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 +1211,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 +1266,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { viewModel.skipLoading() } isVisible = true + @SuppressLint("SetTextI18n") text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})" } } @@ -1359,6 +1387,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } override fun onPause() { + 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 969fa6d95c..c2054825cf 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 @@ -5,41 +5,62 @@ 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 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.SubtitleData import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback 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 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 isUIShowing(): Boolean = isShowing + + override fun onAutoHideUI() { + if (player.getIsPlaying()) uiReset() + } - override fun playerPositionChanged(position: Long, duration : Long) {} + 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 +70,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 +99,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 + layoutParams = lp } anim.duration = 200 anim.start() @@ -120,9 +131,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 autorotation when fullscreen (lockRotation = true). + // PlayerView already set isVerticalOrientation before this callback fires. + if (lockRotation) { + activity?.requestedOrientation = playerHostView?.dynamicOrientation() ?: return + } } override fun showMirrorsDialogue() {} @@ -132,33 +148,39 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { context: Context, loadResponse: LoadResponse?, dismissCallback: () -> Unit - ) { - } + ) {} override fun subtitlesChanged() {} - override fun embeddedSubtitlesFetched(subtitles: List) {} override fun onTracksInfoChanged() {} - override fun exitedPipMode() {} + + 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() + playerHostView?.enterFullscreen() binding?.apply { resultTopBar.isVisible = false 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 @@ -169,36 +191,55 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { resultBinding?.resultSmallscreenHolder?.addView(view) } } - exitFullscreen() + playerHostView?.exitFullscreen() } fixPlayerSize() 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 +} diff --git a/app/src/main/res/layout/trailer_custom_layout.xml b/app/src/main/res/layout/trailer_custom_layout.xml index 8b12505c26..76231a2d3a 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 +