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