public class

PlayerNotificationManager

extends java.lang.Object

 java.lang.Object

↳androidx.media3.session.PlayerNotificationManager

Overview

Starts, updates and cancels a media style notification for a MediaSession. The actions included in the notification can be customized along with their drawables, as described below.

When notification is no longer required, call PlayerNotificationManager.release() to release resources.

Overriding drawables

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:
  • media3_notification_small_icon - The icon passed by default to . A different icon can also be specified programmatically by calling PlayerNotificationManager.setSmallIcon(int).
  • media3_notification_play - The play icon.
  • media3_notification_pause - The pause icon.
  • media3_notification_rewind - The rewind icon.
  • media3_notification_fastforward - The fast forward icon.
  • media3_notification_previous - The previous icon.
  • media3_notification_next - The next icon.

Alternatively, the action icons can be set programatically by using the PlayerNotificationManager.Builder.

Unlike the drawables above, the large icon (i.e. the icon passed to cannot be overridden in this way. Instead, the large icon is obtained from the PlayerNotificationManager.MediaDescriptionAdapter passed to PlayerNotificationManager.Builder.

Summary

Constructors
protectedPlayerNotificationManager(Context context, MediaSession session, Bundle connectionHints, java.lang.String channelId, int notificationId, PlayerNotificationManager.MediaDescriptionAdapter mediaDescriptionAdapter, PlayerNotificationManager.NotificationListener notificationListener, int smallIconResourceId, java.lang.String groupKey)

Methods
protected NotificationCompat.BuildercreateNotification(NotificationCompat.Builder builder, boolean ongoing, Bitmap largeIcon)

Creates the notification given the current session state.

protected int[]getActionButtonIndicesForCompactView(java.util.List<CommandButton> actionButtons)

Gets an array with the indices of the buttons to be shown in compact mode.

protected java.util.List<CommandButton>getActionButtons()

Gets the names and order of the actions to be included in the notification at the current player state.

public final MediaControllergetMediaControllerOrNull()

Gets the MediaController to send command to the session with.

protected booleangetOngoing()

Returns whether the generated notification should be ongoing.

public final voidinvalidate()

Forces an update of the notification if already started.

public voidrelease()

public final voidsetBadgeIconType(int badgeIconType)

Sets the badge icon type of the notification.

public final voidsetColor(int color)

Sets the accent color of the notification.

public final voidsetColorized(boolean colorized)

Sets whether the notification should be colorized.

public final voidsetDefaults(int defaults)

Sets the defaults.

public final voidsetPriority(int priority)

Sets the priority of the notification required for API 25 and lower.

public final voidsetSmallIcon(int smallIconResourceId)

Sets the small icon of the notification which is also shown in the system status bar.

public final voidsetUseChronometer(boolean useChronometer)

Sets whether the elapsed time of the media playback should be displayed.

public final voidsetVisibility(int visibility)

Sets the visibility of the notification which determines whether and how the notification is shown when the device is in lock screen mode.

from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Constructors

protected PlayerNotificationManager(Context context, MediaSession session, Bundle connectionHints, java.lang.String channelId, int notificationId, PlayerNotificationManager.MediaDescriptionAdapter mediaDescriptionAdapter, PlayerNotificationManager.NotificationListener notificationListener, int smallIconResourceId, java.lang.String groupKey)

Methods

public void release()

public final void setBadgeIconType(int badgeIconType)

Sets the badge icon type of the notification.

See .

Parameters:

badgeIconType: The badge icon type.

public final void setColorized(boolean colorized)

Sets whether the notification should be colorized. When set, the color set with PlayerNotificationManager.setColor(int) will be used as the background color for the notification.

See .

Parameters:

colorized: Whether to colorize the notification.

public final void setDefaults(int defaults)

Sets the defaults.

See .

Parameters:

defaults: The default notification options.

public final void setColor(int color)

Sets the accent color of the notification.

See .

Parameters:

color: The color, in ARGB integer form like the constants in .

public final void setPriority(int priority)

Sets the priority of the notification required for API 25 and lower.

See .

To set the priority for API levels above 25, you can create your own with a given importance level and pass the id of the channel to PlayerNotificationManager.Builder.Builder(Context, MediaSession, int, String).

Parameters:

priority: The priority which can be one of NotificationCompat.PRIORITY_DEFAULT, NotificationCompat.PRIORITY_MAX, NotificationCompat.PRIORITY_HIGH, NotificationCompat.PRIORITY_LOW or NotificationCompat.PRIORITY_MIN. If not set NotificationCompat.PRIORITY_LOW is used by default.

public final void setSmallIcon(int smallIconResourceId)

Sets the small icon of the notification which is also shown in the system status bar.

See .

Parameters:

smallIconResourceId: The resource id of the small icon.

public final void setUseChronometer(boolean useChronometer)

Sets whether the elapsed time of the media playback should be displayed.

Note that this setting only works if all of the following are true:

See .

Parameters:

useChronometer: Whether to use chronometer.

public final void setVisibility(int visibility)

Sets the visibility of the notification which determines whether and how the notification is shown when the device is in lock screen mode.

See .

Parameters:

visibility: The visibility which must be one of NotificationCompat.VISIBILITY_PUBLIC, NotificationCompat.VISIBILITY_PRIVATE or NotificationCompat.VISIBILITY_SECRET.

public final void invalidate()

Forces an update of the notification if already started.

public final MediaController getMediaControllerOrNull()

Gets the MediaController to send command to the session with. Can be null if the media controller isn't connected.

protected NotificationCompat.Builder createNotification(NotificationCompat.Builder builder, boolean ongoing, Bitmap largeIcon)

Creates the notification given the current session state.

Parameters:

builder: The builder used to build the last notification, or null. Re-using the builder when possible can prevent notification flicker when Util#SDK_INT < 21.
ongoing: Whether the notification should be ongoing.
largeIcon: The large icon to be used.

Returns:

The on which to call to obtain the notification, or null if no notification should be displayed.

protected java.util.List<CommandButton> getActionButtons()

Gets the names and order of the actions to be included in the notification at the current player state.

The playback and custom actions are combined and placed in the following order if not omitted:

   +------------------------------------------------+
   | prev | << | play/pause | >> | next |
   +------------------------------------------------+
 

This method can be safely overridden.

protected int[] getActionButtonIndicesForCompactView(java.util.List<CommandButton> actionButtons)

Gets an array with the indices of the buttons to be shown in compact mode.

This method can be overridden. The indices must refer to the list of actions passed as the first parameter.

Parameters:

actionButtons: The buttons of the actions included in the notification.

protected boolean getOngoing()

Returns whether the generated notification should be ongoing.

Source

/*
 * Copyright 2021 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.session;

import static androidx.media3.common.Player.COMMAND_INVALID;
import static androidx.media3.common.Player.COMMAND_PLAY_PAUSE;
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_NEXT;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS;
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.checkStateNotNull;
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.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
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.core.content.ContextCompat;
import androidx.media3.common.C;
import androidx.media3.common.Player;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.BundleableUtil;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.NotificationUtil;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.session.MediaStyleNotificationHelper.MediaStyle;
import com.google.common.util.concurrent.ListenableFuture;
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.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.checkerframework.checker.initialization.qual.Initialized;

/**
 * Starts, updates and cancels a media style notification for a {@link MediaSession}. The actions
 * included in the notification can be customized along with their drawables, as described below.
 *
 * <p>When notification is no longer required, call {@link #release()} to release resources.
 *
 * <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 media3_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 media3_notification_play}</b> - The play icon.
 *   <li><b>{@code media3_notification_pause}</b> - The pause icon.
 *   <li><b>{@code media3_notification_rewind}</b> - The rewind icon.
 *   <li><b>{@code media3_notification_fastforward}</b> - The fast forward icon.
 *   <li><b>{@code media3_notification_previous}</b> - The previous icon.
 *   <li><b>{@code media3_notification_next}</b> - The next 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#setMediaDescriptionAdapter(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 session The {@link MediaSession} for which a notification is being built.
     * @return The content title for the current media item.
     */
    CharSequence getCurrentContentTitle(MediaSession session);

    /**
     * Gets the content text for the current media item.
     *
     * <p>See {@link NotificationCompat.Builder#setContentText(CharSequence)}.
     *
     * @param session The {@link MediaSession} 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(MediaSession session);

    /**
     * Gets the content sub text for the current media item.
     *
     * <p>See {@link NotificationCompat.Builder#setSubText(CharSequence)}.
     *
     * @param session The {@link MediaSession} 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(MediaSession session) {
      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 session The {@link MediaSession} 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(MediaSession session, BitmapCallback callback);
  }

  /** 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;

    protected MediaSession session;
    protected Bundle connectionHints;
    @Nullable protected NotificationListener notificationListener;
    protected MediaDescriptionAdapter mediaDescriptionAdapter;
    protected int channelNameResourceId;
    protected int channelDescriptionResourceId;
    protected int channelImportance;
    protected int smallIconResourceId;
    @Nullable protected String groupKey;

    /**
     * Creates an instance.
     *
     * @param context The {@link Context}.
     * @param notificationId The id of the notification to be posted. Must be greater than 0.
     * @param session The session to build notification with.
     * @param channelId The id of the notification channel.
     */
    public Builder(
        Context context,
        MediaSession session,
        @IntRange(from = 1) int notificationId,
        String channelId) {
      checkArgument(notificationId > 0);
      this.context = context;
      this.session = session;
      this.notificationId = notificationId;
      this.channelId = channelId;
      connectionHints = Bundle.EMPTY;
      channelImportance = NotificationUtil.IMPORTANCE_LOW;
      mediaDescriptionAdapter = new DefaultMediaDescriptionAdapter();
      smallIconResourceId = R.drawable.media3_notification_small_icon;
    }

    /**
     * The connection hints for identify {@link MediaController} to deliver commands from the
     * notification.
     *
     * @return This builder
     */
    public Builder setConnectionHints(Bundle connectionHints) {
      this.connectionHints = Assertions.checkNotNull(connectionHints);
      return this;
    }

    /**
     * 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 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#media3_notification_small_icon}.
     *
     * @return This builder.
     */
    public Builder setSmallIconResourceId(int smallIconResourceId) {
      this.smallIconResourceId = smallIconResourceId;
      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,
          session,
          connectionHints,
          channelId,
          notificationId,
          mediaDescriptionAdapter,
          notificationListener,
          smallIconResourceId,
          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 is executed when a button in the notification is clicked. */
  private static final String INTENT_ACTION_COMMAND = "androidx.media3.session.command";

  /**
   * 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 INTENT_ACTION_DISMISS =
      "androidx.media3.session.notification.dismiss";

  private static final String INTENT_EXTRA_PLAYER_COMMAND =
      "androidx.media3.session.EXTRA_PLAYER_COMMAND";
  private static final String INTENT_EXTRA_SESSION_COMMAND =
      "androidx.media3.session.EXTRA_SESSION_COMMAND";
  private static final String INTENT_EXTRA_INSTANCE_ID =
      "androidx.media3.session.notificaiton.EXTRA_INSTANCE_ID";
  private static final String INTENT_SCHEME = "media3";

  private static final String TAG = "NotificationManager";

  // Internal messages.

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

  /**
   * 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 MediaSession session;
  private final ListenableFuture<MediaController> controllerFuture;
  private final String channelId;
  private final int notificationId;
  private final MediaDescriptionAdapter mediaDescriptionAdapter;
  @Nullable private final NotificationListener notificationListener;
  private final Handler mainHandler;
  private final NotificationManagerCompat notificationManager;
  private final IntentFilter intentFilter;
  private final NotificationBroadcastReceiver notificationBroadcastReceiver;
  private final PendingIntent dismissPendingIntent;
  private final int instanceId;
  private final CommandButton playButton;
  private final CommandButton pauseButton;
  private final CommandButton seekToPreviousButton;
  private final CommandButton seekToNextButton;
  private final CommandButton seekBackButton;
  private final CommandButton seekForwardButton;

  @Nullable private NotificationCompat.Builder builder;
  private boolean isNotificationStarted;
  private int currentNotificationTag;
  private int badgeIconType;
  private boolean colorized;
  private int defaults;
  private int color;
  @DrawableRes private int smallIconResourceId;
  private int visibility;
  @Priority private int priority;
  private boolean useChronometer;
  @Nullable private String groupKey;

  protected PlayerNotificationManager(
      Context context,
      MediaSession session,
      Bundle connectionHints,
      String channelId,
      int notificationId,
      MediaDescriptionAdapter mediaDescriptionAdapter,
      @Nullable NotificationListener notificationListener,
      int smallIconResourceId,
      @Nullable String groupKey) {
    context = context.getApplicationContext();
    this.context = context;
    this.session = session;
    this.channelId = channelId;
    this.notificationId = notificationId;
    this.mediaDescriptionAdapter = mediaDescriptionAdapter;
    this.notificationListener = notificationListener;
    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;
    controllerFuture =
        new MediaController.Builder(context, session.getToken())
            .setApplicationLooper(Looper.getMainLooper())
            .setConnectionHints(connectionHints)
            .setListener(new MediaControllerListener())
            .buildAsync();
    controllerFuture.addListener(
        () -> {
          @SuppressWarnings("nullness:assignment")
          @Initialized
          PlayerNotificationManager thisRef = this;
          MediaController controller = thisRef.getMediaControllerOrNull();
          if (controller != null) {
            controller.addListener(new PlayerListener());
          }
        },
        ContextCompat.getMainExecutor(context));
    notificationManager = NotificationManagerCompat.from(context);
    notificationBroadcastReceiver = new NotificationBroadcastReceiver();
    intentFilter = new IntentFilter();
    colorized = true;
    useChronometer = true;
    color = Color.TRANSPARENT;
    defaults = 0;
    priority = NotificationCompat.PRIORITY_LOW;
    badgeIconType = NotificationCompat.BADGE_ICON_SMALL;
    visibility = NotificationCompat.VISIBILITY_PUBLIC;

    // initialize default buttons
    playButton =
        new CommandButton.Builder()
            .setDisplayName(context.getText(R.string.media3_controls_play_description))
            .setIconResId(R.drawable.media3_notification_play)
            .setPlayerCommand(COMMAND_PLAY_PAUSE)
            .build();
    pauseButton =
        new CommandButton.Builder()
            .setDisplayName(context.getText(R.string.media3_controls_pause_description))
            .setIconResId(R.drawable.media3_notification_pause)
            .setPlayerCommand(COMMAND_PLAY_PAUSE)
            .build();
    seekToPreviousButton =
        new CommandButton.Builder()
            .setDisplayName(context.getText(R.string.media3_controls_seek_to_previous_description))
            .setIconResId(R.drawable.media3_notification_seek_to_previous)
            .setPlayerCommand(COMMAND_SEEK_TO_PREVIOUS)
            .build();
    seekToNextButton =
        new CommandButton.Builder()
            .setDisplayName(context.getText(R.string.media3_controls_seek_to_next_description))
            .setIconResId(R.drawable.media3_notification_seek_to_next)
            .setPlayerCommand(COMMAND_SEEK_TO_NEXT)
            .build();
    seekBackButton =
        new CommandButton.Builder()
            .setDisplayName(context.getText(R.string.media3_controls_seek_back_description))
            .setIconResId(R.drawable.media3_notification_seek_back)
            .setPlayerCommand(COMMAND_SEEK_BACK)
            .build();
    seekForwardButton =
        new CommandButton.Builder()
            .setDisplayName(context.getText(R.string.media3_controls_seek_forward_description))
            .setIconResId(R.drawable.media3_notification_seek_forward)
            .setPlayerCommand(COMMAND_SEEK_FORWARD)
            .build();
    intentFilter.addAction(INTENT_ACTION_COMMAND);
    intentFilter.addAction(INTENT_ACTION_DISMISS);
    intentFilter.addDataScheme(INTENT_SCHEME);
    dismissPendingIntent = createBroadcastIntent(context, INTENT_ACTION_DISMISS, instanceId);
  }

  /* Releases all resources, such as internal {@link MediaController}. */
  public void release() {
    // This will indirectly call stopNotification(/* dismissedByUser= */ false).
    MediaController.releaseFuture(controllerFuture);
  }

  /**
   * 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, MediaSession, int, String)}.
   *
   * @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#isCurrentWindowDynamic() 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();
    }
  }

  /**
   * Gets the {@link MediaController} to send command to the session with. Can be {@code null} if
   * the media controller isn't connected.
   */
  @Nullable
  public final MediaController getMediaControllerOrNull() {
    try {
      MediaController controller = controllerFuture.get(0, TimeUnit.MILLISECONDS);
      return controller.isConnected() ? controller : null;
    } catch (ExecutionException | InterruptedException | TimeoutException e) {
      return null;
    }
  }

  private void startOrUpdateNotification(@Nullable Bitmap bitmap) {
    boolean ongoing = getOngoing();
    builder = createNotification(builder, ongoing, bitmap);
    if (builder == null) {
      stopNotification(/* dismissedByUser= */ false);
      return;
    }
    Notification notification = builder.build();
    notificationManager.notify(notificationId, notification);
    if (!isNotificationStarted) {
      context.registerReceiver(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 session state.
   *
   * @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(
      @Nullable NotificationCompat.Builder builder, boolean ongoing, @Nullable Bitmap largeIcon) {
    Player player = session.getPlayer();
    if (player.getPlaybackState() == Player.STATE_IDLE && player.getCurrentTimeline().isEmpty()) {
      return null;
    }

    if (builder == null) {
      builder = new NotificationCompat.Builder(context, channelId);
    }
    List<CommandButton> actionButtons = getActionButtons();
    for (int i = 0; i < actionButtons.size(); i++) {
      CommandButton button = actionButtons.get(i);
      NotificationCompat.Action action =
          new NotificationCompat.Action(
              button.iconResId,
              button.displayName,
              createBroadcastIntent(context, button, instanceId));
      builder.addAction(action);
    }

    MediaStyle mediaStyle = new MediaStyle(session);
    mediaStyle.setShowActionsInCompactView(getActionButtonIndicesForCompactView(actionButtons));
    // 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.isPlaying()
        && !player.isPlayingAd()
        && !player.isCurrentWindowDynamic()
        && 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(session));
    builder.setContentText(mediaDescriptionAdapter.getCurrentContentText(session));
    builder.setSubText(mediaDescriptionAdapter.getCurrentSubText(session));
    if (largeIcon == null) {
      largeIcon =
          mediaDescriptionAdapter.getCurrentLargeIcon(
              session, new BitmapCallback(++currentNotificationTag));
    }
    setLargeIcon(builder, largeIcon);
    MediaController controller = getMediaControllerOrNull();
    if (controller != null) {
      builder.setContentIntent(controller.getSessionActivity());
    }

    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 |
   *   +------------------------------------------------+
   * </pre>
   *
   * <p>This method can be safely overridden.
   */
  protected List<CommandButton> getActionButtons() {
    Player player = session.getPlayer();
    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<CommandButton> buttons = new ArrayList<>();
    if (enablePrevious) {
      buttons.add(seekToPreviousButton);
    }
    if (enableRewind) {
      buttons.add(seekBackButton);
    }
    if (shouldShowPauseButton()) {
      buttons.add(pauseButton);
    } else {
      buttons.add(playButton);
    }
    if (enableFastForward) {
      buttons.add(seekForwardButton);
    }
    if (enableNext) {
      buttons.add(seekToNextButton);
    }
    return buttons;
  }

  /**
   * 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 actionButtons The buttons of the actions included in the notification.
   */
  @SuppressWarnings("unused")
  protected int[] getActionButtonIndicesForCompactView(List<CommandButton> actionButtons) {
    int previousIndex = C.INDEX_UNSET;
    int nextIndex = C.INDEX_UNSET;
    int playPauseIndex = C.INDEX_UNSET;
    for (int i = 0; i < actionButtons.size(); i++) {
      CommandButton button = actionButtons.get(i);
      switch (button.playerCommand) {
        case COMMAND_PLAY_PAUSE:
          playPauseIndex = i;
          break;
        case COMMAND_SEEK_TO_PREVIOUS:
          previousIndex = i;
          break;
        case COMMAND_SEEK_TO_NEXT:
          nextIndex = i;
          break;
        default:
          // Do nothing
      }
    }
    int[] actionIndices = new int[3];
    int actionCounter = 0;
    if (previousIndex != C.INDEX_UNSET) {
      actionIndices[actionCounter++] = previousIndex;
    }
    if (playPauseIndex != C.INDEX_UNSET) {
      actionIndices[actionCounter++] = playPauseIndex;
    }
    if (nextIndex != C.INDEX_UNSET) {
      actionIndices[actionCounter++] = nextIndex;
    }
    return Arrays.copyOf(actionIndices, actionCounter);
  }

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

  private boolean shouldShowPauseButton() {
    Player player = session.getPlayer();
    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:
        startOrUpdateNotification(/* bitmap= */ null);
        break;
      case MSG_UPDATE_NOTIFICATION_BITMAP:
        if (isNotificationStarted && currentNotificationTag == msg.arg1) {
          startOrUpdateNotification((Bitmap) msg.obj);
        }
        break;
      default:
        return false;
    }
    return true;
  }

  private static PendingIntent createBroadcastIntent(
      Context context, CommandButton button, int instanceId) {
    Intent intent = new Intent(INTENT_ACTION_COMMAND).setPackage(context.getPackageName());
    intent.putExtra(INTENT_EXTRA_INSTANCE_ID, instanceId);
    intent.putExtra(INTENT_EXTRA_PLAYER_COMMAND, button.playerCommand);
    intent.putExtra(
        INTENT_EXTRA_SESSION_COMMAND, BundleableUtil.toNullableBundle(button.sessionCommand));
    // Make intent distinguishable by Intent#filterEquals() due to the PendingIntent requirement.
    Uri intentUri =
        new Uri.Builder()
            .scheme(INTENT_SCHEME)
            .appendPath(Integer.toString(instanceId))
            .appendPath(Integer.toString(button.playerCommand))
            .appendPath(button.sessionCommand == null ? "null" : button.sessionCommand.customAction)
            .build();
    intent.setData(intentUri);
    return PendingIntent.getBroadcast(context, instanceId, intent, getPendingIntentFlags());
  }

  private static PendingIntent createBroadcastIntent(
      Context context, String action, int instanceId) {
    Intent intent = new Intent(action).setPackage(context.getPackageName());
    intent.putExtra(INTENT_EXTRA_INSTANCE_ID, instanceId);
    return PendingIntent.getBroadcast(context, instanceId, intent, getPendingIntentFlags());
  }

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

  private static int getPendingIntentFlags() {
    return Util.SDK_INT >= 23
        ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
        : PendingIntent.FLAG_UPDATE_CURRENT;
  }

  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 MediaControllerListener implements MediaController.Listener {

    @Override
    public void onDisconnected(MediaController controller) {
      stopNotification(/* dismissedByUser= */ false);
    }

    @Override
    public void onAvailableSessionCommandsChanged(
        MediaController controller, SessionCommands commands) {
      postStartOrUpdateNotification();
    }
  }

  private class NotificationBroadcastReceiver extends BroadcastReceiver {

    @SuppressWarnings("deprecation")
    @Override
    public void onReceive(Context context, Intent intent) {
      MediaController controller = getMediaControllerOrNull();
      if (controller == null
          || !isNotificationStarted
          || intent.getIntExtra(INTENT_EXTRA_INSTANCE_ID, instanceId) != instanceId) {
        return;
      }
      String action = intent.getAction();
      if (INTENT_ACTION_COMMAND.equals(action)) {
        @Player.Command
        int playerCommand = intent.getIntExtra(INTENT_EXTRA_PLAYER_COMMAND, COMMAND_INVALID);
        switch (playerCommand) {
          case COMMAND_PLAY_PAUSE:
            if (!controller.getPlayWhenReady()) {
              if (controller.getPlaybackState() == controller.STATE_IDLE) {
                controller.prepare();
              } else if (controller.getPlaybackState() == controller.STATE_ENDED) {
                controller.seekToDefaultPosition(controller.getCurrentWindowIndex());
              }
              controller.setPlayWhenReady(true);
            } else {
              controller.setPlayWhenReady(false);
            }
            break;
          case COMMAND_SEEK_TO_PREVIOUS:
            controller.seekToPrevious();
            break;
          case COMMAND_SEEK_BACK:
            controller.seekBack();
            break;
          case COMMAND_SEEK_FORWARD:
            controller.seekForward();
            break;
          case COMMAND_SEEK_TO_NEXT:
            controller.seekToNext();
            break;
          case COMMAND_INVALID:
            SessionCommand sessionCommand =
                checkStateNotNull(
                    BundleableUtil.fromNullableBundle(
                        SessionCommand.CREATOR,
                        intent.getBundleExtra(INTENT_EXTRA_SESSION_COMMAND)));
            ListenableFuture<SessionResult> unused =
                controller.sendCustomCommand(sessionCommand, /* args= */ Bundle.EMPTY);
            break;
          default:
            Log.w(TAG, "Unsupported controller command, playerCommand=" + playerCommand);
            break;
        }
      } else if (INTENT_ACTION_DISMISS.equals(action)) {
        stopNotification(/* dismissedByUser= */ true);
      }
    }
  }
}