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