From c23f5dd8215bbc84bb971927e8424a3e4426f1da Mon Sep 17 00:00:00 2001 From: nutine <40691418+nutine@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:22:59 +0400 Subject: [PATCH 1/2] feat(player): add gesture-based subtitle jump with HUD preview --- app/src/main/AndroidManifest.xml | 1 + .../ui/player/FullScreenPlayer.kt | 45 +++++++++++++++++++ .../cloudstream3/ui/player/PlayerView.kt | 1 + .../lagradost/cloudstream3/utils/UIHelper.kt | 26 +++++++++++ 4 files changed, 73 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee4c978f2be..00f31c8312e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ + 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 d90b6043f28..d3c8e589310 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 @@ -39,6 +39,7 @@ import androidx.preference.PreferenceManager import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.CommonActivity.keyEventListener +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding @@ -62,6 +63,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import com.lagradost.cloudstream3.utils.vibrateDevice import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt @@ -1370,4 +1372,47 @@ open class FullScreenPlayer : AbstractPlayerFragment( .start() } } + + private val subJumpHideRunnable = Runnable { + playerBinding?.playerTimeText?.isVisible = false + } + + private fun jumpToSubtitle(next: Boolean) { + val pos = player.getPosition() ?: return + val rawCues = player.getSubtitleCues() + val cues = rawCues.sortedBy { it.startTimeMs } + + if (cues.isEmpty()) { + showToast("No subtitles loaded") + return + } + + val target = if (next) { + cues.firstOrNull { it.startTimeMs > pos + 400L } + } else { + val currentCue = cues.lastOrNull { it.startTimeMs <= pos && pos <= it.endTimeMs + 500L } + if (currentCue != null && pos - currentCue.startTimeMs > 1000L) { + currentCue + } else { + cues.lastOrNull { it.startTimeMs < pos - 1000L } ?: cues.firstOrNull() + } + } + + if (target != null) { + context?.vibrateDevice(35L) + player.seekTo(target.startTimeMs, PlayerEventSource.UI) + val snippet = target.text.firstOrNull()?.replace("\n", " ")?.trim()?.take(45) ?: "" + val hudText = "${if (next) "⏭" else "⏮"} $snippet" + playerBinding?.playerTimeText?.apply { + isVisible = true + text = hudText + removeCallbacks(subJumpHideRunnable) + postDelayed(subJumpHideRunnable, 1200L) + } + } + } + + override fun onJumpSubtitle(next: Boolean) { + jumpToSubtitle(next) + } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt index 0e6f1a3677d..7bb64f57593 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt @@ -135,6 +135,7 @@ class PlayerView @JvmOverloads constructor( fun onHoldSpeedUp(show: Boolean) {} /** Called during brightness swipe with the current extra-brightness alpha (0–1). */ fun onBrightnessExtra(alpha: Float) {} + fun onJumpSubtitle(next: Boolean) {} /** Touch event callbacks */ diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt index a848bee260e..1beef041a8e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt @@ -22,6 +22,9 @@ import android.graphics.drawable.Drawable import android.os.Build import android.os.Bundle import android.os.TransactionTooLargeException +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager import android.util.Log import android.view.Gravity import android.view.MenuItem @@ -717,4 +720,27 @@ private class CutoutOverlayDrawable( @Suppress("OVERRIDE_DEPRECATION") override fun getOpacity() = PixelFormat.OPAQUE +} + +fun Context.vibrateDevice(durationMillis: Long = 35L) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager + vibratorManager?.defaultVibrator?.vibrate( + VibrationEffect.createOneShot(durationMillis, VibrationEffect.DEFAULT_AMPLITUDE) + ) + } else { + @Suppress("DEPRECATION") + val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator?.vibrate( + VibrationEffect.createOneShot(durationMillis, VibrationEffect.DEFAULT_AMPLITUDE) + ) + } else { + @Suppress("DEPRECATION") + vibrator?.vibrate(durationMillis) + } + } + } catch (_: Throwable) { + } } \ No newline at end of file From cbda17dcee9fe39c84d7d582d1774c8126348988 Mon Sep 17 00:00:00 2001 From: nutine <40691418+nutine@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:23:03 +0400 Subject: [PATCH 2/2] feat(player): add two-finger sentence navigation gestures and edge swipe isolation --- .../ui/player/PlayerGestureHelper.kt | 194 +++++++++++++++--- 1 file changed, 160 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt index 1c7086d1238..6136b6d3e21 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -31,7 +31,9 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.AspectRatioFrameLayout import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.CommonActivity.keyEventListener +import com.lagradost.cloudstream3.CommonActivity.screenHeight import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation +import com.lagradost.cloudstream3.CommonActivity.screenWidth import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R @@ -147,6 +149,8 @@ class PlayerGestureHelper(private val playerView: PlayerView) { /** Touch tracking */ var isCurrentTouchValid = false private set + private var isTouchFromTopEdge = false + private var isTouchFromNavEdge = false private var currentTouchStart: Vector2? = null private var currentTouchLast: Vector2? = null /** Current in-progress swipe action, null when no swipe is active. */ @@ -207,6 +211,13 @@ class PlayerGestureHelper(private val playerView: PlayerView) { private var scaleGestureDetector: ScaleGestureDetector? = null + private var twoPointerStartX: Float? = null + private var twoPointerStartY: Float? = null + private var twoPointerDownTime: Long = 0L + private var twoPointerMaxDisplacement: Float = 0f + private var twoPointerFired: Boolean = false + private var twoPointerIsScaling: Boolean = false + /** Midpoint of the two-finger pan, null when no pan is active. */ var lastPan: Vector2? = null @@ -722,50 +733,96 @@ class PlayerGestureHelper(private val playerView: PlayerView) { when (event.actionMasked) { MotionEvent.ACTION_POINTER_DOWN -> { + if (event.pointerCount >= 2) { + twoPointerStartX = (event.getX(0) + event.getX(1)) / 2f + twoPointerStartY = (event.getY(0) + event.getY(1)) / 2f + twoPointerDownTime = System.currentTimeMillis() + twoPointerMaxDisplacement = 0f + twoPointerFired = false + twoPointerIsScaling = false + } onFirstPointerDown() } MotionEvent.ACTION_MOVE -> { if (event.pointerCount >= 2) { - val newPan = Vector2( - (event.getX(0) + event.getX(1)) / 2f, - (event.getY(0) + event.getY(1)) / 2f - ) - val oldPan = lastPan - if (oldPan != null) { - val matrix = currentZoomMatrix() - matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) - applyZoomMatrix(matrix, false) + val midX = (event.getX(0) + event.getX(1)) / 2f + val midY = (event.getY(0) + event.getY(1)) / 2f + val startX = twoPointerStartX ?: midX + val startY = twoPointerStartY ?: midY + val diffX = midX - startX + val diffY = midY - startY + val displacement = kotlin.math.hypot(diffX.toDouble(), diffY.toDouble()).toFloat() + if (displacement > twoPointerMaxDisplacement) { + twoPointerMaxDisplacement = displacement + } + + val density = ctx.resources.displayMetrics.density + if (!twoPointerFired && !twoPointerIsScaling && abs(diffX) > 40f * density && abs(diffX) > abs(diffY) * 1.3f) { + twoPointerFired = true + playerView.callbacks?.onJumpSubtitle(diffX < 0) + } + + if (scaleGestureDetector?.isInProgress == true) { + twoPointerIsScaling = true + } + + if (twoPointerIsScaling) { + val newPan = Vector2(midX, midY) + val oldPan = lastPan + if (oldPan != null) { + val matrix = currentZoomMatrix() + matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) + applyZoomMatrix(matrix, false) + } + lastPan = newPan } - lastPan = newPan } } MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_UP -> { + val upTime = System.currentTimeMillis() + val startX = twoPointerStartX + val density = ctx.resources.displayMetrics.density + if (!twoPointerFired && !twoPointerIsScaling && startX != null && upTime - twoPointerDownTime < 400L && twoPointerMaxDisplacement < 30f * density) { + twoPointerFired = true + val isRightSide = startX >= screenWidthWithOrientation / 2f + playerView.callbacks?.onJumpSubtitle(isRightSide) + } + + twoPointerStartX = null + twoPointerStartY = null + twoPointerDownTime = 0L + twoPointerMaxDisplacement = 0f + twoPointerFired = false + val wasScaling = twoPointerIsScaling + twoPointerIsScaling = false lastPan = null videoOutline?.isVisible = false matrixAnimation?.cancel() matrixAnimation = null - // Snap to desired matrix after zoom gesture ends - matrixAnimation = ValueAnimator.ofFloat(0f, 1f).apply { - startDelay = 0 - duration = 200 - val startMatrix = currentZoomMatrix() - val endMatrix = desiredMatrix ?: return@apply - val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) - val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) - addUpdateListener { anim -> - val v = anim.animatedValue as Float - val vInv = 1f - v - val m = Matrix() - m.setScale(startScale * vInv + endScale * v, startScale * vInv + endScale * v) - m.postTranslate(startX * vInv + endX * v, startY * vInv + endY * v) - applyZoomMatrix(m, true) + if (wasScaling) { + // Snap to desired matrix after zoom gesture ends + matrixAnimation = ValueAnimator.ofFloat(0f, 1f).apply { + startDelay = 0 + duration = 200 + val startMatrix = currentZoomMatrix() + val endMatrix = desiredMatrix ?: return@apply + val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) + val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) + addUpdateListener { anim -> + val v = anim.animatedValue as Float + val vInv = 1f - v + val m = Matrix() + m.setScale(startScale * vInv + endScale * v, startScale * vInv + endScale * v) + m.postTranslate(startX * vInv + endX * v, startY * vInv + endY * v) + applyZoomMatrix(m, true) + } + start() } - start() } onGestureEnd() @@ -1047,15 +1104,63 @@ class PlayerGestureHelper(private val playerView: PlayerView) { } private fun isValidTouch(rawX: Float, rawY: Float): Boolean { + val holder = playerView.playerHolder ?: return true + val viewW = holder.width.takeIf { it > 0 } ?: screenWidth + val viewH = holder.height.takeIf { it > 0 } ?: screenHeight + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val rootInsets = holder.rootWindowInsets + if (rootInsets != null) { + val visibleInsets = rootInsets.getInsets(WindowInsets.Type.systemBars()) + val validHeight = rawY >= visibleInsets.top && rawY <= viewH - visibleInsets.bottom + val validWidth = rawX >= visibleInsets.left && rawX <= viewW - visibleInsets.right + return validHeight && validWidth + } + return true + } + + return rawY >= context.getStatusBarHeight() && rawX <= viewW + } + + private fun checkEdgeTouch(view: View, x: Float, y: Float): Pair { + val density = context.resources.displayMetrics.density + val minEdge = 32f * density + + var topThreshold = minEdge + var navRight = 0f + var navLeft = 0f + var navBottom = 0f + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val holder = playerView.playerHolder ?: return true - val insets = holder.rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) - val validHeight = rawY > insets.top && rawY < screenHeightWithOrientation - insets.bottom - val validWidth = rawX > insets.left && rawX < screenWidthWithOrientation - insets.right - return validHeight && validWidth + val insets = playerView.playerHolder?.rootWindowInsets?.getInsetsIgnoringVisibility( + WindowInsets.Type.systemBars() or WindowInsets.Type.mandatorySystemGestures() + ) + if (insets != null) { + topThreshold = max(insets.top.toFloat(), minEdge) + navRight = insets.right.toFloat() + navLeft = insets.left.toFloat() + navBottom = insets.bottom.toFloat() + } + } else { + val sb = context.getStatusBarHeight().toFloat() + topThreshold = max(sb, minEdge) } - return rawY > context.getStatusBarHeight() && rawX < screenWidthWithOrientation + val isTop = y <= topThreshold + val isNav = when { + navRight > 0f -> x >= view.width - max(navRight, minEdge) + navLeft > 0f -> x <= max(navLeft, minEdge) + navBottom > 0f -> y >= view.height - max(navBottom, minEdge) + else -> { + if (view.width > view.height) { + x >= view.width - minEdge || x <= minEdge + } else { + y >= view.height - minEdge + } + } + } + + return Pair(isTop, isNav) } private fun handleGesture(view: View, event: MotionEvent): Boolean { @@ -1091,7 +1196,12 @@ class PlayerGestureHelper(private val playerView: PlayerView) { if (isCurrentTouchValid) { playerView.callbacks?.onTouchDown() hasTriggeredSpeedUp = false - if (speedupEnabled && playerView.player.getIsPlaying() && !isLocked) { + + val (isTop, isNav) = checkEdgeTouch(view, event.x, event.y) + isTouchFromTopEdge = isTop + isTouchFromNavEdge = isNav + + if (speedupEnabled && playerView.player.getIsPlaying() && !isLocked && !isTop && !isNav) { holdHandler.postDelayed(holdRunnable, 500) } isVolumeLocked = currentRequestedVolume < 1.0f @@ -1112,6 +1222,13 @@ class PlayerGestureHelper(private val playerView: PlayerView) { if (hasTriggeredSpeedUp) return true if (!isCurrentTouchValid) return true + if (isTouchFromTopEdge && startTouch != null && currentTouch.y - startTouch.y > 0) { + return true + } + if (isTouchFromNavEdge) { + return true + } + if (currentTouchAction == null && startTouch != null) { val diffFromStart = startTouch - currentTouch if (swipeVerticalEnabled) { @@ -1184,10 +1301,17 @@ class PlayerGestureHelper(private val playerView: PlayerView) { } } } - // Tap detection: only fire if the finger was held briefly (not a long-press). + // Tap detection: only fire if the finger was held briefly (not a long-press) and didn't move as a swipe. val holdTime = currentTouchStartTime?.let { System.currentTimeMillis() - it } + val density = context.resources.displayMetrics.density + val touchSlop = 16f * density + val hasMoved = startTouch != null && ( + abs(currentTouch.x - startTouch.x) > touchSlop || + abs(currentTouch.y - startTouch.y) > touchSlop + ) if (currentTouchAction == null && currentLastTouchAction == null && !hasTriggeredSpeedUp + && !hasMoved && (holdTime == null || holdTime < DOUBLE_TAP_MAXIMUM_HOLD_TIME)) { onTapDetected( x = currentTouch.x, @@ -1205,6 +1329,8 @@ class PlayerGestureHelper(private val playerView: PlayerView) { // Reset touch lastTouchEndTime = System.currentTimeMillis() isCurrentTouchValid = false + isTouchFromTopEdge = false + isTouchFromNavEdge = false currentTouchStart = null currentLastTouchAction = currentTouchAction currentTouchAction = null