diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 9f90ae6f9cf..456d40082b3 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -121,6 +121,10 @@ This release includes the following changes since the * Cast Extension: * Stop clearning the timeline after the CastSession disconnects, which enables the sender app to resume playback locally after a disconnection. + * Populate CastPlayer's `DeviceInfo` when a `Context` is provided. This + enables linking the `MediaSession` to a `RoutingSession`, which is + necessary for integrating Output Switcher + ([#1056](https://github.com/androidx/media/issues/1056)). * Test Utilities: * `DataSourceContractTest` now includes tests to verify: * Input stream `read position` is updated. diff --git a/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java b/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java index 01991548aec..d94b0aa165f 100644 --- a/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java +++ b/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java @@ -17,16 +17,26 @@ import static androidx.annotation.VisibleForTesting.PROTECTED; import static androidx.media3.common.util.Assertions.checkArgument; +import static androidx.media3.common.util.Util.SDK_INT; import static androidx.media3.common.util.Util.castNonNull; import static java.lang.Math.min; +import android.content.Context; +import android.media.MediaRouter2; +import android.media.MediaRouter2.RouteCallback; +import android.media.MediaRouter2.RoutingController; +import android.media.MediaRouter2.TransferCallback; +import android.media.RouteDiscoveryPreference; +import android.os.Handler; import android.os.Looper; import android.view.Surface; import android.view.SurfaceHolder; import android.view.SurfaceView; import android.view.TextureView; +import androidx.annotation.DoNotInline; import androidx.annotation.IntRange; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.annotation.VisibleForTesting; import androidx.media3.common.AudioAttributes; import androidx.media3.common.BasePlayer; @@ -83,8 +93,11 @@ @UnstableApi public final class CastPlayer extends BasePlayer { - /** The {@link DeviceInfo} returned by {@link #getDeviceInfo() this player}. */ - public static final DeviceInfo DEVICE_INFO = + /** + * A {@link DeviceInfo#PLAYBACK_TYPE_REMOTE remote} {@link DeviceInfo} with a null {@link + * DeviceInfo#routingControllerId}. + */ + public static final DeviceInfo DEVICE_INFO_REMOTE_EMPTY = new DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE).build(); static { @@ -128,6 +141,7 @@ public final class CastPlayer extends BasePlayer { // TODO: Allow custom implementations of CastTimelineTracker. private final CastTimelineTracker timelineTracker; private final Timeline.Period period; + @Nullable private final Api30Impl api30Impl; // Result callbacks. private final StatusListener statusListener; @@ -153,6 +167,7 @@ public final class CastPlayer extends BasePlayer { private long pendingSeekPositionMs; @Nullable private PositionInfo pendingMediaItemRemovalPosition; private MediaMetadata mediaMetadata; + private DeviceInfo deviceInfo; /** * Creates a new cast player. @@ -202,6 +217,7 @@ public CastPlayer( @IntRange(from = 1) long seekBackIncrementMs, @IntRange(from = 1) long seekForwardIncrementMs) { this( + /* context= */ null, castContext, mediaItemConverter, seekBackIncrementMs, @@ -212,6 +228,8 @@ public CastPlayer( /** * Creates a new cast player. * + * @param context A {@link Context} used to populate {@link #getDeviceInfo()}. If null, {@link + * #getDeviceInfo()} will always return {@link #DEVICE_INFO_REMOTE_EMPTY}. * @param castContext The context from which the cast session is obtained. * @param mediaItemConverter The {@link MediaItemConverter} to use. * @param seekBackIncrementMs The {@link #seekBack()} increment, in milliseconds. @@ -223,6 +241,7 @@ public CastPlayer( * negative. */ public CastPlayer( + @Nullable Context context, CastContext castContext, MediaItemConverter mediaItemConverter, @IntRange(from = 1) long seekBackIncrementMs, @@ -260,6 +279,14 @@ public CastPlayer( CastSession session = sessionManager.getCurrentCastSession(); setRemoteMediaClient(session != null ? session.getRemoteMediaClient() : null); updateInternalStateAndNotifyIfChanged(); + if (SDK_INT >= 30 && context != null) { + api30Impl = new Api30Impl(context); + api30Impl.initialize(); + deviceInfo = api30Impl.fetchDeviceInfo(); + } else { + api30Impl = null; + deviceInfo = DEVICE_INFO_REMOTE_EMPTY; + } } /** @@ -530,6 +557,10 @@ public void stop() { @Override public void release() { + // The SDK_INT check is not necessary, but it prevents a lint error for the release call. + if (SDK_INT >= 30 && api30Impl != null) { + api30Impl.release(); + } SessionManager sessionManager = castContext.getSessionManager(); sessionManager.removeSessionManagerListener(statusListener, CastSession.class); sessionManager.endCurrentSession(false); @@ -782,10 +813,14 @@ public CueGroup getCurrentCues() { return CueGroup.EMPTY_TIME_ZERO; } - /** This method always returns {@link CastPlayer#DEVICE_INFO}. */ + /** + * Returns a {@link DeviceInfo} describing the receiver device. Returns {@link + * #DEVICE_INFO_REMOTE_EMPTY} if no {@link Context} was provided at construction, or if the Cast + * {@link RoutingController} could not be identified. + */ @Override public DeviceInfo getDeviceInfo() { - return DEVICE_INFO; + return deviceInfo; } /** This method is not supported and always returns {@code 0}. */ @@ -1534,4 +1569,109 @@ public boolean acceptsUpdate(@Nullable ResultCallback resultCallback) { return pendingResultCallback == resultCallback; } } + + @RequiresApi(30) + private final class Api30Impl { + + private final MediaRouter2 mediaRouter2; + private final TransferCallback transferCallback; + private final RouteCallback emptyRouteCallback; + private final Handler handler; + + public Api30Impl(Context context) { + mediaRouter2 = MediaRouter2.getInstance(context); + transferCallback = new MediaRouter2TransferCallbackImpl(); + emptyRouteCallback = new MediaRouter2RouteCallbackImpl(); + handler = new Handler(Looper.getMainLooper()); + } + + /** Acquires necessary resources and registers callbacks. */ + @DoNotInline + public void initialize() { + mediaRouter2.registerTransferCallback(handler::post, transferCallback); + // We need at least one route callback registered in order to get transfer callback updates. + mediaRouter2.registerRouteCallback( + handler::post, + emptyRouteCallback, + new RouteDiscoveryPreference.Builder(ImmutableList.of(), /* activeScan= */ false) + .build()); + } + + /** + * Releases any resources acquired in {@link #initialize()} and unregisters any registered + * callbacks. + */ + @DoNotInline + public void release() { + mediaRouter2.unregisterTransferCallback(transferCallback); + mediaRouter2.unregisterRouteCallback(emptyRouteCallback); + handler.removeCallbacksAndMessages(/* token= */ null); + } + + /** Updates the device info with an up-to-date value and notifies the listeners. */ + @DoNotInline + private void updateDeviceInfo() { + DeviceInfo oldDeviceInfo = deviceInfo; + DeviceInfo newDeviceInfo = fetchDeviceInfo(); + deviceInfo = newDeviceInfo; + if (!deviceInfo.equals(oldDeviceInfo)) { + listeners.sendEvent( + EVENT_DEVICE_INFO_CHANGED, listener -> listener.onDeviceInfoChanged(newDeviceInfo)); + } + } + + /** + * Returns a {@link DeviceInfo} with the {@link RoutingController#getId() id} that corresponds + * to the Cast session, or {@link #DEVICE_INFO_REMOTE_EMPTY} if not available. + */ + @DoNotInline + public DeviceInfo fetchDeviceInfo() { + // TODO: b/364833997 - Fetch this information from the AndroidX MediaRouter selected route + // once the selected route id matches the controller id. + List controllers = mediaRouter2.getControllers(); + // The controller at position zero is always the system controller (local playback). All other + // controllers are for remote playback, and could be the Cast one. + if (controllers.size() != 2) { + // There's either no remote routing controller, or there's more than one. In either case we + // don't populate the device info because either there's no Cast routing controller, or we + // cannot safely identify the Cast routing controller. + return DEVICE_INFO_REMOTE_EMPTY; + } else { + // There's only one remote routing controller. It's safe to assume it's the Cast routing + // controller. + RoutingController remoteController = controllers.get(1); + // TODO b/364580007 - Populate volume information, and implement Player volume-related + // methods. + return new DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE) + .setRoutingControllerId(remoteController.getId()) + .build(); + } + } + + /** + * Empty {@link RouteCallback} implementation necessary for registering the {@link MediaRouter2} + * instance with the system_server. + * + *

This callback must be registered so that the media router service notifies the {@link + * MediaRouter2TransferCallbackImpl} of transfer events. + */ + private final class MediaRouter2RouteCallbackImpl extends RouteCallback {} + + /** + * {@link TransferCallback} implementation to listen for {@link RoutingController} creation and + * releases. + */ + private final class MediaRouter2TransferCallbackImpl extends TransferCallback { + + @Override + public void onTransfer(RoutingController oldController, RoutingController newController) { + updateDeviceInfo(); + } + + @Override + public void onStop(RoutingController controller) { + updateDeviceInfo(); + } + } + } } diff --git a/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java b/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java index 7856af78f09..2af48add703 100644 --- a/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java +++ b/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java @@ -1902,7 +1902,7 @@ public void setMediaItems_equalMetadata_doesNotNotifyOnMediaMetadataChanged() { public void getDeviceInfo_returnsCorrectDeviceInfoWithPlaybackTypeRemote() { DeviceInfo deviceInfo = castPlayer.getDeviceInfo(); - assertThat(deviceInfo).isEqualTo(CastPlayer.DEVICE_INFO); + assertThat(deviceInfo).isEqualTo(CastPlayer.DEVICE_INFO_REMOTE_EMPTY); assertThat(deviceInfo.playbackType).isEqualTo(DeviceInfo.PLAYBACK_TYPE_REMOTE); }