/*
* Copyright 2019 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.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.session.SessionResult.RESULT_ERROR_NOT_SUPPORTED;
import static androidx.media3.session.SessionResult.RESULT_SUCCESS;
import android.app.PendingIntent;
import android.content.Context;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.IBinder;
import android.os.Looper;
import android.os.RemoteException;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.view.KeyEvent;
import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.app.NotificationCompat;
import androidx.media.MediaSessionManager.RemoteUserInfo;
import androidx.media3.common.AudioAttributes;
import androidx.media3.common.DeviceInfo;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaLibraryInfo;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.Player;
import androidx.media3.common.Player.DiscontinuityReason;
import androidx.media3.common.Player.PositionInfo;
import androidx.media3.common.Player.RepeatMode;
import androidx.media3.common.Rating;
import androidx.media3.common.Timeline;
import androidx.media3.common.TrackSelectionParameters;
import androidx.media3.common.VideoSize;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.session.MediaLibraryService.LibraryParams;
import com.google.common.base.Objects;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.HashMap;
import java.util.List;
/**
* A session that allows a media app to expose its transport controls and playback information in a
* process to other processes including the Android framework and other apps. The common use cases
* are as follows:
*
* <ul>
* <li>Bluetooth/wired headset key events support
* <li>Android Auto/Wearable support
* <li>Separating UI process and playback process
* </ul>
*
* <p>A session should be created when an app wants to publish media playback information or handle
* media key events. In general, an app only needs one session for all playback, though multiple
* sessions can be created to provide finer grain controls of media. See <a
* href="#MultipleSessions">Supporting Multiple Sessions</a> for details.
*
* <p>If you want to support background playback, {@link MediaSessionService} is preferred. With the
* service, your playback can be revived even after playback is finished. See {@link
* MediaSessionService} for details.
*
* <p>Topics covered here:
*
* <ol>
* <li><a href="#SessionLifecycle">Session Lifecycle</a>
* <li><a href="#ThreadingModel">Threading Model</a>
* <li><a href="#KeyEvents">Media Key Events Mapping</a>
* <li><a href="#MultipleSessions">Supporting Multiple Sessions</a>
* <li><a href="#BackwardCompatibility">Backward Compatibility with Legacy Session APIs</a>
* <li><a href="#CompatibilityController">Backward Compatibility with Legacy Controller APIs</a>
* </ol>
*
* <h2 id="SessionLifecycle">Session Lifecycle</h2>
*
* <p>A session can be created by {@link Builder}. The owner of the session may pass its {@link
* #getToken() session token} to other processes to allow them to create a {@link MediaController}
* to interact with the session.
*
* <p>When a session receives transport control commands, the session sends the commands directly to
* the underlying player set by {@link Builder#Builder(Context, Player)} or {@link
* #setPlayer(Player)}.
*
* <p>When an app is finished performing playback it must call {@link #release()} to clean up the
* session and notify any controllers. The app is responsible for releasing the underlying player
* after releasing the session.
*
* <h2 id="ThreadingModel">Threading Model</h2>
*
* <p>The instances are thread safe, but should be used on the thread with a looper.
*
* <p>{@link Callback} methods will be called from the application thread associated with the {@link
* Player#getApplicationLooper() application looper} of the underlying player. When a new player is
* set by {@link #setPlayer}, the player should use the same application looper as the previous one.
*
* <p>The session listens to events from the underlying player via {@link Player.Listener} and
* expects the callback methods to be called from the application thread. If the player violates the
* threading contract, {@link IllegalStateException} will be thrown.
*
* <h2 id="KeyEvents">Media Key Events Mapping</h2>
*
* <p>When the session receives media key events they are mapped to a method call on the underlying
* player. The following table shows the mapping between the event key code and the {@link Player}
* method which is called.
*
* <table>
* <caption>Key code and corresponding Player API</caption>
* <tr>
* <th>Key code</th>
* <th>Player API</th>
* </tr>
* <tr>
* <td>{@link KeyEvent#KEYCODE_MEDIA_PLAY}</td>
* <td>{@link Player#play()}</td>
* </tr>
* <tr>
* <td>{@link KeyEvent#KEYCODE_MEDIA_PAUSE}</td>
* <td>{@link Player#pause()}</td>
* </tr>
* <tr>
* <td>
* <ul>
* <li>{@link KeyEvent#KEYCODE_MEDIA_PLAY_PAUSE}</li>
* <li>{@link KeyEvent#KEYCODE_HEADSETHOOK}</li>
* </ul>
* </td>
* <td>
* <ul>
* <li>For a single tap,
* <ul>
* <li>
* {@link Player#pause()} if {@link Player#getPlayWhenReady() playWhenReady} is
* {@code true}
* </li>
* <li>{@link Player#play()} otherwise</li>
* </ul>
* <li>For a double tap, {@link Player#seekToNext()}</li>
* </ul>
* </td>
* </tr>
* <tr>
* <td>{@link KeyEvent#KEYCODE_MEDIA_NEXT}</td>
* <td>{@link Player#seekToNext()}</td>
* </tr>
* <tr>
* <td>{@link KeyEvent#KEYCODE_MEDIA_PREVIOUS}</td>
* <td>{@link Player#seekToPrevious()}</td>
* </tr>
* <tr>
* <td>{@link KeyEvent#KEYCODE_MEDIA_STOP}</td>
* <td>{@link Player#stop()}</td>
* </tr>
* <tr>
* <td>{@link KeyEvent#KEYCODE_MEDIA_FAST_FORWARD}</td>
* <td>{@link Player#seekForward()}</td>
* </tr>
* <tr>
* <td>{@link KeyEvent#KEYCODE_MEDIA_REWIND}</td>
* <td>{@link Player#seekBack()}</td>
* </tr>
* </table>
*
* <h2 id="MultipleSessions">Supporting Multiple Sessions</h2>
*
* <p>Generally, multiple sessions aren't necessary for most media apps. One exception is if your
* app can play multiple media content at the same time, but only for the playback of video-only
* media or remote playback, since the <a
* href="https://developer.android.com/guide/topics/media-apps/audio-focus">audio focus policy</a>
* recommends not playing multiple audio content at the same time. Also, keep in mind that multiple
* media sessions would make Android Auto and Bluetooth device with display to show your apps
* multiple times, because they list up media sessions, not media apps.
*
* <h2 id="BackwardCompatibility">Backward Compatibility with Legacy Session APIs</h2>
*
* <p>An active {@link MediaSessionCompat} is internally created with the session for the backward
* compatibility. It's used to handle incoming connection and commands from {@link
* MediaControllerCompat}, and helps to utilize existing APIs that are built with legacy media
* session APIs. Use {@link #getSessionCompatToken} to get the legacy token of {@link
* MediaSessionCompat}.
*
* <h2 id="CompatibilityController">Backward Compatibility with Legacy Controller APIs</h2>
*
* <p>In addition to {@link MediaController}, the session also supports connections from the legacy
* controller APIs - {@linkplain android.media.session.MediaController framework controller} and
* {@linkplain MediaControllerCompat AndroidX controller compat}. However, {@link ControllerInfo}
* may not be precise for legacy controllers. See {@link ControllerInfo} for the details.
*
* <p>Unknown package name nor UID doesn't mean that you should disallow connection nor commands.
* For SDK levels where such issues happen, session tokens could only be obtained by trusted
* controllers (e.g. Bluetooth, Auto, ...), so it may be better for you to allow them as you did
* with legacy sessions.
*/
public class MediaSession {
static {
MediaLibraryInfo.registerModule("media3.session");
}
// It's better to have private static lock instead of using MediaSession.class because the
// private lock object isn't exposed.
private static final Object STATIC_LOCK = new Object();
// Note: This checks the uniqueness of a session ID only in single process.
// When the framework becomes able to check the uniqueness, this logic should be removed.
@GuardedBy("STATIC_LOCK")
private static final HashMap<String, MediaSession> SESSION_ID_TO_SESSION_MAP = new HashMap<>();
/**
* A builder for {@link MediaSession}.
*
* <p>Any incoming requests from the {@link MediaController} will be handled on the application
* thread of the underlying {@link Player}.
*/
public static final class Builder extends BuilderBase<MediaSession, Builder, Callback> {
/**
* Creates a builder for {@link MediaSession}.
*
* @param context The context.
* @param player The underlying player to perform playback and handle transport controls.
* @throws IllegalArgumentException if {@link Player#canAdvertiseSession()} returns false.
*/
public Builder(Context context, Player player) {
super(context, player, new Callback() {});
}
/**
* Sets a {@link PendingIntent} to launch an {@link android.app.Activity} for the {@link
* MediaSession}. This can be used as a quick link to an ongoing media screen.
*
* <p>A client can use this pending intent to start an activity belonging to this session. When
* this pending intent is for instance included in the notification {@linkplain
* NotificationCompat.Builder#setContentIntent(PendingIntent) as the content intent}, tapping
* the notification will open this activity.
*
* <p>See <a href="https://developer.android.com/training/notify-user/navigation">'Start an
* Activity from a Notification'</a> also.
*
* @param pendingIntent The pending intent.
* @return The builder to allow chaining.
*/
@Override
public Builder setSessionActivity(PendingIntent pendingIntent) {
return super.setSessionActivity(pendingIntent);
}
/**
* Sets an ID of the {@link MediaSession}. If not set, an empty string will be used.
*
* <p>Use this if and only if your app supports multiple playback at the same time and also
* wants to provide external apps to have finer-grained controls.
*
* @param id The ID. Must be unique among all {@link MediaSession sessions} per package.
* @return The builder to allow chaining.
*/
// Note: This ID is not visible to the controllers. ID is introduced in order to prevent
// apps from creating multiple sessions without any clear reasons. If they create two
// sessions with the same ID in a process, then an IllegalStateException will be thrown.
@Override
public Builder setId(String id) {
return super.setId(id);
}
/**
* Sets a callback for the {@link MediaSession} to handle incoming requests from {link
* MediaController}.
*
* @param callback The callback.
* @return The builder to allow chaining.
*/
@Override
public Builder setCallback(Callback callback) {
return super.setCallback(callback);
}
/**
* Sets an extra {@link Bundle} for the {@link MediaSession}. The {@link
* MediaSession#getToken()} session token} will have the {@link SessionToken#getExtras()
* extras}. If not set, an empty {@link Bundle} will be used.
*
* @param extras The extra {@link Bundle}.
* @return The builder to allow chaining.
*/
@Override
public Builder setExtras(Bundle extras) {
return super.setExtras(extras);
}
/**
* Builds a {@link MediaSession}.
*
* @return A new session.
* @throws IllegalStateException if a {@link MediaSession} with the same {@link #setId(String)
* ID} already exists in the package.
*/
@Override
public MediaSession build() {
return new MediaSession(context, id, player, sessionActivity, callback, extras);
}
}
/** Information of a {@link MediaController} or a {@link MediaBrowser}. */
public static final class ControllerInfo {
/** The {@linkplain #getControllerVersion() controller version} of legacy controllers. */
public static final int LEGACY_CONTROLLER_VERSION = 0;
private final RemoteUserInfo remoteUserInfo;
private final int controllerVersion;
private final boolean isTrusted;
@Nullable private final ControllerCb controllerCb;
private final Bundle connectionHints;
/**
* Creates an instance.
*
* @param remoteUserInfo The remote user info.
* @param trusted {@code true} if trusted, {@code false} otherwise.
* @param cb ControllerCb. Can be {@code null} only when a MediaBrowserCompat connects to
* MediaSessionService and ControllerInfo is needed for SessionCallback#onConnected().
* @param connectionHints A session-specific argument sent from the controller for the
* connection. The contents of this bundle may affect the connection result.
*/
/* package */ ControllerInfo(
RemoteUserInfo remoteUserInfo,
int controllerVersion,
boolean trusted,
@Nullable ControllerCb cb,
Bundle connectionHints) {
this.remoteUserInfo = remoteUserInfo;
this.controllerVersion = controllerVersion;
isTrusted = trusted;
controllerCb = cb;
this.connectionHints = connectionHints;
}
/* package */ RemoteUserInfo getRemoteUserInfo() {
return remoteUserInfo;
}
/**
* Returns the library version of the controller.
*
* <p>It will be the same as {@link MediaLibraryInfo#VERSION_INT} of the controller, or less
* than {@code 1000000} if the controller is a legacy controller.
*/
public int getControllerVersion() {
return controllerVersion;
}
/**
* Returns the package name. Can be {@link RemoteUserInfo#LEGACY_CONTROLLER} for
* interoperability.
*
* <p>Interoperability: Package name may not be precisely obtained for legacy controller API on
* older device. Here are details.
*
* <table>
* <caption>Summary when package name isn't precise</caption>
* <tr><th>SDK version when package name isn't precise</th>
* <th>{@code ControllerInfo#getPackageName()} for legacy controller</th>
* <tr><td>{@code SDK_INT < 21}</td>
* <td>Actual package name via {@link PackageManager#getNameForUid} with UID.<br>
* It's sufficient for most cases, but doesn't precisely distinguish caller if it
* uses shared user ID.</td>
* <tr><td>{@code 21 <= SDK_INT < 24}</td>
* <td>{@link RemoteUserInfo#LEGACY_CONTROLLER}</td>
* </table>
*/
public String getPackageName() {
return remoteUserInfo.getPackageName();
}
/**
* Returns the UID of the controller. Can be a negative value for interoperability.
*
* <p>Interoperability: If {@code 21 <= SDK_INT < 28}, then UID would be a negative value
* because it cannot be obtained.
*/
public int getUid() {
return remoteUserInfo.getUid();
}
/** Returns the connection hints sent from controller. */
public Bundle getConnectionHints() {
return new Bundle(connectionHints);
}
/**
* Returns if the controller has been granted {@code android.permission.MEDIA_CONTENT_CONTROL}
* or has a enabled notification listener so it can be trusted to accept connection and incoming
* command request.
*/
/* package */ boolean isTrusted() {
return isTrusted;
}
@Override
public int hashCode() {
return Objects.hashCode(controllerCb, remoteUserInfo);
}
@Override
public boolean equals(@Nullable Object obj) {
if (!(obj instanceof ControllerInfo)) {
return false;
}
if (this == obj) {
return true;
}
ControllerInfo other = (ControllerInfo) obj;
if (controllerCb != null || other.controllerCb != null) {
return Util.areEqual(controllerCb, other.controllerCb);
}
return remoteUserInfo.equals(other.remoteUserInfo);
}
@Override
public String toString() {
return "ControllerInfo {pkg="
+ remoteUserInfo.getPackageName()
+ ", uid="
+ remoteUserInfo.getUid()
+ "})";
}
@Nullable
/* package */ ControllerCb getControllerCb() {
return controllerCb;
}
/* package */ static ControllerInfo createLegacyControllerInfo() {
RemoteUserInfo legacyRemoteUserInfo =
new RemoteUserInfo(
RemoteUserInfo.LEGACY_CONTROLLER,
/* pid= */ RemoteUserInfo.UNKNOWN_PID,
/* uid= */ RemoteUserInfo.UNKNOWN_UID);
return new ControllerInfo(
legacyRemoteUserInfo,
ControllerInfo.LEGACY_CONTROLLER_VERSION,
/* trusted= */ false,
/* cb= */ null,
/* connectionHints= */ Bundle.EMPTY);
}
}
private final MediaSessionImpl impl;
// Suppress nullness check as `this` is under initialization.
@SuppressWarnings({"nullness:argument", "nullness:method.invocation"})
/* package */ MediaSession(
Context context,
String id,
Player player,
@Nullable PendingIntent sessionActivity,
Callback callback,
Bundle tokenExtras) {
synchronized (STATIC_LOCK) {
if (SESSION_ID_TO_SESSION_MAP.containsKey(id)) {
throw new IllegalStateException("Session ID must be unique. ID=" + id);
}
SESSION_ID_TO_SESSION_MAP.put(id, this);
}
impl = createImpl(context, id, player, sessionActivity, callback, tokenExtras);
}
/* package */ MediaSessionImpl createImpl(
Context context,
String id,
Player player,
@Nullable PendingIntent sessionActivity,
Callback callback,
Bundle tokenExtras) {
return new MediaSessionImpl(this, context, id, player, sessionActivity, callback, tokenExtras);
}
/* package */ MediaSessionImpl getImpl() {
return impl;
}
@Nullable
/* package */ static MediaSession getSession(Uri sessionUri) {
synchronized (STATIC_LOCK) {
for (MediaSession session : SESSION_ID_TO_SESSION_MAP.values()) {
if (Util.areEqual(session.getUri(), sessionUri)) {
return session;
}
}
}
return null;
}
/**
* Returns the {@link PendingIntent} to launch {@linkplain
* Builder#setSessionActivity(PendingIntent) the session activity} or null if not set.
*
* @return The {@link PendingIntent} to launch an activity belonging to the session.
*/
@Nullable
public PendingIntent getSessionActivity() {
return impl.getSessionActivity();
}
/**
* Sets the underlying {@link Player} for this session to dispatch incoming events to.
*
* @param player A player that handles actual media playback in your app.
* @throws IllegalArgumentException if the new player's application looper differs from the
* current player's looper, or {@link Player#canAdvertiseSession()} returns false.
* @throws IllegalStateException if the new player's application looper differs from the current
* looper.
*/
public void setPlayer(Player player) {
checkNotNull(player);
checkArgument(player.canAdvertiseSession());
checkArgument(player.getApplicationLooper() == getPlayer().getApplicationLooper());
checkState(player.getApplicationLooper() == Looper.myLooper());
impl.setPlayer(player);
}
/**
* Releases the session and disconnects all connected controllers.
*
* <p>The session must not be used after calling this method.
*
* <p>Releasing the session removes the session's listeners from the player but does not
* {@linkplain Player#stop() stop} or {@linkplain Player#release() release} the player. An app can
* further use the player after the session is released and needs to make sure to eventually
* release the player.
*/
public void release() {
try {
synchronized (STATIC_LOCK) {
SESSION_ID_TO_SESSION_MAP.remove(impl.getId());
}
impl.release();
} catch (Exception e) {
// Should not be here.
}
}
/* package */ boolean isReleased() {
return impl.isReleased();
}
/** Returns the underlying {@link Player}. */
public Player getPlayer() {
return impl.getPlayerWrapper().getWrappedPlayer();
}
/** Returns the session ID. */
public String getId() {
return impl.getId();
}
/** Returns the {@link SessionToken} for creating {@link MediaController}. */
public SessionToken getToken() {
return impl.getToken();
}
/** Returns the list of connected controllers. */
public List<ControllerInfo> getConnectedControllers() {
return impl.getConnectedControllers();
}
/**
* Requests that controllers set the ordered list of {@link CommandButton} to build UI with it.
*
* <p>It's up to controller's decision how to represent the layout in its own UI. Here are some
* examples. Note: {@code layout[i]} means a {@link CommandButton} at index {@code i} in the given
* list.
*
* <table>
* <caption>Examples of controller's UI layout</caption>
* <tr>
* <th>Controller UI layout</th>
* <th>Layout example</th>
* </tr>
* <tr>
* <td>
* Row with 3 icons
* </td>
* <td style="white-space: nowrap;">
* {@code layout[1]} {@code layout[0]} {@code layout[2]}
* </td>
* </tr>
* <tr>
* <td>
* Row with 5 icons
* </td>
* <td style="white-space: nowrap;">
* {@code layout[3]} {@code layout[1]} {@code layout[0]} {@code layout[2]} {@code layout[4]}
* </td>
* </tr>
* <tr>
* <td rowspan="2">
* Row with 5 icons and an overflow icon, and another expandable row with 5 extra icons
* </td>
* <td style="white-space: nowrap;">
* {@code layout[5]} {@code layout[6]} {@code layout[7]} {@code layout[8]} {@code layout[9]}
* </td>
* </tr>
* <tr>
* <td style="white-space: nowrap;">
* {@code layout[3]} {@code layout[1]} {@code layout[0]} {@code layout[2]} {@code layout[4]}
* </td>
* </tr>
* </table>
*
* @param controller The controller to specify layout.
* @param layout The ordered list of {@link CommandButton}.
*/
public ListenableFuture<SessionResult> setCustomLayout(
ControllerInfo controller, List<CommandButton> layout) {
checkNotNull(controller, "controller must not be null");
checkNotNull(layout, "layout must not be null");
return impl.setCustomLayout(controller, layout);
}
/**
* Broadcasts the custom layout to all connected Media3 controllers and converts the buttons to
* custom actions in the legacy media session playback state (see {@code
* PlaybackStateCompat.Builder#addCustomAction(PlaybackStateCompat.CustomAction)}) for legacy
* controllers.
*
* <p>When converting, the {@link SessionCommand#customExtras custom extras of the session
* command} is used for the extras of the legacy custom action.
*
* <p>Media3 controllers that connect after calling this method will not receive the broadcast.
* You need to call {@link #setCustomLayout(ControllerInfo, List)} in {@link
* MediaSession.Callback#onPostConnect(MediaSession, ControllerInfo)} to make these controllers
* aware of the custom layout.
*
* @param layout The ordered list of {@link CommandButton}.
*/
public void setCustomLayout(List<CommandButton> layout) {
checkNotNull(layout, "layout must not be null");
impl.setCustomLayout(layout);
}
/**
* Sets the new available commands for the controller.
*
* <p>This is a synchronous call. Changes in the available commands take effect immediately
* regardless of the controller notified about the change through {@link
* Player.Listener#onAvailableCommandsChanged(Player.Commands)} and {@link
* MediaController.Listener#onAvailableSessionCommandsChanged(MediaController, SessionCommands)}.
*
* <p>Note that {@code playerCommands} will be intersected with the {@link
* Player#getAvailableCommands() available commands} of the underlying {@link Player} and the
* controller will only be able to call the commonly available commands.
*
* @param controller The controller to change allowed commands.
* @param sessionCommands The new available session commands.
* @param playerCommands The new available player commands.
*/
public void setAvailableCommands(
ControllerInfo controller, SessionCommands sessionCommands, Player.Commands playerCommands) {
checkNotNull(controller, "controller must not be null");
checkNotNull(sessionCommands, "sessionCommands must not be null");
checkNotNull(playerCommands, "playerCommands must not be null");
impl.setAvailableCommands(controller, sessionCommands, playerCommands);
}
/**
* Broadcasts a custom command to all connected controllers.
*
* <p>This is a synchronous call and doesn't wait for results from the controllers.
*
* <p>A command is not accepted if it is not a custom command.
*
* @param command A custom command.
* @param args A {@link Bundle} for additional arguments. May be empty.
* @see #sendCustomCommand(ControllerInfo, SessionCommand, Bundle)
*/
public void broadcastCustomCommand(SessionCommand command, Bundle args) {
checkNotNull(command);
checkNotNull(args);
checkArgument(
command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM,
"command must be a custom command");
impl.broadcastCustomCommand(command, args);
}
/**
* Sends the session extras to connected controllers.
*
* <p>This is a synchronous call and doesn't wait for results from the controllers.
*
* @param sessionExtras The session extras.
*/
public void setSessionExtras(Bundle sessionExtras) {
checkNotNull(sessionExtras);
impl.setSessionExtras(sessionExtras);
}
/**
* Sends the session extras to the connected controller.
*
* <p>This is a synchronous call and doesn't wait for results from the controller.
*
* @param controller The controller to send the extras to.
* @param sessionExtras The session extras.
*/
public void setSessionExtras(ControllerInfo controller, Bundle sessionExtras) {
checkNotNull(controller, "controller must not be null");
checkNotNull(sessionExtras);
impl.setSessionExtras(controller, sessionExtras);
}
/**
* Sends a custom command to a specific controller.
*
* <p>The result from {@link MediaController.Listener#onCustomCommand(MediaController,
* SessionCommand, Bundle)} will be returned.
*
* <p>A command is not accepted if it is not a custom command.
*
* @param command A custom command.
* @param args A {@link Bundle} for additional arguments. May be empty.
* @return A {@link ListenableFuture} of {@link SessionResult} from the controller.
* @see #broadcastCustomCommand(SessionCommand, Bundle)
*/
public ListenableFuture<SessionResult> sendCustomCommand(
ControllerInfo controller, SessionCommand command, Bundle args) {
checkNotNull(controller);
checkNotNull(command);
checkNotNull(args);
checkArgument(
command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM,
"command must be a custom command");
return impl.sendCustomCommand(controller, command, args);
}
/* package */ MediaSessionCompat getSessionCompat() {
return impl.getSessionCompat();
}
/**
* Returns the {@link MediaSessionCompat.Token} of the {@link MediaSessionCompat} created
* internally by this session. You may cast the {@link Object} to {@link
* MediaSessionCompat.Token}.
*/
@UnstableApi
public Object getSessionCompatToken() {
return impl.getSessionCompat().getSessionToken();
}
/**
* Sets the timeout for disconnecting legacy controllers.
*
* @param timeoutMs The timeout in milliseconds.
*/
/* package */ void setLegacyControllerConnectionTimeoutMs(long timeoutMs) {
impl.setLegacyControllerConnectionTimeoutMs(timeoutMs);
}
/** Handles the controller's connection request from {@link MediaSessionService}. */
/* package */ void handleControllerConnectionFromService(
IMediaController controller,
int controllerVersion,
String packageName,
int pid,
int uid,
Bundle connectionHints) {
impl.connectFromService(controller, controllerVersion, packageName, pid, uid, connectionHints);
}
/* package */ IBinder getLegacyBrowserServiceBinder() {
return impl.getLegacyBrowserServiceBinder();
}
/**
* Sets delay for periodic {@link SessionPositionInfo} updates. This resets previously pended
* update. Should be only called on the application looper.
*
* <p>A {@code updateDelayMs delay} less than or equal to {@code 0} will disable further updates
* after an immediate one-time update.
*/
@VisibleForTesting
/* package */ void setSessionPositionUpdateDelayMs(long updateDelayMs) {
impl.setSessionPositionUpdateDelayMsOnHandler(updateDelayMs);
}
private Uri getUri() {
return impl.getUri();
}
/**
* A callback to handle incoming commands from {@link MediaController}.
*
* <p>The callback methods will be called from the application thread associated with the {@link
* Player#getApplicationLooper() application looper} of the underlying {@link Player}.
*
* <p>If it's not set by {@link MediaSession.Builder#setCallback(Callback)}, the session will
* accept all controllers and all incoming commands by default.
*/
public interface Callback {
/**
* Called when a controller is about to connect to this session. Return a {@link
* ConnectionResult result} containing available commands for the controller by using {@link
* ConnectionResult#accept(SessionCommands, Player.Commands)}. By default it allows all
* connection requests and commands.
*
* <p>Note that the player commands in {@link ConnectionResult#availablePlayerCommands} will be
* intersected with the {@link Player#getAvailableCommands() available commands} of the
* underlying {@link Player} and the controller will only be able to call the commonly available
* commands.
*
* <p>You can reject the connection by returning {@link ConnectionResult#reject()}}. In that
* case, the controller will get {@link SecurityException} when resolving the {@link
* ListenableFuture} returned by {@link MediaController.Builder#buildAsync()}.
*
* <p>The controller isn't connected yet, so calls to the controller (e.g. {@link
* #sendCustomCommand}, {@link #setCustomLayout}) will be ignored. Use {@link #onPostConnect}
* for custom initialization of the controller instead.
*
* <p>Interoperability: If a legacy controller is connecting to the session then this callback
* may block the main thread, even if it's called on a different application thread. If it's
* possible that legacy controllers will connect to the session, you should ensure that the
* callback returns quickly to avoid blocking the main thread for a long period of time.
*
* @param session The session for this event.
* @param controller The controller information.
* @return The {@link ConnectionResult}.
*/
default ConnectionResult onConnect(MediaSession session, ControllerInfo controller) {
SessionCommands sessionCommands =
new SessionCommands.Builder().addAllSessionCommands().build();
Player.Commands playerCommands = new Player.Commands.Builder().addAllCommands().build();
return ConnectionResult.accept(sessionCommands, playerCommands);
}
/**
* Called immediately after a controller is connected. This is for custom initialization of the
* controller.
*
* <p>Note that calls to the controller (e.g. {@link #sendCustomCommand}, {@link
* #setCustomLayout}) work here but don't work in {@link #onConnect} because the controller
* isn't connected yet in {@link #onConnect}.
*
* @param session The session for this event.
* @param controller The controller information.
*/
default void onPostConnect(MediaSession session, ControllerInfo controller) {}
/**
* Called when a controller is disconnected.
*
* <p>Interoperability: For legacy controllers, this is called when the controller doesn't send
* any command for a while. It's because there were no explicit disconnection in legacy
* controller APIs.
*
* @param session The session for this event.
* @param controller The controller information.
*/
default void onDisconnected(MediaSession session, ControllerInfo controller) {}
/**
* Called when a controller sent a command which will be sent directly to the underlying {@link
* Player}.
*
* <p>Return {@link SessionResult#RESULT_SUCCESS} to proceed the command. Otherwise, the command
* won't be sent and the controller will receive the code. This method will be called for every
* single command.
*
* @param session The session for this event.
* @param controller The controller information.
* @param playerCommand A {@link Player.Command command}.
* @return {@link SessionResult#RESULT_SUCCESS} to proceed, or another code to ignore.
*/
default @SessionResult.Code int onPlayerCommandRequest(
MediaSession session, ControllerInfo controller, @Player.Command int playerCommand) {
return RESULT_SUCCESS;
}
/**
* Called when a controller requested to set a rating to a media for the current user by {@link
* MediaController#setRating(String, Rating)}.
*
* <p>To allow setting the user rating for a {@link MediaItem}, the item's {@link
* MediaItem#mediaMetadata metadata} should have the {@link Rating} field in order to provide
* possible rating style for controllers. Controllers will follow the rating style.
*
* <p>Return a {@link ListenableFuture} to send a {@link SessionResult} back to the controller
* asynchronously. You can also return a {@link SessionResult} directly by using Guava's {@link
* Futures#immediateFuture(Object)}.
*
* @param session The session for this event.
* @param controller The controller information.
* @param mediaId The media id.
* @param rating The new rating from the controller.
* @see SessionCommand#COMMAND_CODE_SESSION_SET_RATING
*/
default ListenableFuture<SessionResult> onSetRating(
MediaSession session, ControllerInfo controller, String mediaId, Rating rating) {
return Futures.immediateFuture(new SessionResult(RESULT_ERROR_NOT_SUPPORTED));
}
/**
* Called when a controller requested to set a rating for the current media item for the current
* user by {@link MediaController#setRating(Rating)}.
*
* <p>To allow setting the user rating for the current {@link MediaItem}, the item's {@link
* MediaItem#mediaMetadata metadata} should have the {@link Rating} field in order to provide
* possible rating style for controllers. Controllers will follow the rating style.
*
* <p>Return a {@link ListenableFuture} to send a {@link SessionResult} back to the controller
* asynchronously. You can also return a {@link SessionResult} directly by using Guava's {@link
* Futures#immediateFuture(Object)}.
*
* @param session The session for this event.
* @param controller The controller information.
* @param rating The new rating from the controller.
* @see SessionCommand#COMMAND_CODE_SESSION_SET_RATING
*/
default ListenableFuture<SessionResult> onSetRating(
MediaSession session, ControllerInfo controller, Rating rating) {
return Futures.immediateFuture(new SessionResult(RESULT_ERROR_NOT_SUPPORTED));
}
/**
* Called when a controller sent a custom command through {@link
* MediaController#sendCustomCommand(SessionCommand, Bundle)}.
*
* <p>Interoperability: This will be also called by {@link
* android.support.v4.media.MediaBrowserCompat#sendCustomAction}. If so, {@code extras} from
* {@link android.support.v4.media.MediaBrowserCompat#sendCustomAction} will be considered as
* {@code args} and the custom command will have {@code null} {@link
* SessionCommand#customExtras}.
*
* <p>Return a {@link ListenableFuture} to send a {@link SessionResult} back to the controller
* asynchronously. You can also return a {@link SessionResult} directly by using Guava's {@link
* Futures#immediateFuture(Object)}.
*
* @param session The session for this event.
* @param controller The controller information.
* @param customCommand The custom command.
* @param args A {@link Bundle} for additional arguments. May be empty.
* @return The result of handling the custom command.
* @see SessionCommand#COMMAND_CODE_CUSTOM
*/
default ListenableFuture<SessionResult> onCustomCommand(
MediaSession session,
ControllerInfo controller,
SessionCommand customCommand,
Bundle args) {
return Futures.immediateFuture(new SessionResult(RESULT_ERROR_NOT_SUPPORTED));
}
/**
* Called when a controller requested to add new {@linkplain MediaItem media items} to the
* playlist via one of the {@code Player.addMediaItem(s)} or {@code Player.setMediaItem(s)}
* methods.
*
* <p>Note that the requested {@linkplain MediaItem media items} don't have a {@link
* MediaItem.LocalConfiguration} (for example, a URI) and need to be updated to make them
* playable by the underlying {@link Player}. Typically, this implementation should be able to
* identify the correct item by its {@link MediaItem#mediaId} and/or the {@link
* MediaItem#requestMetadata}.
*
* <p>Return a {@link ListenableFuture} with the resolved {@link MediaItem media items}. You can
* also return the items directly by using Guava's {@link Futures#immediateFuture(Object)}. Once
* the {@link MediaItem media items} have been resolved, the session will call {@link
* Player#setMediaItems} or {@link Player#addMediaItems} as requested.
*
* <p>Interoperability: This method will be called in response to the following {@link
* MediaControllerCompat} methods:
*
* <ul>
* <li>{@link MediaControllerCompat.TransportControls#prepareFromUri prepareFromUri}
* <li>{@link MediaControllerCompat.TransportControls#playFromUri playFromUri}
* <li>{@link MediaControllerCompat.TransportControls#prepareFromMediaId prepareFromMediaId}
* <li>{@link MediaControllerCompat.TransportControls#playFromMediaId playFromMediaId}
* <li>{@link MediaControllerCompat.TransportControls#prepareFromSearch prepareFromSearch}
* <li>{@link MediaControllerCompat.TransportControls#playFromSearch playFromSearch}
* <li>{@link MediaControllerCompat.TransportControls#addQueueItem addQueueItem}
* </ul>
*
* The values of {@link MediaItem#mediaId}, {@link MediaItem.RequestMetadata#mediaUri}, {@link
* MediaItem.RequestMetadata#searchQuery} and {@link MediaItem.RequestMetadata#extras} will be
* set to match the legacy method call. The session will call {@link Player#setMediaItems} or
* {@link Player#addMediaItems}, followed by {@link Player#prepare()} and {@link Player#play()}
* as appropriate once the {@link MediaItem} has been resolved.
*
* @param mediaSession The session for this event.
* @param controller The controller information.
* @param mediaItems The list of requested {@link MediaItem media items}.
* @return A {@link ListenableFuture} for the list of resolved {@link MediaItem media items}
* that are playable by the underlying {@link Player}.
*/
default ListenableFuture<List<MediaItem>> onAddMediaItems(
MediaSession mediaSession, ControllerInfo controller, List<MediaItem> mediaItems) {
return Futures.immediateFailedFuture(new UnsupportedOperationException());
}
}
/**
* A result for {@link Callback#onConnect(MediaSession, ControllerInfo)} to denote the set of
* commands that are available for the given {@link ControllerInfo controller}.
*/
public static final class ConnectionResult {
/** Whether the connection request is accepted or not. */
public final boolean isAccepted;
/** Available session commands. */
public final SessionCommands availableSessionCommands;
/** Available player commands. */
public final Player.Commands availablePlayerCommands;
/** Creates a new instance with the given available session and player commands. */
private ConnectionResult(
boolean accepted,
SessionCommands availableSessionCommands,
Player.Commands availablePlayerCommands) {
isAccepted = accepted;
this.availableSessionCommands = checkNotNull(availableSessionCommands);
this.availablePlayerCommands = checkNotNull(availablePlayerCommands);
}
public static ConnectionResult accept(
SessionCommands availableSessionCommands, Player.Commands availablePlayerCommands) {
return new ConnectionResult(
/* accepted= */ true, availableSessionCommands, availablePlayerCommands);
}
public static ConnectionResult reject() {
return new ConnectionResult(
/* accepted= */ false, SessionCommands.EMPTY, Player.Commands.EMPTY);
}
}
/* package */ interface ControllerCb {
default void onSessionResult(int seq, SessionResult result) throws RemoteException {}
default void onLibraryResult(int seq, LibraryResult<?> result) throws RemoteException {}
default void onPlayerChanged(
int seq, @Nullable PlayerWrapper oldPlayerWrapper, PlayerWrapper newPlayerWrapper)
throws RemoteException {}
default void onPlayerInfoChanged(
int seq,
PlayerInfo playerInfo,
boolean excludeMediaItems,
boolean excludeMediaItemsMetadata,
boolean excludeCues,
boolean excludeTimeline)
throws RemoteException {}
default void onPeriodicSessionPositionInfoChanged(
int seq, SessionPositionInfo sessionPositionInfo) throws RemoteException {}
// Mostly matched with MediaController.ControllerCallback
default void onDisconnected(int seq) throws RemoteException {}
default void setCustomLayout(int seq, List<CommandButton> layout) throws RemoteException {}
default void onSessionExtrasChanged(int seq, Bundle sessionExtras) throws RemoteException {}
default void sendCustomCommand(int seq, SessionCommand command, Bundle args)
throws RemoteException {}
default void onAvailableCommandsChangedFromSession(
int seq, SessionCommands sessionCommands, Player.Commands playerCommands)
throws RemoteException {}
default void onAvailableCommandsChangedFromPlayer(int seq, Player.Commands availableCommands)
throws RemoteException {}
// Mostly matched with MediaBrowser.BrowserCallback
default void onChildrenChanged(
int seq, String parentId, int itemCount, @Nullable LibraryParams params)
throws RemoteException {}
default void onSearchResultChanged(
int seq, String query, int itemCount, @Nullable LibraryParams params)
throws RemoteException {}
// Mostly matched with Player.Listener
default void onPlayerError(int seq, @Nullable PlaybackException playerError)
throws RemoteException {}
default void onPlayWhenReadyChanged(
int seq, boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason)
throws RemoteException {}
default void onPlaybackSuppressionReasonChanged(
int seq, @Player.PlaybackSuppressionReason int reason) throws RemoteException {}
default void onPlaybackStateChanged(
int seq, @Player.State int state, @Nullable PlaybackException playerError)
throws RemoteException {}
default void onIsPlayingChanged(int seq, boolean isPlaying) throws RemoteException {}
default void onIsLoadingChanged(int seq, boolean isLoading) throws RemoteException {}
default void onTrackSelectionParametersChanged(int seq, TrackSelectionParameters parameters)
throws RemoteException {}
default void onPlaybackParametersChanged(int seq, PlaybackParameters playbackParameters)
throws RemoteException {}
default void onPositionDiscontinuity(
int seq,
PositionInfo oldPosition,
PositionInfo newPosition,
@DiscontinuityReason int reason)
throws RemoteException {}
default void onMediaItemTransition(
int seq, @Nullable MediaItem mediaItem, @Player.MediaItemTransitionReason int reason)
throws RemoteException {}
default void onTimelineChanged(
int seq, Timeline timeline, @Player.TimelineChangeReason int reason)
throws RemoteException {}
default void onPlaylistMetadataChanged(int seq, MediaMetadata metadata)
throws RemoteException {}
default void onShuffleModeEnabledChanged(int seq, boolean shuffleModeEnabled)
throws RemoteException {}
default void onRepeatModeChanged(int seq, @RepeatMode int repeatMode) throws RemoteException {}
default void onSeekBackIncrementChanged(int seq, long seekBackIncrementMs)
throws RemoteException {}
default void onSeekForwardIncrementChanged(int seq, long seekForwardIncrementMs)
throws RemoteException {}
default void onVideoSizeChanged(int seq, VideoSize videoSize) throws RemoteException {}
default void onVolumeChanged(int seq, float volume) throws RemoteException {}
default void onAudioAttributesChanged(int seq, AudioAttributes audioAttributes)
throws RemoteException {}
default void onDeviceInfoChanged(int seq, DeviceInfo deviceInfo) throws RemoteException {}
default void onDeviceVolumeChanged(int seq, int volume, boolean muted) throws RemoteException {}
default void onMediaMetadataChanged(int seq, MediaMetadata mediaMetadata)
throws RemoteException {}
default void onRenderedFirstFrame(int seq) throws RemoteException {}
}
/**
* A base class for {@link MediaSession.Builder} and {@link
* MediaLibraryService.MediaLibrarySession.Builder}. Any changes to this class should be also
* applied to the subclasses.
*/
/* package */ abstract static class BuilderBase<
T extends MediaSession, U extends BuilderBase<T, U, C>, C extends Callback> {
/* package */ final Context context;
/* package */ final Player player;
/* package */ String id;
/* package */ C callback;
/* package */ @Nullable PendingIntent sessionActivity;
/* package */ Bundle extras;
public BuilderBase(Context context, Player player, C callback) {
this.context = checkNotNull(context);
this.player = checkNotNull(player);
checkArgument(player.canAdvertiseSession());
id = "";
this.callback = callback;
extras = Bundle.EMPTY;
}
@SuppressWarnings("unchecked")
public U setSessionActivity(PendingIntent pendingIntent) {
sessionActivity = checkNotNull(pendingIntent);
return (U) this;
}
@SuppressWarnings("unchecked")
public U setId(String id) {
this.id = checkNotNull(id);
return (U) this;
}
@SuppressWarnings("unchecked")
/* package */ U setCallback(C callback) {
this.callback = checkNotNull(callback);
return (U) this;
}
@SuppressWarnings("unchecked")
public U setExtras(Bundle extras) {
this.extras = new Bundle(checkNotNull(extras));
return (U) this;
}
public abstract T build();
}
}