diff --git a/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt b/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt
new file mode 100644
index 000000000..cbb4df738
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt
@@ -0,0 +1,106 @@
+package org.schabi.newpipe.views.player
+
+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 org.schabi.newpipe.player.event.DisplayPortion
+
+class CircleClipTapView(context: Context?, attrs: AttributeSet) : View(context, attrs) {
+
+ companion object {
+ const val COLOR_DARK = 0x40000000
+ const val COLOR_DARK_TRANSPARENT = 0x30000000
+ const val COLOR_LIGHT_TRANSPARENT = 0x25EEEEEE
+
+ fun calculateArcSize(view: View): Float = view.height / 11.4f
+ }
+
+ private var backgroundPaint = Paint()
+
+ private var widthPx = 0
+ private var heightPx = 0
+
+ // Background
+
+ private var shapePath = Path()
+ private var isLeft = true
+
+ init {
+ requireNotNull(context) { "Context is null." }
+
+ backgroundPaint.apply {
+ style = Paint.Style.FILL
+ isAntiAlias = true
+ color = COLOR_LIGHT_TRANSPARENT
+ }
+
+ val dm = context.resources.displayMetrics
+ widthPx = dm.widthPixels
+ heightPx = dm.heightPixels
+
+ updatePathShape()
+ }
+
+ var arcSize: Float = 80f
+ set(value) {
+ field = value
+ updatePathShape()
+ }
+
+ var circleBackgroundColor: Int
+ get() = backgroundPaint.color
+ set(value) {
+ backgroundPaint.color = value
+ }
+
+ /*
+ Background
+ */
+
+ fun updatePosition(portion: DisplayPortion) {
+ val newIsLeft = portion == DisplayPortion.LEFT
+ if (isLeft != newIsLeft) {
+ isLeft = newIsLeft
+ updatePathShape()
+ }
+ }
+
+ 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()
+ }
+
+ 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)
+
+ canvas?.clipPath(shapePath)
+ canvas?.drawPath(shapePath, backgroundPaint)
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/views/player/PlayerSeekOverlay.kt b/app/src/main/java/org/schabi/newpipe/views/player/PlayerSeekOverlay.kt
new file mode 100644
index 000000000..5bdf0c97b
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/views/player/PlayerSeekOverlay.kt
@@ -0,0 +1,264 @@
+package org.schabi.newpipe.views.player
+
+import android.content.Context
+import android.util.AttributeSet
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import androidx.annotation.*
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.constraintlayout.widget.ConstraintSet
+import androidx.constraintlayout.widget.ConstraintSet.*
+import androidx.core.content.ContextCompat
+import androidx.core.widget.TextViewCompat
+import androidx.preference.PreferenceManager
+import kotlinx.android.synthetic.main.player_seek_overlay.view.*
+import org.schabi.newpipe.MainActivity
+import org.schabi.newpipe.R
+import org.schabi.newpipe.player.event.DisplayPortion
+import org.schabi.newpipe.player.event.DoubleTapListener
+
+class PlayerSeekOverlay(context: Context?, private val attrs: AttributeSet?) :
+ ConstraintLayout(context, attrs), DoubleTapListener {
+
+ private var secondsView: SecondsView
+ private var circleClipTapView: CircleClipTapView
+
+ private var isForwarding: Boolean? = null
+
+ init {
+ LayoutInflater.from(context).inflate(R.layout.player_seek_overlay, this, true)
+
+ secondsView = findViewById(R.id.seconds_view)
+ circleClipTapView = findViewById(R.id.circle_clip_tap_view)
+
+ initializeAttributes()
+ secondsView.isForward = true
+ isForwarding = null
+ changeConstraints(true)
+ }
+
+ private fun initializeAttributes() {
+ circleBackgroundColorInt(CircleClipTapView.COLOR_LIGHT_TRANSPARENT)
+ iconAnimationDuration(SecondsView.ICON_ANIMATION_DURATION)
+ icon(R.drawable.ic_play_seek_triangle)
+
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ val durationKey = context.getString(R.string.seek_duration_key)
+ val seekValue = prefs.getString(
+ durationKey, context.getString(R.string.seek_duration_default_value)
+ )
+ seekSeconds(seekValue?.toInt()?.div(1000) ?: 10)
+ }
+
+ private var performListener: PerformListener? = null
+
+ fun performListener(listener: PerformListener) = apply {
+ performListener = listener
+ }
+
+ var seekSeconds: Int = 0
+ private set
+
+ fun seekSeconds(seconds: Int) = apply {
+ seekSeconds = seconds
+ }
+
+ var circleBackgroundColor: Int
+ get() = circleClipTapView.circleBackgroundColor
+ private set(value) {
+ circleClipTapView.circleBackgroundColor = value
+ }
+
+ fun circleBackgroundColorRes(@ColorRes resId: Int) = apply {
+ circleBackgroundColor = ContextCompat.getColor(context, resId)
+ }
+
+ fun circleBackgroundColorInt(@ColorInt color: Int) = apply {
+ circleBackgroundColor = color
+ }
+
+ var arcSize: Float
+ get() = circleClipTapView.arcSize
+ internal set(value) {
+ circleClipTapView.arcSize = value
+ }
+
+ fun arcSize(@DimenRes resId: Int) = apply {
+ arcSize = context.resources.getDimension(resId)
+ }
+
+ fun arcSize(px: Float) = apply {
+ arcSize = px
+ }
+
+ var showCircle: Boolean = true
+ private set(value) {
+ circleClipTapView.visibility = if (value) View.VISIBLE else View.GONE
+ field = value
+ }
+
+ fun showCircle(show: Boolean) = apply {
+ showCircle = show
+ }
+
+ var iconAnimationDuration: Long = SecondsView.ICON_ANIMATION_DURATION
+ get() = secondsView.cycleDuration
+ private set(value) {
+ secondsView.cycleDuration = value
+ field = value
+ }
+
+ fun iconAnimationDuration(duration: Long) = apply {
+ iconAnimationDuration = duration
+ }
+
+ @DrawableRes
+ var icon: Int = 0
+ get() = secondsView.icon
+ private set(value) {
+ secondsView.stop()
+ secondsView.icon = value
+ field = value
+ }
+
+ fun icon(@DrawableRes resId: Int) = apply {
+ icon = resId
+ }
+
+ @StyleRes
+ var textAppearance: Int = 0
+ private set(value) {
+ TextViewCompat.setTextAppearance(secondsView.textView, value)
+ field = value
+ }
+
+ fun textAppearance(@StyleRes resId: Int) = apply {
+ textAppearance = resId
+ }
+
+ // Indicates whether this (double) tap is the first of a series
+ // Decides whether to call performListener.onAnimationStart or not
+ private var initTap: Boolean = false
+
+ override fun onDoubleTapStarted(portion: DisplayPortion) {
+ if (DEBUG)
+ Log.d(TAG, "onDoubleTapStarted called with portion = [$portion]")
+
+ initTap = false
+ performListener?.onPrepare()
+
+ changeConstraints(secondsView.isForward)
+ if (showCircle) circleClipTapView.updatePosition(portion)
+
+ isForwarding = null
+
+ if (this.alpha == 0f)
+ secondsView.stop()
+ }
+
+ override fun onDoubleTapProgressDown(portion: DisplayPortion) {
+ val shouldForward: Boolean = performListener?.shouldFastForward(portion) ?: return
+
+ if (DEBUG)
+ Log.d(TAG,"onDoubleTapProgressDown called with " +
+ "shouldForward = [$shouldForward], " +
+ "isForwarding = [$isForwarding], " +
+ "secondsView#isForward = [${secondsView.isForward}], " +
+ "initTap = [$initTap], ")
+
+ // Using this check prevents from fast switching (one touches)
+ if (isForwarding != null && isForwarding != shouldForward) {
+ isForwarding = shouldForward
+ return
+ }
+ isForwarding = shouldForward
+
+ if (this.visibility != View.VISIBLE || !initTap) {
+ if (!initTap) {
+ secondsView.seconds = 0
+ performListener?.onAnimationStart()
+ secondsView.start()
+ initTap = true
+ }
+ }
+
+ if (shouldForward)
+ forwarding()
+ else
+ rewinding()
+ }
+
+ override fun onDoubleTapFinished() {
+ if (DEBUG)
+ Log.d(TAG, "onDoubleTapFinished called with initTap = [$initTap]")
+
+ if (initTap) performListener?.onAnimationEnd()
+ initTap = false
+ }
+
+ private fun forwarding() {
+ if (DEBUG)
+ Log.d(TAG, "forwarding called")
+
+ // First time tap or switched
+ if (!secondsView.isForward) {
+ changeConstraints(true)
+ if (showCircle) circleClipTapView.updatePosition(DisplayPortion.RIGHT)
+ secondsView.apply {
+ isForward = true
+ seconds = 0
+ }
+ }
+ secondsView.seconds += seekSeconds
+ performListener?.seek(forward = true)
+ }
+
+ private fun rewinding() {
+ if (DEBUG)
+ Log.d(TAG, "rewinding called")
+
+ // First time tap or switched
+ if (secondsView.isForward) {
+ changeConstraints(false)
+ if (showCircle) circleClipTapView.updatePosition(DisplayPortion.LEFT)
+ secondsView.apply {
+ isForward = false
+ seconds = 0
+ }
+ }
+ secondsView.seconds += seekSeconds
+ performListener?.seek(forward = false)
+ }
+
+ private fun changeConstraints(forward: Boolean) {
+ val constraintSet = ConstraintSet()
+ with(constraintSet) {
+ clone(root_constraint_layout)
+ if (forward) {
+ clear(seconds_view.id, START)
+ connect(seconds_view.id, END,
+ PARENT_ID, END)
+ } else {
+ clear(seconds_view.id, END)
+ connect(seconds_view.id, START,
+ PARENT_ID, START)
+ }
+ secondsView.start()
+ applyTo(root_constraint_layout)
+ }
+ }
+
+ interface PerformListener {
+ fun onPrepare() {}
+ fun onAnimationStart()
+ fun onAnimationEnd()
+ fun shouldFastForward(portion: DisplayPortion): Boolean?
+ fun seek(forward: Boolean)
+ }
+
+ companion object {
+ private const val TAG = "PlayerSeekOverlay"
+ private val DEBUG = MainActivity.DEBUG
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt b/app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt
new file mode 100644
index 000000000..30bfe1217
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt
@@ -0,0 +1,171 @@
+package org.schabi.newpipe.views.player
+
+import android.animation.Animator
+import android.animation.ValueAnimator
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.TextView
+import androidx.annotation.DrawableRes
+import androidx.constraintlayout.widget.ConstraintLayout
+import kotlinx.android.synthetic.main.player_seek_seconds_view.view.*
+import org.schabi.newpipe.R
+
+class SecondsView(context: Context?, attrs: AttributeSet?) :
+ ConstraintLayout(context, attrs) {
+
+ companion object {
+ const val ICON_ANIMATION_DURATION = 750L
+ }
+
+ var cycleDuration: Long = ICON_ANIMATION_DURATION
+ set(value) {
+ firstAnimator.duration = value / 5
+ secondAnimator.duration = value / 5
+ thirdAnimator.duration = value / 5
+ fourthAnimator.duration = value / 5
+ fifthAnimator.duration = value / 5
+ field = value
+ }
+
+ var seconds: Int = 0
+ set(value) {
+ tv_seconds.text = context.resources.getQuantityString(
+ R.plurals.seconds, value, value
+ )
+ field = value
+ }
+
+ var isForward: Boolean = true
+ set(value) {
+ triangle_container.rotation = if (value) 0f else 180f
+ field = value
+ }
+
+ val textView: TextView
+ get() = tv_seconds
+
+
+ @DrawableRes
+ var icon: Int = R.drawable.ic_play_seek_triangle
+ set(value) {
+ if (value > 0) {
+ icon_1.setImageResource(value)
+ icon_2.setImageResource(value)
+ icon_3.setImageResource(value)
+ }
+ field = value
+ }
+
+ init {
+ LayoutInflater.from(context).inflate(R.layout.player_seek_seconds_view, this, true)
+ }
+
+ fun start() {
+ stop()
+ firstAnimator.start()
+ }
+
+ fun stop() {
+ firstAnimator.cancel()
+ secondAnimator.cancel()
+ thirdAnimator.cancel()
+ fourthAnimator.cancel()
+ fifthAnimator.cancel()
+
+ reset()
+ }
+
+ private fun reset() {
+ icon_1.alpha = 0f
+ icon_2.alpha = 0f
+ icon_3.alpha = 0f
+ }
+
+ private val firstAnimator: ValueAnimator = CustomValueAnimator(
+ {
+ icon_1.alpha = 0f
+ icon_2.alpha = 0f
+ icon_3.alpha = 0f
+ }, {
+ icon_1.alpha = it
+ }, {
+ secondAnimator.start()
+ }
+ )
+
+ private val secondAnimator: ValueAnimator = CustomValueAnimator(
+ {
+ icon_1.alpha = 1f
+ icon_2.alpha = 0f
+ icon_3.alpha = 0f
+ }, {
+ icon_2.alpha = it
+ }, {
+ thirdAnimator.start()
+ }
+ )
+
+ private val thirdAnimator: ValueAnimator = CustomValueAnimator(
+ {
+ icon_1.alpha = 1f
+ icon_2.alpha = 1f
+ icon_3.alpha = 0f
+ }, {
+ icon_1.alpha = 1f - icon_3.alpha
+ icon_3.alpha = it
+ }, {
+ fourthAnimator.start()
+ }
+ )
+
+ private val fourthAnimator: ValueAnimator = CustomValueAnimator(
+ {
+ icon_1.alpha = 0f
+ icon_2.alpha = 1f
+ icon_3.alpha = 1f
+ }, {
+ icon_2.alpha = 1f - it
+ }, {
+ fifthAnimator.start()
+ }
+ )
+
+ private val fifthAnimator: ValueAnimator = CustomValueAnimator(
+ {
+ icon_1.alpha = 0f
+ icon_2.alpha = 0f
+ icon_3.alpha = 1f
+ }, {
+ icon_3.alpha = 1f - it
+ }, {
+ firstAnimator.start()
+ }
+ )
+
+ private inner class CustomValueAnimator(
+ start: () -> Unit, update: (value: Float) -> Unit, end: () -> Unit
+ ): ValueAnimator() {
+
+ init {
+ duration = cycleDuration / 5
+ setFloatValues(0f, 1f)
+
+ addUpdateListener { update(it.animatedValue as Float) }
+ addListener(object : AnimatorListener {
+ override fun onAnimationStart(animation: Animator?) {
+ start()
+ }
+
+ override fun onAnimationEnd(animation: Animator?) {
+ end()
+ }
+
+ override fun onAnimationCancel(animation: Animator?) = Unit
+
+ override fun onAnimationRepeat(animation: Animator?) = Unit
+
+ })
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_play_seek_triangle.xml b/app/src/main/res/drawable/ic_play_seek_triangle.xml
new file mode 100644
index 000000000..1aee026db
--- /dev/null
+++ b/app/src/main/res/drawable/ic_play_seek_triangle.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/player_seek_overlay.xml b/app/src/main/res/layout/player_seek_overlay.xml
new file mode 100644
index 000000000..f4e9f1707
--- /dev/null
+++ b/app/src/main/res/layout/player_seek_overlay.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/player_seek_seconds_view.xml b/app/src/main/res/layout/player_seek_seconds_view.xml
new file mode 100644
index 000000000..14c9eaa2d
--- /dev/null
+++ b/app/src/main/res/layout/player_seek_seconds_view.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+