DefaultTimeBar.java

/*
 * Copyright (C) 2017 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package androidx.media3.ui;

import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewParent;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
import androidx.annotation.ColorInt;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.media3.common.C;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import java.util.Collections;
import java.util.Formatter;
import java.util.Locale;
import java.util.concurrent.CopyOnWriteArraySet;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;

/**
 * A time bar that shows a current position, buffered position, duration and ad markers.
 *
 * <p>A DefaultTimeBar can be customized by setting attributes, as outlined below.
 *
 * <h2>Attributes</h2>
 *
 * The following attributes can be set on a DefaultTimeBar when used in a layout XML file:
 *
 * <ul>
 *   <li><b>{@code bar_height}</b> - Dimension for the height of the time bar.
 *       <ul>
 *         <li>Default: {@link #DEFAULT_BAR_HEIGHT_DP}
 *       </ul>
 *   <li><b>{@code touch_target_height}</b> - Dimension for the height of the area in which touch
 *       interactions with the time bar are handled. If no height is specified, this also determines
 *       the height of the view.
 *       <ul>
 *         <li>Default: {@link #DEFAULT_TOUCH_TARGET_HEIGHT_DP}
 *       </ul>
 *   <li><b>{@code ad_marker_width}</b> - Dimension for the width of any ad markers shown on the
 *       bar. Ad markers are superimposed on the time bar to show the times at which ads will play.
 *       <ul>
 *         <li>Default: {@link #DEFAULT_AD_MARKER_WIDTH_DP}
 *       </ul>
 *   <li><b>{@code scrubber_enabled_size}</b> - Dimension for the diameter of the circular scrubber
 *       handle when scrubbing is enabled but not in progress. Set to zero if no scrubber handle
 *       should be shown.
 *       <ul>
 *         <li>Default: {@link #DEFAULT_SCRUBBER_ENABLED_SIZE_DP}
 *       </ul>
 *   <li><b>{@code scrubber_disabled_size}</b> - Dimension for the diameter of the circular scrubber
 *       handle when scrubbing isn't enabled. Set to zero if no scrubber handle should be shown.
 *       <ul>
 *         <li>Default: {@link #DEFAULT_SCRUBBER_DISABLED_SIZE_DP}
 *       </ul>
 *   <li><b>{@code scrubber_dragged_size}</b> - Dimension for the diameter of the circular scrubber
 *       handle when scrubbing is in progress. Set to zero if no scrubber handle should be shown.
 *       <ul>
 *         <li>Default: {@link #DEFAULT_SCRUBBER_DRAGGED_SIZE_DP}
 *       </ul>
 *   <li><b>{@code scrubber_drawable}</b> - Optional reference to a drawable to draw for the
 *       scrubber handle. If set, this overrides the default behavior, which is to draw a circle for
 *       the scrubber handle.
 *   <li><b>{@code played_color}</b> - Color for the portion of the time bar representing media
 *       before the current playback position.
 *       <ul>
 *         <li>Corresponding method: {@link #setPlayedColor(int)}
 *         <li>Default: {@link #DEFAULT_PLAYED_COLOR}
 *       </ul>
 *   <li><b>{@code scrubber_color}</b> - Color for the scrubber handle.
 *       <ul>
 *         <li>Corresponding method: {@link #setScrubberColor(int)}
 *         <li>Default: {@link #DEFAULT_SCRUBBER_COLOR}
 *       </ul>
 *   <li><b>{@code buffered_color}</b> - Color for the portion of the time bar after the current
 *       played position up to the current buffered position.
 *       <ul>
 *         <li>Corresponding method: {@link #setBufferedColor(int)}
 *         <li>Default: {@link #DEFAULT_BUFFERED_COLOR}
 *       </ul>
 *   <li><b>{@code unplayed_color}</b> - Color for the portion of the time bar after the current
 *       buffered position.
 *       <ul>
 *         <li>Corresponding method: {@link #setUnplayedColor(int)}
 *         <li>Default: {@link #DEFAULT_UNPLAYED_COLOR}
 *       </ul>
 *   <li><b>{@code ad_marker_color}</b> - Color for unplayed ad markers.
 *       <ul>
 *         <li>Corresponding method: {@link #setAdMarkerColor(int)}
 *         <li>Default: {@link #DEFAULT_AD_MARKER_COLOR}
 *       </ul>
 *   <li><b>{@code played_ad_marker_color}</b> - Color for played ad markers.
 *       <ul>
 *         <li>Corresponding method: {@link #setPlayedAdMarkerColor(int)}
 *         <li>Default: {@link #DEFAULT_PLAYED_AD_MARKER_COLOR}
 *       </ul>
 * </ul>
 */
@UnstableApi
public class DefaultTimeBar extends View implements TimeBar {

  /** Default height for the time bar, in dp. */
  public static final int DEFAULT_BAR_HEIGHT_DP = 4;
  /** Default height for the touch target, in dp. */
  public static final int DEFAULT_TOUCH_TARGET_HEIGHT_DP = 26;
  /** Default width for ad markers, in dp. */
  public static final int DEFAULT_AD_MARKER_WIDTH_DP = 4;
  /** Default diameter for the scrubber when enabled, in dp. */
  public static final int DEFAULT_SCRUBBER_ENABLED_SIZE_DP = 12;
  /** Default diameter for the scrubber when disabled, in dp. */
  public static final int DEFAULT_SCRUBBER_DISABLED_SIZE_DP = 0;
  /** Default diameter for the scrubber when dragged, in dp. */
  public static final int DEFAULT_SCRUBBER_DRAGGED_SIZE_DP = 16;
  /** Default color for the played portion of the time bar. */
  public static final int DEFAULT_PLAYED_COLOR = 0xFFFFFFFF;
  /** Default color for the unplayed portion of the time bar. */
  public static final int DEFAULT_UNPLAYED_COLOR = 0x33FFFFFF;
  /** Default color for the buffered portion of the time bar. */
  public static final int DEFAULT_BUFFERED_COLOR = 0xCCFFFFFF;
  /** Default color for the scrubber handle. */
  public static final int DEFAULT_SCRUBBER_COLOR = 0xFFFFFFFF;
  /** Default color for ad markers. */
  public static final int DEFAULT_AD_MARKER_COLOR = 0xB2FFFF00;
  /** Default color for played ad markers. */
  public static final int DEFAULT_PLAYED_AD_MARKER_COLOR = 0x33FFFF00;

  /** Vertical gravity for progress bar to be located at the center in the view. */
  public static final int BAR_GRAVITY_CENTER = 0;
  /** Vertical gravity for progress bar to be located at the bottom in the view. */
  public static final int BAR_GRAVITY_BOTTOM = 1;

  /** The threshold in dps above the bar at which touch events trigger fine scrub mode. */
  private static final int FINE_SCRUB_Y_THRESHOLD_DP = -50;
  /** The ratio by which times are reduced in fine scrub mode. */
  private static final int FINE_SCRUB_RATIO = 3;
  /**
   * The time after which the scrubbing listener is notified that scrubbing has stopped after
   * performing an incremental scrub using key input.
   */
  private static final long STOP_SCRUBBING_TIMEOUT_MS = 1000;

  private static final int DEFAULT_INCREMENT_COUNT = 20;

  private static final float SHOWN_SCRUBBER_SCALE = 1.0f;
  private static final float HIDDEN_SCRUBBER_SCALE = 0.0f;

  /**
   * The name of the Android SDK view that most closely resembles this custom view. Used as the
   * class name for accessibility.
   */
  private static final String ACCESSIBILITY_CLASS_NAME = "android.widget.SeekBar";

  private final Rect seekBounds;
  private final Rect progressBar;
  private final Rect bufferedBar;
  private final Rect scrubberBar;
  private final Paint playedPaint;
  private final Paint bufferedPaint;
  private final Paint unplayedPaint;
  private final Paint adMarkerPaint;
  private final Paint playedAdMarkerPaint;
  private final Paint scrubberPaint;
  @Nullable private final Drawable scrubberDrawable;
  private final int barHeight;
  private final int touchTargetHeight;
  private final int barGravity;
  private final int adMarkerWidth;
  private final int scrubberEnabledSize;
  private final int scrubberDisabledSize;
  private final int scrubberDraggedSize;
  private final int scrubberPadding;
  private final int fineScrubYThreshold;
  private final StringBuilder formatBuilder;
  private final Formatter formatter;
  private final Runnable stopScrubbingRunnable;
  private final CopyOnWriteArraySet<OnScrubListener> listeners;
  private final Point touchPosition;
  private final float density;

  private int keyCountIncrement;
  private long keyTimeIncrement;
  private int lastCoarseScrubXPosition;
  private @MonotonicNonNull Rect lastExclusionRectangle;

  private ValueAnimator scrubberScalingAnimator;
  private float scrubberScale;
  private boolean scrubberPaddingDisabled;
  private boolean scrubbing;
  private long scrubPosition;
  private long duration;
  private long position;
  private long bufferedPosition;
  private int adGroupCount;
  @Nullable private long[] adGroupTimesMs;
  @Nullable private boolean[] playedAdGroups;

  public DefaultTimeBar(Context context) {
    this(context, null);
  }

  public DefaultTimeBar(Context context, @Nullable AttributeSet attrs) {
    this(context, attrs, 0);
  }

  public DefaultTimeBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    this(context, attrs, defStyleAttr, attrs);
  }

  public DefaultTimeBar(
      Context context,
      @Nullable AttributeSet attrs,
      int defStyleAttr,
      @Nullable AttributeSet timebarAttrs) {
    this(context, attrs, defStyleAttr, timebarAttrs, 0);
  }

  // Suppress warnings due to usage of View methods in the constructor.
  @SuppressWarnings("nullness:method.invocation")
  public DefaultTimeBar(
      Context context,
      @Nullable AttributeSet attrs,
      int defStyleAttr,
      @Nullable AttributeSet timebarAttrs,
      int defStyleRes) {
    super(context, attrs, defStyleAttr);
    seekBounds = new Rect();
    progressBar = new Rect();
    bufferedBar = new Rect();
    scrubberBar = new Rect();
    playedPaint = new Paint();
    bufferedPaint = new Paint();
    unplayedPaint = new Paint();
    adMarkerPaint = new Paint();
    playedAdMarkerPaint = new Paint();
    scrubberPaint = new Paint();
    scrubberPaint.setAntiAlias(true);
    listeners = new CopyOnWriteArraySet<>();
    touchPosition = new Point();

    // Calculate the dimensions and paints for drawn elements.
    Resources res = context.getResources();
    DisplayMetrics displayMetrics = res.getDisplayMetrics();
    density = displayMetrics.density;
    fineScrubYThreshold = dpToPx(density, FINE_SCRUB_Y_THRESHOLD_DP);
    int defaultBarHeight = dpToPx(density, DEFAULT_BAR_HEIGHT_DP);
    int defaultTouchTargetHeight = dpToPx(density, DEFAULT_TOUCH_TARGET_HEIGHT_DP);
    int defaultAdMarkerWidth = dpToPx(density, DEFAULT_AD_MARKER_WIDTH_DP);
    int defaultScrubberEnabledSize = dpToPx(density, DEFAULT_SCRUBBER_ENABLED_SIZE_DP);
    int defaultScrubberDisabledSize = dpToPx(density, DEFAULT_SCRUBBER_DISABLED_SIZE_DP);
    int defaultScrubberDraggedSize = dpToPx(density, DEFAULT_SCRUBBER_DRAGGED_SIZE_DP);
    if (timebarAttrs != null) {
      TypedArray a =
          context
              .getTheme()
              .obtainStyledAttributes(
                  timebarAttrs, R.styleable.DefaultTimeBar, defStyleAttr, defStyleRes);
      try {
        scrubberDrawable = a.getDrawable(R.styleable.DefaultTimeBar_scrubber_drawable);
        if (scrubberDrawable != null) {
          setDrawableLayoutDirection(scrubberDrawable);
          defaultTouchTargetHeight =
              Math.max(scrubberDrawable.getMinimumHeight(), defaultTouchTargetHeight);
        }
        barHeight =
            a.getDimensionPixelSize(R.styleable.DefaultTimeBar_bar_height, defaultBarHeight);
        touchTargetHeight =
            a.getDimensionPixelSize(
                R.styleable.DefaultTimeBar_touch_target_height, defaultTouchTargetHeight);
        barGravity = a.getInt(R.styleable.DefaultTimeBar_bar_gravity, BAR_GRAVITY_CENTER);
        adMarkerWidth =
            a.getDimensionPixelSize(
                R.styleable.DefaultTimeBar_ad_marker_width, defaultAdMarkerWidth);
        scrubberEnabledSize =
            a.getDimensionPixelSize(
                R.styleable.DefaultTimeBar_scrubber_enabled_size, defaultScrubberEnabledSize);
        scrubberDisabledSize =
            a.getDimensionPixelSize(
                R.styleable.DefaultTimeBar_scrubber_disabled_size, defaultScrubberDisabledSize);
        scrubberDraggedSize =
            a.getDimensionPixelSize(
                R.styleable.DefaultTimeBar_scrubber_dragged_size, defaultScrubberDraggedSize);
        int playedColor = a.getInt(R.styleable.DefaultTimeBar_played_color, DEFAULT_PLAYED_COLOR);
        int scrubberColor =
            a.getInt(R.styleable.DefaultTimeBar_scrubber_color, DEFAULT_SCRUBBER_COLOR);
        int bufferedColor =
            a.getInt(R.styleable.DefaultTimeBar_buffered_color, DEFAULT_BUFFERED_COLOR);
        int unplayedColor =
            a.getInt(R.styleable.DefaultTimeBar_unplayed_color, DEFAULT_UNPLAYED_COLOR);
        int adMarkerColor =
            a.getInt(R.styleable.DefaultTimeBar_ad_marker_color, DEFAULT_AD_MARKER_COLOR);
        int playedAdMarkerColor =
            a.getInt(
                R.styleable.DefaultTimeBar_played_ad_marker_color, DEFAULT_PLAYED_AD_MARKER_COLOR);
        playedPaint.setColor(playedColor);
        scrubberPaint.setColor(scrubberColor);
        bufferedPaint.setColor(bufferedColor);
        unplayedPaint.setColor(unplayedColor);
        adMarkerPaint.setColor(adMarkerColor);
        playedAdMarkerPaint.setColor(playedAdMarkerColor);
      } finally {
        a.recycle();
      }
    } else {
      barHeight = defaultBarHeight;
      touchTargetHeight = defaultTouchTargetHeight;
      barGravity = BAR_GRAVITY_CENTER;
      adMarkerWidth = defaultAdMarkerWidth;
      scrubberEnabledSize = defaultScrubberEnabledSize;
      scrubberDisabledSize = defaultScrubberDisabledSize;
      scrubberDraggedSize = defaultScrubberDraggedSize;
      playedPaint.setColor(DEFAULT_PLAYED_COLOR);
      scrubberPaint.setColor(DEFAULT_SCRUBBER_COLOR);
      bufferedPaint.setColor(DEFAULT_BUFFERED_COLOR);
      unplayedPaint.setColor(DEFAULT_UNPLAYED_COLOR);
      adMarkerPaint.setColor(DEFAULT_AD_MARKER_COLOR);
      playedAdMarkerPaint.setColor(DEFAULT_PLAYED_AD_MARKER_COLOR);
      scrubberDrawable = null;
    }
    formatBuilder = new StringBuilder();
    formatter = new Formatter(formatBuilder, Locale.getDefault());
    stopScrubbingRunnable = () -> stopScrubbing(/* canceled= */ false);
    if (scrubberDrawable != null) {
      scrubberPadding = (scrubberDrawable.getMinimumWidth() + 1) / 2;
    } else {
      scrubberPadding =
          (Math.max(scrubberDisabledSize, Math.max(scrubberEnabledSize, scrubberDraggedSize)) + 1)
              / 2;
    }
    scrubberScale = 1.0f;
    scrubberScalingAnimator = new ValueAnimator();
    scrubberScalingAnimator.addUpdateListener(
        animation -> {
          scrubberScale = (float) animation.getAnimatedValue();
          invalidate(seekBounds);
        });
    duration = C.TIME_UNSET;
    keyTimeIncrement = C.TIME_UNSET;
    keyCountIncrement = DEFAULT_INCREMENT_COUNT;
    setFocusable(true);
    if (getImportantForAccessibility() == View.IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
      setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
    }
  }

  /** Shows the scrubber handle. */
  public void showScrubber() {
    if (scrubberScalingAnimator.isStarted()) {
      scrubberScalingAnimator.cancel();
    }
    scrubberPaddingDisabled = false;
    scrubberScale = 1;
    invalidate(seekBounds);
  }

  /**
   * Shows the scrubber handle with animation.
   *
   * @param showAnimationDurationMs The duration for scrubber showing animation.
   */
  public void showScrubber(long showAnimationDurationMs) {
    if (scrubberScalingAnimator.isStarted()) {
      scrubberScalingAnimator.cancel();
    }
    scrubberPaddingDisabled = false;
    scrubberScalingAnimator.setFloatValues(scrubberScale, SHOWN_SCRUBBER_SCALE);
    scrubberScalingAnimator.setDuration(showAnimationDurationMs);
    scrubberScalingAnimator.start();
  }

  /** Hides the scrubber handle. */
  public void hideScrubber(boolean disableScrubberPadding) {
    if (scrubberScalingAnimator.isStarted()) {
      scrubberScalingAnimator.cancel();
    }
    scrubberPaddingDisabled = disableScrubberPadding;
    scrubberScale = 0;
    invalidate(seekBounds);
  }

  /**
   * Hides the scrubber handle with animation.
   *
   * @param hideAnimationDurationMs The duration for scrubber hiding animation.
   */
  public void hideScrubber(long hideAnimationDurationMs) {
    if (scrubberScalingAnimator.isStarted()) {
      scrubberScalingAnimator.cancel();
    }
    scrubberScalingAnimator.setFloatValues(scrubberScale, HIDDEN_SCRUBBER_SCALE);
    scrubberScalingAnimator.setDuration(hideAnimationDurationMs);
    scrubberScalingAnimator.start();
  }

  /**
   * Sets the color for the portion of the time bar representing media before the playback position.
   *
   * @param playedColor The color for the portion of the time bar representing media before the
   *     playback position.
   */
  public void setPlayedColor(@ColorInt int playedColor) {
    playedPaint.setColor(playedColor);
    invalidate(seekBounds);
  }

  /**
   * Sets the color for the scrubber handle.
   *
   * @param scrubberColor The color for the scrubber handle.
   */
  public void setScrubberColor(@ColorInt int scrubberColor) {
    scrubberPaint.setColor(scrubberColor);
    invalidate(seekBounds);
  }

  /**
   * Sets the color for the portion of the time bar after the current played position up to the
   * current buffered position.
   *
   * @param bufferedColor The color for the portion of the time bar after the current played
   *     position up to the current buffered position.
   */
  public void setBufferedColor(@ColorInt int bufferedColor) {
    bufferedPaint.setColor(bufferedColor);
    invalidate(seekBounds);
  }

  /**
   * Sets the color for the portion of the time bar after the current played position.
   *
   * @param unplayedColor The color for the portion of the time bar after the current played
   *     position.
   */
  public void setUnplayedColor(@ColorInt int unplayedColor) {
    unplayedPaint.setColor(unplayedColor);
    invalidate(seekBounds);
  }

  /**
   * Sets the color for unplayed ad markers.
   *
   * @param adMarkerColor The color for unplayed ad markers.
   */
  public void setAdMarkerColor(@ColorInt int adMarkerColor) {
    adMarkerPaint.setColor(adMarkerColor);
    invalidate(seekBounds);
  }

  /**
   * Sets the color for played ad markers.
   *
   * @param playedAdMarkerColor The color for played ad markers.
   */
  public void setPlayedAdMarkerColor(@ColorInt int playedAdMarkerColor) {
    playedAdMarkerPaint.setColor(playedAdMarkerColor);
    invalidate(seekBounds);
  }

  // TimeBar implementation.

  @Override
  public void addListener(OnScrubListener listener) {
    Assertions.checkNotNull(listener);
    listeners.add(listener);
  }

  @Override
  public void removeListener(OnScrubListener listener) {
    listeners.remove(listener);
  }

  @Override
  public void setKeyTimeIncrement(long time) {
    Assertions.checkArgument(time > 0);
    keyCountIncrement = C.INDEX_UNSET;
    keyTimeIncrement = time;
  }

  @Override
  public void setKeyCountIncrement(int count) {
    Assertions.checkArgument(count > 0);
    keyCountIncrement = count;
    keyTimeIncrement = C.TIME_UNSET;
  }

  @Override
  public void setPosition(long position) {
    if (this.position == position) {
      return;
    }
    this.position = position;
    setContentDescription(getProgressText());
    update();
  }

  @Override
  public void setBufferedPosition(long bufferedPosition) {
    if (this.bufferedPosition == bufferedPosition) {
      return;
    }
    this.bufferedPosition = bufferedPosition;
    update();
  }

  @Override
  public void setDuration(long duration) {
    if (this.duration == duration) {
      return;
    }
    this.duration = duration;
    if (scrubbing && duration == C.TIME_UNSET) {
      stopScrubbing(/* canceled= */ true);
    }
    update();
  }

  @Override
  public long getPreferredUpdateDelay() {
    int timeBarWidthDp = pxToDp(density, progressBar.width());
    return timeBarWidthDp == 0 || duration == 0 || duration == C.TIME_UNSET
        ? Long.MAX_VALUE
        : duration / timeBarWidthDp;
  }

  @Override
  public void setAdGroupTimesMs(
      @Nullable long[] adGroupTimesMs, @Nullable boolean[] playedAdGroups, int adGroupCount) {
    Assertions.checkArgument(
        adGroupCount == 0 || (adGroupTimesMs != null && playedAdGroups != null));
    this.adGroupCount = adGroupCount;
    this.adGroupTimesMs = adGroupTimesMs;
    this.playedAdGroups = playedAdGroups;
    update();
  }

  // View methods.

  @Override
  public void setEnabled(boolean enabled) {
    super.setEnabled(enabled);
    if (scrubbing && !enabled) {
      stopScrubbing(/* canceled= */ true);
    }
  }

  @Override
  public void onDraw(Canvas canvas) {
    canvas.save();
    drawTimeBar(canvas);
    drawPlayhead(canvas);
    canvas.restore();
  }

  @Override
  public boolean onTouchEvent(MotionEvent event) {
    if (!isEnabled() || duration <= 0) {
      return false;
    }
    Point touchPosition = resolveRelativeTouchPosition(event);
    int x = touchPosition.x;
    int y = touchPosition.y;
    switch (event.getAction()) {
      case MotionEvent.ACTION_DOWN:
        if (isInSeekBar(x, y)) {
          positionScrubber(x);
          startScrubbing(getScrubberPosition());
          update();
          invalidate();
          return true;
        }
        break;
      case MotionEvent.ACTION_MOVE:
        if (scrubbing) {
          if (y < fineScrubYThreshold) {
            int relativeX = x - lastCoarseScrubXPosition;
            positionScrubber(lastCoarseScrubXPosition + relativeX / FINE_SCRUB_RATIO);
          } else {
            lastCoarseScrubXPosition = x;
            positionScrubber(x);
          }
          updateScrubbing(getScrubberPosition());
          update();
          invalidate();
          return true;
        }
        break;
      case MotionEvent.ACTION_UP:
      case MotionEvent.ACTION_CANCEL:
        if (scrubbing) {
          stopScrubbing(/* canceled= */ event.getAction() == MotionEvent.ACTION_CANCEL);
          return true;
        }
        break;
      default:
        // Do nothing.
    }
    return false;
  }

  @Override
  public boolean onKeyDown(int keyCode, KeyEvent event) {
    if (isEnabled()) {
      long positionIncrement = getPositionIncrement();
      switch (keyCode) {
        case KeyEvent.KEYCODE_DPAD_LEFT:
          positionIncrement = -positionIncrement;
          // Fall through.
        case KeyEvent.KEYCODE_DPAD_RIGHT:
          if (scrubIncrementally(positionIncrement)) {
            removeCallbacks(stopScrubbingRunnable);
            postDelayed(stopScrubbingRunnable, STOP_SCRUBBING_TIMEOUT_MS);
            return true;
          }
          break;
        case KeyEvent.KEYCODE_DPAD_CENTER:
        case KeyEvent.KEYCODE_ENTER:
          if (scrubbing) {
            stopScrubbing(/* canceled= */ false);
            return true;
          }
          break;
        default:
          // Do nothing.
      }
    }
    return super.onKeyDown(keyCode, event);
  }

  @Override
  protected void onFocusChanged(
      boolean gainFocus, int direction, @Nullable Rect previouslyFocusedRect) {
    super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
    if (scrubbing && !gainFocus) {
      stopScrubbing(/* canceled= */ false);
    }
  }

  @Override
  protected void drawableStateChanged() {
    super.drawableStateChanged();
    updateDrawableState();
  }

  @Override
  public void jumpDrawablesToCurrentState() {
    super.jumpDrawablesToCurrentState();
    if (scrubberDrawable != null) {
      scrubberDrawable.jumpToCurrentState();
    }
  }

  @Override
  protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    int height =
        heightMode == MeasureSpec.UNSPECIFIED
            ? touchTargetHeight
            : heightMode == MeasureSpec.EXACTLY
                ? heightSize
                : Math.min(touchTargetHeight, heightSize);
    setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), height);
    updateDrawableState();
  }

  @Override
  protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    int width = right - left;
    int height = bottom - top;
    int seekLeft = getPaddingLeft();
    int seekRight = width - getPaddingRight();
    int seekBoundsY;
    int progressBarY;
    int scrubberPadding = scrubberPaddingDisabled ? 0 : this.scrubberPadding;
    if (barGravity == BAR_GRAVITY_BOTTOM) {
      seekBoundsY = height - getPaddingBottom() - touchTargetHeight;
      progressBarY =
          height - getPaddingBottom() - barHeight - Math.max(scrubberPadding - (barHeight / 2), 0);
    } else {
      seekBoundsY = (height - touchTargetHeight) / 2;
      progressBarY = (height - barHeight) / 2;
    }
    seekBounds.set(seekLeft, seekBoundsY, seekRight, seekBoundsY + touchTargetHeight);
    progressBar.set(
        seekBounds.left + scrubberPadding,
        progressBarY,
        seekBounds.right - scrubberPadding,
        progressBarY + barHeight);
    if (Util.SDK_INT >= 29) {
      setSystemGestureExclusionRectsV29(width, height);
    }
    update();
  }

  @Override
  public void onRtlPropertiesChanged(int layoutDirection) {
    if (scrubberDrawable != null && setDrawableLayoutDirection(scrubberDrawable, layoutDirection)) {
      invalidate();
    }
  }

  @Override
  public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
    super.onInitializeAccessibilityEvent(event);
    if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_SELECTED) {
      event.getText().add(getProgressText());
    }
    event.setClassName(ACCESSIBILITY_CLASS_NAME);
  }

  @Override
  public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
    super.onInitializeAccessibilityNodeInfo(info);
    info.setClassName(ACCESSIBILITY_CLASS_NAME);
    info.setContentDescription(getProgressText());
    if (duration <= 0) {
      return;
    }
    if (Util.SDK_INT >= 21) {
      info.addAction(AccessibilityAction.ACTION_SCROLL_FORWARD);
      info.addAction(AccessibilityAction.ACTION_SCROLL_BACKWARD);
    } else {
      info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
      info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
    }
  }

  @Override
  public boolean performAccessibilityAction(int action, @Nullable Bundle args) {
    if (super.performAccessibilityAction(action, args)) {
      return true;
    }
    if (duration <= 0) {
      return false;
    }
    if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
      if (scrubIncrementally(-getPositionIncrement())) {
        stopScrubbing(/* canceled= */ false);
      }
    } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
      if (scrubIncrementally(getPositionIncrement())) {
        stopScrubbing(/* canceled= */ false);
      }
    } else {
      return false;
    }
    sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
    return true;
  }

  // Internal methods.

  private void startScrubbing(long scrubPosition) {
    this.scrubPosition = scrubPosition;
    scrubbing = true;
    setPressed(true);
    ViewParent parent = getParent();
    if (parent != null) {
      parent.requestDisallowInterceptTouchEvent(true);
    }
    for (OnScrubListener listener : listeners) {
      listener.onScrubStart(this, scrubPosition);
    }
  }

  private void updateScrubbing(long scrubPosition) {
    if (this.scrubPosition == scrubPosition) {
      return;
    }
    this.scrubPosition = scrubPosition;
    for (OnScrubListener listener : listeners) {
      listener.onScrubMove(this, scrubPosition);
    }
  }

  private void stopScrubbing(boolean canceled) {
    removeCallbacks(stopScrubbingRunnable);
    scrubbing = false;
    setPressed(false);
    ViewParent parent = getParent();
    if (parent != null) {
      parent.requestDisallowInterceptTouchEvent(false);
    }
    invalidate();
    for (OnScrubListener listener : listeners) {
      listener.onScrubStop(this, scrubPosition, canceled);
    }
  }

  /**
   * Incrementally scrubs the position by {@code positionChange}.
   *
   * @param positionChange The change in the scrubber position, in milliseconds. May be negative.
   * @return Returns whether the scrubber position changed.
   */
  private boolean scrubIncrementally(long positionChange) {
    if (duration <= 0) {
      return false;
    }
    long previousPosition = scrubbing ? scrubPosition : position;
    long scrubPosition = Util.constrainValue(previousPosition + positionChange, 0, duration);
    if (scrubPosition == previousPosition) {
      return false;
    }
    if (!scrubbing) {
      startScrubbing(scrubPosition);
    } else {
      updateScrubbing(scrubPosition);
    }
    update();
    return true;
  }

  private void update() {
    bufferedBar.set(progressBar);
    scrubberBar.set(progressBar);
    long newScrubberTime = scrubbing ? scrubPosition : position;
    if (duration > 0) {
      int bufferedPixelWidth = (int) ((progressBar.width() * bufferedPosition) / duration);
      bufferedBar.right = Math.min(progressBar.left + bufferedPixelWidth, progressBar.right);
      int scrubberPixelPosition = (int) ((progressBar.width() * newScrubberTime) / duration);
      scrubberBar.right = Math.min(progressBar.left + scrubberPixelPosition, progressBar.right);
    } else {
      bufferedBar.right = progressBar.left;
      scrubberBar.right = progressBar.left;
    }
    invalidate(seekBounds);
  }

  private void positionScrubber(float xPosition) {
    scrubberBar.right = Util.constrainValue((int) xPosition, progressBar.left, progressBar.right);
  }

  private Point resolveRelativeTouchPosition(MotionEvent motionEvent) {
    touchPosition.set((int) motionEvent.getX(), (int) motionEvent.getY());
    return touchPosition;
  }

  private long getScrubberPosition() {
    if (progressBar.width() <= 0 || duration == C.TIME_UNSET) {
      return 0;
    }
    return (scrubberBar.width() * duration) / progressBar.width();
  }

  private boolean isInSeekBar(float x, float y) {
    return seekBounds.contains((int) x, (int) y);
  }

  private void drawTimeBar(Canvas canvas) {
    int progressBarHeight = progressBar.height();
    int barTop = progressBar.centerY() - progressBarHeight / 2;
    int barBottom = barTop + progressBarHeight;
    if (duration <= 0) {
      canvas.drawRect(progressBar.left, barTop, progressBar.right, barBottom, unplayedPaint);
      return;
    }
    int bufferedLeft = bufferedBar.left;
    int bufferedRight = bufferedBar.right;
    int progressLeft = Math.max(Math.max(progressBar.left, bufferedRight), scrubberBar.right);
    if (progressLeft < progressBar.right) {
      canvas.drawRect(progressLeft, barTop, progressBar.right, barBottom, unplayedPaint);
    }
    bufferedLeft = Math.max(bufferedLeft, scrubberBar.right);
    if (bufferedRight > bufferedLeft) {
      canvas.drawRect(bufferedLeft, barTop, bufferedRight, barBottom, bufferedPaint);
    }
    if (scrubberBar.width() > 0) {
      canvas.drawRect(scrubberBar.left, barTop, scrubberBar.right, barBottom, playedPaint);
    }
    if (adGroupCount == 0) {
      return;
    }
    long[] adGroupTimesMs = Assertions.checkNotNull(this.adGroupTimesMs);
    boolean[] playedAdGroups = Assertions.checkNotNull(this.playedAdGroups);
    int adMarkerOffset = adMarkerWidth / 2;
    for (int i = 0; i < adGroupCount; i++) {
      long adGroupTimeMs = Util.constrainValue(adGroupTimesMs[i], 0, duration);
      int markerPositionOffset =
          (int) (progressBar.width() * adGroupTimeMs / duration) - adMarkerOffset;
      int markerLeft =
          progressBar.left
              + Math.min(progressBar.width() - adMarkerWidth, Math.max(0, markerPositionOffset));
      Paint paint = playedAdGroups[i] ? playedAdMarkerPaint : adMarkerPaint;
      canvas.drawRect(markerLeft, barTop, markerLeft + adMarkerWidth, barBottom, paint);
    }
  }

  private void drawPlayhead(Canvas canvas) {
    if (duration <= 0) {
      return;
    }
    int playheadX = Util.constrainValue(scrubberBar.right, scrubberBar.left, progressBar.right);
    int playheadY = scrubberBar.centerY();
    if (scrubberDrawable == null) {
      int scrubberSize =
          (scrubbing || isFocused())
              ? scrubberDraggedSize
              : (isEnabled() ? scrubberEnabledSize : scrubberDisabledSize);
      int playheadRadius = (int) ((scrubberSize * scrubberScale) / 2);
      canvas.drawCircle(playheadX, playheadY, playheadRadius, scrubberPaint);
    } else {
      int scrubberDrawableWidth = (int) (scrubberDrawable.getIntrinsicWidth() * scrubberScale);
      int scrubberDrawableHeight = (int) (scrubberDrawable.getIntrinsicHeight() * scrubberScale);
      scrubberDrawable.setBounds(
          playheadX - scrubberDrawableWidth / 2,
          playheadY - scrubberDrawableHeight / 2,
          playheadX + scrubberDrawableWidth / 2,
          playheadY + scrubberDrawableHeight / 2);
      scrubberDrawable.draw(canvas);
    }
  }

  private void updateDrawableState() {
    if (scrubberDrawable != null
        && scrubberDrawable.isStateful()
        && scrubberDrawable.setState(getDrawableState())) {
      invalidate();
    }
  }

  @RequiresApi(29)
  private void setSystemGestureExclusionRectsV29(int width, int height) {
    if (lastExclusionRectangle != null
        && lastExclusionRectangle.width() == width
        && lastExclusionRectangle.height() == height) {
      // Allocating inside onLayout is considered a DrawAllocation lint error, so avoid if possible.
      return;
    }
    lastExclusionRectangle = new Rect(/* left= */ 0, /* top= */ 0, width, height);
    setSystemGestureExclusionRects(Collections.singletonList(lastExclusionRectangle));
  }

  private String getProgressText() {
    return Util.getStringForTime(formatBuilder, formatter, position);
  }

  private long getPositionIncrement() {
    return keyTimeIncrement == C.TIME_UNSET
        ? (duration == C.TIME_UNSET ? 0 : (duration / keyCountIncrement))
        : keyTimeIncrement;
  }

  private boolean setDrawableLayoutDirection(Drawable drawable) {
    return Util.SDK_INT >= 23 && setDrawableLayoutDirection(drawable, getLayoutDirection());
  }

  private static boolean setDrawableLayoutDirection(Drawable drawable, int layoutDirection) {
    return Util.SDK_INT >= 23 && drawable.setLayoutDirection(layoutDirection);
  }

  private static int dpToPx(float density, int dps) {
    return (int) (dps * density + 0.5f);
  }

  private static int pxToDp(float density, int px) {
    return (int) (px / density);
  }
}