Jannik
0a31c2fd88
* exoplayer 2.17.1 -> 2.18.2 * security-crypto 1.1.0-alpha03 -> 1.1.0-alpha04 * androidx:junit 1.1.3 -> 1.1.4 * androidx:espresso-core 3.4.0 -> 3.5.0
565 lines
20 KiB
Kotlin
565 lines
20 KiB
Kotlin
/**
|
|
* Teapod
|
|
*
|
|
* Copyright 2020-2022 <seil0@mosad.xyz>
|
|
*
|
|
* This program is free software; you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation; either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program; if not, write to the Free Software
|
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
|
|
* MA 02110-1301, USA.
|
|
*
|
|
*/
|
|
|
|
package org.mosad.teapod.ui.activity.player
|
|
|
|
import android.animation.Animator
|
|
import android.animation.AnimatorListenerAdapter
|
|
import android.annotation.SuppressLint
|
|
import android.app.PictureInPictureParams
|
|
import android.content.Intent
|
|
import android.content.pm.PackageManager
|
|
import android.content.res.Configuration
|
|
import android.graphics.Rect
|
|
import android.os.Build
|
|
import android.os.Bundle
|
|
import android.util.Log
|
|
import android.util.Rational
|
|
import android.view.GestureDetector
|
|
import android.view.MotionEvent
|
|
import android.view.View
|
|
import androidx.activity.viewModels
|
|
import androidx.annotation.RequiresApi
|
|
import androidx.appcompat.app.AppCompatActivity
|
|
import androidx.core.view.GestureDetectorCompat
|
|
import androidx.core.view.isVisible
|
|
import androidx.lifecycle.lifecycleScope
|
|
import com.google.android.exoplayer2.ExoPlayer
|
|
import com.google.android.exoplayer2.Player
|
|
import com.google.android.exoplayer2.ui.StyledPlayerControlView
|
|
import com.google.android.exoplayer2.ui.StyledPlayerView
|
|
import com.google.android.exoplayer2.util.Util
|
|
import kotlinx.coroutines.launch
|
|
import org.mosad.teapod.R
|
|
import org.mosad.teapod.databinding.ActivityPlayerBinding
|
|
import org.mosad.teapod.databinding.PlayerControlsBinding
|
|
import org.mosad.teapod.parser.crunchyroll.NoneEpisode
|
|
import org.mosad.teapod.preferences.Preferences
|
|
import org.mosad.teapod.ui.activity.player.fragment.EpisodeListDialogFragment
|
|
import org.mosad.teapod.ui.activity.player.fragment.LanguageSettingsDialogFragment
|
|
import org.mosad.teapod.util.hideBars
|
|
import org.mosad.teapod.util.isInPiPMode
|
|
import org.mosad.teapod.util.navToLauncherTask
|
|
import java.util.*
|
|
import java.util.concurrent.TimeUnit
|
|
import kotlin.concurrent.scheduleAtFixedRate
|
|
|
|
class PlayerActivity : AppCompatActivity() {
|
|
|
|
private val model: PlayerViewModel by viewModels()
|
|
private lateinit var playerBinding: ActivityPlayerBinding
|
|
private lateinit var controlsBinding: PlayerControlsBinding
|
|
|
|
private lateinit var controller: StyledPlayerControlView
|
|
private lateinit var gestureDetector: GestureDetectorCompat
|
|
private lateinit var controlsUpdates: TimerTask
|
|
|
|
private var wasInPiP = false
|
|
private var remainingTime: Long = 0
|
|
|
|
private val rwdTime: Long = 10000.unaryMinus()
|
|
private val fwdTime: Long = 10000
|
|
private val defaultShowTimeoutMs = 5000
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
setContentView(R.layout.activity_player)
|
|
hideBars() // Initial hide the bars
|
|
|
|
playerBinding = ActivityPlayerBinding.bind(findViewById(R.id.player_root))
|
|
controlsBinding = PlayerControlsBinding.bind(findViewById(R.id.player_controls_root))
|
|
|
|
model.loadMediaAsync(
|
|
intent.getStringExtra(getString(R.string.intent_season_id)) ?: "",
|
|
intent.getStringExtra(getString(R.string.intent_episode_id)) ?: ""
|
|
)
|
|
model.currentEpisodeChangedListener.add { onMediaChanged() }
|
|
gestureDetector = GestureDetectorCompat(this, PlayerGestureListener())
|
|
|
|
controller = playerBinding.videoView.findViewById(R.id.exo_controller)
|
|
controller.isAnimationEnabled = false // disable controls (time-bar) animation
|
|
|
|
initExoPlayer() // call in onCreate, exoplayer lives in view model
|
|
initGUI()
|
|
initActions()
|
|
}
|
|
|
|
/**
|
|
* once minimum is android 7.0 this can be simplified
|
|
* only onStart and onStop should be needed then
|
|
* see: https://developer.android.com/guide/topics/ui/picture-in-picture#continuing_playback
|
|
*/
|
|
override fun onStart() {
|
|
super.onStart()
|
|
if (Util.SDK_INT > 23) {
|
|
initPlayer()
|
|
playerBinding.videoView.onResume()
|
|
}
|
|
}
|
|
|
|
override fun onResume() {
|
|
super.onResume()
|
|
if (isInPiPMode()) { return }
|
|
|
|
if (Util.SDK_INT <= 23) {
|
|
initPlayer()
|
|
playerBinding.videoView.onResume()
|
|
}
|
|
}
|
|
|
|
override fun onPause() {
|
|
super.onPause()
|
|
|
|
if (isInPiPMode()) { return }
|
|
if (Util.SDK_INT <= 23) { onPauseOnStop() }
|
|
}
|
|
|
|
override fun onStop() {
|
|
super.onStop()
|
|
|
|
if (Util.SDK_INT > 23) { onPauseOnStop() }
|
|
// if the player was in pip, it's on a different task
|
|
if (wasInPiP) { navToLauncherTask() }
|
|
// if the player is in pip, remove the task, else we'll get a zombie
|
|
if (isInPiPMode()) { finishAndRemoveTask() }
|
|
}
|
|
|
|
/**
|
|
* used, when the player is in pip and the user selects a new media
|
|
*/
|
|
override fun onNewIntent(intent: Intent?) {
|
|
super.onNewIntent(intent)
|
|
|
|
// when the intent changed, load the new media and play it
|
|
intent?.let {
|
|
model.loadMediaAsync(
|
|
it.getStringExtra(getString(R.string.intent_season_id)) ?: "",
|
|
it.getStringExtra(getString(R.string.intent_episode_id)) ?: ""
|
|
)
|
|
model.playCurrentMedia()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* previous to android n, don't override
|
|
*/
|
|
@RequiresApi(Build.VERSION_CODES.N)
|
|
override fun onUserLeaveHint() {
|
|
super.onUserLeaveHint()
|
|
|
|
// start pip mode, if supported
|
|
if (packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) {
|
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
|
@Suppress("deprecation")
|
|
enterPictureInPictureMode()
|
|
} else {
|
|
val width = model.player.videoFormat?.width ?: 0
|
|
val height = model.player.videoFormat?.height ?: 0
|
|
val contentFrame: View = playerBinding.videoView.findViewById(R.id.exo_content_frame)
|
|
val contentRect = with(contentFrame) {
|
|
val (x, y) = intArrayOf(0, 0).also(::getLocationInWindow)
|
|
Rect(x, y, x + width, y + height)
|
|
}
|
|
|
|
val params = PictureInPictureParams.Builder()
|
|
.setAspectRatio(Rational(width, height))
|
|
.setSourceRectHint(contentRect)
|
|
.build()
|
|
enterPictureInPictureMode(params)
|
|
}
|
|
|
|
wasInPiP = isInPiPMode()
|
|
}
|
|
}
|
|
|
|
override fun onPictureInPictureModeChanged(
|
|
isInPictureInPictureMode: Boolean,
|
|
newConfig: Configuration
|
|
) {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
|
}
|
|
|
|
// Hide the full-screen UI (controls, etc.) while in picture-in-picture mode.
|
|
playerBinding.videoView.useController = !isInPictureInPictureMode
|
|
|
|
// TODO also hide language settings/episodes list
|
|
}
|
|
|
|
private fun initPlayer() {
|
|
initVideoView()
|
|
initTimeUpdates()
|
|
|
|
// if the player is ready or buffering we can simply play the file again, else do nothing
|
|
val playbackState = model.player.playbackState
|
|
if ((playbackState == ExoPlayer.STATE_READY || playbackState == ExoPlayer.STATE_BUFFERING)) {
|
|
model.player.play()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* set play when ready and listeners
|
|
*/
|
|
private fun initExoPlayer() {
|
|
model.player.addListener(object : Player.Listener {
|
|
override fun onPlaybackStateChanged(state: Int) {
|
|
super.onPlaybackStateChanged(state)
|
|
|
|
playerBinding.loading.visibility = when (state) {
|
|
ExoPlayer.STATE_READY -> View.GONE
|
|
ExoPlayer.STATE_BUFFERING -> View.VISIBLE
|
|
else -> View.GONE
|
|
}
|
|
|
|
// don't use isVisible to hide exoPlayPause, as it will set the visibility to GONE
|
|
controlsBinding.exoPlayPause.visibility = when(playerBinding.loading.isVisible) {
|
|
true -> View.INVISIBLE
|
|
false -> View.VISIBLE
|
|
}
|
|
|
|
if (state == ExoPlayer.STATE_ENDED && hasNextEpisode() && Preferences.autoplay) {
|
|
playNextEpisode()
|
|
}
|
|
}
|
|
})
|
|
|
|
// revert back to the old behaviour (blocking init) in case there are any issues with async init
|
|
// start playing the current episode, after all needed player components have been initialized
|
|
//model.playCurrentMedia(model.currentPlayhead)
|
|
}
|
|
|
|
@SuppressLint("ClickableViewAccessibility")
|
|
private fun initVideoView() {
|
|
playerBinding.videoView.player = model.player
|
|
|
|
// when the player controls get hidden, hide the bars too
|
|
playerBinding.videoView.setControllerVisibilityListener(StyledPlayerView.ControllerVisibilityListener {
|
|
when (it) {
|
|
View.GONE -> {
|
|
hideBars()
|
|
// TODO also hide the skip op button
|
|
}
|
|
View.VISIBLE -> updateControls()
|
|
}
|
|
})
|
|
|
|
playerBinding.videoView.setOnTouchListener { _, event ->
|
|
gestureDetector.onTouchEvent(event)
|
|
true
|
|
}
|
|
}
|
|
|
|
private fun initActions() {
|
|
controlsBinding.exoClosePlayer.setOnClickListener {
|
|
this.finish()
|
|
}
|
|
controlsBinding.rwd10.setOnButtonClickListener { rewind() }
|
|
controlsBinding.ffwd10.setOnButtonClickListener { fastForward() }
|
|
playerBinding.buttonNextEp.setOnClickListener { playNextEpisode() }
|
|
playerBinding.buttonSkipOp.setOnClickListener { skipOpening() }
|
|
controlsBinding.buttonLanguage.setOnClickListener { showLanguageSettings() }
|
|
controlsBinding.buttonEpisodes.setOnClickListener { showEpisodesList() }
|
|
controlsBinding.buttonNextEpC.setOnClickListener { playNextEpisode() }
|
|
}
|
|
|
|
private fun initGUI() {
|
|
// TODO reimplement for cr
|
|
// if (model.media.type == DataTypes.MediaType.MOVIE) {
|
|
// button_episodes.visibility = View.GONE
|
|
// }
|
|
}
|
|
|
|
private fun initTimeUpdates() {
|
|
if (this::controlsUpdates.isInitialized) {
|
|
controlsUpdates.cancel()
|
|
}
|
|
|
|
controlsUpdates = Timer().scheduleAtFixedRate(0, 500) {
|
|
lifecycleScope.launch {
|
|
val currentPosition = model.player.currentPosition
|
|
val btnNextEpIsVisible = playerBinding.buttonNextEp.isVisible
|
|
val controlsVisible = controller.isVisible
|
|
|
|
// make sure remaining time is > 0
|
|
if (model.player.duration > 0) {
|
|
remainingTime = model.player.duration - currentPosition
|
|
remainingTime = if (remainingTime < 0) 0 else remainingTime
|
|
} else {
|
|
remainingTime = 0
|
|
}
|
|
|
|
// TODO add metaDB ending_start support
|
|
// if remaining time > 1 and < 20 sec, a next ep is set, autoplay is enabled
|
|
// and not in pip: show next ep button
|
|
if (remainingTime in 1000..20000) {
|
|
if (!btnNextEpIsVisible && hasNextEpisode() && Preferences.autoplay && !isInPiPMode()) {
|
|
showButtonNextEp()
|
|
}
|
|
} else if (btnNextEpIsVisible) {
|
|
hideButtonNextEp()
|
|
}
|
|
|
|
// into metadata is present and we can show the skip button
|
|
if (model.currentIntroMetadata.duration >= 10) {
|
|
val startTime = model.currentIntroMetadata.startTime.toInt() * 1000
|
|
if (currentPosition in startTime..(startTime + 10000) && !playerBinding.buttonSkipOp.isVisible) {
|
|
showButtonSkipOp()
|
|
} else if (playerBinding.buttonSkipOp.isVisible &&
|
|
currentPosition !in startTime..(startTime + 10000)
|
|
) {
|
|
// the button should only be visible if currentEpisodeMeta != null
|
|
hideButtonSkipOp()
|
|
}
|
|
|
|
}
|
|
|
|
// if controls are visible, update them
|
|
if (controlsVisible) {
|
|
updateControls()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun onPauseOnStop() {
|
|
playerBinding.videoView.onPause()
|
|
model.player.pause()
|
|
controlsUpdates.cancel()
|
|
}
|
|
|
|
/**
|
|
* update the custom controls
|
|
*/
|
|
private fun updateControls() {
|
|
// update remaining time label
|
|
val hours = TimeUnit.MILLISECONDS.toHours(remainingTime) % 24
|
|
val minutes = TimeUnit.MILLISECONDS.toMinutes(remainingTime) % 60
|
|
val seconds = TimeUnit.MILLISECONDS.toSeconds(remainingTime) % 60
|
|
|
|
// if remaining time is below 60 minutes, don't show hours
|
|
controlsBinding.exoRemaining.text = if (TimeUnit.MILLISECONDS.toMinutes(remainingTime) < 60) {
|
|
getString(R.string.time_min_sec, minutes, seconds)
|
|
} else {
|
|
getString(R.string.time_hour_min_sec, hours, minutes, seconds)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This methode is called, if the current episode has changed.
|
|
* Update title text and next ep button visibility.
|
|
* If the currentEpisode changed to NoneEpisode, exit the activity.
|
|
*/
|
|
private fun onMediaChanged() {
|
|
if (model.currentEpisode == NoneEpisode) {
|
|
Log.e(javaClass.name, "No media was set.")
|
|
this.finish()
|
|
}
|
|
|
|
controlsBinding.exoTextTitle.text = model.getMediaTitle()
|
|
|
|
// hide the next episode button, if there is none
|
|
controlsBinding.buttonNextEpC.isVisible = hasNextEpisode()
|
|
}
|
|
|
|
/**
|
|
* Check if the current episode has a next episode.
|
|
*
|
|
* @return Boolean: true if there is a next episode, else false.
|
|
*/
|
|
private fun hasNextEpisode(): Boolean {
|
|
return (model.currentEpisode.nextEpisodeId != null && !model.currentEpisodeIsLastEpisode())
|
|
}
|
|
|
|
/**
|
|
* TODO set position of rewind/fast forward indicators programmatically
|
|
*/
|
|
|
|
private fun rewind() {
|
|
model.seekToOffset(rwdTime)
|
|
|
|
// hide/show needed components
|
|
playerBinding.exoDoubleTapIndicator.visibility = View.VISIBLE
|
|
playerBinding.ffwd10Indicator.visibility = View.INVISIBLE
|
|
controlsBinding.rwd10.visibility = View.INVISIBLE
|
|
|
|
playerBinding.rwd10Indicator.onAnimationEndCallback = {
|
|
playerBinding.exoDoubleTapIndicator.visibility = View.GONE
|
|
playerBinding.ffwd10Indicator.visibility = View.VISIBLE
|
|
controlsBinding.rwd10.visibility = View.VISIBLE
|
|
}
|
|
|
|
// run animation
|
|
playerBinding.rwd10Indicator.runOnClickAnimation()
|
|
}
|
|
|
|
private fun fastForward() {
|
|
model.seekToOffset(fwdTime)
|
|
|
|
// hide/show needed components
|
|
playerBinding.exoDoubleTapIndicator.visibility = View.VISIBLE
|
|
playerBinding.rwd10Indicator.visibility = View.INVISIBLE
|
|
controlsBinding.ffwd10.visibility = View.INVISIBLE
|
|
|
|
playerBinding.ffwd10Indicator.onAnimationEndCallback = {
|
|
playerBinding.exoDoubleTapIndicator.visibility = View.GONE
|
|
playerBinding.rwd10Indicator.visibility = View.VISIBLE
|
|
controlsBinding.ffwd10.visibility = View.VISIBLE
|
|
}
|
|
|
|
// run animation
|
|
playerBinding.ffwd10Indicator.runOnClickAnimation()
|
|
}
|
|
|
|
private fun playNextEpisode() {
|
|
// disable the next episode buttons, so a user can't double click it
|
|
playerBinding.buttonNextEp.isClickable = false
|
|
controlsBinding.buttonNextEpC.isClickable = false
|
|
|
|
hideButtonNextEp()
|
|
model.playNextEpisode()
|
|
|
|
// enable the next episode buttons when playNextEpisode() has returned
|
|
playerBinding.buttonNextEp.isClickable = true
|
|
controlsBinding.buttonNextEpC.isClickable = true
|
|
}
|
|
|
|
private fun skipOpening() {
|
|
// calculate the seek time
|
|
if (model.currentIntroMetadata.duration > 10) {
|
|
val endTime = model.currentIntroMetadata.endTime.toInt() * 1000
|
|
val seekTime = endTime - model.player.currentPosition
|
|
model.seekToOffset(seekTime)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* show the next episode button
|
|
* TODO improve the show animation
|
|
*/
|
|
private fun showButtonNextEp() {
|
|
playerBinding.buttonNextEp.isVisible = true
|
|
playerBinding.buttonNextEp.alpha = 0.0f
|
|
|
|
playerBinding.buttonNextEp.animate()
|
|
.alpha(1.0f)
|
|
.setListener(null)
|
|
}
|
|
|
|
/**
|
|
* hide the next episode button
|
|
* TODO improve the hide animation
|
|
*/
|
|
private fun hideButtonNextEp() {
|
|
playerBinding.buttonNextEp.animate()
|
|
.alpha(0.0f)
|
|
.setListener(object : AnimatorListenerAdapter() {
|
|
override fun onAnimationEnd(animation: Animator) {
|
|
super.onAnimationEnd(animation)
|
|
playerBinding.buttonNextEp.isVisible = false
|
|
}
|
|
})
|
|
}
|
|
|
|
private fun showButtonSkipOp() {
|
|
playerBinding.buttonSkipOp.isVisible = true
|
|
playerBinding.buttonSkipOp.alpha = 0.0f
|
|
|
|
playerBinding.buttonSkipOp.animate()
|
|
.alpha(1.0f)
|
|
.setListener(null)
|
|
}
|
|
|
|
private fun hideButtonSkipOp() {
|
|
playerBinding.buttonSkipOp.animate()
|
|
.alpha(0.0f)
|
|
.setListener(object : AnimatorListenerAdapter() {
|
|
override fun onAnimationEnd(animation: Animator) {
|
|
super.onAnimationEnd(animation)
|
|
playerBinding.buttonSkipOp.isVisible = false
|
|
}
|
|
})
|
|
|
|
}
|
|
|
|
private fun showEpisodesList() {
|
|
pauseAndHideControls()
|
|
EpisodeListDialogFragment().show(supportFragmentManager, EpisodeListDialogFragment.TAG)
|
|
}
|
|
|
|
private fun showLanguageSettings() {
|
|
pauseAndHideControls()
|
|
LanguageSettingsDialogFragment().show(supportFragmentManager, LanguageSettingsDialogFragment.TAG)
|
|
}
|
|
|
|
/**
|
|
* pause playback and hide controls
|
|
*/
|
|
private fun pauseAndHideControls() {
|
|
model.player.pause() // showTimeoutMs is set to 0 when calling pause, but why
|
|
controller.showTimeoutMs = defaultShowTimeoutMs // fix showTimeoutMs set to 0
|
|
controller.hide()
|
|
}
|
|
|
|
inner class PlayerGestureListener : GestureDetector.SimpleOnGestureListener() {
|
|
|
|
/**
|
|
* on single tap hide or show the controls
|
|
*/
|
|
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
|
if (!isInPiPMode()) {
|
|
if (controller.isVisible) controller.hide() else controller.show()
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* on double tap rewind or forward
|
|
*/
|
|
override fun onDoubleTap(e: MotionEvent): Boolean {
|
|
val eventPosX = e.x.toInt()
|
|
val viewCenterX = playerBinding.videoView.measuredWidth / 2
|
|
|
|
// if the event position is on the left side rewind, if it's on the right forward
|
|
if (eventPosX < viewCenterX) rewind() else fastForward()
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* not used
|
|
*/
|
|
override fun onDoubleTapEvent(e: MotionEvent): Boolean {
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* on long press toggle pause/play
|
|
*/
|
|
override fun onLongPress(e: MotionEvent) {
|
|
model.togglePausePlay()
|
|
}
|
|
|
|
}
|
|
|
|
}
|