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