Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <!-- Used for player vertical slide -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <!-- Used for app update -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- Used for app notifications on Android 13+ -->
<uses-permission android:name="android.permission.VIBRATE" /> <!-- Used for subtitle jump and player haptics -->
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> <!-- Used for Android TV watch next -->
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" /> <!-- Used for updates without prompt -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- Used for update service -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -1370,4 +1372,47 @@ open class FullScreenPlayer : AbstractPlayerFragment<FragmentPlayerBinding>(
.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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<Boolean, Boolean> {
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 {
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down
Loading