SubtitleView.java

/*
 * Copyright (C) 2016 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 static java.lang.annotation.ElementType.TYPE_USE;
import static java.lang.annotation.RetentionPolicy.SOURCE;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.View;
import android.view.accessibility.CaptioningManager;
import android.webkit.WebView;
import android.widget.FrameLayout;
import androidx.annotation.Dimension;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.media3.common.text.Cue;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/** A view for displaying subtitle {@link Cue}s. */
@UnstableApi
public final class SubtitleView extends FrameLayout {

  /**
   * An output for displaying subtitles.
   *
   * <p>Implementations of this also need to extend {@link View} in order to be attached to the
   * Android view hierarchy.
   */
  /* package */ interface Output {

    /**
     * Updates the list of cues displayed.
     *
     * @param cues The cues to display.
     * @param style A {@link CaptionStyleCompat} to use for styling unset properties of cues.
     * @param defaultTextSize The default font size to apply when {@link Cue#textSize} is {@link
     *     Cue#DIMEN_UNSET}.
     * @param defaultTextSizeType The type of {@code defaultTextSize}.
     * @param bottomPaddingFraction The bottom padding to apply when {@link Cue#line} is {@link
     *     Cue#DIMEN_UNSET}, as a fraction of the view's remaining height after its top and bottom
     *     padding have been subtracted.
     * @see #setStyle(CaptionStyleCompat)
     * @see #setTextSize(int, float)
     * @see #setBottomPaddingFraction(float)
     */
    void update(
        List<Cue> cues,
        CaptionStyleCompat style,
        float defaultTextSize,
        @Cue.TextSizeType int defaultTextSizeType,
        float bottomPaddingFraction);
  }

  /**
   * The default fractional text size.
   *
   * @see SubtitleView#setFractionalTextSize(float, boolean)
   */
  public static final float DEFAULT_TEXT_SIZE_FRACTION = 0.0533f;

  /**
   * The default bottom padding to apply when {@link Cue#line} is {@link Cue#DIMEN_UNSET}, as a
   * fraction of the viewport height.
   *
   * @see #setBottomPaddingFraction(float)
   */
  public static final float DEFAULT_BOTTOM_PADDING_FRACTION = 0.08f;

  /** Indicates subtitles should be displayed using a {@link Canvas}. This is the default. */
  public static final int VIEW_TYPE_CANVAS = 1;

  /**
   * Indicates subtitles should be displayed using a {@link WebView}.
   *
   * <p>This will use CSS and HTML styling to render the subtitles. This supports some additional
   * styling features beyond those supported by {@link #VIEW_TYPE_CANVAS} such as vertical text.
   */
  public static final int VIEW_TYPE_WEB = 2;

  /**
   * The type of {@link View} to use to display subtitles.
   *
   * <p>One of:
   *
   * <ul>
   *   <li>{@link #VIEW_TYPE_CANVAS}
   *   <li>{@link #VIEW_TYPE_WEB}
   * </ul>
   */
  @Documented
  @Retention(SOURCE)
  @Target(TYPE_USE)
  @IntDef({VIEW_TYPE_CANVAS, VIEW_TYPE_WEB})
  public @interface ViewType {}

  private List<Cue> cues;
  private CaptionStyleCompat style;
  private @Cue.TextSizeType int defaultTextSizeType;
  private float defaultTextSize;
  private float bottomPaddingFraction;
  private boolean applyEmbeddedStyles;
  private boolean applyEmbeddedFontSizes;

  private @ViewType int viewType;
  private Output output;
  private View innerSubtitleView;

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

  public SubtitleView(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    cues = Collections.emptyList();
    style = CaptionStyleCompat.DEFAULT;
    defaultTextSizeType = Cue.TEXT_SIZE_TYPE_FRACTIONAL;
    defaultTextSize = DEFAULT_TEXT_SIZE_FRACTION;
    bottomPaddingFraction = DEFAULT_BOTTOM_PADDING_FRACTION;
    applyEmbeddedStyles = true;
    applyEmbeddedFontSizes = true;

    CanvasSubtitleOutput canvasSubtitleOutput = new CanvasSubtitleOutput(context);
    output = canvasSubtitleOutput;
    innerSubtitleView = canvasSubtitleOutput;
    addView(innerSubtitleView);
    viewType = VIEW_TYPE_CANVAS;
  }

  /**
   * Sets the cues to be displayed by the view.
   *
   * @param cues The cues to display, or null to clear the cues.
   */
  public void setCues(@Nullable List<Cue> cues) {
    this.cues = (cues != null ? cues : Collections.emptyList());
    updateOutput();
  }

  /**
   * Sets the type of {@link View} used to display subtitles.
   *
   * <p>NOTE: {@link #VIEW_TYPE_WEB} is currently very experimental, and doesn't support most
   * styling and layout properties of {@link Cue}.
   *
   * @param viewType The {@link ViewType} to use.
   */
  public void setViewType(@ViewType int viewType) {
    if (this.viewType == viewType) {
      return;
    }
    switch (viewType) {
      case VIEW_TYPE_CANVAS:
        setView(new CanvasSubtitleOutput(getContext()));
        break;
      case VIEW_TYPE_WEB:
        setView(new WebViewSubtitleOutput(getContext()));
        break;
      default:
        throw new IllegalArgumentException();
    }
    this.viewType = viewType;
  }

  private <T extends View & Output> void setView(T view) {
    removeView(innerSubtitleView);
    if (innerSubtitleView instanceof WebViewSubtitleOutput) {
      ((WebViewSubtitleOutput) innerSubtitleView).destroy();
    }
    innerSubtitleView = view;
    output = view;
    addView(view);
  }

  /**
   * Sets the text size to a given unit and value.
   *
   * <p>See {@link TypedValue} for the possible dimension units.
   *
   * @param unit The desired dimension unit.
   * @param size The desired size in the given units.
   */
  public void setFixedTextSize(@Dimension int unit, float size) {
    Context context = getContext();
    Resources resources;
    if (context == null) {
      resources = Resources.getSystem();
    } else {
      resources = context.getResources();
    }
    setTextSize(
        Cue.TEXT_SIZE_TYPE_ABSOLUTE,
        TypedValue.applyDimension(unit, size, resources.getDisplayMetrics()));
  }

  /**
   * Sets the text size based on {@link CaptioningManager#getFontScale()} if {@link
   * CaptioningManager} is available and enabled.
   *
   * <p>Otherwise (and always before API level 19) uses a default font scale of 1.0.
   */
  public void setUserDefaultTextSize() {
    setFractionalTextSize(DEFAULT_TEXT_SIZE_FRACTION * getUserCaptionFontScale());
  }

  /**
   * Sets the text size to be a fraction of the view's remaining height after its top and bottom
   * padding have been subtracted.
   *
   * <p>Equivalent to {@code #setFractionalTextSize(fractionOfHeight, false)}.
   *
   * @param fractionOfHeight A fraction between 0 and 1.
   */
  public void setFractionalTextSize(float fractionOfHeight) {
    setFractionalTextSize(fractionOfHeight, false);
  }

  /**
   * Sets the text size to be a fraction of the height of this view.
   *
   * @param fractionOfHeight A fraction between 0 and 1.
   * @param ignorePadding Set to true if {@code fractionOfHeight} should be interpreted as a
   *     fraction of this view's height ignoring any top and bottom padding. Set to false if {@code
   *     fractionOfHeight} should be interpreted as a fraction of this view's remaining height after
   *     the top and bottom padding has been subtracted.
   */
  public void setFractionalTextSize(float fractionOfHeight, boolean ignorePadding) {
    setTextSize(
        ignorePadding
            ? Cue.TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING
            : Cue.TEXT_SIZE_TYPE_FRACTIONAL,
        fractionOfHeight);
  }

  private void setTextSize(@Cue.TextSizeType int textSizeType, float textSize) {
    this.defaultTextSizeType = textSizeType;
    this.defaultTextSize = textSize;
    updateOutput();
  }

  /**
   * Sets whether styling embedded within the cues should be applied. Enabled by default. Overrides
   * any setting made with {@link SubtitleView#setApplyEmbeddedFontSizes}.
   *
   * @param applyEmbeddedStyles Whether styling embedded within the cues should be applied.
   */
  public void setApplyEmbeddedStyles(boolean applyEmbeddedStyles) {
    this.applyEmbeddedStyles = applyEmbeddedStyles;
    updateOutput();
  }

  /**
   * Sets whether font sizes embedded within the cues should be applied. Enabled by default. Only
   * takes effect if {@link SubtitleView#setApplyEmbeddedStyles} is set to true.
   *
   * @param applyEmbeddedFontSizes Whether font sizes embedded within the cues should be applied.
   */
  public void setApplyEmbeddedFontSizes(boolean applyEmbeddedFontSizes) {
    this.applyEmbeddedFontSizes = applyEmbeddedFontSizes;
    updateOutput();
  }

  /**
   * Styles the captions using {@link CaptioningManager#getUserStyle()} if {@link CaptioningManager}
   * is available and enabled.
   *
   * <p>Otherwise (and always before API level 19) uses a default style.
   */
  public void setUserDefaultStyle() {
    setStyle(getUserCaptionStyle());
  }

  /**
   * Sets the caption style.
   *
   * @param style A style for the view.
   */
  public void setStyle(CaptionStyleCompat style) {
    this.style = style;
    updateOutput();
  }

  /**
   * Sets the bottom padding fraction to apply when {@link Cue#line} is {@link Cue#DIMEN_UNSET}, as
   * a fraction of the view's remaining height after its top and bottom padding have been
   * subtracted.
   *
   * <p>Note that this padding is applied in addition to any standard view padding.
   *
   * @param bottomPaddingFraction The bottom padding fraction.
   */
  public void setBottomPaddingFraction(float bottomPaddingFraction) {
    this.bottomPaddingFraction = bottomPaddingFraction;
    updateOutput();
  }

  private float getUserCaptionFontScale() {
    if (Util.SDK_INT < 19 || isInEditMode()) {
      return 1f;
    }
    @Nullable
    CaptioningManager captioningManager =
        (CaptioningManager) getContext().getSystemService(Context.CAPTIONING_SERVICE);
    return captioningManager != null && captioningManager.isEnabled()
        ? captioningManager.getFontScale()
        : 1f;
  }

  private CaptionStyleCompat getUserCaptionStyle() {
    if (Util.SDK_INT < 19 || isInEditMode()) {
      return CaptionStyleCompat.DEFAULT;
    }
    @Nullable
    CaptioningManager captioningManager =
        (CaptioningManager) getContext().getSystemService(Context.CAPTIONING_SERVICE);
    return captioningManager != null && captioningManager.isEnabled()
        ? CaptionStyleCompat.createFromCaptionStyle(captioningManager.getUserStyle())
        : CaptionStyleCompat.DEFAULT;
  }

  private void updateOutput() {
    output.update(
        getCuesWithStylingPreferencesApplied(),
        style,
        defaultTextSize,
        defaultTextSizeType,
        bottomPaddingFraction);
  }

  /**
   * Returns {@link #cues} with {@link #applyEmbeddedStyles} and {@link #applyEmbeddedFontSizes}
   * applied.
   *
   * <p>If {@link #applyEmbeddedStyles} is false then all styling spans are removed from {@link
   * Cue#text}, {@link Cue#textSize} and {@link Cue#textSizeType} are set to {@link Cue#DIMEN_UNSET}
   * and {@link Cue#windowColorSet} is set to false.
   *
   * <p>Otherwise if {@link #applyEmbeddedFontSizes} is false then only size-related styling spans
   * are removed from {@link Cue#text} and {@link Cue#textSize} and {@link Cue#textSizeType} are set
   * to {@link Cue#DIMEN_UNSET}
   */
  private List<Cue> getCuesWithStylingPreferencesApplied() {
    if (applyEmbeddedStyles && applyEmbeddedFontSizes) {
      return cues;
    }
    List<Cue> strippedCues = new ArrayList<>(cues.size());
    for (int i = 0; i < cues.size(); i++) {
      strippedCues.add(removeEmbeddedStyling(cues.get(i)));
    }
    return strippedCues;
  }

  private Cue removeEmbeddedStyling(Cue cue) {
    Cue.Builder strippedCue = cue.buildUpon();
    if (!applyEmbeddedStyles) {
      SubtitleViewUtils.removeAllEmbeddedStyling(strippedCue);
    } else if (!applyEmbeddedFontSizes) {
      SubtitleViewUtils.removeEmbeddedFontSizes(strippedCue);
    }
    return strippedCue.build();
  }
}