diff --git a/README.md b/README.md
index 0473e3c..ee6609a 100644
--- a/README.md
+++ b/README.md
@@ -151,6 +151,7 @@ Thanks to these open source projects that DroidFS uses:
### Modified code:
- [gocryptfs](https://github.com/rfjakob/gocryptfs) to encrypt your data
+- [DoubleTapPlayerView](https://github.com/vkay94/DoubleTapPlayerView) to add double-click controls to the video player
### Borrowed code:
- [MaterialFiles](https://github.com/zhanghai/MaterialFiles) for kotlin natural sorting implementation
### Libraries:
diff --git a/app/src/main/java/sushi/hardcore/droidfs/file_viewers/VideoPlayer.kt b/app/src/main/java/sushi/hardcore/droidfs/file_viewers/VideoPlayer.kt
index c66f80a..023f80d 100644
--- a/app/src/main/java/sushi/hardcore/droidfs/file_viewers/VideoPlayer.kt
+++ b/app/src/main/java/sushi/hardcore/droidfs/file_viewers/VideoPlayer.kt
@@ -19,6 +19,7 @@ class VideoPlayer: MediaPlayer() {
override fun bindPlayer(player: ExoPlayer) {
binding.videoPlayer.player = player
+ binding.videoPlayer.doubleTapOverlay = binding.doubleTapOverlay
binding.videoPlayer.setControllerVisibilityListener { visibility ->
binding.rotateButton.visibility = visibility
}
diff --git a/app/src/main/java/sushi/hardcore/droidfs/widgets/CircleClipTapView.kt b/app/src/main/java/sushi/hardcore/droidfs/widgets/CircleClipTapView.kt
new file mode 100644
index 0000000..2475831
--- /dev/null
+++ b/app/src/main/java/sushi/hardcore/droidfs/widgets/CircleClipTapView.kt
@@ -0,0 +1,198 @@
+package sushi.hardcore.droidfs.widgets
+
+import android.animation.Animator
+import android.animation.ValueAnimator
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Path
+import android.util.AttributeSet
+import android.view.View
+import androidx.core.content.ContextCompat
+import sushi.hardcore.droidfs.R
+
+/**
+ * View class
+ *
+ * (Borrowed from https://github.com/vkay94/DoubleTapPlayerView)
+ *
+ * Draws a arc shape and provides a circle scaling animation.
+ * Used by [DoubleTapOverlay][sushi.hardcore.droidfs.widgets.DoubleTapOverlay].
+ */
+internal class CircleClipTapView(context: Context, attrs: AttributeSet): View(context, attrs) {
+
+ private var backgroundPaint = Paint()
+ private var circlePaint = Paint()
+
+ private var widthPx = 0
+ private var heightPx = 0
+
+ // Background
+
+ private var shapePath = Path()
+ private var isLeft = true
+
+ // Circle
+
+ private var cX = 0f
+ private var cY = 0f
+
+ private var currentRadius = 0f
+ private var minRadius: Int = 0
+ private var maxRadius: Int = 0
+
+ // Animation
+
+ private var valueAnimator: ValueAnimator? = null
+ private var forceReset = false
+
+ init {
+ backgroundPaint.apply {
+ style = Paint.Style.FILL
+ isAntiAlias = true
+ color = ContextCompat.getColor(context, R.color.playerDoubleTapBackground)
+ }
+
+ circlePaint.apply {
+ style = Paint.Style.FILL
+ isAntiAlias = true
+ color = ContextCompat.getColor(context, R.color.playerDoubleTapTouch)
+ }
+
+ // Pre-configurations depending on device display metrics
+ val dm = context.resources.displayMetrics
+
+ widthPx = dm.widthPixels
+ heightPx = dm.heightPixels
+
+ minRadius = (30f * dm.density).toInt()
+ maxRadius = (400f * dm.density).toInt()
+
+ updatePathShape()
+
+ valueAnimator = getCircleAnimator()
+ }
+
+ var performAtEnd: () -> Unit = { }
+
+ var arcSize: Float = 80f
+ set(value) {
+ field = value
+ updatePathShape()
+ }
+
+ var animationDuration: Long
+ get() = valueAnimator?.duration ?: 650
+ set(value) {
+ getCircleAnimator().duration = value
+ }
+
+ /*
+ Circle
+ */
+
+ fun updatePosition(x: Float, y: Float) {
+ cX = x
+ cY = y
+
+ val newIsLeft = x <= resources.displayMetrics.widthPixels / 2
+ if (isLeft != newIsLeft) {
+ isLeft = newIsLeft
+ updatePathShape()
+ }
+ }
+
+ private fun invalidateWithCurrentRadius(factor: Float) {
+ currentRadius = minRadius + ((maxRadius - minRadius) * factor)
+ invalidate()
+ }
+
+ /*
+ Background
+ */
+
+ private fun updatePathShape() {
+ val halfWidth = widthPx * 0.5f
+
+ shapePath.reset()
+
+ val w = if (isLeft) 0f else widthPx.toFloat()
+ val f = if (isLeft) 1 else -1
+
+ shapePath.moveTo(w, 0f)
+ shapePath.lineTo(f * (halfWidth - arcSize) + w, 0f)
+ shapePath.quadTo(
+ f * (halfWidth + arcSize) + w,
+ heightPx.toFloat() / 2,
+ f * (halfWidth - arcSize) + w,
+ heightPx.toFloat()
+ )
+ shapePath.lineTo(w, heightPx.toFloat())
+
+ shapePath.close()
+ invalidate()
+ }
+
+ /*
+ Animation
+ */
+
+ private fun getCircleAnimator(): ValueAnimator {
+ if (valueAnimator == null) {
+ valueAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
+ duration = animationDuration
+ addUpdateListener {
+ invalidateWithCurrentRadius(it.animatedValue as Float)
+ }
+
+ addListener(object : Animator.AnimatorListener {
+ override fun onAnimationStart(animation: Animator?) {
+ visibility = VISIBLE
+ }
+
+ override fun onAnimationEnd(animation: Animator?) {
+ if (!forceReset) performAtEnd()
+ }
+
+ override fun onAnimationRepeat(animation: Animator?) {}
+ override fun onAnimationCancel(animation: Animator?) {}
+ })
+ }
+ }
+ return valueAnimator!!
+ }
+
+ fun resetAnimation(body: () -> Unit) {
+ forceReset = true
+ getCircleAnimator().end()
+ body()
+ forceReset = false
+ getCircleAnimator().start()
+ }
+
+ fun endAnimation() {
+ getCircleAnimator().end()
+ }
+
+ /*
+ Others: Drawing and Measurements
+ */
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ widthPx = w
+ heightPx = h
+ updatePathShape()
+ }
+
+ override fun onDraw(canvas: Canvas?) {
+ super.onDraw(canvas)
+
+ // Background
+ canvas?.clipPath(shapePath)
+ canvas?.drawPath(shapePath, backgroundPaint)
+
+ // Circle
+ canvas?.drawCircle(cX, cY, currentRadius, circlePaint)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/sushi/hardcore/droidfs/widgets/DoubleTapOverlay.kt b/app/src/main/java/sushi/hardcore/droidfs/widgets/DoubleTapOverlay.kt
new file mode 100644
index 0000000..31877d8
--- /dev/null
+++ b/app/src/main/java/sushi/hardcore/droidfs/widgets/DoubleTapOverlay.kt
@@ -0,0 +1,216 @@
+package sushi.hardcore.droidfs.widgets
+
+import android.animation.ValueAnimator
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.constraintlayout.widget.ConstraintSet
+import androidx.core.animation.doOnEnd
+import androidx.core.animation.doOnStart
+import sushi.hardcore.droidfs.R
+
+class DoubleTapOverlay @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+): ConstraintLayout(context, attrs, defStyleAttr) {
+
+ companion object {
+ const val CYCLE_DURATION = 750L
+ }
+
+ private var rootLayout: ConstraintLayout
+ private var indicatorContainer: LinearLayout
+ private var circleClipTapView: CircleClipTapView
+ private var trianglesContainer: LinearLayout
+ private var secondsTextView: TextView
+ private var icon1: ImageView
+ private var icon2: ImageView
+ private var icon3: ImageView
+ private var secondsOffset = 0
+
+ private var isForward: Boolean = true
+ set(value) {
+ // Mirror triangles depending on seek direction
+ trianglesContainer.rotation = if (value) 0f else 180f
+ field = value
+ }
+
+ init {
+ LayoutInflater.from(context).inflate(R.layout.double_tap_overlay, this, true)
+ rootLayout = findViewById(R.id.root_constraint_layout)
+ indicatorContainer = findViewById(R.id.indicators_container)
+ circleClipTapView = findViewById(R.id.circle_clip_tap_view)
+ trianglesContainer = findViewById(R.id.triangle_container)
+ secondsTextView = findViewById(R.id.seconds_textview)
+ icon1 = findViewById(R.id.icon_1)
+ icon2 = findViewById(R.id.icon_2)
+ icon3 = findViewById(R.id.icon_3)
+
+ circleClipTapView.performAtEnd = {
+ visibility = View.INVISIBLE
+ secondsOffset = 0
+ stop()
+ }
+ }
+
+ /**
+ * Starts the triangle animation
+ */
+ private fun start() {
+ stop()
+ firstAnimator.start()
+ }
+
+ /**
+ * Stops the triangle animation
+ */
+ private fun stop() {
+ firstAnimator.cancel()
+ secondAnimator.cancel()
+ thirdAnimator.cancel()
+ fourthAnimator.cancel()
+ fifthAnimator.cancel()
+ reset()
+ }
+
+ private fun reset() {
+ icon1.alpha = 0f
+ icon2.alpha = 0f
+ icon3.alpha = 0f
+ }
+
+ private val firstAnimator: ValueAnimator by lazy {
+ ValueAnimator.ofFloat(0f, 1f).setDuration(CYCLE_DURATION / 5).apply {
+ doOnStart {
+ icon1.alpha = 0f
+ icon2.alpha = 0f
+ icon3.alpha = 0f
+ }
+ addUpdateListener {
+ icon1.alpha = (it.animatedValue as Float)
+ }
+
+ doOnEnd {
+ secondAnimator.start()
+ }
+ }
+ }
+
+ private val secondAnimator: ValueAnimator by lazy {
+ ValueAnimator.ofFloat(0f, 1f).setDuration(CYCLE_DURATION / 5).apply {
+ doOnStart {
+ icon1.alpha = 1f
+ icon2.alpha = 0f
+ icon3.alpha = 0f
+ }
+ addUpdateListener {
+ icon2.alpha = (it.animatedValue as Float)
+ }
+ doOnEnd {
+ thirdAnimator.start()
+ }
+ }
+ }
+
+ private val thirdAnimator: ValueAnimator by lazy {
+ ValueAnimator.ofFloat(0f, 1f).setDuration(CYCLE_DURATION / 5).apply {
+ doOnStart {
+ icon1.alpha = 1f
+ icon2.alpha = 1f
+ icon3.alpha = 0f
+ }
+ addUpdateListener {
+ icon1.alpha =
+ 1f - icon3.alpha // or 1f - it (t3.alpha => all three stay a little longer together)
+ icon3.alpha = (it.animatedValue as Float)
+ }
+ doOnEnd {
+ fourthAnimator.start()
+ }
+ }
+
+ }
+
+ private val fourthAnimator: ValueAnimator by lazy {
+ ValueAnimator.ofFloat(0f, 1f).setDuration(CYCLE_DURATION / 5).apply {
+ doOnStart {
+ icon1.alpha = 0f
+ icon2.alpha = 1f
+ icon3.alpha = 1f
+ }
+ addUpdateListener {
+ icon2.alpha = 1f - (it.animatedValue as Float)
+ }
+ doOnEnd {
+ fifthAnimator.start()
+ }
+
+ }
+ }
+
+ private val fifthAnimator: ValueAnimator by lazy {
+ ValueAnimator.ofFloat(0f, 1f).setDuration(CYCLE_DURATION / 5).apply {
+ doOnStart {
+ icon1.alpha = 0f
+ icon2.alpha = 0f
+ icon3.alpha = 1f
+ }
+ addUpdateListener {
+ icon3.alpha = 1f - (it.animatedValue as Float)
+ }
+ doOnEnd {
+ firstAnimator.start()
+ }
+ }
+ }
+
+ private fun changeConstraints(forward: Boolean) {
+ val constraintSet = ConstraintSet()
+ with(constraintSet) {
+ clone(rootLayout)
+ if (forward) {
+ clear(indicatorContainer.id, ConstraintSet.START)
+ connect(indicatorContainer.id, ConstraintSet.END,
+ ConstraintSet.PARENT_ID, ConstraintSet.END)
+ } else {
+ clear(indicatorContainer.id, ConstraintSet.END)
+ connect(indicatorContainer.id, ConstraintSet.START,
+ ConstraintSet.PARENT_ID, ConstraintSet.START)
+ }
+ start()
+ applyTo(rootLayout)
+ }
+ }
+
+ fun showAnimation(forward: Boolean, x: Float, y: Float) {
+ if (visibility != View.VISIBLE) {
+ visibility = View.VISIBLE
+ start()
+ }
+ if (forward xor isForward) {
+ changeConstraints(forward)
+ isForward = forward
+ secondsOffset = 0
+ }
+ secondsOffset += DoubleTapPlayerView.SEEK_SECONDS
+ secondsTextView.text = context.getString(
+ if (forward)
+ R.string.seek_seconds_forward
+ else
+ R.string.seek_seconds_backward
+ , secondsOffset
+ )
+
+ // Cancel ripple and start new without triggering overlay disappearance
+ // (resetting instead of ending)
+ circleClipTapView.resetAnimation {
+ circleClipTapView.updatePosition(x, y)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/sushi/hardcore/droidfs/widgets/DoubleTapPlayerView.kt b/app/src/main/java/sushi/hardcore/droidfs/widgets/DoubleTapPlayerView.kt
new file mode 100644
index 0000000..ad9d2f2
--- /dev/null
+++ b/app/src/main/java/sushi/hardcore/droidfs/widgets/DoubleTapPlayerView.kt
@@ -0,0 +1,114 @@
+package sushi.hardcore.droidfs.widgets
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.media.session.PlaybackState
+import android.os.Handler
+import android.os.Looper
+import android.util.AttributeSet
+import android.view.GestureDetector
+import android.view.MotionEvent
+import androidx.core.view.GestureDetectorCompat
+import com.google.android.exoplayer2.ui.PlayerView
+
+class DoubleTapPlayerView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+): PlayerView(context, attrs, defStyleAttr) {
+
+ companion object {
+ const val SEEK_SECONDS = 10
+ const val SEEK_MILLISECONDS = SEEK_SECONDS*1000
+ }
+
+ lateinit var doubleTapOverlay: DoubleTapOverlay
+ private val gestureListener = object : GestureDetector.SimpleOnGestureListener() {
+ private var isDoubleTapping = false
+ private val handler = Handler(Looper.getMainLooper())
+ private val stopDoubleTap = Runnable {
+ isDoubleTapping = false
+ }
+
+ override fun onSingleTapUp(e: MotionEvent): Boolean {
+ if (isDoubleTapping) {
+ handleDoubleTap(e.x, e.y)
+ }
+ return true
+ }
+
+ override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
+ if (!isDoubleTapping)
+ performClick()
+ return true
+ }
+
+ override fun onDoubleTap(e: MotionEvent): Boolean {
+ if (!isDoubleTapping)
+ keepDoubleTapping()
+ return true
+ }
+
+ override fun onDoubleTapEvent(e: MotionEvent): Boolean {
+ if (e.actionMasked == MotionEvent.ACTION_UP && isDoubleTapping)
+ handleDoubleTap(e.x, e.y)
+ return true
+ }
+
+ fun cancelDoubleTap() {
+ handler.removeCallbacks(stopDoubleTap)
+ isDoubleTapping = false
+ }
+
+ fun keepDoubleTapping() {
+ handler.removeCallbacks(stopDoubleTap)
+ isDoubleTapping = true
+ handler.postDelayed(stopDoubleTap, 700)
+ }
+ }
+ private val gestureDetector = GestureDetectorCompat(context, gestureListener)
+
+ @SuppressLint("ClickableViewAccessibility")
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ gestureDetector.onTouchEvent(event)
+ return true
+ }
+
+ fun handleDoubleTap(x: Float, y: Float) {
+ player?.let { player ->
+ if (player.playbackState == PlaybackState.STATE_ERROR ||
+ player.playbackState == PlaybackState.STATE_NONE ||
+ player.playbackState == PlaybackState.STATE_STOPPED)
+ gestureListener.cancelDoubleTap()
+ else if (player.currentPosition > 500 && x < doubleTapOverlay.width * 0.35)
+ triggerSeek(false, x, y)
+ else if (player.currentPosition < player.duration && x > doubleTapOverlay.width * 0.65)
+ triggerSeek(true, x, y)
+ }
+ }
+
+ private fun triggerSeek(forward: Boolean, x: Float, y: Float) {
+ doubleTapOverlay.showAnimation(forward, x, y)
+ player?.let { player ->
+ seekTo(
+ if (forward)
+ player.currentPosition + SEEK_MILLISECONDS
+ else
+ player.currentPosition - SEEK_MILLISECONDS
+ )
+ }
+ }
+
+ private fun seekTo(position: Long) {
+ player?.let { player ->
+ when {
+ position <= 0 -> player.seekTo(0)
+ position >= player.duration -> player.seekTo(player.duration)
+ else -> {
+ gestureListener.keepDoubleTapping()
+ player.seekTo(position)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/icon_triangle.xml b/app/src/main/res/drawable/icon_triangle.xml
new file mode 100644
index 0000000..1aee026
--- /dev/null
+++ b/app/src/main/res/drawable/icon_triangle.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_video_player.xml b/app/src/main/res/layout/activity_video_player.xml
index 4accef8..0c2e352 100644
--- a/app/src/main/res/layout/activity_video_player.xml
+++ b/app/src/main/res/layout/activity_video_player.xml
@@ -7,13 +7,25 @@
android:layoutDirection="ltr"
android:background="@color/fullScreenBackgroundColor">
-
+ android:layout_gravity="center">
+
+
+
+
+
+
+ app:tint="@color/neutralIconTint"/>
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/double_tap_overlay.xml b/app/src/main/res/layout/double_tap_overlay.xml
new file mode 100644
index 0000000..1eab192
--- /dev/null
+++ b/app/src/main/res/layout/double_tap_overlay.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index d67ee5f..937382a 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -13,4 +13,6 @@
@color/black
@color/white
#66666666
+ #18FFFFFF
+ #20EEEEEE
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9e85694..fe35ec9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -208,4 +208,6 @@
Customize app theme
Thumbnails
Show images and videos thumbnails
+ +%d seconds
+ -%d seconds