From 471c1e8b1da9ce46c6fd860e71fb05b6e557a5f9 Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Mon, 14 Jun 2021 18:37:22 +0200 Subject: [PATCH 1/4] Remove unnecessary variable --- .../main/java/org/jellyfin/mobile/player/PlayerFragment.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt index f452c116d..f4fb76376 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt @@ -93,8 +93,6 @@ class PlayerFragment : Fragment() { private var isZoomEnabled = false - private val swipeGesturesEnabled by appPreferences::exoPlayerAllowSwipeGestures - /** * Tracks a value during a swipe gesture (between multiple onScroll calls). * When the gesture starts it's reset to an initial value and gets increased or decreased @@ -381,7 +379,7 @@ class PlayerFragment : Fragment() { } override fun onScroll(firstEvent: MotionEvent, currentEvent: MotionEvent, distanceX: Float, distanceY: Float): Boolean { - if (!swipeGesturesEnabled) + if (!appPreferences.exoPlayerAllowSwipeGestures) return false // Check whether swipe was started in excluded region From 7b9f2c6d499b96efdc65d260c95ba1bf3cc38ee0 Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Mon, 14 Jun 2021 19:27:36 +0200 Subject: [PATCH 2/4] Move player screen lock handling to extra class --- .../jellyfin/mobile/player/PlaybackMenus.kt | 2 +- .../jellyfin/mobile/player/PlayerFragment.kt | 53 ++------------- .../mobile/player/PlayerLockScreenHelper.kt | 67 +++++++++++++++++++ 3 files changed, 74 insertions(+), 48 deletions(-) create mode 100644 app/src/main/java/org/jellyfin/mobile/player/PlayerLockScreenHelper.kt diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt b/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt index 644b29191..f05a403fc 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlaybackMenus.kt @@ -47,7 +47,7 @@ class PlaybackMenus( fragment.onSkipToNext() } lockScreenButton.setOnClickListener { - fragment.lockScreen() + fragment.playerLockScreenHelper.lockScreen() } audioStreamsButton.setOnClickListener { fragment.suppressControllerAutoHide(true) diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt index f4fb76376..d7b8f33b2 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt @@ -59,16 +59,13 @@ import org.jellyfin.mobile.utils.SmartOrientationListener import org.jellyfin.mobile.utils.dip import org.jellyfin.mobile.utils.disableFullscreen import org.jellyfin.mobile.utils.enableFullscreen -import org.jellyfin.mobile.utils.isAutoRotateOn import org.jellyfin.mobile.utils.isFullscreen -import org.jellyfin.mobile.utils.lockOrientation import org.jellyfin.mobile.utils.toast import org.jellyfin.sdk.model.api.MediaStream import org.koin.android.ext.android.inject import kotlin.math.abs class PlayerFragment : Fragment() { - private val appPreferences: AppPreferences by inject() private val viewModel: PlayerViewModel by viewModels() private var _playerBinding: FragmentPlayerBinding? = null @@ -76,7 +73,6 @@ class PlayerFragment : Fragment() { private val playerView: PlayerView get() = playerBinding.playerView private val playerOverlay: View get() = playerBinding.playerOverlay private val loadingIndicator: View get() = playerBinding.loadingIndicator - private val unlockScreenButton: ImageButton get() = playerBinding.unlockScreenButton private val gestureIndicatorOverlayLayout: LinearLayout get() = playerBinding.gestureOverlayLayout private val gestureIndicatorOverlayImage: ImageView get() = playerBinding.gestureOverlayImage private val gestureIndicatorOverlayProgress: ProgressBar get() = playerBinding.gestureOverlayProgress @@ -88,6 +84,8 @@ class PlayerFragment : Fragment() { private var playbackMenus: PlaybackMenus? = null private val audioManager: AudioManager by lazy { requireContext().getSystemService()!! } + lateinit var playerLockScreenHelper: PlayerLockScreenHelper + private val currentVideoStream: MediaStream? get() = viewModel.mediaSourceOrNull?.selectedVideoStream @@ -110,15 +108,6 @@ class PlayerFragment : Fragment() { */ private val orientationListener: OrientationEventListener by lazy { SmartOrientationListener(requireActivity()) } - /** - * Runnable that hides the unlock screen button, used by [peekUnlockButton] - */ - private val hideUnlockButtonAction = Runnable { - if (_playerBinding != null) { - unlockScreenButton.isVisible = false - } - } - /** * Runnable that hides [playerView] controller */ @@ -220,6 +209,8 @@ class PlayerFragment : Fragment() { // Set controller timeout suppressControllerAutoHide(false) + playerLockScreenHelper = PlayerLockScreenHelper(this, playerBinding, orientationListener) + // Setup gesture handling setupGestureDetector() @@ -241,12 +232,6 @@ class PlayerFragment : Fragment() { } } } - - // Handle unlock action - unlockScreenButton.setOnClickListener { - unlockScreenButton.isVisible = false - unlockScreen() - } } override fun onStart() { @@ -263,26 +248,6 @@ class PlayerFragment : Fragment() { } } - fun lockScreen() { - playerView.useController = false - orientationListener.disable() - requireActivity().lockOrientation() - peekUnlockButton() - } - - private fun unlockScreen() { - if (requireActivity().isAutoRotateOn()) { - requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - } - orientationListener.enable() - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || !requireActivity().isInPictureInPictureMode) { - playerView.useController = true - playerView.apply { - if (!isControllerVisible) showController() - } - } - } - /** * Handle current orientation and update fullscreen state and switcher icon */ @@ -324,18 +289,12 @@ class PlayerFragment : Fragment() { playerView.controllerShowTimeoutMs = if (suppress) -1 else DEFAULT_CONTROLS_TIMEOUT_MS } - private fun peekUnlockButton() { - playerView.removeCallbacks(hideUnlockButtonAction) - unlockScreenButton.isVisible = true - playerView.postDelayed(hideUnlockButtonAction, DEFAULT_CONTROLS_TIMEOUT_MS.toLong()) - } - @SuppressLint("ClickableViewAccessibility") private fun setupGestureDetector() { // Handles taps when controls are locked val unlockDetector = GestureDetector(requireContext(), object : GestureDetector.SimpleOnGestureListener() { override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { - peekUnlockButton() + playerLockScreenHelper.peekUnlockButton() return true } }) @@ -548,7 +507,7 @@ class PlayerFragment : Fragment() { playerView.useController = !isInPictureInPictureMode if (isInPictureInPictureMode) { playbackMenus?.dismissPlaybackInfo() - hideUnlockButtonAction.run() + playerLockScreenHelper.hideUnlockButton() } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerLockScreenHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerLockScreenHelper.kt new file mode 100644 index 000000000..d7d820339 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerLockScreenHelper.kt @@ -0,0 +1,67 @@ +package org.jellyfin.mobile.player + +import android.content.pm.ActivityInfo +import android.os.Build +import android.view.OrientationEventListener +import android.widget.ImageButton +import androidx.core.view.isVisible +import com.google.android.exoplayer2.ui.PlayerView +import org.jellyfin.mobile.databinding.FragmentPlayerBinding +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.isAutoRotateOn +import org.jellyfin.mobile.utils.lockOrientation + +class PlayerLockScreenHelper( + private val playerFragment: PlayerFragment, + private val playerBinding: FragmentPlayerBinding, + private val orientationListener: OrientationEventListener, +) { + private val playerView: PlayerView by playerBinding::playerView + private val unlockScreenButton: ImageButton by playerBinding::unlockScreenButton + + /** + * Runnable that hides the unlock screen button, used by [peekUnlockButton] + */ + private val hideUnlockButtonAction = Runnable { + hideUnlockButton() + } + + init { + // Handle unlock action + unlockScreenButton.setOnClickListener { + unlockScreen() + } + } + + fun lockScreen() { + playerView.useController = false + orientationListener.disable() + playerFragment.requireActivity().lockOrientation() + peekUnlockButton() + } + + private fun unlockScreen() { + hideUnlockButton() + val activity = playerFragment.requireActivity() + if (activity.isAutoRotateOn()) { + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + orientationListener.enable() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || !activity.isInPictureInPictureMode) { + playerView.useController = true + playerView.apply { + if (!isControllerVisible) showController() + } + } + } + + fun peekUnlockButton() { + playerView.removeCallbacks(hideUnlockButtonAction) + unlockScreenButton.isVisible = true + playerView.postDelayed(hideUnlockButtonAction, Constants.DEFAULT_CONTROLS_TIMEOUT_MS.toLong()) + } + + fun hideUnlockButton() { + unlockScreenButton.isVisible = false + } +} From ed74ec27e47d0dffdf882d37652fd779ef49327f Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Mon, 14 Jun 2021 19:55:20 +0200 Subject: [PATCH 3/4] Move player gesture handling to extra class --- .../jellyfin/mobile/player/PlayerFragment.kt | 221 +---------------- .../mobile/player/PlayerGestureHelper.kt | 227 ++++++++++++++++++ .../org/jellyfin/mobile/utils/UIExtensions.kt | 9 + 3 files changed, 244 insertions(+), 213 deletions(-) create mode 100644 app/src/main/java/org/jellyfin/mobile/player/PlayerGestureHelper.kt diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt index d7b8f33b2..19134e426 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt @@ -1,47 +1,31 @@ package org.jellyfin.mobile.player -import android.annotation.SuppressLint import android.app.Activity import android.app.PictureInPictureParams import android.content.pm.ActivityInfo import android.content.res.Configuration -import android.media.AudioManager import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper -import android.provider.Settings.System -import android.view.GestureDetector import android.view.LayoutInflater -import android.view.MotionEvent import android.view.OrientationEventListener -import android.view.ScaleGestureDetector import android.view.View import android.view.ViewGroup -import android.view.Window -import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE -import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_OFF import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON import android.widget.ImageButton -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.ProgressBar import android.widget.TextView import androidx.annotation.RequiresApi -import androidx.core.content.getSystemService import androidx.core.view.ViewCompat import androidx.core.view.isVisible -import androidx.core.view.postDelayed import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout import com.google.android.exoplayer2.ui.PlayerView import kotlinx.coroutines.launch -import org.jellyfin.mobile.AppPreferences import org.jellyfin.mobile.R import org.jellyfin.mobile.api.aspectRational import org.jellyfin.mobile.api.isLandscape @@ -49,55 +33,37 @@ import org.jellyfin.mobile.bridge.PlayOptions import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding import org.jellyfin.mobile.databinding.FragmentPlayerBinding import org.jellyfin.mobile.utils.Constants -import org.jellyfin.mobile.utils.Constants.DEFAULT_CENTER_OVERLAY_TIMEOUT_MS import org.jellyfin.mobile.utils.Constants.DEFAULT_CONTROLS_TIMEOUT_MS -import org.jellyfin.mobile.utils.Constants.DEFAULT_SEEK_TIME_MS -import org.jellyfin.mobile.utils.Constants.GESTURE_EXCLUSION_AREA_TOP import org.jellyfin.mobile.utils.Constants.PIP_MAX_RATIONAL import org.jellyfin.mobile.utils.Constants.PIP_MIN_RATIONAL import org.jellyfin.mobile.utils.SmartOrientationListener -import org.jellyfin.mobile.utils.dip +import org.jellyfin.mobile.utils.brightness import org.jellyfin.mobile.utils.disableFullscreen import org.jellyfin.mobile.utils.enableFullscreen import org.jellyfin.mobile.utils.isFullscreen import org.jellyfin.mobile.utils.toast import org.jellyfin.sdk.model.api.MediaStream -import org.koin.android.ext.android.inject -import kotlin.math.abs class PlayerFragment : Fragment() { - private val appPreferences: AppPreferences by inject() private val viewModel: PlayerViewModel by viewModels() private var _playerBinding: FragmentPlayerBinding? = null private val playerBinding: FragmentPlayerBinding get() = _playerBinding!! private val playerView: PlayerView get() = playerBinding.playerView private val playerOverlay: View get() = playerBinding.playerOverlay private val loadingIndicator: View get() = playerBinding.loadingIndicator - private val gestureIndicatorOverlayLayout: LinearLayout get() = playerBinding.gestureOverlayLayout - private val gestureIndicatorOverlayImage: ImageView get() = playerBinding.gestureOverlayImage - private val gestureIndicatorOverlayProgress: ProgressBar get() = playerBinding.gestureOverlayProgress private var _playerControlsBinding: ExoPlayerControlViewBinding? = null private val playerControlsBinding: ExoPlayerControlViewBinding get() = _playerControlsBinding!! private val playerControlsView: View get() = playerControlsBinding.root private val titleTextView: TextView get() = playerControlsBinding.trackTitle private val fullscreenSwitcher: ImageButton get() = playerControlsBinding.fullscreenSwitcher private var playbackMenus: PlaybackMenus? = null - private val audioManager: AudioManager by lazy { requireContext().getSystemService()!! } lateinit var playerLockScreenHelper: PlayerLockScreenHelper + lateinit var playerGestureHelper: PlayerGestureHelper private val currentVideoStream: MediaStream? get() = viewModel.mediaSourceOrNull?.selectedVideoStream - private var isZoomEnabled = false - - /** - * Tracks a value during a swipe gesture (between multiple onScroll calls). - * When the gesture starts it's reset to an initial value and gets increased or decreased - * (depending on the direction) as the gesture progresses. - */ - private var swipeGestureValueTracker = -1f - /** * Listener that watches the current device orientation. * It makes sure that the orientation sensor can still be used (if enabled) @@ -108,22 +74,6 @@ class PlayerFragment : Fragment() { */ private val orientationListener: OrientationEventListener by lazy { SmartOrientationListener(requireActivity()) } - /** - * Runnable that hides [playerView] controller - */ - private val hidePlayerViewControllerAction = Runnable { - if (_playerBinding != null) { - playerView.hideController() - } - } - - /** - * Runnable that hides [gestureIndicatorOverlayLayout] - */ - private val hideGestureIndicatorOverlayAction = Runnable { - gestureIndicatorOverlayLayout.isVisible = false - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -212,7 +162,7 @@ class PlayerFragment : Fragment() { playerLockScreenHelper = PlayerLockScreenHelper(this, playerBinding, orientationListener) // Setup gesture handling - setupGestureDetector() + playerGestureHelper = PlayerGestureHelper(this, playerBinding, playerLockScreenHelper) // Handle fullscreen switcher fullscreenSwitcher.setOnClickListener { @@ -278,10 +228,6 @@ class PlayerFragment : Fragment() { fullscreenSwitcher.setImageResource(fullscreenDrawable) } - private fun updateZoomMode(enabled: Boolean) { - playerView.resizeMode = if (enabled) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT - } - /** * If true, the player controls will show indefinitely */ @@ -289,156 +235,13 @@ class PlayerFragment : Fragment() { playerView.controllerShowTimeoutMs = if (suppress) -1 else DEFAULT_CONTROLS_TIMEOUT_MS } - @SuppressLint("ClickableViewAccessibility") - private fun setupGestureDetector() { - // Handles taps when controls are locked - val unlockDetector = GestureDetector(requireContext(), object : GestureDetector.SimpleOnGestureListener() { - override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { - playerLockScreenHelper.peekUnlockButton() - return true - } - }) - // Handles double tap to seek and brightness/volume gestures - val gestureDetector = GestureDetector(requireContext(), object : GestureDetector.SimpleOnGestureListener() { - override fun onDoubleTap(e: MotionEvent): Boolean { - val viewWidth = playerView.measuredWidth - val viewHeight = playerView.measuredHeight - val viewCenterX = viewWidth / 2 - val viewCenterY = viewHeight / 2 - val fastForward = e.x.toInt() > viewCenterX - - // Show ripple effect - playerView.foreground?.apply { - val left = if (fastForward) viewCenterX else 0 - val right = if (fastForward) viewWidth else viewCenterX - setBounds(left, viewCenterY - viewCenterX / 2, right, viewCenterY + viewCenterX / 2) - setHotspot(e.x, e.y) - state = intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed) - playerView.postDelayed(Constants.DOUBLE_TAP_RIPPLE_DURATION_MS) { - state = IntArray(0) - } - } - - // Fast-forward/rewind - viewModel.seekToOffset(if (fastForward) DEFAULT_SEEK_TIME_MS else DEFAULT_SEEK_TIME_MS.unaryMinus()) - - // Cancel previous runnable to not hide controller while seeking - playerView.removeCallbacks(hidePlayerViewControllerAction) - - // Ensure controller gets hidden after seeking - playerView.postDelayed(hidePlayerViewControllerAction, DEFAULT_CONTROLS_TIMEOUT_MS.toLong()) - return true - } - - override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { - playerView.apply { - if (!isControllerVisible) showController() else hideController() - } - return true - } - - override fun onScroll(firstEvent: MotionEvent, currentEvent: MotionEvent, distanceX: Float, distanceY: Float): Boolean { - if (!appPreferences.exoPlayerAllowSwipeGestures) - return false - - // Check whether swipe was started in excluded region - if (firstEvent.y < resources.dip(GESTURE_EXCLUSION_AREA_TOP)) - return false - - // Check whether swipe was oriented vertically - if (abs(distanceY / distanceX) < 2) - return false - - val viewCenterX = playerView.measuredWidth / 2 - - // Distance to swipe to go from min to max - val distanceFull = playerView.measuredHeight * Constants.FULL_SWIPE_RANGE_SCREEN_RATIO - val ratioChange = distanceY / distanceFull - - if (firstEvent.x.toInt() > viewCenterX) { - // Swiping on the right, change volume - - val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - if (swipeGestureValueTracker == -1f) swipeGestureValueTracker = currentVolume.toFloat() - - val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - val change = ratioChange * maxVolume - swipeGestureValueTracker += change - - val toSet = swipeGestureValueTracker.toInt().coerceIn(0, maxVolume) - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, toSet, 0) - - gestureIndicatorOverlayImage.setImageResource(R.drawable.ic_volume_white_24dp) - gestureIndicatorOverlayProgress.max = maxVolume - gestureIndicatorOverlayProgress.progress = toSet - } else { - // Swiping on the left, change brightness - - val window = requireActivity().window - val brightnessRange = BRIGHTNESS_OVERRIDE_OFF..BRIGHTNESS_OVERRIDE_FULL - - // Initialize on first swipe - if (swipeGestureValueTracker == -1f) { - val brightness = window.brightness - swipeGestureValueTracker = when (brightness) { - in brightnessRange -> brightness - else -> System.getFloat(requireContext().contentResolver, System.SCREEN_BRIGHTNESS) / Constants.SCREEN_BRIGHTNESS_MAX - } - } - - swipeGestureValueTracker = (swipeGestureValueTracker + ratioChange).coerceIn(brightnessRange) - window.brightness = swipeGestureValueTracker - - gestureIndicatorOverlayImage.setImageResource(R.drawable.ic_brightness_white_24dp) - gestureIndicatorOverlayProgress.max = Constants.PERCENT_MAX - gestureIndicatorOverlayProgress.progress = (swipeGestureValueTracker * Constants.PERCENT_MAX).toInt() - } - - gestureIndicatorOverlayLayout.isVisible = true - return true - } - }) - // Handles scale/zoom gesture - val zoomGestureDetector = ScaleGestureDetector(requireContext(), object : ScaleGestureDetector.OnScaleGestureListener { - override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = isLandscape() - - override fun onScale(detector: ScaleGestureDetector): Boolean { - val scaleFactor = detector.scaleFactor - if (abs(scaleFactor - Constants.ZOOM_SCALE_BASE) > Constants.ZOOM_SCALE_THRESHOLD) { - isZoomEnabled = scaleFactor > 1 - updateZoomMode(isZoomEnabled) - } - return true - } - - override fun onScaleEnd(detector: ScaleGestureDetector) = Unit - }) - zoomGestureDetector.isQuickScaleEnabled = false - - playerView.setOnTouchListener { _, event -> - if (playerView.useController) { - when (event.pointerCount) { - 1 -> gestureDetector.onTouchEvent(event) - 2 -> zoomGestureDetector.onTouchEvent(event) - } - } else unlockDetector.onTouchEvent(event) - if (event.action == MotionEvent.ACTION_UP) { - // Hide gesture indicator after timeout, if shown - gestureIndicatorOverlayLayout.apply { - if (isVisible) { - removeCallbacks(hideGestureIndicatorOverlayAction) - postDelayed(hideGestureIndicatorOverlayAction, DEFAULT_CENTER_OVERLAY_TIMEOUT_MS.toLong()) - } - } - swipeGestureValueTracker = -1f - } - true - } - } - fun isLandscape(configuration: Configuration = resources.configuration) = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + fun onSeek(offsetMs: Long) { + viewModel.seekToOffset(offsetMs) + } + /** * @return true if the audio track was changed */ @@ -515,7 +318,7 @@ class PlayerFragment : Fragment() { super.onConfigurationChanged(newConfig) Handler(Looper.getMainLooper()).post { updateFullscreenState(newConfig) - updateZoomMode(isLandscape(newConfig) && isZoomEnabled) + playerGestureHelper.handleConfiguration(newConfig) } } @@ -545,12 +348,4 @@ class PlayerFragment : Fragment() { window.brightness = BRIGHTNESS_OVERRIDE_NONE } } - - private inline var Window.brightness: Float - get() = attributes.screenBrightness - set(value) { - attributes = attributes.apply { - screenBrightness = value - } - } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerGestureHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerGestureHelper.kt new file mode 100644 index 000000000..96277ec54 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerGestureHelper.kt @@ -0,0 +1,227 @@ +package org.jellyfin.mobile.player + +import android.content.res.Configuration +import android.media.AudioManager +import android.provider.Settings +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.WindowManager +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ProgressBar +import androidx.core.content.getSystemService +import androidx.core.view.isVisible +import androidx.core.view.postDelayed +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout +import com.google.android.exoplayer2.ui.PlayerView +import org.jellyfin.mobile.AppPreferences +import org.jellyfin.mobile.databinding.FragmentPlayerBinding +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.brightness +import org.jellyfin.mobile.utils.dip +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.math.abs + +class PlayerGestureHelper( + private val fragment: PlayerFragment, + private val playerBinding: FragmentPlayerBinding, + private val playerLockScreenHelper: PlayerLockScreenHelper, +) : KoinComponent { + private val appPreferences: AppPreferences by inject() + private val audioManager: AudioManager by lazy { fragment.requireActivity().getSystemService()!! } + private val playerView: PlayerView by playerBinding::playerView + private val gestureIndicatorOverlayLayout: LinearLayout by playerBinding::gestureOverlayLayout + private val gestureIndicatorOverlayImage: ImageView by playerBinding::gestureOverlayImage + private val gestureIndicatorOverlayProgress: ProgressBar by playerBinding::gestureOverlayProgress + + /** + * Tracks whether video content should fill the screen, cutting off unwanted content on the sides. + * Useful on wide-screen phones to remove black bars from some movies. + */ + private var isZoomEnabled = false + + /** + * Tracks a value during a swipe gesture (between multiple onScroll calls). + * When the gesture starts it's reset to an initial value and gets increased or decreased + * (depending on the direction) as the gesture progresses. + */ + private var swipeGestureValueTracker = -1f + + /** + * Runnable that hides [playerView] controller + */ + private val hidePlayerViewControllerAction = Runnable { + playerView.hideController() + } + + /** + * Runnable that hides [gestureIndicatorOverlayLayout] + */ + private val hideGestureIndicatorOverlayAction = Runnable { + gestureIndicatorOverlayLayout.isVisible = false + } + + /** + * Handles taps when controls are locked + */ + private val unlockDetector = GestureDetector(playerView.context, object : GestureDetector.SimpleOnGestureListener() { + override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { + playerLockScreenHelper.peekUnlockButton() + return true + } + }) + + /** + * Handles double tap to seek and brightness/volume gestures + */ + private val gestureDetector = GestureDetector(playerView.context, object : GestureDetector.SimpleOnGestureListener() { + override fun onDoubleTap(e: MotionEvent): Boolean { + val viewWidth = playerView.measuredWidth + val viewHeight = playerView.measuredHeight + val viewCenterX = viewWidth / 2 + val viewCenterY = viewHeight / 2 + val fastForward = e.x.toInt() > viewCenterX + + // Show ripple effect + playerView.foreground?.apply { + val left = if (fastForward) viewCenterX else 0 + val right = if (fastForward) viewWidth else viewCenterX + setBounds(left, viewCenterY - viewCenterX / 2, right, viewCenterY + viewCenterX / 2) + setHotspot(e.x, e.y) + state = intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed) + playerView.postDelayed(Constants.DOUBLE_TAP_RIPPLE_DURATION_MS) { + state = IntArray(0) + } + } + + // Fast-forward/rewind + fragment.onSeek(if (fastForward) Constants.DEFAULT_SEEK_TIME_MS else Constants.DEFAULT_SEEK_TIME_MS.unaryMinus()) + + // Cancel previous runnable to not hide controller while seeking + playerView.removeCallbacks(hidePlayerViewControllerAction) + + // Ensure controller gets hidden after seeking + playerView.postDelayed(hidePlayerViewControllerAction, Constants.DEFAULT_CONTROLS_TIMEOUT_MS.toLong()) + return true + } + + override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { + playerView.apply { + if (!isControllerVisible) showController() else hideController() + } + return true + } + + override fun onScroll(firstEvent: MotionEvent, currentEvent: MotionEvent, distanceX: Float, distanceY: Float): Boolean { + if (!appPreferences.exoPlayerAllowSwipeGestures) + return false + + // Check whether swipe was started in excluded region + if (firstEvent.y < playerView.resources.dip(Constants.GESTURE_EXCLUSION_AREA_TOP)) + return false + + // Check whether swipe was oriented vertically + if (abs(distanceY / distanceX) < 2) + return false + + val viewCenterX = playerView.measuredWidth / 2 + + // Distance to swipe to go from min to max + val distanceFull = playerView.measuredHeight * Constants.FULL_SWIPE_RANGE_SCREEN_RATIO + val ratioChange = distanceY / distanceFull + + if (firstEvent.x.toInt() > viewCenterX) { + // Swiping on the right, change volume + + val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + if (swipeGestureValueTracker == -1f) swipeGestureValueTracker = currentVolume.toFloat() + + val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val change = ratioChange * maxVolume + swipeGestureValueTracker += change + + val toSet = swipeGestureValueTracker.toInt().coerceIn(0, maxVolume) + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, toSet, 0) + + gestureIndicatorOverlayImage.setImageResource(org.jellyfin.mobile.R.drawable.ic_volume_white_24dp) + gestureIndicatorOverlayProgress.max = maxVolume + gestureIndicatorOverlayProgress.progress = toSet + } else { + // Swiping on the left, change brightness + + val window = fragment.requireActivity().window + val brightnessRange = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_OFF..WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL + + // Initialize on first swipe + if (swipeGestureValueTracker == -1f) { + val brightness = window.brightness + swipeGestureValueTracker = when (brightness) { + in brightnessRange -> brightness + else -> Settings.System.getFloat(fragment.requireActivity().contentResolver, Settings.System.SCREEN_BRIGHTNESS) / Constants.SCREEN_BRIGHTNESS_MAX + } + } + + swipeGestureValueTracker = (swipeGestureValueTracker + ratioChange).coerceIn(brightnessRange) + window.brightness = swipeGestureValueTracker + + gestureIndicatorOverlayImage.setImageResource(org.jellyfin.mobile.R.drawable.ic_brightness_white_24dp) + gestureIndicatorOverlayProgress.max = Constants.PERCENT_MAX + gestureIndicatorOverlayProgress.progress = (swipeGestureValueTracker * Constants.PERCENT_MAX).toInt() + } + + gestureIndicatorOverlayLayout.isVisible = true + return true + } + }) + + /** + * Handles scale/zoom gesture + */ + private val zoomGestureDetector = ScaleGestureDetector(playerView.context, object : ScaleGestureDetector.OnScaleGestureListener { + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = fragment.isLandscape() + + override fun onScale(detector: ScaleGestureDetector): Boolean { + val scaleFactor = detector.scaleFactor + if (abs(scaleFactor - Constants.ZOOM_SCALE_BASE) > Constants.ZOOM_SCALE_THRESHOLD) { + isZoomEnabled = scaleFactor > 1 + updateZoomMode(isZoomEnabled) + } + return true + } + + override fun onScaleEnd(detector: ScaleGestureDetector) = Unit + }).apply { isQuickScaleEnabled = false } + + init { + @Suppress("ClickableViewAccessibility") + playerView.setOnTouchListener { _, event -> + if (playerView.useController) { + when (event.pointerCount) { + 1 -> gestureDetector.onTouchEvent(event) + 2 -> zoomGestureDetector.onTouchEvent(event) + } + } else unlockDetector.onTouchEvent(event) + if (event.action == MotionEvent.ACTION_UP) { + // Hide gesture indicator after timeout, if shown + gestureIndicatorOverlayLayout.apply { + if (isVisible) { + removeCallbacks(hideGestureIndicatorOverlayAction) + postDelayed(hideGestureIndicatorOverlayAction, Constants.DEFAULT_CENTER_OVERLAY_TIMEOUT_MS.toLong()) + } + } + swipeGestureValueTracker = -1f + } + true + } + } + + fun handleConfiguration(newConfig: Configuration) { + updateZoomMode(fragment.isLandscape(newConfig) && isZoomEnabled) + } + + private fun updateZoomMode(enabled: Boolean) { + playerView.resizeMode = if (enabled) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt b/app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt index ee632ce7e..53c4a5535 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt @@ -8,6 +8,7 @@ import android.view.ContextThemeWrapper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.Window import android.widget.Toast import androidx.annotation.StringRes import androidx.annotation.StyleRes @@ -46,3 +47,11 @@ fun View.applyWindowInsetsAsMargins() { } inline fun Resources.dip(px: Int) = (px * displayMetrics.density).toInt() + +inline var Window.brightness: Float + get() = attributes.screenBrightness + set(value) { + attributes = attributes.apply { + screenBrightness = value + } + } From 57ae1c2a5744fa1cc1bee4265bb51230605a5141 Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Mon, 14 Jun 2021 20:19:46 +0200 Subject: [PATCH 4/4] Set PiP source hints to match video bounds --- .../main/java/org/jellyfin/mobile/player/PlayerFragment.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt index 19134e426..7234c967f 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerFragment.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.app.PictureInPictureParams import android.content.pm.ActivityInfo import android.content.res.Configuration +import android.graphics.Rect import android.os.Build import android.os.Bundle import android.os.Handler @@ -298,6 +299,12 @@ class PlayerFragment : Fragment() { } } setAspectRatio(aspectRational) + val contentFrame: View = playerView.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) + } + setSourceRectHint(contentRect) }.build() enterPictureInPictureMode(params) } else {