Add PiP mode (#502)

Co-authored-by: jmir1 <jhmiramon@gmail.com>
This commit is contained in:
Quickdesh 2022-04-11 00:35:34 +09:00 committed by GitHub
parent a14cd93279
commit 56b2c12a97
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 299 additions and 71 deletions

View file

@ -96,11 +96,12 @@
<activity
android:name=".ui.player.PlayerActivity"
android:launchMode="singleTask"
android:screenOrientation="userLandscape"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize|keyboardHidden|keyboard"
android:supportsPictureInPicture="true"
android:resizeableActivity="true"
android:exported="false">
android:exported="false"
tools:targetApi="n">
<intent-filter>
<action android:name="com.samsung.android.support.REMOTE_ACTION" />
</intent-filter>

View file

@ -25,8 +25,6 @@ object PreferenceKeys {
const val alwaysUseExternalPlayer = "pref_always_use_external_player"
const val pipPlayerPreference = "pref_pip_player"
const val externalPlayerPreference = "external_player_preference"
const val jumpToChapters = "jump_to_chapters"

View file

@ -136,8 +136,6 @@ class PreferencesHelper(val context: Context) {
putInt(Keys.playerViewMode, newMode)
}
fun pipPlayerPreference() = prefs.getBoolean(Keys.pipPlayerPreference, false)
fun alwaysUseExternalPlayer() = prefs.getBoolean(Keys.alwaysUseExternalPlayer, false)
fun externalPlayerPreference() = prefs.getString(Keys.externalPlayerPreference, "")

View file

@ -2,10 +2,18 @@ package eu.kanade.tachiyomi.ui.player
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.drawable.Icon
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
@ -13,11 +21,15 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.DisplayMetrics
import android.util.Rational
import android.view.MotionEvent
import android.view.View
import android.view.ViewAnimationUtils
import android.view.WindowManager
import android.view.animation.AnimationUtils
import android.widget.RelativeLayout
import android.widget.SeekBar
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
@ -62,6 +74,27 @@ class PlayerActivity :
}
}
}
override fun onNewIntent(intent: Intent?) {
// TODO: When in PiP mode, selecting an episode from list should load new episode
// Currently, below finish simply closes the activity. I don't know how to return a new Intent to update the activity
finish()
super.onNewIntent(intent)
}
private val ACTION_MEDIA_CONTROL = "media_control"
private val EXTRA_CONTROL_TYPE = "control_type"
private val REQUEST_PLAY = 1
private val REQUEST_PAUSE = 2
private val CONTROL_TYPE_PLAY = 1
private val CONTROL_TYPE_PAUSE = 2
private val REQUEST_PREVIOUS = 3
private val REQUEST_NEXT = 4
private val CONTROL_TYPE_PREVIOUS = 3
private val CONTROL_TYPE_NEXT = 4
private var isInPipMode: Boolean = false
private var mReceiver: BroadcastReceiver? = null
lateinit var binding: PlayerActivityBinding
@ -254,8 +287,14 @@ class PlayerActivity :
mDetector.onTouchEvent(event)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
binding.pipBtn.isVisible = packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
}
binding.backArrowBtn.setOnClickListener { onBackPressed() }
binding.pipBtn.setOnClickListener { startPiP() }
// Lock and Unlock controls
binding.lockBtn.setOnClickListener { isLocked = true; toggleControls() }
binding.unlockBtn.setOnClickListener { isLocked = false; toggleControls() }
@ -268,26 +307,8 @@ class PlayerActivity :
binding.playbackSeekbar.setOnSeekBarChangeListener(seekBarChangeListener)
// player.playFile(currentVideoList!!.first().videoUrl!!.toString())
binding.nextBtn.setOnClickListener {
val wasPlayerPaused = player.paused
player.paused = true
showLoadingIndicator(true)
presenter.nextEpisode {
if (wasPlayerPaused == false) {
player.paused = false
}
}
}
binding.prevBtn.setOnClickListener {
val wasPlayerPaused = player.paused
player.paused = true
showLoadingIndicator(true)
presenter.previousEpisode {
if (wasPlayerPaused == false) {
player.paused = false
}
}
}
binding.nextBtn.setOnClickListener { goNextEpisode() }
binding.prevBtn.setOnClickListener { goPreviousEpisode() }
if (presenter?.needsInit() == true) {
val anime = intent.extras!!.getLong("anime", -1)
@ -302,6 +323,42 @@ class PlayerActivity :
playerIsDestroyed = false
}
private fun goNextEpisode() {
val wasPlayerPaused = player.paused
player.paused = true
showLoadingIndicator(true)
val nEpTxt = presenter.nextEpisode {
if (wasPlayerPaused == false) {
player.paused = false
}
}
when {
nEpTxt == "Invalid" -> return
nEpTxt == null -> { launchUI { toast(R.string.no_next_episode) }; showLoadingIndicator(false) }
isInPipMode -> launchUI { toast(nEpTxt) }
}
}
private fun goPreviousEpisode() {
val wasPlayerPaused = player.paused
player.paused = true
showLoadingIndicator(true)
val pEpTxt = presenter.previousEpisode {
if (wasPlayerPaused == false) {
player.paused = false
}
}
when {
pEpTxt == "Invalid" -> return
pEpTxt == null -> { launchUI { toast(R.string.no_previous_episode) }; showLoadingIndicator(false) }
isInPipMode -> launchUI { toast(pEpTxt) }
}
}
fun toggleControls() {
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
if (isLocked) {
@ -312,15 +369,38 @@ class PlayerActivity :
// Toggle unlock button
binding.unlockBtn.isVisible = !binding.unlockBtn.isVisible
} else {
var animation = R.anim.fade_in_medium
if (binding.background.isVisible) {
animation = R.anim.fade_out_medium
}
AnimationUtils.loadAnimation(this, animation).also { fadeAnimation ->
findViewById<RelativeLayout>(R.id.controls_top).startAnimation(fadeAnimation)
findViewById<RelativeLayout>(R.id.controls_bottom).startAnimation(fadeAnimation)
findViewById<View>(R.id.background).startAnimation(fadeAnimation)
binding.controlsTop.isVisible = !binding.controlsTop.isVisible
binding.controlsBottom.isVisible = !binding.controlsBottom.isVisible
binding.background.isVisible = !binding.background.isVisible
}
// Toggle controls
binding.controlsTop.isVisible = !binding.controlsTop.isVisible
binding.controlsBottom.isVisible = !binding.controlsBottom.isVisible
binding.background.isVisible = !binding.background.isVisible
// Hide unlock button
binding.unlockBtn.isVisible = false
}
}
private fun hideControls(hide: Boolean) {
if (hide) {
binding.controlsTop.isVisible = false
binding.controlsBottom.isVisible = false
binding.background.isVisible = false
} else {
binding.controlsTop.isVisible = true
binding.controlsBottom.isVisible = true
binding.background.isVisible = true
}
}
private fun showLoadingIndicator(visible: Boolean) {
if (binding.loadingIndicator.isVisible == visible) return
binding.playBtn.isVisible = !visible
@ -598,7 +678,7 @@ class PlayerActivity :
0 -> 1
1 -> 2
2 -> 0
else -> 1
else -> 0
}
preferences.setPlayerViewMode(playerViewMode)
setViewMode()
@ -695,15 +775,6 @@ class PlayerActivity :
val plCount = presenter.episodeList.size
val plPos = presenter.getCurrentEpisodeIndex()
if (plCount == 1) {
// use View.GONE so the buttons won't take up any space
binding.prevBtn.visibility = View.GONE
binding.nextBtn.visibility = View.GONE
return
}
binding.prevBtn.visibility = View.VISIBLE
binding.nextBtn.visibility = View.VISIBLE
val g = ContextCompat.getColor(this, R.color.tint_disabled)
val w = ContextCompat.getColor(this, R.color.tint_normal)
binding.prevBtn.imageTintList = ColorStateList.valueOf(if (plPos == 0) g else w)
@ -711,7 +782,7 @@ class PlayerActivity :
}
private fun updatePlaybackStatus(paused: Boolean) {
val r = if (paused) R.drawable.ic_play_arrow_100dp else R.drawable.ic_pause_100dp
val r = if (paused) R.drawable.ic_play_arrow_80dp else R.drawable.ic_pause_80dp
binding.playBtn.setImageResource(r)
if (paused) {
@ -729,6 +800,9 @@ class PlayerActivity :
player.destroy()
}
abandonAudioFocus()
if (packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
finishAndRemoveTask()
}
super.onDestroy()
}
@ -738,9 +812,139 @@ class PlayerActivity :
presenter.saveEpisodeProgress(player.timePos, player.duration)
player.paused = true
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isInPipMode) finishAndRemoveTask()
super.onStop()
}
override fun onResume() {
super.onResume()
setVisibilities()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) player.paused?.let { updatePictureInPictureActions(!it) }
}
@RequiresApi(Build.VERSION_CODES.O)
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration?) {
isInPipMode = isInPictureInPictureMode
hideControls(!isInPictureInPictureMode)
if (isInPictureInPictureMode) binding.loadingIndicator.indicatorSize = binding.loadingIndicator.indicatorSize / 2
else binding.loadingIndicator.indicatorSize = binding.loadingIndicator.indicatorSize * 2
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
if (isInPictureInPictureMode) {
// On Android TV it is required to hide controller in this PIP change callback
hideControls(true)
mReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent == null || ACTION_MEDIA_CONTROL != intent.action) {
return
}
when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) {
CONTROL_TYPE_PLAY -> {
player.paused = false
updatePictureInPictureActions(true)
}
CONTROL_TYPE_PAUSE -> {
player.paused = true
updatePictureInPictureActions(false)
}
CONTROL_TYPE_PREVIOUS -> {
goPreviousEpisode()
player.paused?.let { updatePictureInPictureActions(!it) }
}
CONTROL_TYPE_NEXT -> {
goNextEpisode()
player.paused?.let { updatePictureInPictureActions(!it) }
}
}
}
}
registerReceiver(mReceiver, IntentFilter(ACTION_MEDIA_CONTROL))
} else {
if (mReceiver != null) {
unregisterReceiver(mReceiver)
mReceiver = null
}
hideControls(false)
}
}
@Suppress("DEPRECATION")
private fun startPiP() {
if (packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
hideControls(true)
player.paused?.let { updatePictureInPictureActions(!it) }
?.let { this.enterPictureInPictureMode(it) }
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createRemoteAction(
iconResId: Int,
titleResId: Int,
requestCode: Int,
controlType: Int
): RemoteAction {
return RemoteAction(
Icon.createWithResource(this, iconResId),
getString(titleResId),
getString(titleResId),
PendingIntent.getBroadcast(
this,
requestCode,
Intent(ACTION_MEDIA_CONTROL)
.putExtra(EXTRA_CONTROL_TYPE, controlType),
PendingIntent.FLAG_IMMUTABLE
)
)
}
@RequiresApi(Build.VERSION_CODES.O)
fun updatePictureInPictureActions(
playing: Boolean
): PictureInPictureParams {
val mPictureInPictureParams = PictureInPictureParams.Builder()
// Set action items for the picture-in-picture mode. These are the only custom controls
// available during the picture-in-picture mode.
.setActions(
arrayListOf(
createRemoteAction(
R.drawable.ic_skip_previous_24dp,
R.string.action_previous_episode,
CONTROL_TYPE_PREVIOUS,
REQUEST_PREVIOUS
),
if (playing) {
createRemoteAction(
R.drawable.ic_pause_24dp,
R.string.action_pause,
CONTROL_TYPE_PAUSE,
REQUEST_PAUSE
)
} else {
createRemoteAction(
R.drawable.ic_play_arrow_24dp,
R.string.action_play,
CONTROL_TYPE_PLAY,
REQUEST_PLAY
)
},
createRemoteAction(
R.drawable.ic_skip_next_24dp,
R.string.action_next_episode,
CONTROL_TYPE_NEXT,
REQUEST_NEXT
)
)
)
.setAspectRatio(player.videoAspect?.times(10000)?.let { Rational(it.toInt(), 10000) })
.build()
setPictureInPictureParams(mPictureInPictureParams)
return mPictureInPictureParams
}
/**
* Called from the presenter if the initial load couldn't load the videos of the episode. In
* this case the activity is closed and a toast is shown to the user.
@ -811,7 +1015,6 @@ class PlayerActivity :
}
private fun fileLoaded() {
launchUI { showLoadingIndicator(false) }
clearTracks()
player.loadTracks()
subTracks += player.tracks.getValue("sub")
@ -864,6 +1067,8 @@ class PlayerActivity :
selectedAudio = audioTracks.indexOfFirst { it.url == mpvAudio.mpvId.toString() }
}
}
launchUI { showLoadingIndicator(false) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) player.paused?.let { updatePictureInPictureActions(!it) }
}
// mpv events

View file

@ -185,12 +185,12 @@ class PlayerPresenter(
return source is AnimeHttpSource && !EpisodeLoader.isDownloaded(currentEpisode, anime)
}
fun nextEpisode(callback: () -> Unit) {
val anime = anime ?: return
fun nextEpisode(callback: () -> Unit): String? {
val anime = anime ?: return "Invalid"
val source = sourceManager.getOrStub(anime.source)
val index = getCurrentEpisodeIndex()
if (index == episodeList.lastIndex) return
if (index == episodeList.lastIndex) return null
currentEpisode = episodeList[index + 1]
launchIO {
try {
@ -208,14 +208,15 @@ class PlayerPresenter(
logcat(LogPriority.ERROR, e) { e.message ?: "error getting links" }
}
}
return anime.title + " - " + episodeList[index + 1].name
}
fun previousEpisode(callback: () -> Unit) {
val anime = anime ?: return
fun previousEpisode(callback: () -> Unit): String? {
val anime = anime ?: return "Invalid"
val source = sourceManager.getOrStub(anime.source)
val index = getCurrentEpisodeIndex()
if (index == 0) return
if (index == 0) return null
currentEpisode = episodeList[index - 1]
launchIO {
try {
@ -233,6 +234,7 @@ class PlayerPresenter(
logcat(LogPriority.ERROR, e) { e.message ?: "error getting links" }
}
}
return anime.title + " - " + episodeList[index - 1].name
}
fun saveEpisodeHistory() {

View file

@ -6,7 +6,6 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.preference.defaultValue
import eu.kanade.tachiyomi.util.preference.entriesRes
import eu.kanade.tachiyomi.util.preference.listPreference
import eu.kanade.tachiyomi.util.preference.summaryRes
import eu.kanade.tachiyomi.util.preference.switchPreference
import eu.kanade.tachiyomi.util.preference.titleRes
import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys
@ -105,12 +104,5 @@ class SettingsPlayerController : SettingsController() {
summary = "%s"
}
switchPreference {
key = Keys.pipPlayerPreference
titleRes = R.string.pref_pip_player
summaryRes = R.string.pref_pip_player_summary
defaultValue = false
}
}
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="150"
android:fromAlpha="0.0"
android:toAlpha="1.0"
android:interpolator="@android:interpolator/linear"/>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="150"
android:fromAlpha="1.0"
android:toAlpha="0.0"
android:interpolator="@android:interpolator/linear"/>

View file

@ -1,5 +0,0 @@
<vector android:height="100dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="100dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="80dp"
android:width="80dp"
android:tint="?attr/colorControlNormal"
android:viewportHeight="24"
android:viewportWidth="24" >
<path
android:fillColor="@android:color/white"
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector android:height="100dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="100dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M8,5v14l11,-7z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="80dp"
android:width="80dp"
android:tint="?attr/colorControlNormal"
android:viewportHeight="24"
android:viewportWidth="24" >
<path
android:fillColor="@android:color/white"
android:pathData="M8,5v14l11,-7z"/>
</vector>

View file

@ -14,6 +14,7 @@
android:id="@+id/player"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<View
android:id="@+id/background"
android:layout_width="match_parent"
@ -178,8 +179,8 @@
<ImageButton
android:id="@+id/play_btn"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_centerInParent="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Play/Pause"
@ -187,7 +188,7 @@
android:textColor="@android:color/white"
android:visibility="gone"
app:tint="?attr/colorOnPrimarySurface"
tools:src="@drawable/ic_play_arrow_100dp"
tools:src="@drawable/ic_play_arrow_80dp"
tools:visibility="visible" />
@ -264,6 +265,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="right"
android:layout_marginRight="10dp"
android:orientation="horizontal">
<Button
@ -279,13 +281,21 @@
android:id="@+id/cycleViewModeBtn"
android:layout_width="48dp"
android:layout_height="match_parent"
android:layout_marginRight="10dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Cycle view modes"
android:onClick="cycleViewMode"
android:src="@drawable/ic_fullscreen_black_24dp"
app:tint="?attr/colorOnPrimarySurface" />
<ImageButton
android:id="@+id/pipBtn"
android:layout_width="48dp"
android:layout_height="match_parent"
android:src="@drawable/ic_picture_in_picture_24dp"
android:background="?android:attr/selectableItemBackground"
android:visibility="visible"
android:contentDescription="@string/action_player_pip" />
</LinearLayout>
</LinearLayout>

View file

@ -107,6 +107,7 @@
<string name="action_next_chapter">Next chapter</string>
<string name="action_next_episode">Next episode</string>
<string name="action_screen_fit">Screen fit mode</string>
<string name="action_player_pip">PiP mode</string>
<string name="action_retry">Retry</string>
<string name="action_remove">Remove</string>
<string name="action_start">Start</string>
@ -441,8 +442,6 @@
<string name="pref_skip_20">20s</string>
<string name="pref_skip_10">10s</string>
<string name="pref_skip_5">5s</string>
<string name="pref_pip_player">Enable picture in picture mode</string>
<string name="pref_pip_player_summary">Note: this is still an experimental feature!</string>
<string name="pref_always_use_external_player">Always use external player</string>
<string name="pref_player_fullscreen">Show content in display cutout</string>
<string name="pref_external_player_preference">External player preference</string>
@ -953,6 +952,7 @@
<string name="player_controls_skip_intro_text">+85 s</string>
<string name="no_next_episode">Next Episode not found!</string>
<string name="no_previous_episode">Previous Episode not found!</string>
<string name="anime_description_cover">Cover of Anime</string>
<string name="label_history">Manga</string>
<string name="label_animehistory">Anime</string>