package org.schabi.newpipe.player.event import android.content.Context import android.os.Handler import android.util.Log import android.view.GestureDetector import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import org.schabi.newpipe.player.BasePlayer import org.schabi.newpipe.player.MainPlayer import org.schabi.newpipe.player.VideoPlayerImpl import org.schabi.newpipe.player.helper.PlayerHelper import org.schabi.newpipe.util.AnimationUtils import kotlin.math.abs import kotlin.math.hypot import kotlin.math.max /** * Base gesture handling for [VideoPlayerImpl] * * This class contains the logic for the player gestures like View preparations * and provides some abstract methods to make it easier separating the logic from the UI. */ abstract class BasePlayerGestureListener( @JvmField protected val playerImpl: VideoPlayerImpl, @JvmField protected val service: MainPlayer ) : GestureDetector.SimpleOnGestureListener(), View.OnTouchListener { // /////////////////////////////////////////////////////////////////// // Abstract methods for VIDEO and POPUP // /////////////////////////////////////////////////////////////////// abstract fun onDoubleTap(event: MotionEvent, portion: DisplayPortion) abstract fun onSingleTap(playerType: MainPlayer.PlayerType) abstract fun onScroll( playerType: MainPlayer.PlayerType, portion: DisplayPortion, initialEvent: MotionEvent, movingEvent: MotionEvent, distanceX: Float, distanceY: Float ) abstract fun onScrollEnd(playerType: MainPlayer.PlayerType, event: MotionEvent) // /////////////////////////////////////////////////////////////////// // Abstract methods for POPUP (exclusive) // /////////////////////////////////////////////////////////////////// abstract fun onPopupResizingStart() abstract fun onPopupResizingEnd() private var initialPopupX: Int = -1 private var initialPopupY: Int = -1 private var isMovingInMain = false private var isMovingInPopup = false private var isResizing = false private val tossFlingVelocity = PlayerHelper.getTossFlingVelocity() // [popup] initial coordinates and distance between fingers private var initPointerDistance = -1.0 private var initFirstPointerX = -1f private var initFirstPointerY = -1f private var initSecPointerX = -1f private var initSecPointerY = -1f // /////////////////////////////////////////////////////////////////// // onTouch implementation // /////////////////////////////////////////////////////////////////// override fun onTouch(v: View, event: MotionEvent): Boolean { return if (playerImpl.popupPlayerSelected()) { onTouchInPopup(v, event) } else { onTouchInMain(v, event) } } private fun onTouchInMain(v: View, event: MotionEvent): Boolean { playerImpl.gestureDetector.onTouchEvent(event) if (event.action == MotionEvent.ACTION_UP && isMovingInMain) { isMovingInMain = false onScrollEnd(MainPlayer.PlayerType.VIDEO, event) } return when (event.action) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { v.parent.requestDisallowInterceptTouchEvent(playerImpl.isFullscreen) true } MotionEvent.ACTION_UP -> { v.parent.requestDisallowInterceptTouchEvent(false) false } else -> true } } private fun onTouchInPopup(v: View, event: MotionEvent): Boolean { playerImpl.gestureDetector.onTouchEvent(event) if (event.pointerCount == 2 && !isMovingInPopup && !isResizing) { if (DEBUG) { Log.d(TAG, "onTouch() 2 finger pointer detected, enabling resizing.") } onPopupResizingStart() // record coordinates of fingers initFirstPointerX = event.getX(0) initFirstPointerY = event.getY(0) initSecPointerX = event.getX(1) initSecPointerY = event.getY(1) // record distance between fingers initPointerDistance = Math.hypot(initFirstPointerX - initSecPointerX.toDouble(), initFirstPointerY - initSecPointerY.toDouble()) isResizing = true } if (event.action == MotionEvent.ACTION_MOVE && !isMovingInPopup && isResizing) { if (DEBUG) { Log.d(TAG, "onTouch() ACTION_MOVE > v = [$v], e1.getRaw = [${event.rawX}" + ", ${event.rawY}]") } return handleMultiDrag(event) } if (event.action == MotionEvent.ACTION_UP) { if (DEBUG) { Log.d(TAG, "onTouch() ACTION_UP > v = [$v], e1.getRaw = [${event.rawX}" + ", ${event.rawY}]") } if (isMovingInPopup) { isMovingInPopup = false onScrollEnd(MainPlayer.PlayerType.POPUP, event) } if (isResizing) { isResizing = false initPointerDistance = (-1).toDouble() initFirstPointerX = (-1).toFloat() initFirstPointerY = (-1).toFloat() initSecPointerX = (-1).toFloat() initSecPointerY = (-1).toFloat() onPopupResizingEnd() playerImpl.changeState(playerImpl.currentState) } if (!playerImpl.isPopupClosing) { playerImpl.savePositionAndSize() } } v.performClick() return true } private fun handleMultiDrag(event: MotionEvent): Boolean { if (initPointerDistance != -1.0 && event.pointerCount == 2) { // get the movements of the fingers val firstPointerMove = hypot(event.getX(0) - initFirstPointerX.toDouble(), event.getY(0) - initFirstPointerY.toDouble()) val secPointerMove = hypot(event.getX(1) - initSecPointerX.toDouble(), event.getY(1) - initSecPointerY.toDouble()) // minimum threshold beyond which pinch gesture will work val minimumMove = ViewConfiguration.get(service).scaledTouchSlop if (max(firstPointerMove, secPointerMove) > minimumMove) { // calculate current distance between the pointers val currentPointerDistance = hypot(event.getX(0) - event.getX(1).toDouble(), event.getY(0) - event.getY(1).toDouble()) val popupWidth = playerImpl.popupWidth.toDouble() // change co-ordinates of popup so the center stays at the same position val newWidth = popupWidth * currentPointerDistance / initPointerDistance initPointerDistance = currentPointerDistance playerImpl.popupLayoutParams.x += ((popupWidth - newWidth) / 2.0).toInt() playerImpl.checkPopupPositionBounds() playerImpl.updateScreenSize() playerImpl.updatePopupSize( Math.min(playerImpl.screenWidth.toDouble(), newWidth).toInt(), -1) return true } } return false } // /////////////////////////////////////////////////////////////////// // Simple gestures // /////////////////////////////////////////////////////////////////// override fun onDown(e: MotionEvent): Boolean { if (DEBUG) Log.d(TAG, "onDown called with e = [$e]") if (isDoubleTapping && isDoubleTapEnabled) { doubleTapControls?.onDoubleTapProgressDown(getDisplayPortion(e)) return true } return if (playerImpl.popupPlayerSelected()) onDownInPopup(e) else true } private fun onDownInPopup(e: MotionEvent): Boolean { // Fix popup position when the user touch it, it may have the wrong one // because the soft input is visible (the draggable area is currently resized). playerImpl.updateScreenSize() playerImpl.checkPopupPositionBounds() initialPopupX = playerImpl.popupLayoutParams.x initialPopupY = playerImpl.popupLayoutParams.y playerImpl.popupWidth = playerImpl.popupLayoutParams.width.toFloat() playerImpl.popupHeight = playerImpl.popupLayoutParams.height.toFloat() return super.onDown(e) } override fun onDoubleTap(e: MotionEvent): Boolean { if (DEBUG) Log.d(TAG, "onDoubleTap called with e = [$e]") onDoubleTap(e, getDisplayPortion(e)) return true } override fun onSingleTapConfirmed(e: MotionEvent): Boolean { if (DEBUG) Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]") if (isDoubleTapping) return true if (playerImpl.popupPlayerSelected()) { if (playerImpl.player == null) return false onSingleTap(MainPlayer.PlayerType.POPUP) return true } else { super.onSingleTapConfirmed(e) if (playerImpl.currentState == BasePlayer.STATE_BLOCKED) return true onSingleTap(MainPlayer.PlayerType.VIDEO) } return true } override fun onLongPress(e: MotionEvent?) { if (playerImpl.popupPlayerSelected()) { playerImpl.updateScreenSize() playerImpl.checkPopupPositionBounds() playerImpl.updatePopupSize(playerImpl.screenWidth.toInt(), -1) } } override fun onScroll( initialEvent: MotionEvent, movingEvent: MotionEvent, distanceX: Float, distanceY: Float ): Boolean { return if (playerImpl.popupPlayerSelected()) { onScrollInPopup(initialEvent, movingEvent, distanceX, distanceY) } else { onScrollInMain(initialEvent, movingEvent, distanceX, distanceY) } } override fun onFling( e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float ): Boolean { return if (playerImpl.popupPlayerSelected()) { val absVelocityX = abs(velocityX) val absVelocityY = abs(velocityY) if (absVelocityX.coerceAtLeast(absVelocityY) > tossFlingVelocity) { if (absVelocityX > tossFlingVelocity) { playerImpl.popupLayoutParams.x = velocityX.toInt() } if (absVelocityY > tossFlingVelocity) { playerImpl.popupLayoutParams.y = velocityY.toInt() } playerImpl.checkPopupPositionBounds() playerImpl.windowManager .updateViewLayout(playerImpl.rootView, playerImpl.popupLayoutParams) return true } return false } else { true } } private fun onScrollInMain( initialEvent: MotionEvent, movingEvent: MotionEvent, distanceX: Float, distanceY: Float ): Boolean { if (!playerImpl.isFullscreen) { return false } val isTouchingStatusBar: Boolean = initialEvent.y < getStatusBarHeight(service) val isTouchingNavigationBar: Boolean = (initialEvent.y > playerImpl.rootView.height - getNavigationBarHeight(service)) if (isTouchingStatusBar || isTouchingNavigationBar) { return false } val insideThreshold = abs(movingEvent.y - initialEvent.y) <= MOVEMENT_THRESHOLD if (!isMovingInMain && (insideThreshold || abs(distanceX) > abs(distanceY)) || playerImpl.currentState == BasePlayer.STATE_COMPLETED) { return false } isMovingInMain = true onScroll(MainPlayer.PlayerType.VIDEO, getDisplayHalfPortion(initialEvent), initialEvent, movingEvent, distanceX, distanceY) return true } private fun onScrollInPopup( initialEvent: MotionEvent, movingEvent: MotionEvent, distanceX: Float, distanceY: Float ): Boolean { if (isResizing) { return super.onScroll(initialEvent, movingEvent, distanceX, distanceY) } if (!isMovingInPopup) { AnimationUtils.animateView(playerImpl.closeOverlayButton, true, 200) } isMovingInPopup = true val diffX: Float = (movingEvent.rawX - initialEvent.rawX) var posX: Float = (initialPopupX + diffX) val diffY: Float = (movingEvent.rawY - initialEvent.rawY) var posY: Float = (initialPopupY + diffY) if (posX > playerImpl.screenWidth - playerImpl.popupWidth) { posX = (playerImpl.screenWidth - playerImpl.popupWidth) } else if (posX < 0) { posX = 0f } if (posY > playerImpl.screenHeight - playerImpl.popupHeight) { posY = (playerImpl.screenHeight - playerImpl.popupHeight) } else if (posY < 0) { posY = 0f } playerImpl.popupLayoutParams.x = posX.toInt() playerImpl.popupLayoutParams.y = posY.toInt() onScroll(MainPlayer.PlayerType.POPUP, getDisplayHalfPortion(initialEvent), initialEvent, movingEvent, distanceX, distanceY) playerImpl.windowManager .updateViewLayout(playerImpl.rootView, playerImpl.popupLayoutParams) return true } // /////////////////////////////////////////////////////////////////// // Multi double tapping // /////////////////////////////////////////////////////////////////// var doubleTapControls: DoubleTapListener? = null private set val isDoubleTapEnabled: Boolean get() = doubleTapDelay > 0 var isDoubleTapping = false private set fun doubleTapControls(listener: DoubleTapListener) = apply { doubleTapControls = listener } private var doubleTapDelay = DOUBLE_TAP_DELAY private val doubleTapHandler: Handler = Handler() private val doubleTapRunnable = Runnable { if (DEBUG) Log.d(TAG, "doubleTapRunnable called") isDoubleTapping = false doubleTapControls?.onDoubleTapFinished() } fun startMultiDoubleTap(e: MotionEvent) { if (!isDoubleTapping) { if (DEBUG) Log.d(TAG, "startMultiDoubleTap called with e = [$e]") keepInDoubleTapMode() doubleTapControls?.onDoubleTapStarted(getDisplayPortion(e)) } } fun keepInDoubleTapMode() { if (DEBUG) Log.d(TAG, "keepInDoubleTapMode called") isDoubleTapping = true doubleTapHandler.removeCallbacks(doubleTapRunnable) doubleTapHandler.postDelayed(doubleTapRunnable, doubleTapDelay) } fun endMultiDoubleTap() { if (DEBUG) Log.d(TAG, "endMultiDoubleTap called") isDoubleTapping = false doubleTapHandler.removeCallbacks(doubleTapRunnable) doubleTapControls?.onDoubleTapFinished() } fun enableMultiDoubleTap(enable: Boolean) = apply { doubleTapDelay = if (enable) DOUBLE_TAP_DELAY else 0 } // /////////////////////////////////////////////////////////////////// // Utils // /////////////////////////////////////////////////////////////////// private fun getDisplayPortion(e: MotionEvent): DisplayPortion { return if (playerImpl.playerType == MainPlayer.PlayerType.POPUP) { when { e.x < playerImpl.popupWidth / 3.0 -> DisplayPortion.LEFT e.x > playerImpl.popupWidth * 2.0 / 3.0 -> DisplayPortion.RIGHT else -> DisplayPortion.MIDDLE } } else /* MainPlayer.PlayerType.VIDEO */ { when { e.x < playerImpl.rootView.width / 3.0 -> DisplayPortion.LEFT e.x > playerImpl.rootView.width * 2.0 / 3.0 -> DisplayPortion.RIGHT else -> DisplayPortion.MIDDLE } } } // Currently needed for scrolling since there is no action more the middle portion private fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion { return if (playerImpl.playerType == MainPlayer.PlayerType.POPUP) { when { e.x < playerImpl.popupWidth / 2.0 -> DisplayPortion.LEFT_HALF else -> DisplayPortion.RIGHT_HALF } } else /* MainPlayer.PlayerType.VIDEO */ { when { e.x < playerImpl.rootView.width / 2.0 -> DisplayPortion.LEFT_HALF else -> DisplayPortion.RIGHT_HALF } } } private fun getNavigationBarHeight(context: Context): Int { val resId = context.resources .getIdentifier("navigation_bar_height", "dimen", "android") return if (resId > 0) { context.resources.getDimensionPixelSize(resId) } else 0 } private fun getStatusBarHeight(context: Context): Int { val resId = context.resources .getIdentifier("status_bar_height", "dimen", "android") return if (resId > 0) { context.resources.getDimensionPixelSize(resId) } else 0 } companion object { private const val TAG = "BasePlayerGestListener" private val DEBUG = BasePlayer.DEBUG private const val DOUBLE_TAP_DELAY = 550L private const val MOVEMENT_THRESHOLD = 40 } }