diff --git a/app/src/main/java/org/schabi/newpipe/ktx/View.kt b/app/src/main/java/org/schabi/newpipe/ktx/View.kt
index a1a96b20d..8dcc9d85c 100644
--- a/app/src/main/java/org/schabi/newpipe/ktx/View.kt
+++ b/app/src/main/java/org/schabi/newpipe/ktx/View.kt
@@ -75,6 +75,7 @@ fun View.animate(
}
animate().setListener(null).cancel()
isVisible = true
+
when (animationType) {
AnimationType.ALPHA -> animateAlpha(enterOrExit, duration, delay, execOnEnd)
AnimationType.SCALE_AND_ALPHA -> animateScaleAndAlpha(enterOrExit, duration, delay, execOnEnd)
diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java
index 43533b52e..993357ac4 100644
--- a/app/src/main/java/org/schabi/newpipe/player/Player.java
+++ b/app/src/main/java/org/schabi/newpipe/player/Player.java
@@ -51,9 +51,6 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
-import android.animation.ObjectAnimator;
-import android.animation.PropertyValuesHolder;
-import android.animation.ValueAnimator;
import android.annotation.SuppressLint;
import android.content.BroadcastReceiver;
import android.content.Context;
@@ -154,6 +151,7 @@ import org.schabi.newpipe.info_list.StreamSegmentAdapter;
import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
+import org.schabi.newpipe.player.event.DisplayPortion;
import org.schabi.newpipe.player.event.PlayerEventListener;
import org.schabi.newpipe.player.event.PlayerGestureListener;
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
@@ -188,6 +186,7 @@ import org.schabi.newpipe.util.StreamTypeUtil;
import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.views.ExpandableSurfaceView;
+import org.schabi.newpipe.views.player.PlayerFastSeekOverlay;
import java.io.IOException;
import java.util.ArrayList;
@@ -247,6 +246,7 @@ public final class Player implements
public static final int DEFAULT_CONTROLS_DURATION = 300; // 300 millis
public static final int DEFAULT_CONTROLS_HIDE_TIME = 2000; // 2 Seconds
public static final int DPAD_CONTROLS_HIDE_TIME = 7000; // 7 Seconds
+ public static final int SEEK_OVERLAY_DURATION = 450; // 450 millis
/*//////////////////////////////////////////////////////////////////////////
// Other constants
@@ -313,7 +313,6 @@ public final class Player implements
private PlayerBinding binding;
- private ValueAnimator controlViewAnimator;
private final Handler controlsVisibilityHandler = new Handler();
// fullscreen player
@@ -365,6 +364,7 @@ public final class Player implements
private int maxGestureLength; // scaled
private GestureDetectorCompat gestureDetector;
+ private PlayerGestureListener playerGestureListener;
/*//////////////////////////////////////////////////////////////////////////
// Listeners and disposables
@@ -449,6 +449,8 @@ public final class Player implements
initPlayer(true);
}
initListeners();
+
+ setupPlayerSeekOverlay();
}
private void initViews(@NonNull final PlayerBinding playerBinding) {
@@ -525,9 +527,9 @@ public final class Player implements
binding.resizeTextView.setOnClickListener(this);
binding.playbackLiveSync.setOnClickListener(this);
- final PlayerGestureListener listener = new PlayerGestureListener(this, service);
- gestureDetector = new GestureDetectorCompat(context, listener);
- binding.getRoot().setOnTouchListener(listener);
+ playerGestureListener = new PlayerGestureListener(this, service);
+ gestureDetector = new GestureDetectorCompat(context, playerGestureListener);
+ binding.getRoot().setOnTouchListener(playerGestureListener);
binding.queueButton.setOnClickListener(this);
binding.segmentsButton.setOnClickListener(this);
@@ -578,6 +580,68 @@ public final class Player implements
v.getPaddingRight(),
v.getPaddingBottom()));
}
+
+ /**
+ * Initializes the Fast-For/Backward overlay.
+ */
+ private void setupPlayerSeekOverlay() {
+ binding.fastSeekOverlay
+ .seekSecondsSupplier(
+ () -> (int) (retrieveSeekDurationFromPreferences(this) / 1000.0f))
+ .performListener(new PlayerFastSeekOverlay.PerformListener() {
+
+ @Override
+ public void onDoubleTap() {
+ animate(binding.fastSeekOverlay, true, SEEK_OVERLAY_DURATION);
+ }
+
+ @Override
+ public void onDoubleTapEnd() {
+ animate(binding.fastSeekOverlay, false, SEEK_OVERLAY_DURATION);
+ }
+
+ @Override
+ public FastSeekDirection getFastSeekDirection(
+ @NonNull final DisplayPortion portion
+ ) {
+ if (exoPlayerIsNull()) {
+ // Abort seeking
+ playerGestureListener.endMultiDoubleTap();
+ return FastSeekDirection.NONE;
+ }
+ if (portion == DisplayPortion.LEFT) {
+ // Check if it's possible to rewind
+ // Small puffer to eliminate infinite rewind seeking
+ if (simpleExoPlayer.getCurrentPosition() < 500L) {
+ return FastSeekDirection.NONE;
+ }
+ return FastSeekDirection.BACKWARD;
+ } else if (portion == DisplayPortion.RIGHT) {
+ // Check if it's possible to fast-forward
+ if (currentState == STATE_COMPLETED
+ || simpleExoPlayer.getCurrentPosition()
+ >= simpleExoPlayer.getDuration()) {
+ return FastSeekDirection.NONE;
+ }
+ return FastSeekDirection.FORWARD;
+ }
+ /* portion == DisplayPortion.MIDDLE */
+ return FastSeekDirection.NONE;
+ }
+
+ @Override
+ public void seek(final boolean forward) {
+ playerGestureListener.keepInDoubleTapMode();
+ if (forward) {
+ fastForward();
+ } else {
+ fastRewind();
+ }
+ }
+ });
+ playerGestureListener.doubleTapControls(binding.fastSeekOverlay);
+ }
+
//endregion
@@ -1796,71 +1860,6 @@ public final class Player implements
return binding != null && binding.playbackControlRoot.getVisibility() == View.VISIBLE;
}
- /**
- * Show a animation, and depending on goneOnEnd, will stay on the screen or be gone.
- *
- * @param drawableId the drawable that will be used to animate,
- * pass -1 to clear any animation that is visible
- * @param goneOnEnd will set the animation view to GONE on the end of the animation
- */
- public void showAndAnimateControl(final int drawableId, final boolean goneOnEnd) {
- if (DEBUG) {
- Log.d(TAG, "showAndAnimateControl() called with: "
- + "drawableId = [" + drawableId + "], goneOnEnd = [" + goneOnEnd + "]");
- }
- if (controlViewAnimator != null && controlViewAnimator.isRunning()) {
- if (DEBUG) {
- Log.d(TAG, "showAndAnimateControl: controlViewAnimator.isRunning");
- }
- controlViewAnimator.end();
- }
-
- if (drawableId == -1) {
- if (binding.controlAnimationView.getVisibility() == View.VISIBLE) {
- controlViewAnimator = ObjectAnimator.ofPropertyValuesHolder(
- binding.controlAnimationView,
- PropertyValuesHolder.ofFloat(View.ALPHA, 1.0f, 0.0f),
- PropertyValuesHolder.ofFloat(View.SCALE_X, 1.4f, 1.0f),
- PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.4f, 1.0f)
- ).setDuration(DEFAULT_CONTROLS_DURATION);
- controlViewAnimator.addListener(new AnimatorListenerAdapter() {
- @Override
- public void onAnimationEnd(final Animator animation) {
- binding.controlAnimationView.setVisibility(View.GONE);
- }
- });
- controlViewAnimator.start();
- }
- return;
- }
-
- final float scaleFrom = goneOnEnd ? 1f : 1f;
- final float scaleTo = goneOnEnd ? 1.8f : 1.4f;
- final float alphaFrom = goneOnEnd ? 1f : 0f;
- final float alphaTo = goneOnEnd ? 0f : 1f;
-
-
- controlViewAnimator = ObjectAnimator.ofPropertyValuesHolder(
- binding.controlAnimationView,
- PropertyValuesHolder.ofFloat(View.ALPHA, alphaFrom, alphaTo),
- PropertyValuesHolder.ofFloat(View.SCALE_X, scaleFrom, scaleTo),
- PropertyValuesHolder.ofFloat(View.SCALE_Y, scaleFrom, scaleTo)
- );
- controlViewAnimator.setDuration(goneOnEnd ? 1000 : 500);
- controlViewAnimator.addListener(new AnimatorListenerAdapter() {
- @Override
- public void onAnimationEnd(final Animator animation) {
- binding.controlAnimationView.setVisibility(goneOnEnd ? View.GONE : View.VISIBLE);
- }
- });
-
-
- binding.controlAnimationView.setVisibility(View.VISIBLE);
- binding.controlAnimationView.setImageDrawable(
- AppCompatResources.getDrawable(context, drawableId));
- controlViewAnimator.start();
- }
-
public void showControlsThenHide() {
if (DEBUG) {
Log.d(TAG, "showControlsThenHide() called");
@@ -1905,6 +1904,7 @@ public final class Player implements
}
private void showHideShadow(final boolean show, final long duration) {
+ animate(binding.playbackControlsShadow, show, duration, AnimationType.ALPHA, 0, null);
animate(binding.playerTopShadow, show, duration, AnimationType.ALPHA, 0, null);
animate(binding.playerBottomShadow, show, duration, AnimationType.ALPHA, 0, null);
}
@@ -2102,8 +2102,8 @@ public final class Player implements
startProgressLoop();
}
- controlsVisibilityHandler.removeCallbacksAndMessages(null);
- animate(binding.playbackControlRoot, false, DEFAULT_CONTROLS_DURATION);
+ // if we are e.g. switching players, hide controls
+ hideControls(DEFAULT_CONTROLS_DURATION, 0);
binding.playbackSeekBar.setEnabled(false);
binding.playbackSeekBar.getThumb()
@@ -2130,8 +2130,6 @@ public final class Player implements
updateStreamRelatedViews();
- showAndAnimateControl(-1, true);
-
binding.playbackSeekBar.setEnabled(true);
binding.playbackSeekBar.getThumb()
.setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN));
@@ -2179,18 +2177,21 @@ public final class Player implements
stopProgressLoop();
}
- showControls(400);
- binding.loadingPanel.setVisibility(View.GONE);
-
- animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0,
- () -> {
- binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow);
- animatePlayButtons(true, 200);
- if (!isQueueVisible) {
- binding.playPauseButton.requestFocus();
- }
- });
+ // Don't let UI elements popup during double tap seeking. This state is entered sometimes
+ // during seeking/loading. This if-else check ensures that the controls aren't popping up.
+ if (!playerGestureListener.isDoubleTapping()) {
+ showControls(400);
+ binding.loadingPanel.setVisibility(View.GONE);
+ animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0,
+ () -> {
+ binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow);
+ animatePlayButtons(true, 200);
+ if (!isQueueVisible) {
+ binding.playPauseButton.requestFocus();
+ }
+ });
+ }
changePopupWindowFlags(IDLE_WINDOW_FLAGS);
// Remove running notification when user does not want minimization to background or popup
@@ -2208,7 +2209,6 @@ public final class Player implements
if (DEBUG) {
Log.d(TAG, "onPausedSeek() called");
}
- showAndAnimateControl(-1, true);
animatePlayButtons(false, 100);
binding.getRoot().setKeepScreenOn(true);
@@ -2838,7 +2838,6 @@ public final class Player implements
}
seekBy(retrieveSeekDurationFromPreferences(this));
triggerProgressUpdate();
- showAndAnimateControl(R.drawable.ic_fast_forward, true);
}
public void fastRewind() {
@@ -2847,7 +2846,6 @@ public final class Player implements
}
seekBy(-retrieveSeekDurationFromPreferences(this));
triggerProgressUpdate();
- showAndAnimateControl(R.drawable.ic_fast_rewind, true);
}
//endregion
@@ -4279,6 +4277,10 @@ public final class Player implements
return binding.currentDisplaySeek;
}
+ public PlayerFastSeekOverlay getFastSeekOverlay() {
+ return binding.fastSeekOverlay;
+ }
+
@Nullable
public WindowManager.LayoutParams getPopupLayoutParams() {
return popupLayoutParams;
diff --git a/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt
index 29ae7c5c3..c89eabb47 100644
--- a/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt
@@ -411,7 +411,7 @@ abstract class BasePlayerGestureListener(
var doubleTapControls: DoubleTapListener? = null
private set
- val isDoubleTapEnabled: Boolean
+ private val isDoubleTapEnabled: Boolean
get() = doubleTapDelay > 0
var isDoubleTapping = false
@@ -459,10 +459,6 @@ abstract class BasePlayerGestureListener(
doubleTapControls?.onDoubleTapFinished()
}
- fun enableMultiDoubleTap(enable: Boolean) = apply {
- doubleTapDelay = if (enable) DOUBLE_TAP_DELAY else 0
- }
-
// ///////////////////////////////////////////////////////////////////
// Utils
// ///////////////////////////////////////////////////////////////////
diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerGestureListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerGestureListener.java
index 25ace1c05..794fe9b3c 100644
--- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerGestureListener.java
+++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerGestureListener.java
@@ -55,12 +55,10 @@ public class PlayerGestureListener
player.hideControls(0, 0);
}
- if (portion == DisplayPortion.LEFT) {
- player.fastRewind();
+ if (portion == DisplayPortion.LEFT || portion == DisplayPortion.RIGHT) {
+ startMultiDoubleTap(event);
} else if (portion == DisplayPortion.MIDDLE) {
player.playPause();
- } else if (portion == DisplayPortion.RIGHT) {
- player.fastForward();
}
}
@@ -232,10 +230,10 @@ public class PlayerGestureListener
if (DEBUG) {
Log.d(TAG, "onPopupResizingStart called");
}
- player.showAndAnimateControl(-1, true);
player.getLoadingPanel().setVisibility(View.GONE);
player.hideControls(0, 0);
+ animate(player.getFastSeekOverlay(), false, 0);
animate(player.getCurrentDisplaySeek(), false, 0, ALPHA, 0);
}
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..e3d142916
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt
@@ -0,0 +1,89 @@
+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
+
+class CircleClipTapView(context: Context?, attrs: AttributeSet) : View(context, attrs) {
+
+ private var backgroundPaint = Paint()
+
+ private var widthPx = 0
+ private var heightPx = 0
+
+ // Background
+
+ private var shapePath = Path()
+ private var arcSize: Float = 80f
+ private var isLeft = true
+
+ init {
+ requireNotNull(context) { "Context is null." }
+
+ backgroundPaint.apply {
+ style = Paint.Style.FILL
+ isAntiAlias = true
+ color = 0x30000000
+ }
+
+ val dm = context.resources.displayMetrics
+ widthPx = dm.widthPixels
+ heightPx = dm.heightPixels
+
+ updatePathShape()
+ }
+
+ fun updateArcSize(baseView: View) {
+ val newArcSize = baseView.height / 11.4f
+ if (arcSize != newArcSize) {
+ arcSize = newArcSize
+ updatePathShape()
+ }
+ }
+
+ fun updatePosition(newIsLeft: Boolean) {
+ 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/PlayerFastSeekOverlay.kt b/app/src/main/java/org/schabi/newpipe/views/player/PlayerFastSeekOverlay.kt
new file mode 100644
index 000000000..649b60494
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/views/player/PlayerFastSeekOverlay.kt
@@ -0,0 +1,145 @@
+package org.schabi.newpipe.views.player
+
+import android.content.Context
+import android.util.AttributeSet
+import android.util.Log
+import android.view.LayoutInflater
+import androidx.annotation.NonNull
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.END
+import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
+import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.START
+import androidx.constraintlayout.widget.ConstraintSet
+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 PlayerFastSeekOverlay(context: Context, attrs: AttributeSet?) :
+ ConstraintLayout(context, attrs), DoubleTapListener {
+
+ private var secondsView: SecondsView
+ private var circleClipTapView: CircleClipTapView
+ private var rootConstraintLayout: ConstraintLayout
+
+ private var wasForwarding: Boolean = false
+
+ init {
+ LayoutInflater.from(context).inflate(R.layout.player_fast_seek_overlay, this, true)
+
+ secondsView = findViewById(R.id.seconds_view)
+ circleClipTapView = findViewById(R.id.circle_clip_tap_view)
+ rootConstraintLayout = findViewById(R.id.root_constraint_layout)
+
+ addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
+ circleClipTapView.updateArcSize(view)
+ }
+ }
+
+ private var performListener: PerformListener? = null
+
+ fun performListener(listener: PerformListener) = apply {
+ performListener = listener
+ }
+
+ private var seekSecondsSupplier: () -> Int = { 0 }
+
+ fun seekSecondsSupplier(supplier: () -> Int) = apply {
+ seekSecondsSupplier = supplier
+ }
+
+ // 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
+
+ secondsView.stopAnimation()
+ }
+
+ override fun onDoubleTapProgressDown(portion: DisplayPortion) {
+ val shouldForward: Boolean =
+ performListener?.getFastSeekDirection(portion)?.directionAsBoolean ?: return
+
+ if (DEBUG)
+ Log.d(
+ TAG,
+ "onDoubleTapProgressDown called with " +
+ "shouldForward = [$shouldForward], " +
+ "wasForwarding = [$wasForwarding], " +
+ "initTap = [$initTap], "
+ )
+
+ /*
+ * Check if a initial tap occurred or if direction was switched
+ */
+ if (!initTap || wasForwarding != shouldForward) {
+ // Reset seconds and update position
+ secondsView.seconds = 0
+ changeConstraints(shouldForward)
+ circleClipTapView.updatePosition(!shouldForward)
+ secondsView.setForwarding(shouldForward)
+
+ wasForwarding = shouldForward
+
+ if (!initTap) {
+ initTap = true
+ }
+ }
+
+ performListener?.onDoubleTap()
+
+ secondsView.seconds += seekSecondsSupplier.invoke()
+ performListener?.seek(forward = shouldForward)
+ }
+
+ override fun onDoubleTapFinished() {
+ if (DEBUG)
+ Log.d(TAG, "onDoubleTapFinished called with initTap = [$initTap]")
+
+ if (initTap) performListener?.onDoubleTapEnd()
+ initTap = false
+
+ secondsView.stopAnimation()
+ }
+
+ private fun changeConstraints(forward: Boolean) {
+ val constraintSet = ConstraintSet()
+ with(constraintSet) {
+ clone(rootConstraintLayout)
+ clear(secondsView.id, if (forward) START else END)
+ connect(
+ secondsView.id, if (forward) END else START,
+ PARENT_ID, if (forward) END else START
+ )
+ secondsView.startAnimation()
+ applyTo(rootConstraintLayout)
+ }
+ }
+
+ interface PerformListener {
+ fun onDoubleTap()
+ fun onDoubleTapEnd()
+ /**
+ * Determines if the playback should forward/rewind or do nothing.
+ */
+ @NonNull
+ fun getFastSeekDirection(portion: DisplayPortion): FastSeekDirection
+ fun seek(forward: Boolean)
+
+ enum class FastSeekDirection(val directionAsBoolean: Boolean?) {
+ NONE(null),
+ FORWARD(true),
+ BACKWARD(false);
+ }
+ }
+
+ companion object {
+ private const val TAG = "PlayerFastSeekOverlay"
+ 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..d209d24da
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt
@@ -0,0 +1,181 @@
+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.LinearLayout
+import org.schabi.newpipe.R
+import org.schabi.newpipe.databinding.PlayerFastSeekSecondsViewBinding
+import org.schabi.newpipe.util.DeviceUtils
+
+class SecondsView(context: Context, attrs: AttributeSet?) : LinearLayout(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) {
+ binding.tvSeconds.text = context.resources.getQuantityString(
+ R.plurals.seconds, value, value
+ )
+ field = value
+ }
+
+ // Done as a field so that we don't have to compute on each tab if animations are enabled
+ private val animationsEnabled = DeviceUtils.hasAnimationsAnimatorDurationEnabled(context)
+
+ val binding = PlayerFastSeekSecondsViewBinding.inflate(LayoutInflater.from(context), this)
+
+ init {
+ orientation = VERTICAL
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
+ }
+
+ fun setForwarding(isForward: Boolean) {
+ binding.triangleContainer.rotation = if (isForward) 0f else 180f
+ }
+
+ fun startAnimation() {
+ stopAnimation()
+
+ if (animationsEnabled) {
+ firstAnimator.start()
+ } else {
+ // If no animations are enable show the arrow(s) without animation
+ showWithoutAnimation()
+ }
+ }
+
+ fun stopAnimation() {
+ firstAnimator.cancel()
+ secondAnimator.cancel()
+ thirdAnimator.cancel()
+ fourthAnimator.cancel()
+ fifthAnimator.cancel()
+
+ reset()
+ }
+
+ private fun reset() {
+ binding.icon1.alpha = 0f
+ binding.icon2.alpha = 0f
+ binding.icon3.alpha = 0f
+ }
+
+ private fun showWithoutAnimation() {
+ binding.icon1.alpha = 1f
+ binding.icon2.alpha = 1f
+ binding.icon3.alpha = 1f
+ }
+
+ private val firstAnimator: ValueAnimator = CustomValueAnimator(
+ {
+ binding.icon1.alpha = 0f
+ binding.icon2.alpha = 0f
+ binding.icon3.alpha = 0f
+ },
+ {
+ binding.icon1.alpha = it
+ },
+ {
+ secondAnimator.start()
+ }
+ )
+
+ private val secondAnimator: ValueAnimator = CustomValueAnimator(
+ {
+ binding.icon1.alpha = 1f
+ binding.icon2.alpha = 0f
+ binding.icon3.alpha = 0f
+ },
+ {
+ binding.icon2.alpha = it
+ },
+ {
+ thirdAnimator.start()
+ }
+ )
+
+ private val thirdAnimator: ValueAnimator = CustomValueAnimator(
+ {
+ binding.icon1.alpha = 1f
+ binding.icon2.alpha = 1f
+ binding.icon3.alpha = 0f
+ },
+ {
+ binding.icon1.alpha = 1f - binding.icon3.alpha
+ binding.icon3.alpha = it
+ },
+ {
+ fourthAnimator.start()
+ }
+ )
+
+ private val fourthAnimator: ValueAnimator = CustomValueAnimator(
+ {
+ binding.icon1.alpha = 0f
+ binding.icon2.alpha = 1f
+ binding.icon3.alpha = 1f
+ },
+ {
+ binding.icon2.alpha = 1f - it
+ },
+ {
+ fifthAnimator.start()
+ }
+ )
+
+ private val fifthAnimator: ValueAnimator = CustomValueAnimator(
+ {
+ binding.icon1.alpha = 0f
+ binding.icon2.alpha = 0f
+ binding.icon3.alpha = 1f
+ },
+ {
+ binding.icon3.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-large-land/player.xml b/app/src/main/res/layout-large-land/player.xml
index f3b1056d2..71a325cf3 100644
--- a/app/src/main/res/layout-large-land/player.xml
+++ b/app/src/main/res/layout-large-land/player.xml
@@ -54,11 +54,21 @@
tools:ignore="ContentDescription"
tools:visibility="visible" />
+
+
+
@@ -469,8 +479,8 @@
android:padding="@dimen/player_main_buttons_padding"
android:scaleType="fitCenter"
android:visibility="gone"
- app:tint="@color/white"
app:srcCompat="@drawable/ic_fullscreen"
+ app:tint="@color/white"
tools:ignore="ContentDescription,RtlHardcoded"
tools:visibility="visible" />
@@ -493,8 +503,8 @@
android:clickable="true"
android:focusable="true"
android:scaleType="fitCenter"
- app:tint="@color/white"
app:srcCompat="@drawable/ic_previous"
+ app:tint="@color/white"
tools:ignore="ContentDescription" />
@@ -505,8 +515,8 @@
android:layout_weight="1"
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleType="fitCenter"
- app:tint="@color/white"
app:srcCompat="@drawable/ic_pause"
+ app:tint="@color/white"
tools:ignore="ContentDescription" />
@@ -572,8 +582,8 @@
android:focusable="true"
android:padding="10dp"
android:scaleType="fitXY"
- app:tint="@color/white"
- app:srcCompat="@drawable/ic_close" />
+ app:srcCompat="@drawable/ic_close"
+ app:tint="@color/white" />
-
-
-
-
-
+
+
diff --git a/app/src/main/res/layout/player.xml b/app/src/main/res/layout/player.xml
index c2d1c84ff..180292fb1 100644
--- a/app/src/main/res/layout/player.xml
+++ b/app/src/main/res/layout/player.xml
@@ -54,11 +54,21 @@
tools:ignore="ContentDescription"
tools:visibility="visible" />
+
+
+
@@ -633,24 +643,6 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
-
-
-
-
-
+
+
diff --git a/app/src/main/res/layout/player_fast_seek_overlay.xml b/app/src/main/res/layout/player_fast_seek_overlay.xml
new file mode 100644
index 000000000..1e0640eb7
--- /dev/null
+++ b/app/src/main/res/layout/player_fast_seek_overlay.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/player_fast_seek_seconds_view.xml b/app/src/main/res/layout/player_fast_seek_seconds_view.xml
new file mode 100644
index 000000000..57f5aa787
--- /dev/null
+++ b/app/src/main/res/layout/player_fast_seek_seconds_view.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+