DoubleTapPlayerView

This commit is contained in:
Matéo Duparc 2022-01-18 20:18:44 +01:00
parent 3ae7e4df70
commit 832fd1d34b
Signed by untrusted user: hardcoresushi
GPG Key ID: 007F84120107191E
10 changed files with 630 additions and 6 deletions

View File

@ -151,6 +151,7 @@ Thanks to these open source projects that DroidFS uses:
### Modified code: ### Modified code:
- [gocryptfs](https://github.com/rfjakob/gocryptfs) to encrypt your data - [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: ### Borrowed code:
- [MaterialFiles](https://github.com/zhanghai/MaterialFiles) for kotlin natural sorting implementation - [MaterialFiles](https://github.com/zhanghai/MaterialFiles) for kotlin natural sorting implementation
### Libraries: ### Libraries:

View File

@ -19,6 +19,7 @@ class VideoPlayer: MediaPlayer() {
override fun bindPlayer(player: ExoPlayer) { override fun bindPlayer(player: ExoPlayer) {
binding.videoPlayer.player = player binding.videoPlayer.player = player
binding.videoPlayer.doubleTapOverlay = binding.doubleTapOverlay
binding.videoPlayer.setControllerVisibilityListener { visibility -> binding.videoPlayer.setControllerVisibilityListener { visibility ->
binding.rotateButton.visibility = visibility binding.rotateButton.visibility = visibility
} }

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="20dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M3,2 L22,12 L3,22 Z" />
</vector>

View File

@ -7,19 +7,32 @@
android:layoutDirection="ltr" android:layoutDirection="ltr"
android:background="@color/fullScreenBackgroundColor"> android:background="@color/fullScreenBackgroundColor">
<com.google.android.exoplayer2.ui.PlayerView <FrameLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center">
<sushi.hardcore.droidfs.widgets.DoubleTapPlayerView
android:id="@+id/video_player" android:id="@+id/video_player"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_gravity="center"
app:show_shuffle_button="true" app:show_shuffle_button="true"
app:repeat_toggle_modes="all|one" /> app:repeat_toggle_modes="all|one" />
<sushi.hardcore.droidfs.widgets.DoubleTapOverlay
android:id="@+id/double_tap_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="invisible"/>
</FrameLayout>
<ImageButton <ImageButton
android:id="@+id/rotate_button" android:id="@+id/rotate_button"
style="@style/ExoMediaButton" style="@style/ExoMediaButton"
android:layout_gravity="top|end" android:layout_gravity="top|end"
android:layout_marginTop="15dp" android:layout_marginTop="15dp"
android:src="@drawable/icon_screen_rotation" android:src="@drawable/icon_screen_rotation"
app:tint="@color/white"/> app:tint="@color/neutralIconTint"/>
</FrameLayout> </FrameLayout>

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root_constraint_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<sushi.hardcore.droidfs.widgets.CircleClipTapView
android:id="@+id/circle_clip_tap_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="false"
android:focusable="false" />
<LinearLayout
android:id="@+id/indicators_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_default="percent"
app:layout_constraintWidth_percent="0.5"
android:orientation="vertical">
<LinearLayout
android:id="@+id/triangle_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="horizontal">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/icon_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:srcCompat="@drawable/icon_triangle"
tools:alpha="0.18" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/icon_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:srcCompat="@drawable/icon_triangle"
tools:alpha="0.5" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/icon_3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:srcCompat="@drawable/icon_triangle"
tools:alpha="1" />
</LinearLayout>
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/seconds_textview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="4dp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -13,4 +13,6 @@
<color name="fullScreenBackgroundColor">@color/black</color> <color name="fullScreenBackgroundColor">@color/black</color>
<color name="textColor">@color/white</color> <color name="textColor">@color/white</color>
<color name="itemSelected">#66666666</color> <color name="itemSelected">#66666666</color>
<color name="playerDoubleTapTouch">#18FFFFFF</color>
<color name="playerDoubleTapBackground">#20EEEEEE</color>
</resources> </resources>

View File

@ -208,4 +208,6 @@
<string name="theme_summary">Customize app theme</string> <string name="theme_summary">Customize app theme</string>
<string name="thumbnails">Thumbnails</string> <string name="thumbnails">Thumbnails</string>
<string name="thumbnails_summary">Show images and videos thumbnails</string> <string name="thumbnails_summary">Show images and videos thumbnails</string>
<string name="seek_seconds_forward">+%d seconds</string>
<string name="seek_seconds_backward">-%d seconds</string>
</resources> </resources>