teapod/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerActivity.kt

548 lines
18 KiB
Kotlin
Raw Normal View History

2022-01-02 17:59:23 +01:00
/**
* 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
2020-10-11 13:18:20 +02:00
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
2021-01-06 15:07:31 +01:00
import android.app.PictureInPictureParams
import android.content.Intent
2021-01-06 15:07:31 +01:00
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Rect
2020-10-11 14:14:38 +02:00
import android.os.Build
2020-10-11 13:18:20 +02:00
import android.os.Bundle
2020-10-11 14:14:38 +02:00
import android.util.Log
2021-01-06 15:07:31 +01:00
import android.util.Rational
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import androidx.activity.viewModels
2021-01-06 15:07:31 +01:00
import androidx.annotation.RequiresApi
2020-10-11 13:18:20 +02:00
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.GestureDetectorCompat
2020-11-13 15:36:12 +01:00
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
2020-11-21 19:40:55 +01:00
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.Player
2020-11-06 10:21:57 +01:00
import com.google.android.exoplayer2.ui.StyledPlayerControlView
2020-10-11 13:18:20 +02:00
import com.google.android.exoplayer2.util.Util
import kotlinx.android.synthetic.main.activity_player.*
import kotlinx.android.synthetic.main.player_controls.*
import kotlinx.coroutines.launch
2020-11-25 22:35:55 +01:00
import org.mosad.teapod.R
import org.mosad.teapod.parser.crunchyroll.NoneEpisode
import org.mosad.teapod.preferences.Preferences
2020-12-15 23:15:14 +01:00
import org.mosad.teapod.ui.components.EpisodesListPlayer
import org.mosad.teapod.ui.components.LanguageSettingsPlayer
import org.mosad.teapod.util.*
import java.util.*
2020-11-07 13:33:59 +01:00
import java.util.concurrent.TimeUnit
import kotlin.concurrent.scheduleAtFixedRate
2020-10-11 13:18:20 +02:00
class PlayerActivity : AppCompatActivity() {
2020-11-25 22:35:55 +01:00
private val model: PlayerViewModel by viewModels()
2020-11-06 10:21:57 +01:00
private lateinit var controller: StyledPlayerControlView
private lateinit var gestureDetector: GestureDetectorCompat
private lateinit var timerUpdates: TimerTask
2020-10-11 13:18:20 +02:00
private var wasInPiP = false
2020-11-07 13:33:59 +01:00
private var remainingTime: Long = 0
2020-10-11 13:18:20 +02:00
private val rwdTime: Long = 10000.unaryMinus()
private val fwdTime: Long = 10000
private val defaultShowTimeoutMs = 5000
2020-10-11 14:14:38 +02:00
2020-10-11 13:18:20 +02:00
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_player)
2020-10-11 14:14:38 +02:00
hideBars() // Initial hide the bars
2020-10-11 13:18:20 +02:00
model.loadMediaAsync(
2021-12-20 22:14:58 +01:00
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 = video_view.findViewById(R.id.exo_controller)
controller.isAnimationEnabled = false // disable controls (time-bar) animation
initExoPlayer() // call in onCreate, exoplayer lives in view model
initGUI()
initActions()
2020-10-11 13:18:20 +02:00
}
2021-01-06 15:07:31 +01:00
/**
* 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
*/
2020-10-11 13:18:20 +02:00
override fun onStart() {
super.onStart()
if (Util.SDK_INT > 23) {
initPlayer()
2020-11-25 22:35:55 +01:00
video_view?.onResume()
2020-10-11 13:18:20 +02:00
}
}
override fun onResume() {
super.onResume()
2021-01-06 15:07:31 +01:00
if (isInPiPMode()) { return }
2020-10-11 13:18:20 +02:00
if (Util.SDK_INT <= 23) {
initPlayer()
2020-11-25 22:35:55 +01:00
video_view?.onResume()
2020-10-11 13:18:20 +02:00
}
}
override fun onPause() {
super.onPause()
2021-01-06 15:07:31 +01:00
2021-03-11 19:11:12 +01:00
if (isInPiPMode()) { return }
if (Util.SDK_INT <= 23) { onPauseOnStop() }
2020-10-11 13:18:20 +02:00
}
override fun onStop() {
super.onStop()
2021-03-11 19:11:12 +01:00
if (Util.SDK_INT > 23) { onPauseOnStop() }
// if the player was in pip, it's on a different task
if (wasInPiP) { navToLauncherTask() }
2021-03-04 20:19:54 +01:00
// if the player is in pip, remove the task, else we'll get a zombie
if (isInPiPMode()) { finishAndRemoveTask() }
2020-10-11 13:18:20 +02:00
}
/**
* 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(
2021-12-20 22:14:58 +01:00
it.getStringExtra(getString(R.string.intent_season_id)) ?: "",
it.getStringExtra(getString(R.string.intent_episode_id)) ?: ""
)
model.playCurrentMedia()
}
}
2021-01-06 15:07:31 +01:00
/**
* 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 = video_view.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)
}
2021-01-06 15:07:31 +01:00
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(width, height))
.setSourceRectHint(contentRect)
2021-01-06 15:07:31 +01:00
.build()
enterPictureInPictureMode(params)
}
wasInPiP = isInPiPMode()
2021-01-06 15:07:31 +01:00
}
}
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration?
) {
2021-01-06 15:07:31 +01:00
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
// Hide the full-screen UI (controls, etc.) while in picture-in-picture mode.
2021-03-04 20:19:54 +01:00
video_view.useController = !isInPictureInPictureMode
2021-01-06 15:07:31 +01:00
}
2020-10-11 13:18:20 +02:00
private fun initPlayer() {
initVideoView()
2020-11-13 15:36:12 +01:00
initTimeUpdates()
// if the player is ready or buffering we can simply play the file again, else do nothing
2021-03-04 20:19:54 +01:00
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 {
2020-10-11 13:18:20 +02:00
override fun onPlaybackStateChanged(state: Int) {
super.onPlaybackStateChanged(state)
loading.visibility = when (state) {
2020-10-11 14:14:38 +02:00
ExoPlayer.STATE_READY -> View.GONE
2020-10-11 13:18:20 +02:00
ExoPlayer.STATE_BUFFERING -> View.VISIBLE
else -> View.GONE
}
exo_play_pause.visibility = when (loading.visibility) {
View.GONE -> View.VISIBLE
View.VISIBLE -> View.INVISIBLE
else -> View.VISIBLE
}
2020-11-13 15:36:12 +01:00
if (state == ExoPlayer.STATE_ENDED && hasNextEpisode() && Preferences.autoplay) {
playNextEpisode()
2020-11-13 15:36:12 +01:00
}
2020-10-11 13:18:20 +02:00
}
})
// 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() {
video_view.player = model.player
2020-10-11 13:18:20 +02:00
2020-10-11 14:14:38 +02:00
// when the player controls get hidden, hide the bars too
video_view.setControllerVisibilityListener {
when (it) {
View.GONE -> {
hideBars()
// TODO also hide the skip op button
}
View.VISIBLE -> updateControls()
2020-11-07 13:33:59 +01:00
}
}
video_view.setOnTouchListener { _, event ->
gestureDetector.onTouchEvent(event)
true
}
}
private fun initActions() {
2021-01-06 15:07:31 +01:00
exo_close_player.setOnClickListener {
this.finish()
}
rwd_10.setOnButtonClickListener { rewind() }
2020-11-25 23:33:06 +01:00
ffwd_10.setOnButtonClickListener { fastForward() }
2020-11-13 15:36:12 +01:00
button_next_ep.setOnClickListener { playNextEpisode() }
button_skip_op.setOnClickListener { skipOpening() }
button_language.setOnClickListener { showLanguageSettings() }
2020-12-14 23:46:55 +01:00
button_episodes.setOnClickListener { showEpisodesList() }
button_next_ep_c.setOnClickListener { playNextEpisode() }
2020-11-13 15:36:12 +01:00
}
private fun initGUI() {
// TODO reimplement for cr
// if (model.media.type == DataTypes.MediaType.MOVIE) {
// button_episodes.visibility = View.GONE
// }
}
private fun initTimeUpdates() {
if (this::timerUpdates.isInitialized) {
timerUpdates.cancel()
}
timerUpdates = Timer().scheduleAtFixedRate(0, 500) {
lifecycleScope.launch {
val currentPosition = model.player.currentPosition
val btnNextEpIsVisible = button_next_ep.isVisible
val controlsVisible = controller.isVisible
2020-11-13 15:36:12 +01:00
// make sure remaining time is > 0
if (model.player.duration > 0) {
remainingTime = model.player.duration - currentPosition
remainingTime = if (remainingTime < 0) 0 else remainingTime
}
// TODO add metaDB ending_start support
// if remaining time < 20 sec, a next ep is set, autoplay is enabled and not in pip:
// show next ep button
2020-11-22 14:20:17 +01:00
if (remainingTime in 1..20000) {
if (!btnNextEpIsVisible && hasNextEpisode() && Preferences.autoplay && !isInPiPMode()) {
showButtonNextEp()
2020-11-22 14:20:17 +01:00
}
} else if (btnNextEpIsVisible) {
hideButtonNextEp()
}
// if meta data is present and opening_start & opening_duration are valid, show skip opening
model.currentEpisodeMeta?.let {
if (it.openingDuration > 0 &&
currentPosition in it.openingStart..(it.openingStart + 10000) &&
!button_skip_op.isVisible
) {
showButtonSkipOp()
} else if (button_skip_op.isVisible && currentPosition !in it.openingStart..(it.openingStart + 10000)) {
// the button should only be visible, if currentEpisodeMeta != null
hideButtonSkipOp()
}
}
// if controls are visible, update them
if (controlsVisible) {
updateControls()
2020-11-13 15:36:12 +01:00
}
}
}
}
2021-03-11 19:11:12 +01:00
private fun onPauseOnStop() {
video_view?.onPause()
model.player.pause()
timerUpdates.cancel()
2020-10-11 13:18:20 +02:00
}
/**
* 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
exo_remaining.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()
}
exo_text_title.text = model.getMediaTitle()
// hide the next episode button, if there is none
button_next_ep_c.visibility = if (hasNextEpisode()) View.VISIBLE else View.GONE
}
/**
* 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
exo_double_tap_indicator.visibility = View.VISIBLE
ffwd_10_indicator.visibility = View.INVISIBLE
rwd_10.visibility = View.INVISIBLE
rwd_10_indicator.onAnimationEndCallback = {
exo_double_tap_indicator.visibility = View.GONE
ffwd_10_indicator.visibility = View.VISIBLE
rwd_10.visibility = View.VISIBLE
}
// run animation
rwd_10_indicator.runOnClickAnimation()
}
2020-11-25 23:33:06 +01:00
private fun fastForward() {
model.seekToOffset(fwdTime)
// hide/show needed components
exo_double_tap_indicator.visibility = View.VISIBLE
rwd_10_indicator.visibility = View.INVISIBLE
ffwd_10.visibility = View.INVISIBLE
ffwd_10_indicator.onAnimationEndCallback = {
exo_double_tap_indicator.visibility = View.GONE
rwd_10_indicator.visibility = View.VISIBLE
ffwd_10.visibility = View.VISIBLE
}
// run animation
ffwd_10_indicator.runOnClickAnimation()
}
private fun playNextEpisode() {
model.playNextEpisode()
hideButtonNextEp()
}
private fun skipOpening() {
// calculate the seek time
model.currentEpisodeMeta?.let {
val seekTime = (it.openingStart + it.openingDuration) - model.player.currentPosition
model.seekToOffset(seekTime)
}
}
/**
* show the next episode button
* TODO improve the show animation
*/
private fun showButtonNextEp() {
button_next_ep.isVisible = true
button_next_ep.alpha = 0.0f
button_next_ep.animate()
.alpha(1.0f)
.setListener(null)
}
/**
* hide the next episode button
* TODO improve the hide animation
*/
private fun hideButtonNextEp() {
button_next_ep.animate()
.alpha(0.0f)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
super.onAnimationEnd(animation)
button_next_ep.isVisible = false
}
})
}
private fun showButtonSkipOp() {
button_skip_op.isVisible = true
button_skip_op.alpha = 0.0f
button_skip_op.animate()
.alpha(1.0f)
.setListener(null)
}
private fun hideButtonSkipOp() {
button_skip_op.animate()
.alpha(0.0f)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
super.onAnimationEnd(animation)
button_skip_op.isVisible = false
}
})
}
2020-12-14 23:46:55 +01:00
private fun showEpisodesList() {
2020-12-15 23:15:14 +01:00
val episodesList = EpisodesListPlayer(this, model = model).apply {
onViewRemovedAction = { model.player.play() }
2020-12-15 23:15:14 +01:00
}
player_layout.addView(episodesList)
pauseAndHideControls()
}
private fun showLanguageSettings() {
val languageSettings = LanguageSettingsPlayer(this, model = model).apply {
onViewRemovedAction = { model.player.play() }
}
player_layout.addView(languageSettings)
pauseAndHideControls()
}
/**
* 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()
2020-12-14 23:46:55 +01:00
}
inner class PlayerGestureListener : GestureDetector.SimpleOnGestureListener() {
/**
* on single tap hide or show the controls
*/
override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
2021-01-06 15:07:31 +01:00
if (!isInPiPMode()) {
if (controller.isVisible) controller.hide() else controller.show()
2021-01-06 15:07:31 +01:00
}
return true
}
/**
* on double tap rewind or forward
*/
override fun onDoubleTap(e: MotionEvent?): Boolean {
val eventPosX = e?.x?.toInt() ?: 0
val viewCenterX = video_view.measuredWidth / 2
// if the event position is on the left side rewind, if it's on the right forward
2020-12-15 23:15:14 +01:00
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()
}
}
2020-10-11 13:18:20 +02:00
}