AquaStream/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt

653 lines
24 KiB
Kotlin

package com.lagradost.cloudstream3.ui.player
import android.annotation.SuppressLint
import android.content.*
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.StringRes
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.media3.common.PlaybackException
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.canEnterPipMode
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.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.unixTimeMs
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
import com.lagradost.cloudstream3.utils.AppUtils
import com.lagradost.cloudstream3.utils.AppUtils.requestLocalAudioFocus
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.UIHelper
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
enum class PlayerResize(@StringRes val nameRes: Int) {
Fit(R.string.resize_fit),
Fill(R.string.resize_fill),
Zoom(R.string.resize_zoom),
}
// when the player should switch skip op to next episode
const val SKIP_OP_VIDEO_PERCENTAGE = 50
// when the player should preload the next episode for faster loading
const val PRELOAD_NEXT_EPISODE_PERCENTAGE = 80
// when the player should mark the episode as watched and resume watching the next
const val NEXT_WATCH_EPISODE_PERCENTAGE = 90
// when the player should sync the progress of "watched", TODO MAKE SETTING
const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80
abstract class AbstractPlayerFragment(
val player: IPlayer = CS3IPlayer()
) : Fragment() {
var resizeMode: Int = 0
var subStyle: SaveCaptionStyle? = null
var subView: SubtitleView? = null
var isBuffering = true
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
@LayoutRes
protected open var layout: Int = R.layout.fragment_player
open fun nextEpisode() {
throw NotImplementedError()
}
open fun prevEpisode() {
throw NotImplementedError()
}
open fun playerPositionChanged(position: Long, duration: Long) {
throw NotImplementedError()
}
open fun playerStatusChanged(){}
open fun playerDimensionsLoaded(width: Int, height: Int) {
throw NotImplementedError()
}
open fun subtitlesChanged() {
throw NotImplementedError()
}
open fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {
throw NotImplementedError()
}
open fun onTracksInfoChanged() {
throw NotImplementedError()
}
open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) {
}
open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) {
}
open fun exitedPipMode() {
throw NotImplementedError()
}
private fun keepScreenOn(on: Boolean) {
if (on) {
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
private fun updateIsPlaying(
wasPlaying: CSPlayerLoading,
isPlaying: CSPlayerLoading
) {
val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying
val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying
keepScreenOn(!isPausedRightNow)
isBuffering = CSPlayerLoading.IsBuffering == isPlaying
if (isBuffering) {
playerPausePlayHolderHolder?.isVisible = false
playerBuffering?.isVisible = true
} else {
playerPausePlayHolderHolder?.isVisible = true
playerBuffering?.isVisible = false
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)
}
}
canEnterPipMode = isPlayingRightNow && hasPipModeSupport
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity?.let { act ->
PlayerPipHelper.updatePIPModeActions(
act,
isPlayingRightNow,
player.getAspectRatio()
)
}
}
}
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.values()[intent.getIntExtra(
EXTRA_CONTROL_TYPE,
0
)], source = PlayerEventSource.UI
)
}
}
val filter = IntentFilter()
filter.addAction(ACTION_MEDIA_CONTROL)
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
normalSafeApiCall {
activity?.unregisterReceiver(it)
}
}
activity?.hideSystemUI()
this.view?.let { UIHelper.hideKeyboard(it) }
}
} catch (e: Exception) {
logError(e)
}
}
open fun hasNextMirror(): Boolean {
throw NotImplementedError()
}
open fun nextMirror() {
throw NotImplementedError()
}
private fun requestAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity?.requestLocalAudioFocus(AppUtils.getFocusRequest())
}
}
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_PARSING_CONTAINER_UNSUPPORTED, PlaybackException.ERROR_CODE_IO_NO_PERMISSION, PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> {
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_INVALID_HTTP_CONTENT_TYPE -> {
showToast(
"${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg",
gotoNext = true
)
}
PlaybackException.ERROR_CODE_DECODING_FAILED, PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, 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
)
}
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
)
}
else -> {
exception.message?.let {
showToast(
it,
gotoNext = false
)
}
}
}
}
private fun onSubStyleChanged(style: SaveCaptionStyle) {
if (player is CS3IPlayer) {
player.updateSubtitleStyle(style)
}
}
@SuppressLint("UnsafeOptInUsageError")
private 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(unixTimeMs.toString())
.build()
}
// Necessary for multiple combined videos
playerView?.setShowMultiWindowTimeBar(true)
playerView?.player = player
playerView?.performClick()
}
}
private 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)
// }
//}
/** 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) {
Log.i(TAG, "Handle event: $event")
when (event) {
is ResizedEvent -> {
playerDimensionsLoaded(event.width, event.height)
}
is PlayerAttachedEvent -> {
playerUpdated(event.player)
}
is SubtitlesUpdatedEvent -> {
subtitlesChanged()
}
is TimestampSkippedEvent -> {
onTimestampSkipped(event.timestamp)
}
is TimestampInvokedEvent -> {
onTimestamp(event.timestamp)
}
is TracksChangedEvent -> {
onTracksInfoChanged()
}
is EmbeddedSubtitlesFetchedEvent -> {
embeddedSubtitlesFetched(event.tracks)
}
is ErrorEvent -> {
playerError(event.error)
}
is RequestAudioFocusEvent -> {
requestAudioFocus()
}
is EpisodeSeekEvent -> {
when (event.offset) {
-1 -> prevEpisode()
1 -> nextEpisode()
else -> {}
}
}
is StatusEvent -> {
updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying)
playerStatusChanged()
}
is PositionEvent -> {
playerPositionChanged(position = event.toMs, duration = event.durationMs)
}
is VideoEndedEvent -> {
context?.let { ctx ->
// Only play next episode if autoplay is on (default)
if (PreferenceManager.getDefaultSharedPreferences(ctx)
?.getBoolean(
ctx.getString(R.string.autoplay_next_key),
true
) == true
) {
player.handleEvent(
CSPlayerEvent.NextEpisode,
source = PlayerEventSource.Player
)
}
}
}
is PauseEvent -> Unit
is PlayEvent -> Unit
}
}
@SuppressLint("SetTextI18n", "UnsafeOptInUsageError")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
resizeMode = DataStoreHelper.resizeMode
resize(resizeMode, false)
player.releaseCallbacks()
player.initCallbacks(
eventHandler = ::mainCallback,
requestedListeningPercentages = listOf(
SKIP_OP_VIDEO_PERCENTAGE,
PRELOAD_NEXT_EPISODE_PERCENTAGE,
NEXT_WATCH_EPISODE_PERCENTAGE,
UPDATE_SYNC_PROGRESS_PERCENTAGE,
),
)
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
)
}
override fun onScrubMove(
previewBar: PreviewBar?,
progress: Int,
fromUser: Boolean
) {
}
override fun onScrubStop(previewBar: PreviewBar?) {
if (resume) player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player)
}
})
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(R.id.exo_subtitles)
subStyle = SubtitlesFragment.getCurrentSavedStyle()
player.initSubtitles(subView, subtitleHolder, subStyle)
(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<DefaultTimeBar>(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 onDestroy() {
playerEventListener = null
keyEventListener = null
canEnterPipMode = false
mMediaSession?.release()
mMediaSession = null
playerView?.player = null
SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged
keepScreenOn(false)
super.onDestroy()
}
fun nextResize() {
resizeMode = (resizeMode + 1) % PlayerResize.values().size
resize(resizeMode, true)
}
fun resize(resize: Int, showToast: Boolean) {
resize(PlayerResize.values()[resize], showToast)
}
@SuppressLint("UnsafeOptInUsageError")
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 onStop() {
player.onStop()
super.onStop()
}
override fun onResume() {
context?.let { ctx ->
player.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
}
}