PlayerNotificationManager.java

/*
 * Copyright (C) 2018 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 androidx.media3.common.Player.COMMAND_CHANGE_MEDIA_ITEMS;
import static androidx.media3.common.Player.COMMAND_GET_CURRENT_MEDIA_ITEM;
import static androidx.media3.common.Player.COMMAND_GET_TIMELINE;
import static androidx.media3.common.Player.COMMAND_PLAY_PAUSE;
import static androidx.media3.common.Player.COMMAND_PREPARE;
import static androidx.media3.common.Player.COMMAND_SEEK_BACK;
import static androidx.media3.common.Player.COMMAND_SEEK_FORWARD;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_DEFAULT_POSITION;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS;
import static androidx.media3.common.Player.COMMAND_STOP;
import static androidx.media3.common.Player.EVENT_IS_PLAYING_CHANGED;
import static androidx.media3.common.Player.EVENT_MEDIA_METADATA_CHANGED;
import static androidx.media3.common.Player.EVENT_PLAYBACK_PARAMETERS_CHANGED;
import static androidx.media3.common.Player.EVENT_PLAYBACK_STATE_CHANGED;
import static androidx.media3.common.Player.EVENT_PLAY_WHEN_READY_CHANGED;
import static androidx.media3.common.Player.EVENT_POSITION_DISCONTINUITY;
import static androidx.media3.common.Player.EVENT_REPEAT_MODE_CHANGED;
import static androidx.media3.common.Player.EVENT_SHUFFLE_MODE_ENABLED_CHANGED;
import static androidx.media3.common.Player.EVENT_TIMELINE_CHANGED;
import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkState;
import static java.lang.annotation.ElementType.TYPE_USE;

import android.app.Notification;
import android.app.NotificationChannel;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.support.v4.media.session.MediaSessionCompat;
import androidx.annotation.DrawableRes;
import androidx.annotation.IntDef;
import androidx.annotation.IntRange;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.media.app.NotificationCompat.MediaStyle;
import androidx.media3.common.C;
import androidx.media3.common.Player;
import androidx.media3.common.util.NotificationUtil;
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.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Starts, updates and cancels a media style notification reflecting the player state. The actions
 * included in the notification can be customized along with their drawables, as described below.
 *
 * <p>The notification is cancelled when {@code null} is passed to {@link #setPlayer(Player)} or
 * when the notification is dismissed by the user.
 *
 * <p>If the player is released it must be removed from the manager by calling {@code
 * setPlayer(null)}.
 *
 * <h2>Action customization</h2>
 *
 * Playback actions can be included or omitted as follows:
 *
 * <ul>
 *   <li><b>{@code usePlayPauseActions}</b> - Sets whether the play and pause actions are used.
 *       <ul>
 *         <li>Corresponding setter: {@link #setUsePlayPauseActions(boolean)}
 *         <li>Default: {@code true}
 *       </ul>
 *   <li><b>{@code useRewindAction}</b> - Sets whether the rewind action is used.
 *       <ul>
 *         <li>Corresponding setter: {@link #setUseRewindAction(boolean)}
 *         <li>Default: {@code true}
 *       </ul>
 *   <li><b>{@code useRewindActionInCompactView}</b> - If {@code useRewindAction} is {@code true},
 *       sets whether the rewind action is also used in compact view (including the lock screen
 *       notification). Else does nothing.
 *       <ul>
 *         <li>Corresponding setter: {@link #setUseRewindActionInCompactView(boolean)}
 *         <li>Default: {@code false}
 *       </ul>
 *   <li><b>{@code useFastForwardAction}</b> - Sets whether the fast forward action is used.
 *       <ul>
 *         <li>Corresponding setter: {@link #setUseFastForwardAction(boolean)}
 *         <li>Default: {@code true}
 *       </ul>
 *   <li><b>{@code useFastForwardActionInCompactView}</b> - If {@code useFastForwardAction} is
 *       {@code true}, sets whether the fast forward action is also used in compact view (including
 *       the lock screen notification). Else does nothing.
 *       <ul>
 *         <li>Corresponding setter: {@link #setUseFastForwardActionInCompactView(boolean)}
 *         <li>Default: {@code false}
 *       </ul>
 *   <li><b>{@code usePreviousAction}</b> - Whether the previous action is used.
 *       <ul>
 *         <li>Corresponding setter: {@link #setUsePreviousAction(boolean)}
 *         <li>Default: {@code true}
 *       </ul>
 *   <li><b>{@code usePreviousActionInCompactView}</b> - If {@code usePreviousAction} is {@code
 *       true}, sets whether the previous action is also used in compact view (including the lock
 *       screen notification). Else does nothing.
 *       <ul>
 *         <li>Corresponding setter: {@link #setUsePreviousActionInCompactView(boolean)}
 *         <li>Default: {@code false}
 *       </ul>
 *   <li><b>{@code useNextAction}</b> - Whether the next action is used.
 *       <ul>
 *         <li>Corresponding setter: {@link #setUseNextAction(boolean)}
 *         <li>Default: {@code true}
 *       </ul>
 *   <li><b>{@code useNextActionInCompactView}</b> - If {@code useNextAction} is {@code true}, sets
 *       whether the next action is also used in compact view (including the lock screen
 *       notification). Else does nothing.
 *       <ul>
 *         <li>Corresponding setter: {@link #setUseNextActionInCompactView(boolean)}
 *         <li>Default: {@code false}
 *       </ul>
 *   <li><b>{@code useStopAction}</b> - Sets whether the stop action is used.
 *       <ul>
 *         <li>Corresponding setter: {@link #setUseStopAction(boolean)}
 *         <li>Default: {@code false}
 *       </ul>
 * </ul>
 *
 * <h2>Overriding drawables</h2>
 *
 * The drawables used by PlayerNotificationManager can be overridden by drawables with the same
 * names defined in your application. The drawables that can be overridden are:
 *
 * <ul>
 *   <li><b>{@code exo_notification_small_icon}</b> - The icon passed by default to {@link
 *       NotificationCompat.Builder#setSmallIcon(int)}. A different icon can also be specified
 *       programmatically by calling {@link #setSmallIcon(int)}.
 *   <li><b>{@code exo_notification_play}</b> - The play icon.
 *   <li><b>{@code exo_notification_pause}</b> - The pause icon.
 *   <li><b>{@code exo_notification_rewind}</b> - The rewind icon.
 *   <li><b>{@code exo_notification_fastforward}</b> - The fast forward icon.
 *   <li><b>{@code exo_notification_previous}</b> - The previous icon.
 *   <li><b>{@code exo_notification_next}</b> - The next icon.
 *   <li><b>{@code exo_notification_stop}</b> - The stop icon.
 * </ul>
 *
 * <p>Alternatively, the action icons can be set programatically by using the {@link Builder}.
 *
 * <p>Unlike the drawables above, the large icon (i.e. the icon passed to {@link
 * NotificationCompat.Builder#setLargeIcon(Bitmap)} cannot be overridden in this way. Instead, the
 * large icon is obtained from the {@link MediaDescriptionAdapter} passed to {@link
 * Builder#Builder(Context, int, String, MediaDescriptionAdapter)}.
 */
@UnstableApi
public class PlayerNotificationManager {

  /** An adapter to provide content assets of the media currently playing. */
  public interface MediaDescriptionAdapter {

    /**
     * Gets the content title for the current media item.
     *
     * <p>See {@link NotificationCompat.Builder#setContentTitle(CharSequence)}.
     *
     * @param player The {@link Player} for which a notification is being built.
     * @return The content title for the current media item.
     */
    CharSequence getCurrentContentTitle(Player player);

    /**
     * Creates a content intent for the current media item.
     *
     * <p>See {@link NotificationCompat.Builder#setContentIntent(PendingIntent)}.
     *
     * @param player The {@link Player} for which a notification is being built.
     * @return The content intent for the current media item, or null if no intent should be fired.
     */
    @Nullable
    PendingIntent createCurrentContentIntent(Player player);

    /**
     * Gets the content text for the current media item.
     *
     * <p>See {@link NotificationCompat.Builder#setContentText(CharSequence)}.
     *
     * @param player The {@link Player} for which a notification is being built.
     * @return The content text for the current media item, or null if no context text should be
     *     displayed.
     */
    @Nullable
    CharSequence getCurrentContentText(Player player);

    /**
     * Gets the content sub text for the current media item.
     *
     * <p>See {@link NotificationCompat.Builder#setSubText(CharSequence)}.
     *
     * @param player The {@link Player} for which a notification is being built.
     * @return The content subtext for the current media item, or null if no subtext should be
     *     displayed.
     */
    @Nullable
    default CharSequence getCurrentSubText(Player player) {
      return null;
    }

    /**
     * Gets the large icon for the current media item.
     *
     * <p>When a bitmap needs to be loaded asynchronously, a placeholder bitmap (or null) should be
     * returned. The actual bitmap should be passed to the {@link BitmapCallback} once it has been
     * loaded. Because the adapter may be called multiple times for the same media item, bitmaps
     * should be cached by the app and returned synchronously when possible.
     *
     * <p>See {@link NotificationCompat.Builder#setLargeIcon(Bitmap)}.
     *
     * @param player The {@link Player} for which a notification is being built.
     * @param callback A {@link BitmapCallback} to provide a {@link Bitmap} asynchronously.
     * @return The large icon for the current media item, or null if the icon will be returned
     *     through the {@link BitmapCallback} or if no icon should be displayed.
     */
    @Nullable
    Bitmap getCurrentLargeIcon(Player player, BitmapCallback callback);
  }

  /** Defines and handles custom actions. */
  public interface CustomActionReceiver {

    /**
     * Gets the actions handled by this receiver.
     *
     * <p>If multiple {@link PlayerNotificationManager} instances are in use at the same time, the
     * {@code instanceId} must be set as an intent extra with key {@link
     * PlayerNotificationManager#EXTRA_INSTANCE_ID} to avoid sending the action to every custom
     * action receiver. It's also necessary to ensure something is different about the actions. This
     * may be any of the {@link Intent} attributes considered by {@link Intent#filterEquals}, or
     * different request code integers when creating the {@link PendingIntent}s with {@link
     * PendingIntent#getBroadcast}. The easiest approach is to use the {@code instanceId} as the
     * request code.
     *
     * @param context The {@link Context}.
     * @param instanceId The instance id of the {@link PlayerNotificationManager}.
     * @return A map of custom actions.
     */
    Map<String, NotificationCompat.Action> createCustomActions(Context context, int instanceId);

    /**
     * Gets the actions to be included in the notification given the current player state.
     *
     * @param player The {@link Player} for which a notification is being built.
     * @return The actions to be included in the notification.
     */
    List<String> getCustomActions(Player player);

    /**
     * Called when a custom action has been received.
     *
     * @param player The player.
     * @param action The action from {@link Intent#getAction()}.
     * @param intent The received {@link Intent}.
     */
    void onCustomAction(Player player, String action, Intent intent);
  }

  /** A listener for changes to the notification. */
  public interface NotificationListener {

    /**
     * Called after the notification has been cancelled.
     *
     * @param notificationId The id of the notification which has been cancelled.
     * @param dismissedByUser {@code true} if the notification is cancelled because the user
     *     dismissed the notification.
     */
    default void onNotificationCancelled(int notificationId, boolean dismissedByUser) {}

    /**
     * Called each time after the notification has been posted.
     *
     * <p>For a service, the {@code ongoing} flag can be used as an indicator as to whether it
     * should be in the foreground.
     *
     * @param notificationId The id of the notification which has been posted.
     * @param notification The {@link Notification}.
     * @param ongoing Whether the notification is ongoing.
     */
    default void onNotificationPosted(
        int notificationId, Notification notification, boolean ongoing) {}
  }

  /** A builder for {@link PlayerNotificationManager} instances. */
  public static class Builder {

    protected final Context context;
    protected final int notificationId;
    protected final String channelId;

    @Nullable protected NotificationListener notificationListener;
    @Nullable protected CustomActionReceiver customActionReceiver;
    protected MediaDescriptionAdapter mediaDescriptionAdapter;
    protected int channelNameResourceId;
    protected int channelDescriptionResourceId;
    protected int channelImportance;
    protected int smallIconResourceId;
    protected int rewindActionIconResourceId;
    protected int playActionIconResourceId;
    protected int pauseActionIconResourceId;
    protected int stopActionIconResourceId;
    protected int fastForwardActionIconResourceId;
    protected int previousActionIconResourceId;
    protected int nextActionIconResourceId;
    @Nullable protected String groupKey;

    /**
     * @deprecated Use {@link #Builder(Context, int, String)} instead, then call {@link
     *     #setMediaDescriptionAdapter(MediaDescriptionAdapter)}.
     */
    @Deprecated
    public Builder(
        Context context,
        int notificationId,
        String channelId,
        MediaDescriptionAdapter mediaDescriptionAdapter) {
      this(context, notificationId, channelId);
      this.mediaDescriptionAdapter = mediaDescriptionAdapter;
    }

    /**
     * Creates an instance.
     *
     * @param context The {@link Context}.
     * @param notificationId The id of the notification to be posted. Must be greater than 0.
     * @param channelId The id of the notification channel of an existing notification channel or of
     *     the channel that should be automatically created. In the latter case, {@link
     *     #setChannelNameResourceId(int)} needs to be called as well.
     */
    public Builder(Context context, @IntRange(from = 1) int notificationId, String channelId) {
      checkArgument(notificationId > 0);
      this.context = context;
      this.notificationId = notificationId;
      this.channelId = channelId;
      channelImportance = NotificationUtil.IMPORTANCE_LOW;
      mediaDescriptionAdapter = new DefaultMediaDescriptionAdapter(/* pendingIntent= */ null);
      smallIconResourceId = R.drawable.exo_notification_small_icon;
      playActionIconResourceId = R.drawable.exo_notification_play;
      pauseActionIconResourceId = R.drawable.exo_notification_pause;
      stopActionIconResourceId = R.drawable.exo_notification_stop;
      rewindActionIconResourceId = R.drawable.exo_notification_rewind;
      fastForwardActionIconResourceId = R.drawable.exo_notification_fastforward;
      previousActionIconResourceId = R.drawable.exo_notification_previous;
      nextActionIconResourceId = R.drawable.exo_notification_next;
    }

    /**
     * The name of the channel. If set to a value other than {@code 0}, the channel is automatically
     * created when {@link #build()} is called. If the application has already created the
     * notification channel, then this method should not be called.
     *
     * <p>The default is {@code 0}.
     *
     * @return This builder.
     */
    public Builder setChannelNameResourceId(int channelNameResourceId) {
      this.channelNameResourceId = channelNameResourceId;
      return this;
    }

    /**
     * The description of the channel. Ignored if {@link #setChannelNameResourceId(int)} is not
     * called with a value other than {@code 0}. If the application has already created the
     * notification channel, then this method should not be called.
     *
     * <p>The default is {@code 0}.
     *
     * @return This builder.
     */
    public Builder setChannelDescriptionResourceId(int channelDescriptionResourceId) {
      this.channelDescriptionResourceId = channelDescriptionResourceId;
      return this;
    }

    /**
     * The importance of the channel. Ignored if {@link #setChannelNameResourceId(int)} is not
     * called with a value other than {@code 0}. If the application has already created the
     * notification channel, then this method should not be called.
     *
     * <p>The default is {@link NotificationUtil#IMPORTANCE_LOW}.
     *
     * @return This builder.
     */
    public Builder setChannelImportance(@NotificationUtil.Importance int channelImportance) {
      this.channelImportance = channelImportance;
      return this;
    }

    /**
     * The {@link NotificationListener} to be used.
     *
     * <p>The default is {@code null}.
     *
     * @return This builder.
     */
    public Builder setNotificationListener(NotificationListener notificationListener) {
      this.notificationListener = notificationListener;
      return this;
    }

    /**
     * The {@link CustomActionReceiver} to be used.
     *
     * <p>The default is {@code null}.
     *
     * @return This builder.
     */
    public Builder setCustomActionReceiver(CustomActionReceiver customActionReceiver) {
      this.customActionReceiver = customActionReceiver;
      return this;
    }

    /**
     * The resource id of the small icon of the notification shown in the status bar. See {@link
     * NotificationCompat.Builder#setSmallIcon(int)}.
     *
     * <p>The default is {@code R.drawable#exo_notification_small_icon}.
     *
     * @return This builder.
     */
    public Builder setSmallIconResourceId(int smallIconResourceId) {
      this.smallIconResourceId = smallIconResourceId;
      return this;
    }

    /**
     * The resource id of the drawable to be used as the icon of action {@link #ACTION_PLAY}.
     *
     * <p>The default is {@code R.drawable#exo_notification_play}.
     *
     * @return This builder.
     */
    public Builder setPlayActionIconResourceId(int playActionIconResourceId) {
      this.playActionIconResourceId = playActionIconResourceId;
      return this;
    }

    /**
     * The resource id of the drawable to be used as the icon of action {@link #ACTION_PAUSE}.
     *
     * <p>The default is {@code R.drawable#exo_notification_pause}.
     *
     * @return This builder.
     */
    public Builder setPauseActionIconResourceId(int pauseActionIconResourceId) {
      this.pauseActionIconResourceId = pauseActionIconResourceId;
      return this;
    }

    /**
     * The resource id of the drawable to be used as the icon of action {@link #ACTION_STOP}.
     *
     * <p>The default is {@code R.drawable#exo_notification_stop}.
     *
     * @return This builder.
     */
    public Builder setStopActionIconResourceId(int stopActionIconResourceId) {
      this.stopActionIconResourceId = stopActionIconResourceId;
      return this;
    }

    /**
     * The resource id of the drawable to be used as the icon of action {@link #ACTION_REWIND}.
     *
     * <p>The default is {@code R.drawable#exo_notification_rewind}.
     *
     * @return This builder.
     */
    public Builder setRewindActionIconResourceId(int rewindActionIconResourceId) {
      this.rewindActionIconResourceId = rewindActionIconResourceId;
      return this;
    }

    /**
     * The resource id of the drawable to be used as the icon of action {@link
     * #ACTION_FAST_FORWARD}.
     *
     * <p>The default is {@code R.drawable#exo_notification_fastforward}.
     *
     * @return This builder.
     */
    public Builder setFastForwardActionIconResourceId(int fastForwardActionIconResourceId) {
      this.fastForwardActionIconResourceId = fastForwardActionIconResourceId;
      return this;
    }

    /**
     * The resource id of the drawable to be used as the icon of action {@link #ACTION_PREVIOUS}.
     *
     * <p>The default is {@code R.drawable#exo_notification_previous}.
     *
     * @return This builder.
     */
    public Builder setPreviousActionIconResourceId(int previousActionIconResourceId) {
      this.previousActionIconResourceId = previousActionIconResourceId;
      return this;
    }

    /**
     * The resource id of the drawable to be used as the icon of action {@link #ACTION_NEXT}.
     *
     * <p>The default is {@code R.drawable#exo_notification_next}.
     *
     * @return This builder.
     */
    public Builder setNextActionIconResourceId(int nextActionIconResourceId) {
      this.nextActionIconResourceId = nextActionIconResourceId;
      return this;
    }

    /**
     * The key of the group the media notification should belong to.
     *
     * <p>The default is {@code null}
     *
     * @return This builder.
     */
    public Builder setGroup(String groupKey) {
      this.groupKey = groupKey;
      return this;
    }

    /**
     * The {@link MediaDescriptionAdapter} to be queried for the notification contents.
     *
     * <p>The default is {@link DefaultMediaDescriptionAdapter} with no {@link PendingIntent}
     *
     * @return This builder.
     */
    public Builder setMediaDescriptionAdapter(MediaDescriptionAdapter mediaDescriptionAdapter) {
      this.mediaDescriptionAdapter = mediaDescriptionAdapter;
      return this;
    }

    /** Builds the {@link PlayerNotificationManager}. */
    public PlayerNotificationManager build() {
      if (channelNameResourceId != 0) {
        NotificationUtil.createNotificationChannel(
            context,
            channelId,
            channelNameResourceId,
            channelDescriptionResourceId,
            channelImportance);
      }

      return new PlayerNotificationManager(
          context,
          channelId,
          notificationId,
          mediaDescriptionAdapter,
          notificationListener,
          customActionReceiver,
          smallIconResourceId,
          playActionIconResourceId,
          pauseActionIconResourceId,
          stopActionIconResourceId,
          rewindActionIconResourceId,
          fastForwardActionIconResourceId,
          previousActionIconResourceId,
          nextActionIconResourceId,
          groupKey);
    }
  }

  /** Receives a {@link Bitmap}. */
  public final class BitmapCallback {
    private final int notificationTag;

    /** Create the receiver. */
    private BitmapCallback(int notificationTag) {
      this.notificationTag = notificationTag;
    }

    /**
     * Called when {@link Bitmap} is available.
     *
     * @param bitmap The bitmap to use as the large icon of the notification.
     */
    public void onBitmap(final Bitmap bitmap) {
      if (bitmap != null) {
        postUpdateNotificationBitmap(bitmap, notificationTag);
      }
    }
  }

  /** The action which starts playback. */
  public static final String ACTION_PLAY = "androidx.media3.ui.notification.play";
  /** The action which pauses playback. */
  public static final String ACTION_PAUSE = "androidx.media3.ui.notification.pause";
  /** The action which skips to the previous media item. */
  public static final String ACTION_PREVIOUS = "androidx.media3.ui.notification.prev";
  /** The action which skips to the next media item. */
  public static final String ACTION_NEXT = "androidx.media3.ui.notification.next";
  /** The action which fast forwards. */
  public static final String ACTION_FAST_FORWARD = "androidx.media3.ui.notification.ffwd";
  /** The action which rewinds. */
  public static final String ACTION_REWIND = "androidx.media3.ui.notification.rewind";
  /** The action which stops playback. */
  public static final String ACTION_STOP = "androidx.media3.ui.notification.stop";
  /** The extra key of the instance id of the player notification manager. */
  public static final String EXTRA_INSTANCE_ID = "INSTANCE_ID";
  /**
   * The action which is executed when the notification is dismissed. It cancels the notification
   * and calls {@link NotificationListener#onNotificationCancelled(int, boolean)}.
   */
  private static final String ACTION_DISMISS = "androidx.media3.ui.notification.dismiss";

  // Internal messages.

  private static final int MSG_START_OR_UPDATE_NOTIFICATION = 0;
  private static final int MSG_UPDATE_NOTIFICATION_BITMAP = 1;

  /**
   * Visibility of notification on the lock screen. One of {@link
   * NotificationCompat#VISIBILITY_PRIVATE}, {@link NotificationCompat#VISIBILITY_PUBLIC} or {@link
   * NotificationCompat#VISIBILITY_SECRET}.
   */
  @Documented
  @Retention(RetentionPolicy.SOURCE)
  @Target(TYPE_USE)
  @IntDef({
    NotificationCompat.VISIBILITY_PRIVATE,
    NotificationCompat.VISIBILITY_PUBLIC,
    NotificationCompat.VISIBILITY_SECRET
  })
  public @interface Visibility {}

  /**
   * Priority of the notification (required for API 25 and lower). One of {@link
   * NotificationCompat#PRIORITY_DEFAULT}, {@link NotificationCompat#PRIORITY_MAX}, {@link
   * NotificationCompat#PRIORITY_HIGH}, {@link NotificationCompat#PRIORITY_LOW }or {@link
   * NotificationCompat#PRIORITY_MIN}.
   */
  @Documented
  @Retention(RetentionPolicy.SOURCE)
  @Target(TYPE_USE)
  @IntDef({
    NotificationCompat.PRIORITY_DEFAULT,
    NotificationCompat.PRIORITY_MAX,
    NotificationCompat.PRIORITY_HIGH,
    NotificationCompat.PRIORITY_LOW,
    NotificationCompat.PRIORITY_MIN
  })
  public @interface Priority {}

  private static int instanceIdCounter;

  private final Context context;
  private final String channelId;
  private final int notificationId;
  private final MediaDescriptionAdapter mediaDescriptionAdapter;
  @Nullable private final NotificationListener notificationListener;
  @Nullable private final CustomActionReceiver customActionReceiver;
  private final Handler mainHandler;
  private final NotificationManagerCompat notificationManager;
  private final IntentFilter intentFilter;
  private final Player.Listener playerListener;
  private final NotificationBroadcastReceiver notificationBroadcastReceiver;
  private final Map<String, NotificationCompat.Action> playbackActions;
  private final Map<String, NotificationCompat.Action> customActions;
  private final PendingIntent dismissPendingIntent;
  private final int instanceId;

  @Nullable private NotificationCompat.Builder builder;
  @Nullable private List<NotificationCompat.Action> builderActions;
  @Nullable private Player player;
  private boolean isNotificationStarted;
  private int currentNotificationTag;
  @Nullable private MediaSessionCompat.Token mediaSessionToken;
  private boolean usePreviousAction;
  private boolean useNextAction;
  private boolean usePreviousActionInCompactView;
  private boolean useNextActionInCompactView;
  private boolean useRewindAction;
  private boolean useFastForwardAction;
  private boolean useRewindActionInCompactView;
  private boolean useFastForwardActionInCompactView;
  private boolean usePlayPauseActions;
  private boolean useStopAction;
  private int badgeIconType;
  private boolean colorized;
  private int defaults;
  private int color;
  @DrawableRes private int smallIconResourceId;
  private int visibility;
  private @Priority int priority;
  private boolean useChronometer;
  @Nullable private String groupKey;

  protected PlayerNotificationManager(
      Context context,
      String channelId,
      int notificationId,
      MediaDescriptionAdapter mediaDescriptionAdapter,
      @Nullable NotificationListener notificationListener,
      @Nullable CustomActionReceiver customActionReceiver,
      int smallIconResourceId,
      int playActionIconResourceId,
      int pauseActionIconResourceId,
      int stopActionIconResourceId,
      int rewindActionIconResourceId,
      int fastForwardActionIconResourceId,
      int previousActionIconResourceId,
      int nextActionIconResourceId,
      @Nullable String groupKey) {
    context = context.getApplicationContext();
    this.context = context;
    this.channelId = channelId;
    this.notificationId = notificationId;
    this.mediaDescriptionAdapter = mediaDescriptionAdapter;
    this.notificationListener = notificationListener;
    this.customActionReceiver = customActionReceiver;
    this.smallIconResourceId = smallIconResourceId;
    this.groupKey = groupKey;
    instanceId = instanceIdCounter++;
    // This fails the nullness checker because handleMessage() is 'called' while `this` is still
    // @UnderInitialization. No tasks are scheduled on mainHandler before the constructor completes,
    // so this is safe and we can suppress the warning.
    @SuppressWarnings("nullness:methodref.receiver.bound")
    Handler mainHandler = Util.createHandler(Looper.getMainLooper(), this::handleMessage);
    this.mainHandler = mainHandler;
    notificationManager = NotificationManagerCompat.from(context);
    playerListener = new PlayerListener();
    notificationBroadcastReceiver = new NotificationBroadcastReceiver();
    intentFilter = new IntentFilter();
    usePreviousAction = true;
    useNextAction = true;
    usePlayPauseActions = true;
    useRewindAction = true;
    useFastForwardAction = true;
    colorized = true;
    useChronometer = true;
    color = Color.TRANSPARENT;
    defaults = 0;
    priority = NotificationCompat.PRIORITY_LOW;
    badgeIconType = NotificationCompat.BADGE_ICON_SMALL;
    visibility = NotificationCompat.VISIBILITY_PUBLIC;

    // initialize actions
    playbackActions =
        createPlaybackActions(
            context,
            instanceId,
            playActionIconResourceId,
            pauseActionIconResourceId,
            stopActionIconResourceId,
            rewindActionIconResourceId,
            fastForwardActionIconResourceId,
            previousActionIconResourceId,
            nextActionIconResourceId);
    for (String action : playbackActions.keySet()) {
      intentFilter.addAction(action);
    }
    customActions =
        customActionReceiver != null
            ? customActionReceiver.createCustomActions(context, instanceId)
            : Collections.emptyMap();
    for (String action : customActions.keySet()) {
      intentFilter.addAction(action);
    }
    dismissPendingIntent = createBroadcastIntent(ACTION_DISMISS, context, instanceId);
    intentFilter.addAction(ACTION_DISMISS);
  }

  /**
   * Sets the {@link Player}.
   *
   * <p>Setting the player starts a notification immediately unless the player is in {@link
   * Player#STATE_IDLE}, in which case the notification is started as soon as the player transitions
   * away from being idle.
   *
   * <p>If the player is released it must be removed from the manager by calling {@code
   * setPlayer(null)}. This will cancel the notification.
   *
   * @param player The {@link Player} to use, or {@code null} to remove the current player. Only
   *     players which are accessed on the main thread are supported ({@code
   *     player.getApplicationLooper() == Looper.getMainLooper()}).
   */
  public final void setPlayer(@Nullable Player player) {
    checkState(Looper.myLooper() == Looper.getMainLooper());
    checkArgument(player == null || player.getApplicationLooper() == Looper.getMainLooper());
    if (this.player == player) {
      return;
    }
    if (this.player != null) {
      this.player.removeListener(playerListener);
      if (player == null) {
        stopNotification(/* dismissedByUser= */ false);
      }
    }
    this.player = player;
    if (player != null) {
      player.addListener(playerListener);
      postStartOrUpdateNotification();
    }
  }

  /**
   * Sets whether the next action should be used.
   *
   * @param useNextAction Whether to use the next action.
   */
  public final void setUseNextAction(boolean useNextAction) {
    if (this.useNextAction != useNextAction) {
      this.useNextAction = useNextAction;
      invalidate();
    }
  }

  /**
   * Sets whether the previous action should be used.
   *
   * @param usePreviousAction Whether to use the previous action.
   */
  public final void setUsePreviousAction(boolean usePreviousAction) {
    if (this.usePreviousAction != usePreviousAction) {
      this.usePreviousAction = usePreviousAction;
      invalidate();
    }
  }

  /**
   * If {@link #setUseNextAction useNextAction} is {@code true}, sets whether the next action should
   * also be used in compact view. Has no effect if {@link #setUseNextAction useNextAction} is
   * {@code false}.
   *
   * <p>If set to {@code true}, {@link #setUseFastForwardActionInCompactView(boolean)
   * setUseFastForwardActionInCompactView} is set to false.
   *
   * @param useNextActionInCompactView Whether to use the next action in compact view.
   */
  public final void setUseNextActionInCompactView(boolean useNextActionInCompactView) {
    if (this.useNextActionInCompactView != useNextActionInCompactView) {
      this.useNextActionInCompactView = useNextActionInCompactView;
      if (useNextActionInCompactView) {
        useFastForwardActionInCompactView = false;
      }
      invalidate();
    }
  }

  /**
   * If {@link #setUsePreviousAction usePreviousAction} is {@code true}, sets whether the previous
   * action should also be used in compact view. Has no effect if {@link #setUsePreviousAction
   * usePreviousAction} is {@code false}.
   *
   * <p>If set to {@code true}, {@link #setUseRewindActionInCompactView(boolean)
   * setUseRewindActionInCompactView} is set to false.
   *
   * @param usePreviousActionInCompactView Whether to use the previous action in compact view.
   */
  public final void setUsePreviousActionInCompactView(boolean usePreviousActionInCompactView) {
    if (this.usePreviousActionInCompactView != usePreviousActionInCompactView) {
      this.usePreviousActionInCompactView = usePreviousActionInCompactView;
      if (usePreviousActionInCompactView) {
        useRewindActionInCompactView = false;
      }
      invalidate();
    }
  }

  /**
   * Sets whether the fast forward action should be used.
   *
   * @param useFastForwardAction Whether to use the fast forward action.
   */
  public final void setUseFastForwardAction(boolean useFastForwardAction) {
    if (this.useFastForwardAction != useFastForwardAction) {
      this.useFastForwardAction = useFastForwardAction;
      invalidate();
    }
  }

  /**
   * Sets whether the rewind action should be used.
   *
   * @param useRewindAction Whether to use the rewind action.
   */
  public final void setUseRewindAction(boolean useRewindAction) {
    if (this.useRewindAction != useRewindAction) {
      this.useRewindAction = useRewindAction;
      invalidate();
    }
  }

  /**
   * Sets whether the fast forward action should also be used in compact view. Has no effect if
   * {@link #ACTION_FAST_FORWARD} is not enabled, for instance if the media is not seekable.
   *
   * <p>If set to {@code true}, {@link #setUseNextActionInCompactView(boolean)
   * setUseNextActionInCompactView} is set to false.
   *
   * @param useFastForwardActionInCompactView Whether to use the fast forward action in compact
   *     view.
   */
  public final void setUseFastForwardActionInCompactView(
      boolean useFastForwardActionInCompactView) {
    if (this.useFastForwardActionInCompactView != useFastForwardActionInCompactView) {
      this.useFastForwardActionInCompactView = useFastForwardActionInCompactView;
      if (useFastForwardActionInCompactView) {
        useNextActionInCompactView = false;
      }
      invalidate();
    }
  }

  /**
   * Sets whether the rewind action should also be used in compact view. Has no effect if {@link
   * #ACTION_REWIND} is not enabled, for instance if the media is not seekable.
   *
   * <p>If set to {@code true}, {@link #setUsePreviousActionInCompactView(boolean)
   * setUsePreviousActionInCompactView} is set to false.
   *
   * @param useRewindActionInCompactView Whether to use the rewind action in compact view.
   */
  public final void setUseRewindActionInCompactView(boolean useRewindActionInCompactView) {
    if (this.useRewindActionInCompactView != useRewindActionInCompactView) {
      this.useRewindActionInCompactView = useRewindActionInCompactView;
      if (useRewindActionInCompactView) {
        usePreviousActionInCompactView = false;
      }
      invalidate();
    }
  }

  /**
   * Sets whether the play and pause actions should be used.
   *
   * @param usePlayPauseActions Whether to use play and pause actions.
   */
  public final void setUsePlayPauseActions(boolean usePlayPauseActions) {
    if (this.usePlayPauseActions != usePlayPauseActions) {
      this.usePlayPauseActions = usePlayPauseActions;
      invalidate();
    }
  }

  /**
   * Sets whether the stop action should be used.
   *
   * @param useStopAction Whether to use the stop action.
   */
  public final void setUseStopAction(boolean useStopAction) {
    if (this.useStopAction == useStopAction) {
      return;
    }
    this.useStopAction = useStopAction;
    invalidate();
  }

  /**
   * Sets the {@link MediaSessionCompat.Token}.
   *
   * @param token The {@link MediaSessionCompat.Token}.
   */
  public final void setMediaSessionToken(MediaSessionCompat.Token token) {
    if (!Util.areEqual(this.mediaSessionToken, token)) {
      mediaSessionToken = token;
      invalidate();
    }
  }

  /**
   * Sets the badge icon type of the notification.
   *
   * <p>See {@link NotificationCompat.Builder#setBadgeIconType(int)}.
   *
   * @param badgeIconType The badge icon type.
   */
  public final void setBadgeIconType(@NotificationCompat.BadgeIconType int badgeIconType) {
    if (this.badgeIconType == badgeIconType) {
      return;
    }
    switch (badgeIconType) {
      case NotificationCompat.BADGE_ICON_NONE:
      case NotificationCompat.BADGE_ICON_SMALL:
      case NotificationCompat.BADGE_ICON_LARGE:
        this.badgeIconType = badgeIconType;
        break;
      default:
        throw new IllegalArgumentException();
    }
    invalidate();
  }

  /**
   * Sets whether the notification should be colorized. When set, the color set with {@link
   * #setColor(int)} will be used as the background color for the notification.
   *
   * <p>See {@link NotificationCompat.Builder#setColorized(boolean)}.
   *
   * @param colorized Whether to colorize the notification.
   */
  public final void setColorized(boolean colorized) {
    if (this.colorized != colorized) {
      this.colorized = colorized;
      invalidate();
    }
  }

  /**
   * Sets the defaults.
   *
   * <p>See {@link NotificationCompat.Builder#setDefaults(int)}.
   *
   * @param defaults The default notification options.
   */
  public final void setDefaults(int defaults) {
    if (this.defaults != defaults) {
      this.defaults = defaults;
      invalidate();
    }
  }

  /**
   * Sets the accent color of the notification.
   *
   * <p>See {@link NotificationCompat.Builder#setColor(int)}.
   *
   * @param color The color, in ARGB integer form like the constants in {@link Color}.
   */
  public final void setColor(int color) {
    if (this.color != color) {
      this.color = color;
      invalidate();
    }
  }

  /**
   * Sets the priority of the notification required for API 25 and lower.
   *
   * <p>See {@link NotificationCompat.Builder#setPriority(int)}.
   *
   * <p>To set the priority for API levels above 25, you can create your own {@link
   * NotificationChannel} with a given importance level and pass the id of the channel to {@link
   * Builder#Builder(Context, int, String, MediaDescriptionAdapter)}.
   *
   * @param priority The priority which can be one of {@link NotificationCompat#PRIORITY_DEFAULT},
   *     {@link NotificationCompat#PRIORITY_MAX}, {@link NotificationCompat#PRIORITY_HIGH}, {@link
   *     NotificationCompat#PRIORITY_LOW} or {@link NotificationCompat#PRIORITY_MIN}. If not set
   *     {@link NotificationCompat#PRIORITY_LOW} is used by default.
   */
  public final void setPriority(@Priority int priority) {
    if (this.priority == priority) {
      return;
    }
    switch (priority) {
      case NotificationCompat.PRIORITY_DEFAULT:
      case NotificationCompat.PRIORITY_MAX:
      case NotificationCompat.PRIORITY_HIGH:
      case NotificationCompat.PRIORITY_LOW:
      case NotificationCompat.PRIORITY_MIN:
        this.priority = priority;
        break;
      default:
        throw new IllegalArgumentException();
    }
    invalidate();
  }

  /**
   * Sets the small icon of the notification which is also shown in the system status bar.
   *
   * <p>See {@link NotificationCompat.Builder#setSmallIcon(int)}.
   *
   * @param smallIconResourceId The resource id of the small icon.
   */
  public final void setSmallIcon(@DrawableRes int smallIconResourceId) {
    if (this.smallIconResourceId != smallIconResourceId) {
      this.smallIconResourceId = smallIconResourceId;
      invalidate();
    }
  }

  /**
   * Sets whether the elapsed time of the media playback should be displayed.
   *
   * <p>Note that this setting only works if all of the following are true:
   *
   * <ul>
   *   <li>The media is {@link Player#isPlaying() actively playing}.
   *   <li>The media is not {@link Player#isCurrentMediaItemDynamic() dynamically changing its
   *       duration} (like for example a live stream).
   *   <li>The media is not {@link Player#isPlayingAd() interrupted by an ad}.
   *   <li>The media is played at {@link Player#getPlaybackParameters() regular speed}.
   *   <li>The device is running at least API 21 (Lollipop).
   * </ul>
   *
   * <p>See {@link NotificationCompat.Builder#setUsesChronometer(boolean)}.
   *
   * @param useChronometer Whether to use chronometer.
   */
  public final void setUseChronometer(boolean useChronometer) {
    if (this.useChronometer != useChronometer) {
      this.useChronometer = useChronometer;
      invalidate();
    }
  }

  /**
   * Sets the visibility of the notification which determines whether and how the notification is
   * shown when the device is in lock screen mode.
   *
   * <p>See {@link NotificationCompat.Builder#setVisibility(int)}.
   *
   * @param visibility The visibility which must be one of {@link
   *     NotificationCompat#VISIBILITY_PUBLIC}, {@link NotificationCompat#VISIBILITY_PRIVATE} or
   *     {@link NotificationCompat#VISIBILITY_SECRET}.
   */
  public final void setVisibility(@Visibility int visibility) {
    if (this.visibility == visibility) {
      return;
    }
    switch (visibility) {
      case NotificationCompat.VISIBILITY_PRIVATE:
      case NotificationCompat.VISIBILITY_PUBLIC:
      case NotificationCompat.VISIBILITY_SECRET:
        this.visibility = visibility;
        break;
      default:
        throw new IllegalStateException();
    }
    invalidate();
  }

  /** Forces an update of the notification if already started. */
  public final void invalidate() {
    if (isNotificationStarted) {
      postStartOrUpdateNotification();
    }
  }

  private void startOrUpdateNotification(Player player, @Nullable Bitmap bitmap) {
    boolean ongoing = getOngoing(player);
    builder = createNotification(player, builder, ongoing, bitmap);
    if (builder == null) {
      stopNotification(/* dismissedByUser= */ false);
      return;
    }
    Notification notification = builder.build();
    notificationManager.notify(notificationId, notification);
    if (!isNotificationStarted) {
      Util.registerReceiverNotExported(context, notificationBroadcastReceiver, intentFilter);
    }
    if (notificationListener != null) {
      // Always pass true for ongoing with the first notification to tell a service to go into
      // foreground even when paused.
      notificationListener.onNotificationPosted(
          notificationId, notification, ongoing || !isNotificationStarted);
    }
    isNotificationStarted = true;
  }

  private void stopNotification(boolean dismissedByUser) {
    if (isNotificationStarted) {
      isNotificationStarted = false;
      mainHandler.removeMessages(MSG_START_OR_UPDATE_NOTIFICATION);
      notificationManager.cancel(notificationId);
      context.unregisterReceiver(notificationBroadcastReceiver);
      if (notificationListener != null) {
        notificationListener.onNotificationCancelled(notificationId, dismissedByUser);
      }
    }
  }

  /**
   * Creates the notification given the current player state.
   *
   * @param player The player for which state to build a notification.
   * @param builder The builder used to build the last notification, or {@code null}. Re-using the
   *     builder when possible can prevent notification flicker when {@code Util#SDK_INT} &lt; 21.
   * @param ongoing Whether the notification should be ongoing.
   * @param largeIcon The large icon to be used.
   * @return The {@link NotificationCompat.Builder} on which to call {@link
   *     NotificationCompat.Builder#build()} to obtain the notification, or {@code null} if no
   *     notification should be displayed.
   */
  @Nullable
  protected NotificationCompat.Builder createNotification(
      Player player,
      @Nullable NotificationCompat.Builder builder,
      boolean ongoing,
      @Nullable Bitmap largeIcon) {
    if (player.getPlaybackState() == Player.STATE_IDLE
        && player.isCommandAvailable(COMMAND_GET_TIMELINE)
        && player.getCurrentTimeline().isEmpty()) {
      builderActions = null;
      return null;
    }

    List<String> actionNames = getActions(player);
    List<NotificationCompat.Action> actions = new ArrayList<>(actionNames.size());
    for (int i = 0; i < actionNames.size(); i++) {
      String actionName = actionNames.get(i);
      @Nullable
      NotificationCompat.Action action =
          playbackActions.containsKey(actionName)
              ? playbackActions.get(actionName)
              : customActions.get(actionName);
      if (action != null) {
        actions.add(action);
      }
    }

    if (builder == null || !actions.equals(builderActions)) {
      builder = new NotificationCompat.Builder(context, channelId);
      builderActions = actions;
      for (int i = 0; i < actions.size(); i++) {
        builder.addAction(actions.get(i));
      }
    }

    MediaStyle mediaStyle = new MediaStyle();
    if (mediaSessionToken != null) {
      mediaStyle.setMediaSession(mediaSessionToken);
    }
    mediaStyle.setShowActionsInCompactView(getActionIndicesForCompactView(actionNames, player));
    // Configure dismiss action prior to API 21 ('x' button).
    mediaStyle.setShowCancelButton(!ongoing);
    mediaStyle.setCancelButtonIntent(dismissPendingIntent);
    builder.setStyle(mediaStyle);

    // Set intent which is sent if the user selects 'clear all'
    builder.setDeleteIntent(dismissPendingIntent);

    // Set notification properties from getters.
    builder
        .setBadgeIconType(badgeIconType)
        .setOngoing(ongoing)
        .setColor(color)
        .setColorized(colorized)
        .setSmallIcon(smallIconResourceId)
        .setVisibility(visibility)
        .setPriority(priority)
        .setDefaults(defaults);

    // Changing "showWhen" causes notification flicker if SDK_INT < 21.
    if (Util.SDK_INT >= 21
        && useChronometer
        && player.isCommandAvailable(COMMAND_GET_CURRENT_MEDIA_ITEM)
        && player.isPlaying()
        && !player.isPlayingAd()
        && !player.isCurrentMediaItemDynamic()
        && player.getPlaybackParameters().speed == 1f) {
      builder
          .setWhen(System.currentTimeMillis() - player.getContentPosition())
          .setShowWhen(true)
          .setUsesChronometer(true);
    } else {
      builder.setShowWhen(false).setUsesChronometer(false);
    }

    // Set media specific notification properties from MediaDescriptionAdapter.
    builder.setContentTitle(mediaDescriptionAdapter.getCurrentContentTitle(player));
    builder.setContentText(mediaDescriptionAdapter.getCurrentContentText(player));
    builder.setSubText(mediaDescriptionAdapter.getCurrentSubText(player));
    if (largeIcon == null) {
      largeIcon =
          mediaDescriptionAdapter.getCurrentLargeIcon(
              player, new BitmapCallback(++currentNotificationTag));
    }
    setLargeIcon(builder, largeIcon);
    builder.setContentIntent(mediaDescriptionAdapter.createCurrentContentIntent(player));

    if (groupKey != null) {
      builder.setGroup(groupKey);
    }

    builder.setOnlyAlertOnce(true);
    return builder;
  }

  /**
   * Gets the names and order of the actions to be included in the notification at the current
   * player state.
   *
   * <p>The playback and custom actions are combined and placed in the following order if not
   * omitted:
   *
   * <pre>
   *   +------------------------------------------------------------------------+
   *   | prev | &lt;&lt; | play/pause | &gt;&gt; | next | custom actions | stop |
   *   +------------------------------------------------------------------------+
   * </pre>
   *
   * <p>This method can be safely overridden. However, the names must be of the playback actions
   * {@link #ACTION_PAUSE}, {@link #ACTION_PLAY}, {@link #ACTION_FAST_FORWARD}, {@link
   * #ACTION_REWIND}, {@link #ACTION_NEXT} or {@link #ACTION_PREVIOUS}, or a key contained in the
   * map returned by {@link CustomActionReceiver#createCustomActions(Context, int)}. Otherwise the
   * action name is ignored.
   */
  protected List<String> getActions(Player player) {
    boolean enablePrevious = player.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS);
    boolean enableRewind = player.isCommandAvailable(COMMAND_SEEK_BACK);
    boolean enableFastForward = player.isCommandAvailable(COMMAND_SEEK_FORWARD);
    boolean enableNext = player.isCommandAvailable(COMMAND_SEEK_TO_NEXT);

    List<String> stringActions = new ArrayList<>();
    if (usePreviousAction && enablePrevious) {
      stringActions.add(ACTION_PREVIOUS);
    }
    if (useRewindAction && enableRewind) {
      stringActions.add(ACTION_REWIND);
    }
    if (usePlayPauseActions) {
      if (shouldShowPauseButton(player)) {
        stringActions.add(ACTION_PAUSE);
      } else {
        stringActions.add(ACTION_PLAY);
      }
    }
    if (useFastForwardAction && enableFastForward) {
      stringActions.add(ACTION_FAST_FORWARD);
    }
    if (useNextAction && enableNext) {
      stringActions.add(ACTION_NEXT);
    }
    if (customActionReceiver != null) {
      stringActions.addAll(customActionReceiver.getCustomActions(player));
    }
    if (useStopAction) {
      stringActions.add(ACTION_STOP);
    }
    return stringActions;
  }

  /**
   * Gets an array with the indices of the buttons to be shown in compact mode.
   *
   * <p>This method can be overridden. The indices must refer to the list of actions passed as the
   * first parameter.
   *
   * @param actionNames The names of the actions included in the notification.
   * @param player The player for which a notification is being built.
   */
  @SuppressWarnings("unused")
  protected int[] getActionIndicesForCompactView(List<String> actionNames, Player player) {
    int pauseActionIndex = actionNames.indexOf(ACTION_PAUSE);
    int playActionIndex = actionNames.indexOf(ACTION_PLAY);
    int leftSideActionIndex =
        usePreviousActionInCompactView
            ? actionNames.indexOf(ACTION_PREVIOUS)
            : (useRewindActionInCompactView ? actionNames.indexOf(ACTION_REWIND) : -1);
    int rightSideActionIndex =
        useNextActionInCompactView
            ? actionNames.indexOf(ACTION_NEXT)
            : (useFastForwardActionInCompactView ? actionNames.indexOf(ACTION_FAST_FORWARD) : -1);

    int[] actionIndices = new int[3];
    int actionCounter = 0;
    if (leftSideActionIndex != -1) {
      actionIndices[actionCounter++] = leftSideActionIndex;
    }
    boolean shouldShowPauseButton = shouldShowPauseButton(player);
    if (pauseActionIndex != -1 && shouldShowPauseButton) {
      actionIndices[actionCounter++] = pauseActionIndex;
    } else if (playActionIndex != -1 && !shouldShowPauseButton) {
      actionIndices[actionCounter++] = playActionIndex;
    }
    if (rightSideActionIndex != -1) {
      actionIndices[actionCounter++] = rightSideActionIndex;
    }
    return Arrays.copyOf(actionIndices, actionCounter);
  }

  /** Returns whether the generated notification should be ongoing. */
  protected boolean getOngoing(Player player) {
    int playbackState = player.getPlaybackState();
    return (playbackState == Player.STATE_BUFFERING || playbackState == Player.STATE_READY)
        && player.getPlayWhenReady();
  }

  private boolean shouldShowPauseButton(Player player) {
    return player.getPlaybackState() != Player.STATE_ENDED
        && player.getPlaybackState() != Player.STATE_IDLE
        && player.getPlayWhenReady();
  }

  private void postStartOrUpdateNotification() {
    if (!mainHandler.hasMessages(MSG_START_OR_UPDATE_NOTIFICATION)) {
      mainHandler.sendEmptyMessage(MSG_START_OR_UPDATE_NOTIFICATION);
    }
  }

  private void postUpdateNotificationBitmap(Bitmap bitmap, int notificationTag) {
    mainHandler
        .obtainMessage(
            MSG_UPDATE_NOTIFICATION_BITMAP, notificationTag, C.INDEX_UNSET /* ignored */, bitmap)
        .sendToTarget();
  }

  private boolean handleMessage(Message msg) {
    switch (msg.what) {
      case MSG_START_OR_UPDATE_NOTIFICATION:
        if (player != null) {
          startOrUpdateNotification(player, /* bitmap= */ null);
        }
        break;
      case MSG_UPDATE_NOTIFICATION_BITMAP:
        if (player != null && isNotificationStarted && currentNotificationTag == msg.arg1) {
          startOrUpdateNotification(player, (Bitmap) msg.obj);
        }
        break;
      default:
        return false;
    }
    return true;
  }

  private static Map<String, NotificationCompat.Action> createPlaybackActions(
      Context context,
      int instanceId,
      int playActionIconResourceId,
      int pauseActionIconResourceId,
      int stopActionIconResourceId,
      int rewindActionIconResourceId,
      int fastForwardActionIconResourceId,
      int previousActionIconResourceId,
      int nextActionIconResourceId) {
    Map<String, NotificationCompat.Action> actions = new HashMap<>();
    actions.put(
        ACTION_PLAY,
        new NotificationCompat.Action(
            playActionIconResourceId,
            context.getString(R.string.exo_controls_play_description),
            createBroadcastIntent(ACTION_PLAY, context, instanceId)));
    actions.put(
        ACTION_PAUSE,
        new NotificationCompat.Action(
            pauseActionIconResourceId,
            context.getString(R.string.exo_controls_pause_description),
            createBroadcastIntent(ACTION_PAUSE, context, instanceId)));
    actions.put(
        ACTION_STOP,
        new NotificationCompat.Action(
            stopActionIconResourceId,
            context.getString(R.string.exo_controls_stop_description),
            createBroadcastIntent(ACTION_STOP, context, instanceId)));
    actions.put(
        ACTION_REWIND,
        new NotificationCompat.Action(
            rewindActionIconResourceId,
            context.getString(R.string.exo_controls_rewind_description),
            createBroadcastIntent(ACTION_REWIND, context, instanceId)));
    actions.put(
        ACTION_FAST_FORWARD,
        new NotificationCompat.Action(
            fastForwardActionIconResourceId,
            context.getString(R.string.exo_controls_fastforward_description),
            createBroadcastIntent(ACTION_FAST_FORWARD, context, instanceId)));
    actions.put(
        ACTION_PREVIOUS,
        new NotificationCompat.Action(
            previousActionIconResourceId,
            context.getString(R.string.exo_controls_previous_description),
            createBroadcastIntent(ACTION_PREVIOUS, context, instanceId)));
    actions.put(
        ACTION_NEXT,
        new NotificationCompat.Action(
            nextActionIconResourceId,
            context.getString(R.string.exo_controls_next_description),
            createBroadcastIntent(ACTION_NEXT, context, instanceId)));
    return actions;
  }

  @SuppressWarnings("UnspecifiedImmutableFlag") // Warning is spurious.
  private static PendingIntent createBroadcastIntent(
      String action, Context context, int instanceId) {
    Intent intent = new Intent(action).setPackage(context.getPackageName());
    intent.putExtra(EXTRA_INSTANCE_ID, instanceId);

    int pendingFlags;
    if (Util.SDK_INT >= 23) {
      pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE;
    } else {
      pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT;
    }

    return PendingIntent.getBroadcast(context, instanceId, intent, pendingFlags);
  }

  @SuppressWarnings("nullness:argument")
  private static void setLargeIcon(NotificationCompat.Builder builder, @Nullable Bitmap largeIcon) {
    builder.setLargeIcon(largeIcon);
  }

  private class PlayerListener implements Player.Listener {

    @Override
    public void onEvents(Player player, Player.Events events) {
      if (events.containsAny(
          EVENT_PLAYBACK_STATE_CHANGED,
          EVENT_PLAY_WHEN_READY_CHANGED,
          EVENT_IS_PLAYING_CHANGED,
          EVENT_TIMELINE_CHANGED,
          EVENT_PLAYBACK_PARAMETERS_CHANGED,
          EVENT_POSITION_DISCONTINUITY,
          EVENT_REPEAT_MODE_CHANGED,
          EVENT_SHUFFLE_MODE_ENABLED_CHANGED,
          EVENT_MEDIA_METADATA_CHANGED)) {
        postStartOrUpdateNotification();
      }
    }
  }

  private class NotificationBroadcastReceiver extends BroadcastReceiver {

    @SuppressWarnings("deprecation")
    @Override
    public void onReceive(Context context, Intent intent) {
      Player player = PlayerNotificationManager.this.player;
      if (player == null
          || !isNotificationStarted
          || intent.getIntExtra(EXTRA_INSTANCE_ID, instanceId) != instanceId) {
        return;
      }
      String action = intent.getAction();
      if (ACTION_PLAY.equals(action)) {
        if (player.getPlaybackState() == Player.STATE_IDLE
            && player.isCommandAvailable(COMMAND_PREPARE)) {
          player.prepare();
        } else if (player.getPlaybackState() == Player.STATE_ENDED
            && player.isCommandAvailable(COMMAND_SEEK_TO_DEFAULT_POSITION)) {
          player.seekToDefaultPosition();
        }
        if (player.isCommandAvailable(COMMAND_PLAY_PAUSE)) {
          player.play();
        }
      } else if (ACTION_PAUSE.equals(action)) {
        if (player.isCommandAvailable(COMMAND_PLAY_PAUSE)) {
          player.pause();
        }
      } else if (ACTION_PREVIOUS.equals(action)) {
        if (player.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS)) {
          player.seekToPrevious();
        }
      } else if (ACTION_REWIND.equals(action)) {
        if (player.isCommandAvailable(COMMAND_SEEK_BACK)) {
          player.seekBack();
        }
      } else if (ACTION_FAST_FORWARD.equals(action)) {
        if (player.isCommandAvailable(COMMAND_SEEK_FORWARD)) {
          player.seekForward();
        }
      } else if (ACTION_NEXT.equals(action)) {
        if (player.isCommandAvailable(COMMAND_SEEK_TO_NEXT)) {
          player.seekToNext();
        }
      } else if (ACTION_STOP.equals(action)) {
        if (player.isCommandAvailable(COMMAND_STOP)) {
          player.stop();
        }
        if (player.isCommandAvailable(COMMAND_CHANGE_MEDIA_ITEMS)) {
          player.clearMediaItems();
        }
      } else if (ACTION_DISMISS.equals(action)) {
        stopNotification(/* dismissedByUser= */ true);
      } else if (action != null
          && customActionReceiver != null
          && customActions.containsKey(action)) {
        customActionReceiver.onCustomAction(player, action, intent);
      }
    }
  }
}