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 @@ + + + + + + + + + + + + + + + +