feat: picture-in-picture (#277)

* add pip

* fixed OnResume() OnStop()
add picture in picture button
add pip settings

* fixed sourceRectHint
add aspectRatio

* fix import

* improve hide playerControls

* add onNewIntent()

* Home button/gesture settings

* add summary

* add GESTURE_EXCLUSION_AREA_SIDE

* remove if else in sourceRectHint
fix onStop() behavior

* fix behavior when using pip button, now go home

* test

* fix onStop()

* fix: mpv aspect ratio

* fix when in PiP mode and starting new playback

* refactor: pip implementation

Remove option to disable pip button, always show the button when pip is supported
Remove the option to completely disable pip
Format using ktlint

* fix when in pip mode and play a new video

* fix recent app behavior

* lint

* Some adjustments

* fix: Aspect ratio is too extreme

* fix: Activity recreation

* fix merge issues

* fix merge issues

* ktlintFormat

* Add Picture in Picture

* fix

* fix sourceRectHint, updateZoomMode before entering pip

* lint

* fix: disable pip when player is locked

* lint

* lint

* fix: sourceRectHint

* fix: replace media items in mpv

* fix: don't show skip intro button in pip

* chore: remove `android:resizeableActivity` from manifest since the default is already `true`

* refactor: remove option to force 16:9 aspect ratio

* refactor: update strings

---------

Co-authored-by: Jarne Demeulemeester <jarnedemeulemeester@gmail.com>
This commit is contained in:
Cd16d 2023-08-14 22:47:42 +02:00 committed by GitHub
parent 1911e524a9
commit d28e80d68e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 175 additions and 14 deletions

View file

@ -26,6 +26,9 @@
android:name=".PlayerActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:screenOrientation="sensorLandscape"
android:launchMode="singleTask"
android:supportsPictureInPicture="true"
android:autoRemoveFromRecents="true"
android:theme="@style/Theme.Findroid.Player" />
<activity

View file

@ -17,6 +17,7 @@ abstract class BasePlayerActivity : AppCompatActivity() {
abstract val viewModel: PlayerActivityViewModel
private lateinit var mediaSession: MediaSession
private var wasPip: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -32,21 +33,33 @@ abstract class BasePlayerActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
viewModel.player.playWhenReady = viewModel.playWhenReady
if (wasPip) {
wasPip = false
} else {
viewModel.player.playWhenReady = viewModel.playWhenReady
}
hideSystemUI()
}
override fun onPause() {
super.onPause()
viewModel.playWhenReady = viewModel.player.playWhenReady == true
viewModel.player.playWhenReady = false
if (isInPictureInPictureMode) {
wasPip = true
} else {
viewModel.playWhenReady = viewModel.player.playWhenReady == true
viewModel.player.playWhenReady = false
}
}
override fun onStop() {
super.onStop()
mediaSession.release()
if (wasPip) {
finish()
}
}
protected fun hideSystemUI() {

View file

@ -1,9 +1,15 @@
package dev.jdtech.jellyfin
import android.app.PictureInPictureParams
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Rect
import android.media.AudioManager
import android.os.Bundle
import android.util.Rational
import android.view.View
import android.view.WindowManager
import android.widget.Button
@ -18,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.C
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView
import androidx.media3.ui.TrackSelectionDialogBuilder
@ -47,12 +54,17 @@ class PlayerActivity : BasePlayerActivity() {
lateinit var binding: ActivityPlayerBinding
private var playerGestureHelper: PlayerGestureHelper? = null
override val viewModel: PlayerActivityViewModel by viewModels()
private val args: PlayerActivityArgs by navArgs()
private var previewScrubListener: PreviewScrubListener? = null
private val isPipSupported by lazy {
packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args: PlayerActivityArgs by navArgs()
binding = ActivityPlayerBinding.inflate(layoutInflater)
setContentView(binding.root)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@ -97,6 +109,7 @@ class PlayerActivity : BasePlayerActivity() {
val subtitleButton = binding.playerView.findViewById<ImageButton>(R.id.btn_subtitle)
val speedButton = binding.playerView.findViewById<ImageButton>(R.id.btn_speed)
val skipIntroButton = binding.playerView.findViewById<Button>(R.id.btn_skip_intro)
val pipButton = binding.playerView.findViewById<ImageButton>(R.id.btn_pip)
val lockButton = binding.playerView.findViewById<ImageButton>(R.id.btn_lockview)
val unlockButton = binding.playerView.findViewById<ImageButton>(R.id.btn_unlock)
@ -110,7 +123,7 @@ class PlayerActivity : BasePlayerActivity() {
videoNameTextView.text = currentItemTitle
// Skip Intro button
skipIntroButton.isVisible = currentIntro != null
skipIntroButton.isVisible = !isInPictureInPictureMode && currentIntro != null
skipIntroButton.setOnClickListener {
currentIntro?.let {
binding.playerView.player?.seekTo((it.introEnd * 1000).toLong())
@ -132,6 +145,8 @@ class PlayerActivity : BasePlayerActivity() {
subtitleButton.imageAlpha = 255
speedButton.isEnabled = true
speedButton.imageAlpha = 255
pipButton.isEnabled = true
pipButton.imageAlpha = 255
}
}
}
@ -157,6 +172,13 @@ class PlayerActivity : BasePlayerActivity() {
speedButton.isEnabled = false
speedButton.imageAlpha = 75
if (isPipSupported) {
pipButton.isEnabled = false
pipButton.imageAlpha = 75
} else {
pipButton.isVisible = false
}
audioButton.setOnClickListener {
when (viewModel.player) {
is MPVPlayer -> {
@ -249,6 +271,10 @@ class PlayerActivity : BasePlayerActivity() {
)
}
pipButton.setOnClickListener {
pictureInPicture()
}
if (appPreferences.playerTrickPlay) {
val imagePreview = binding.playerView.findViewById<ImageView>(R.id.image_preview)
val timeBar = binding.playerView.findViewById<DefaultTimeBar>(R.id.exo_progress)
@ -264,4 +290,78 @@ class PlayerActivity : BasePlayerActivity() {
viewModel.initializePlayer(args.items)
hideSystemUI()
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
setIntent(intent)
val args: PlayerActivityArgs by navArgs()
viewModel.initializePlayer(args.items)
}
override fun onUserLeaveHint() {
if (appPreferences.playerPipGesture && viewModel.player.isPlaying && !isControlsLocked) {
pictureInPicture()
}
}
private fun pipParams(): PictureInPictureParams {
val displayAspectRatio = Rational(binding.playerView.width, binding.playerView.height)
val aspectRatio = binding.playerView.player?.videoSize?.let {
Rational(
it.width.coerceAtMost((it.height * 2.39f).toInt()),
it.height.coerceAtMost((it.width * 2.39f).toInt()),
)
}
val sourceRectHint = if (displayAspectRatio < aspectRatio!!) {
val space = ((binding.playerView.height - (binding.playerView.width.toFloat() / aspectRatio.toFloat())) / 2).toInt()
Rect(
0,
space,
binding.playerView.width,
(binding.playerView.width.toFloat() / aspectRatio.toFloat()).toInt() + space,
)
} else {
val space = ((binding.playerView.width - (binding.playerView.height.toFloat() * aspectRatio.toFloat())) / 2).toInt()
Rect(
space,
0,
(binding.playerView.height.toFloat() * aspectRatio.toFloat()).toInt() + space,
binding.playerView.height,
)
}
return PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio)
.setSourceRectHint(sourceRectHint)
.build()
}
private fun pictureInPicture() {
if (!isPipSupported) {
return
}
binding.playerView.useController = false
binding.playerView.findViewById<Button>(R.id.btn_skip_intro).isVisible = false
if (binding.playerView.player is MPVPlayer) {
(binding.playerView.player as MPVPlayer).updateZoomMode(false)
} else {
binding.playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
}
enterPictureInPictureMode(pipParams())
}
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration,
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
if (!isInPictureInPictureMode) {
binding.playerView.useController = true
}
}
}

View file

@ -73,6 +73,21 @@
android:layout_height="0dp"
android:layout_weight="1" />
<ImageButton
android:id="@+id/btn_pip"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="end"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/picture_in_picture"
android:padding="16dp"
android:src="@drawable/ic_picture_in_picture"
app:tint="@android:color/white" />
<Space
android:layout_width="16dp"
android:layout_height="0dp" />
<ImageButton
android:id="@+id/btn_speed"
android:layout_width="wrap_content"

View file

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:pathData="m14,13.5h6c1.108,0 2,0.892 2,2v3c0,1.108 -0.892,2 -2,2h-6c-1.108,0 -2,-0.892 -2,-2v-3c0,-1.108 0.892,-2 2,-2zM3,13.5v2c0,1.05 0.95,2 2,2h3m13,-8v-3c0,-1.16 -0.84,-2 -2,-2h-7m-10,-1 l6,6m0,-5v5h-5"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
</vector>

View file

@ -150,6 +150,9 @@
<string name="add_server_address">Add server address</string>
<string name="add">Add</string>
<string name="quick_connect">Quick Connect</string>
<string name="picture_in_picture">Picture-in-picture</string>
<string name="picture_in_picture_gesture">Picture-in-picture home gesture</string>
<string name="picture_in_picture_gesture_summary">Use home button or gesture to enter picture-in-picture while the video is playing</string>
<string name="size">Size</string>
<string name="video">Video</string>
<string name="audio">Audio</string>

View file

@ -107,4 +107,11 @@
app:title="@string/pref_player_trick_play"
app:widgetLayout="@layout/preference_material3_switch" />
<PreferenceCategory app:title="@string/picture_in_picture">
<SwitchPreferenceCompat
app:key="pref_player_picture_in_picture_gesture"
app:title="@string/picture_in_picture_gesture"
app:summary="@string/picture_in_picture_gesture_summary" />
</PreferenceCategory>
</PreferenceScreen>

View file

@ -590,6 +590,7 @@ class MPVPlayer(
startWindowIndex: Int,
startPositionMs: Long,
) {
MPVLib.command(arrayOf("playlist-clear"))
internalMediaItems = mediaItems
currentIndex = startWindowIndex
initialSeekTo = startPositionMs / 1000
@ -706,12 +707,12 @@ class MPVPlayer(
/** Prepares the player. */
override fun prepare() {
internalMediaItems.forEach { mediaItem ->
internalMediaItems.forEachIndexed { index, mediaItem ->
MPVLib.command(
arrayOf(
"loadfile",
"${mediaItem.localConfiguration?.uri}",
"append",
if (index == 0) "replace" else "append",
),
)
}
@ -1201,7 +1202,10 @@ class MPVPlayer(
* @see androidx.media3.common.Player.Listener.onVideoSizeChanged
*/
override fun getVideoSize(): VideoSize {
return VideoSize.UNKNOWN
val width = MPVLib.getPropertyInt("width")
val height = MPVLib.getPropertyInt("height")
if (width == null || height == null) return VideoSize.UNKNOWN
return VideoSize(width, height)
}
override fun getSurfaceSize(): Size {

View file

@ -126,10 +126,6 @@ constructor(
fun initializePlayer(
items: Array<PlayerItem>,
) {
// Skip initialization when there are already items
if (this.items.isNotEmpty()) {
return
}
this.items = items
player.addListener(this)

View file

@ -4,6 +4,7 @@
<string name="select_playback_speed">Select playback speed</string>
<string name="select_a_version">"Select a version"</string>
<string name="external">External</string>
<string name="player_controls_picture_in_picture">Enter picture-in-picture</string>
<string name="player_controls_lock">Locks the player</string>
<string name="player_controls_skip_back">Skip back</string>
<string name="player_controls_play_pause">Play pause</string>

View file

@ -81,6 +81,8 @@ constructor(
val playerIntroSkipper get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_INTRO_SKIPPER, true)
val playerTrickPlay get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_TRICK_PLAY, true)
val playerPipGesture get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_PIP_GESTURE, false)
// Language
val preferredAudioLanguage get() = sharedPreferences.getString(Constants.PREF_AUDIO_LANGUAGE, "")!!
val preferredSubtitleLanguage get() = sharedPreferences.getString(Constants.PREF_SUBTITLE_LANGUAGE, "")!!

View file

@ -1,6 +1,7 @@
package dev.jdtech.jellyfin
object Constants {
// player
const val GESTURE_EXCLUSION_AREA_VERTICAL = 48
const val GESTURE_EXCLUSION_AREA_HORIZONTAL = 24
@ -27,6 +28,8 @@ object Constants {
const val PREF_PLAYER_MPV_GPU_API = "pref_player_mpv_gpu_api"
const val PREF_PLAYER_INTRO_SKIPPER = "pref_player_intro_skipper"
const val PREF_PLAYER_TRICK_PLAY = "pref_player_trick_play"
const val PREF_PLAYER_PIP_GESTURE = "pref_player_picture_in_picture_gesture"
const val PREF_PLAYER_PIP_ASPECT_RATIO = "pref_player_picture_in_picture_aspect_ratio"
const val PREF_AUDIO_LANGUAGE = "pref_audio_language"
const val PREF_SUBTITLE_LANGUAGE = "pref_subtitle_language"
const val PREF_IMAGE_CACHE = "pref_image_cache"