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:name=".PlayerActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:screenOrientation="sensorLandscape" android:screenOrientation="sensorLandscape"
android:launchMode="singleTask"
android:supportsPictureInPicture="true"
android:autoRemoveFromRecents="true"
android:theme="@style/Theme.Findroid.Player" /> android:theme="@style/Theme.Findroid.Player" />
<activity <activity

View file

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

View file

@ -1,9 +1,15 @@
package dev.jdtech.jellyfin package dev.jdtech.jellyfin
import android.app.PictureInPictureParams
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo 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.media.AudioManager
import android.os.Bundle import android.os.Bundle
import android.util.Rational
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.Button import android.widget.Button
@ -18,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.media3.ui.TrackSelectionDialogBuilder import androidx.media3.ui.TrackSelectionDialogBuilder
@ -47,12 +54,17 @@ class PlayerActivity : BasePlayerActivity() {
lateinit var binding: ActivityPlayerBinding lateinit var binding: ActivityPlayerBinding
private var playerGestureHelper: PlayerGestureHelper? = null private var playerGestureHelper: PlayerGestureHelper? = null
override val viewModel: PlayerActivityViewModel by viewModels() override val viewModel: PlayerActivityViewModel by viewModels()
private val args: PlayerActivityArgs by navArgs()
private var previewScrubListener: PreviewScrubListener? = null private var previewScrubListener: PreviewScrubListener? = null
private val isPipSupported by lazy {
packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val args: PlayerActivityArgs by navArgs()
binding = ActivityPlayerBinding.inflate(layoutInflater) binding = ActivityPlayerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 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 subtitleButton = binding.playerView.findViewById<ImageButton>(R.id.btn_subtitle)
val speedButton = binding.playerView.findViewById<ImageButton>(R.id.btn_speed) val speedButton = binding.playerView.findViewById<ImageButton>(R.id.btn_speed)
val skipIntroButton = binding.playerView.findViewById<Button>(R.id.btn_skip_intro) 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 lockButton = binding.playerView.findViewById<ImageButton>(R.id.btn_lockview)
val unlockButton = binding.playerView.findViewById<ImageButton>(R.id.btn_unlock) val unlockButton = binding.playerView.findViewById<ImageButton>(R.id.btn_unlock)
@ -110,7 +123,7 @@ class PlayerActivity : BasePlayerActivity() {
videoNameTextView.text = currentItemTitle videoNameTextView.text = currentItemTitle
// Skip Intro button // Skip Intro button
skipIntroButton.isVisible = currentIntro != null skipIntroButton.isVisible = !isInPictureInPictureMode && currentIntro != null
skipIntroButton.setOnClickListener { skipIntroButton.setOnClickListener {
currentIntro?.let { currentIntro?.let {
binding.playerView.player?.seekTo((it.introEnd * 1000).toLong()) binding.playerView.player?.seekTo((it.introEnd * 1000).toLong())
@ -132,6 +145,8 @@ class PlayerActivity : BasePlayerActivity() {
subtitleButton.imageAlpha = 255 subtitleButton.imageAlpha = 255
speedButton.isEnabled = true speedButton.isEnabled = true
speedButton.imageAlpha = 255 speedButton.imageAlpha = 255
pipButton.isEnabled = true
pipButton.imageAlpha = 255
} }
} }
} }
@ -157,6 +172,13 @@ class PlayerActivity : BasePlayerActivity() {
speedButton.isEnabled = false speedButton.isEnabled = false
speedButton.imageAlpha = 75 speedButton.imageAlpha = 75
if (isPipSupported) {
pipButton.isEnabled = false
pipButton.imageAlpha = 75
} else {
pipButton.isVisible = false
}
audioButton.setOnClickListener { audioButton.setOnClickListener {
when (viewModel.player) { when (viewModel.player) {
is MPVPlayer -> { is MPVPlayer -> {
@ -249,6 +271,10 @@ class PlayerActivity : BasePlayerActivity() {
) )
} }
pipButton.setOnClickListener {
pictureInPicture()
}
if (appPreferences.playerTrickPlay) { if (appPreferences.playerTrickPlay) {
val imagePreview = binding.playerView.findViewById<ImageView>(R.id.image_preview) val imagePreview = binding.playerView.findViewById<ImageView>(R.id.image_preview)
val timeBar = binding.playerView.findViewById<DefaultTimeBar>(R.id.exo_progress) val timeBar = binding.playerView.findViewById<DefaultTimeBar>(R.id.exo_progress)
@ -264,4 +290,78 @@ class PlayerActivity : BasePlayerActivity() {
viewModel.initializePlayer(args.items) viewModel.initializePlayer(args.items)
hideSystemUI() 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_height="0dp"
android:layout_weight="1" /> 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 <ImageButton
android:id="@+id/btn_speed" android:id="@+id/btn_speed"
android:layout_width="wrap_content" 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_server_address">Add server address</string>
<string name="add">Add</string> <string name="add">Add</string>
<string name="quick_connect">Quick Connect</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="size">Size</string>
<string name="video">Video</string> <string name="video">Video</string>
<string name="audio">Audio</string> <string name="audio">Audio</string>

View file

@ -107,4 +107,11 @@
app:title="@string/pref_player_trick_play" app:title="@string/pref_player_trick_play"
app:widgetLayout="@layout/preference_material3_switch" /> 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> </PreferenceScreen>

View file

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

View file

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

View file

@ -4,6 +4,7 @@
<string name="select_playback_speed">Select playback speed</string> <string name="select_playback_speed">Select playback speed</string>
<string name="select_a_version">"Select a version"</string> <string name="select_a_version">"Select a version"</string>
<string name="external">External</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_lock">Locks the player</string>
<string name="player_controls_skip_back">Skip back</string> <string name="player_controls_skip_back">Skip back</string>
<string name="player_controls_play_pause">Play pause</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 playerIntroSkipper get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_INTRO_SKIPPER, true)
val playerTrickPlay get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_TRICK_PLAY, true) val playerTrickPlay get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_TRICK_PLAY, true)
val playerPipGesture get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_PIP_GESTURE, false)
// Language // Language
val preferredAudioLanguage get() = sharedPreferences.getString(Constants.PREF_AUDIO_LANGUAGE, "")!! val preferredAudioLanguage get() = sharedPreferences.getString(Constants.PREF_AUDIO_LANGUAGE, "")!!
val preferredSubtitleLanguage get() = sharedPreferences.getString(Constants.PREF_SUBTITLE_LANGUAGE, "")!! val preferredSubtitleLanguage get() = sharedPreferences.getString(Constants.PREF_SUBTITLE_LANGUAGE, "")!!

View file

@ -1,6 +1,7 @@
package dev.jdtech.jellyfin package dev.jdtech.jellyfin
object Constants { object Constants {
// player // player
const val GESTURE_EXCLUSION_AREA_VERTICAL = 48 const val GESTURE_EXCLUSION_AREA_VERTICAL = 48
const val GESTURE_EXCLUSION_AREA_HORIZONTAL = 24 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_MPV_GPU_API = "pref_player_mpv_gpu_api"
const val PREF_PLAYER_INTRO_SKIPPER = "pref_player_intro_skipper" const val PREF_PLAYER_INTRO_SKIPPER = "pref_player_intro_skipper"
const val PREF_PLAYER_TRICK_PLAY = "pref_player_trick_play" 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_AUDIO_LANGUAGE = "pref_audio_language"
const val PREF_SUBTITLE_LANGUAGE = "pref_subtitle_language" const val PREF_SUBTITLE_LANGUAGE = "pref_subtitle_language"
const val PREF_IMAGE_CACHE = "pref_image_cache" const val PREF_IMAGE_CACHE = "pref_image_cache"