From 4d9b2466b7650ecc321d86bf6d11e7f33571795e Mon Sep 17 00:00:00 2001 From: KingLucius Date: Fri, 12 Sep 2025 13:42:28 +0300 Subject: [PATCH 1/4] feat(Player): Pinch-to-Zoom support --- .../cloudstream3/utils/ZoomablePlayerView.kt | 164 ++++++++++++++++++ app/src/main/res/layout/fragment_player.xml | 5 +- 2 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/ZoomablePlayerView.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ZoomablePlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ZoomablePlayerView.kt new file mode 100644 index 0000000000..e3ef123928 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ZoomablePlayerView.kt @@ -0,0 +1,164 @@ +package com.lagradost.cloudstream3.utils + +import android.content.Context +import android.graphics.Matrix +import android.util.AttributeSet +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.TextureView +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.PlayerView +import kotlin.math.max + +class ZoomablePlayerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : PlayerView(context, attrs, defStyleAttr) { + + private val minScale = 1f + private val maxScale = 4f + + private var scale = 1f + private var translateX = 0f + private var translateY = 0f + + private var focusX = 0f + private var focusY = 0f + + private var isScaling = false + private var isPanning = false + + private val scaleDetector = ScaleGestureDetector(context, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { + isScaling = true + parent?.requestDisallowInterceptTouchEvent(true) + return true + } + + override fun onScale(detector: ScaleGestureDetector): Boolean { + scale = (scale * detector.scaleFactor).coerceIn(minScale, maxScale) + + focusX = detector.focusX + focusY = detector.focusY + + if (scale <= 1f + 1e-4) { + resetTransforms() + } else { + clampTranslations() + applyTransform() + } + return true + } + + override fun onScaleEnd(detector: ScaleGestureDetector) { + isScaling = false + parent?.requestDisallowInterceptTouchEvent(scale > 1f || isPanning) + } + }) + + private val gestureDetector = GestureDetector(context, + object : GestureDetector.SimpleOnGestureListener() { + override fun onDoubleTap(e: MotionEvent): Boolean { + if (scale > 1f) { + resetTransforms() + } else { + scale = 2f + focusX = e.x + focusY = e.y + applyTransform() + } + return true + } + + override fun onScroll( + e1: MotionEvent?, + e2: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + if (scale > 1f) { + isPanning = true + parent?.requestDisallowInterceptTouchEvent(true) + translateX -= distanceX + translateY -= distanceY + clampTranslations() + applyTransform() + return true + } + return false + } + }) + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + if (ev.pointerCount > 1 || isScaling || (scale > 1f && isPanning)) { + parent?.requestDisallowInterceptTouchEvent(true) + return onTouchEvent(ev) + } + return super.dispatchTouchEvent(ev) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + scaleDetector.onTouchEvent(event) + gestureDetector.onTouchEvent(event) + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> if (event.pointerCount == 1) isPanning = false + MotionEvent.ACTION_POINTER_DOWN -> parent?.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (!isScaling && !isPanning) { + performClick() + } + isPanning = false + parent?.requestDisallowInterceptTouchEvent(scale > 1f) + } + } + return isScaling || scale > 1f || isPanning || super.onTouchEvent(event) + } + + override fun performClick(): Boolean { + super.performClick() + return true + } + + @OptIn(UnstableApi::class) + private fun applyTransform() { + val tv = videoSurfaceView as? TextureView ?: return + val matrix = Matrix() + + matrix.postScale(scale, scale, focusX, focusY) + + // pan + matrix.postTranslate(translateX, translateY) + + tv.setTransform(matrix) + tv.invalidate() + } + + @OptIn(UnstableApi::class) + private fun resetTransforms() { + scale = 1f + translateX = 0f + translateY = 0f + focusX = width / 2f + focusY = height / 2f + val tv = videoSurfaceView as? TextureView ?: return + tv.setTransform(Matrix()) + tv.invalidate() + } + + @OptIn(UnstableApi::class) + private fun clampTranslations() { + val tv = videoSurfaceView as? TextureView ?: return + val scaledW = tv.width * scale + val scaledH = tv.height * scale + val maxTx = max(0f, (scaledW - tv.width) / 2f) + val maxTy = max(0f, (scaledH - tv.height) / 2f) + + translateX = translateX.coerceIn(-maxTx, maxTx) + translateY = translateY.coerceIn(-maxTy, maxTy) + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_player.xml b/app/src/main/res/layout/fragment_player.xml index a620b6aee8..28620f5e59 100644 --- a/app/src/main/res/layout/fragment_player.xml +++ b/app/src/main/res/layout/fragment_player.xml @@ -13,7 +13,7 @@ - + app:show_timeout="0" + app:surface_type="texture_view" /> Date: Sat, 13 Sep 2025 09:15:07 +0300 Subject: [PATCH 2/4] Removed double-tap --- .../cloudstream3/utils/ZoomablePlayerView.kt | 45 +++++++------------ 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ZoomablePlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ZoomablePlayerView.kt index e3ef123928..4794787894 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ZoomablePlayerView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ZoomablePlayerView.kt @@ -31,7 +31,8 @@ class ZoomablePlayerView @JvmOverloads constructor( private var isScaling = false private var isPanning = false - private val scaleDetector = ScaleGestureDetector(context, + private val scaleDetector = ScaleGestureDetector( + context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { isScaling = true @@ -41,7 +42,6 @@ class ZoomablePlayerView @JvmOverloads constructor( override fun onScale(detector: ScaleGestureDetector): Boolean { scale = (scale * detector.scaleFactor).coerceIn(minScale, maxScale) - focusX = detector.focusX focusY = detector.focusY @@ -60,20 +60,9 @@ class ZoomablePlayerView @JvmOverloads constructor( } }) - private val gestureDetector = GestureDetector(context, + private val gestureDetector = GestureDetector( + context, object : GestureDetector.SimpleOnGestureListener() { - override fun onDoubleTap(e: MotionEvent): Boolean { - if (scale > 1f) { - resetTransforms() - } else { - scale = 2f - focusX = e.x - focusY = e.y - applyTransform() - } - return true - } - override fun onScroll( e1: MotionEvent?, e2: MotionEvent, @@ -91,7 +80,13 @@ class ZoomablePlayerView @JvmOverloads constructor( } return false } - }) + + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + performClick() + return true + } + } + ) override fun dispatchTouchEvent(ev: MotionEvent): Boolean { if (ev.pointerCount > 1 || isScaling || (scale > 1f && isPanning)) { @@ -103,20 +98,19 @@ class ZoomablePlayerView @JvmOverloads constructor( override fun onTouchEvent(event: MotionEvent): Boolean { scaleDetector.onTouchEvent(event) - gestureDetector.onTouchEvent(event) - + if (!isScaling) { + gestureDetector.onTouchEvent(event) + } when (event.actionMasked) { MotionEvent.ACTION_DOWN -> if (event.pointerCount == 1) isPanning = false MotionEvent.ACTION_POINTER_DOWN -> parent?.requestDisallowInterceptTouchEvent(true) MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - if (!isScaling && !isPanning) { - performClick() - } + if (!isScaling && !isPanning) performClick() isPanning = false parent?.requestDisallowInterceptTouchEvent(scale > 1f) } } - return isScaling || scale > 1f || isPanning || super.onTouchEvent(event) + return true } override fun performClick(): Boolean { @@ -128,12 +122,8 @@ class ZoomablePlayerView @JvmOverloads constructor( private fun applyTransform() { val tv = videoSurfaceView as? TextureView ?: return val matrix = Matrix() - matrix.postScale(scale, scale, focusX, focusY) - - // pan matrix.postTranslate(translateX, translateY) - tv.setTransform(matrix) tv.invalidate() } @@ -157,8 +147,7 @@ class ZoomablePlayerView @JvmOverloads constructor( val scaledH = tv.height * scale val maxTx = max(0f, (scaledW - tv.width) / 2f) val maxTy = max(0f, (scaledH - tv.height) / 2f) - translateX = translateX.coerceIn(-maxTx, maxTx) translateY = translateY.coerceIn(-maxTy, maxTy) } -} \ No newline at end of file +} From e0da66ea8e26caa54a98dbf519db6e17d8ca54ed Mon Sep 17 00:00:00 2001 From: KingLucius Date: Fri, 19 Sep 2025 22:44:19 +0300 Subject: [PATCH 3/4] New way of Zoom & Pan --- .../ui/player/FullScreenPlayer.kt | 79 ++++++++++++++++++- app/src/main/res/layout/fragment_player.xml | 2 +- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 0e2090b2d2..7f1e7d3fae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -21,6 +21,7 @@ import android.text.format.DateUtils import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent +import android.view.ScaleGestureDetector import android.view.Surface import android.view.View import android.view.ViewGroup @@ -87,7 +88,6 @@ import kotlin.math.roundToInt import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback - const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // in percentage @@ -255,6 +255,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { animateLayoutChanges() } + @OptIn(androidx.media3.common.util.UnstableApi::class) private fun animateLayoutChangesForSubtitles() = // Post here as bottomPlayerBar is gone the first frame => bottomPlayerBar.height = 0 playerBinding?.bottomPlayerBar?.post { @@ -275,6 +276,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } + @OptIn(UnstableApi::class) protected fun animateLayoutChanges() { if (isLayout(PHONE)) { // isEnabled also disables the onKeyDown playerBinding?.exoProgress?.isEnabled = isShowing // Prevent accidental clicks/drags @@ -1056,6 +1058,13 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } + private var scaleGestureDetector: ScaleGestureDetector? = null + private var scaleFactor = 1f + private var lastPanX = 0f + private var lastPanY = 0f + private var isPanning = false + + @OptIn(androidx.media3.common.util.UnstableApi::class) @SuppressLint("SetTextI18n") private fun handleMotionEvent(view: View?, event: MotionEvent?): Boolean { if (event == null || view == null) return false @@ -1065,7 +1074,69 @@ open class FullScreenPlayer : AbstractPlayerFragment() { playerBinding?.apply { playerIntroPlay.isGone = true - when (event.action) { + // Gesture detectors for zoom & pan + if (scaleGestureDetector == null) { + scaleGestureDetector = ScaleGestureDetector(view.context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + scaleFactor *= detector.scaleFactor + scaleFactor = scaleFactor.coerceIn(1.0f, 4.0f) + playerView?.videoSurfaceView?.let { videoView -> + videoView.scaleX = scaleFactor + videoView.scaleY = scaleFactor + + val maxTransX = (videoView.width * scaleFactor - videoView.width) / 2f + val maxTransY = (videoView.height * scaleFactor - videoView.height) / 2f + videoView.translationX = videoView.translationX.coerceIn(-maxTransX, maxTransX) + videoView.translationY = videoView.translationY.coerceIn(-maxTransY, maxTransY) + } + return true + } + }) + } + + // Handle pan with two fingers + if (event.pointerCount == 2) { + when (event.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + lastPanX = (event.getX(0) + event.getX(1)) / 2f + lastPanY = (event.getY(0) + event.getY(1)) / 2f + isPanning = true + } + MotionEvent.ACTION_MOVE -> { + if (isPanning) { + val newX = (event.getX(0) + event.getX(1)) / 2f + val newY = (event.getY(0) + event.getY(1)) / 2f + val dx = newX - lastPanX + val dy = newY - lastPanY + + playerView?.videoSurfaceView?.let { videoView -> + val maxTransX = (videoView.width * scaleFactor - videoView.width) / 2f + val maxTransY = (videoView.height * scaleFactor - videoView.height) / 2f + + videoView.translationX = (videoView.translationX + dx).coerceIn(-maxTransX, maxTransX) + videoView.translationY = (videoView.translationY + dy).coerceIn(-maxTransY, maxTransY) + } + + lastPanX = newX + lastPanY = newY + } + } + MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_UP -> { + isPanning = false + lastPanX = 0f + lastPanY = 0f + currentTouchStart = null + currentTouchLast = null + currentTouchAction = null + } + } + scaleGestureDetector?.onTouchEvent(event) + return true + } + + scaleGestureDetector?.onTouchEvent(event) + + when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { // validates if the touch is inside of the player area isCurrentTouchValid = view.isValidTouch(currentTouch.x, currentTouch.y) @@ -1101,7 +1172,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) showOrHideSpeedUp(false) } - if (isCurrentTouchValid && !isLocked && isFullScreenPlayer) { + if (isCurrentTouchValid && !isLocked && isFullScreenPlayer && !isPanning) { // seek time if (swipeHorizontalEnabled && currentTouchAction == TouchAction.Time) { val startTime = currentTouchStartPlayerTime @@ -1197,7 +1268,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { if (hasTriggeredSpeedUp) { return true } - if (startTouch != null && isCurrentTouchValid && !isLocked && isFullScreenPlayer) { + if (startTouch != null && isCurrentTouchValid && !isLocked && isFullScreenPlayer && !isPanning) { // action is unassigned and can therefore be assigned if (currentTouchAction == null) { diff --git a/app/src/main/res/layout/fragment_player.xml b/app/src/main/res/layout/fragment_player.xml index 28620f5e59..66100df19f 100644 --- a/app/src/main/res/layout/fragment_player.xml +++ b/app/src/main/res/layout/fragment_player.xml @@ -13,7 +13,7 @@ - Date: Sat, 20 Sep 2025 05:47:22 +0300 Subject: [PATCH 4/4] Remove ZoomablePlayerView.kt --- .../cloudstream3/utils/ZoomablePlayerView.kt | 153 ------------------ 1 file changed, 153 deletions(-) delete mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/ZoomablePlayerView.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ZoomablePlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ZoomablePlayerView.kt deleted file mode 100644 index 4794787894..0000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ZoomablePlayerView.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.content.Context -import android.graphics.Matrix -import android.util.AttributeSet -import android.view.GestureDetector -import android.view.MotionEvent -import android.view.ScaleGestureDetector -import android.view.TextureView -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.PlayerView -import kotlin.math.max - -class ZoomablePlayerView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : PlayerView(context, attrs, defStyleAttr) { - - private val minScale = 1f - private val maxScale = 4f - - private var scale = 1f - private var translateX = 0f - private var translateY = 0f - - private var focusX = 0f - private var focusY = 0f - - private var isScaling = false - private var isPanning = false - - private val scaleDetector = ScaleGestureDetector( - context, - object : ScaleGestureDetector.SimpleOnScaleGestureListener() { - override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { - isScaling = true - parent?.requestDisallowInterceptTouchEvent(true) - return true - } - - override fun onScale(detector: ScaleGestureDetector): Boolean { - scale = (scale * detector.scaleFactor).coerceIn(minScale, maxScale) - focusX = detector.focusX - focusY = detector.focusY - - if (scale <= 1f + 1e-4) { - resetTransforms() - } else { - clampTranslations() - applyTransform() - } - return true - } - - override fun onScaleEnd(detector: ScaleGestureDetector) { - isScaling = false - parent?.requestDisallowInterceptTouchEvent(scale > 1f || isPanning) - } - }) - - private val gestureDetector = GestureDetector( - context, - object : GestureDetector.SimpleOnGestureListener() { - override fun onScroll( - e1: MotionEvent?, - e2: MotionEvent, - distanceX: Float, - distanceY: Float - ): Boolean { - if (scale > 1f) { - isPanning = true - parent?.requestDisallowInterceptTouchEvent(true) - translateX -= distanceX - translateY -= distanceY - clampTranslations() - applyTransform() - return true - } - return false - } - - override fun onSingleTapConfirmed(e: MotionEvent): Boolean { - performClick() - return true - } - } - ) - - override fun dispatchTouchEvent(ev: MotionEvent): Boolean { - if (ev.pointerCount > 1 || isScaling || (scale > 1f && isPanning)) { - parent?.requestDisallowInterceptTouchEvent(true) - return onTouchEvent(ev) - } - return super.dispatchTouchEvent(ev) - } - - override fun onTouchEvent(event: MotionEvent): Boolean { - scaleDetector.onTouchEvent(event) - if (!isScaling) { - gestureDetector.onTouchEvent(event) - } - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> if (event.pointerCount == 1) isPanning = false - MotionEvent.ACTION_POINTER_DOWN -> parent?.requestDisallowInterceptTouchEvent(true) - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - if (!isScaling && !isPanning) performClick() - isPanning = false - parent?.requestDisallowInterceptTouchEvent(scale > 1f) - } - } - return true - } - - override fun performClick(): Boolean { - super.performClick() - return true - } - - @OptIn(UnstableApi::class) - private fun applyTransform() { - val tv = videoSurfaceView as? TextureView ?: return - val matrix = Matrix() - matrix.postScale(scale, scale, focusX, focusY) - matrix.postTranslate(translateX, translateY) - tv.setTransform(matrix) - tv.invalidate() - } - - @OptIn(UnstableApi::class) - private fun resetTransforms() { - scale = 1f - translateX = 0f - translateY = 0f - focusX = width / 2f - focusY = height / 2f - val tv = videoSurfaceView as? TextureView ?: return - tv.setTransform(Matrix()) - tv.invalidate() - } - - @OptIn(UnstableApi::class) - private fun clampTranslations() { - val tv = videoSurfaceView as? TextureView ?: return - val scaledW = tv.width * scale - val scaledH = tv.height * scale - val maxTx = max(0f, (scaledW - tv.width) / 2f) - val maxTy = max(0f, (scaledH - tv.height) / 2f) - translateX = translateX.coerceIn(-maxTx, maxTx) - translateY = translateY.coerceIn(-maxTy, maxTy) - } -}