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:
parent
1911e524a9
commit
d28e80d68e
12 changed files with 175 additions and 14 deletions
|
@ -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
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
14
core/src/main/res/drawable/ic_picture_in_picture.xml
Normal file
14
core/src/main/res/drawable/ic_picture_in_picture.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -99,7 +99,7 @@
|
|||
app:summary="@string/pref_player_intro_skipper_summary"
|
||||
app:title="@string/pref_player_intro_skipper"
|
||||
app:widgetLayout="@layout/preference_material3_switch" />
|
||||
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
app:defaultValue="true"
|
||||
app:key="pref_player_trick_play"
|
||||
|
@ -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>
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
@ -14,4 +15,4 @@
|
|||
<string name="player_controls_skip_forward">Skip forward</string>
|
||||
<string name="player_trickplay">Trickplay</string>
|
||||
<string name="player_controls_progress">Progress bar</string>
|
||||
</resources>
|
||||
</resources>
|
|
@ -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, "")!!
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue