diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 3d3d544107..a9c94ee336 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -71,6 +71,9 @@ * MIDI extension: * Leanback extension: * Cast extension: + * Add support for `getDeviceVolume()`, `setDeviceVolume()`, + `getDeviceMuted()`, and `setDeviceMuted()` + ([#2089](https://github.com/androidx/media/issues/2089)). * Test Utilities: * Removed `transformer.TestUtil.addAudioDecoders(String...)`, `transformer.TestUtil.addAudioEncoders(String...)`, and 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 fea8df2056..973c9d2c57 100644 --- a/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java +++ b/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java @@ -29,6 +29,7 @@ import android.media.MediaRouter2.TransferCallback; import android.media.RouteDiscoveryPreference; import android.os.Handler; import android.os.Looper; +import android.util.Range; import android.view.Surface; import android.view.SurfaceHolder; import android.view.SurfaceView; @@ -59,6 +60,7 @@ import androidx.media3.common.util.Log; import androidx.media3.common.util.Size; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; +import com.google.android.gms.cast.Cast; import com.google.android.gms.cast.CastStatusCodes; import com.google.android.gms.cast.MediaInfo; import com.google.android.gms.cast.MediaQueueItem; @@ -73,6 +75,7 @@ import com.google.android.gms.cast.framework.media.RemoteMediaClient.MediaChanne import com.google.android.gms.common.api.PendingResult; import com.google.android.gms.common.api.ResultCallback; import com.google.common.collect.ImmutableList; +import java.io.IOException; import java.util.List; import java.util.Objects; import org.checkerframework.checker.nullness.qual.RequiresNonNull; @@ -93,12 +96,23 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @UnstableApi public final class CastPlayer extends BasePlayer { + /** + * Maximum volume to use for {@link #getDeviceVolume()} and {@link #setDeviceVolume}. + * + *

These methods are implemented around {@link CastSession#setVolume} and {@link + * CastSession#getVolume} which operate on a {@code [0, 1]} range. So this value allows us to + * convert to and from the int-based volume scale that {@link #getDeviceVolume()} uses. + */ + private static final int MAX_VOLUME = 20; + /** * 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(); + new DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE).setMaxVolume(MAX_VOLUME).build(); + + private static final Range VOLUME_RANGE = new Range<>(0, MAX_VOLUME); static { MediaLibraryInfo.registerModule("media3.cast"); @@ -113,6 +127,11 @@ public final class CastPlayer extends BasePlayer { COMMAND_STOP, COMMAND_SEEK_TO_DEFAULT_POSITION, COMMAND_SEEK_TO_MEDIA_ITEM, + COMMAND_GET_DEVICE_VOLUME, + COMMAND_ADJUST_DEVICE_VOLUME, + COMMAND_ADJUST_DEVICE_VOLUME_WITH_FLAGS, + COMMAND_SET_DEVICE_VOLUME, + COMMAND_SET_DEVICE_VOLUME_WITH_FLAGS, COMMAND_SET_REPEAT_MODE, COMMAND_SET_SPEED_AND_PITCH, COMMAND_GET_CURRENT_MEDIA_ITEM, @@ -144,6 +163,8 @@ public final class CastPlayer extends BasePlayer { @Nullable private final Api30Impl api30Impl; // Result callbacks. + private final Cast.Listener castListener; + private final StatusListener statusListener; private final SeekResultCallback seekResultCallback; @@ -154,7 +175,10 @@ public final class CastPlayer extends BasePlayer { // Internal state. private final StateHolder playWhenReady; private final StateHolder repeatMode; + private boolean isMuted; + private int deviceVolume; private final StateHolder playbackParameters; + @Nullable private CastSession castSession; @Nullable private RemoteMediaClient remoteMediaClient; private CastTimeline currentTimeline; private Tracks currentTracks; @@ -257,6 +281,7 @@ public final class CastPlayer extends BasePlayer { this.maxSeekToPreviousPositionMs = maxSeekToPreviousPositionMs; timelineTracker = new CastTimelineTracker(mediaItemConverter); period = new Timeline.Period(); + castListener = new CastListener(); statusListener = new StatusListener(); seekResultCallback = new SeekResultCallback(); listeners = @@ -266,6 +291,7 @@ public final class CastPlayer extends BasePlayer { (listener, flags) -> listener.onEvents(/* player= */ this, new Events(flags))); playWhenReady = new StateHolder<>(false); repeatMode = new StateHolder<>(REPEAT_MODE_OFF); + deviceVolume = MAX_VOLUME; playbackParameters = new StateHolder<>(PlaybackParameters.DEFAULT); playbackState = STATE_IDLE; currentTimeline = CastTimeline.EMPTY_CAST_TIMELINE; @@ -278,8 +304,7 @@ public final class CastPlayer extends BasePlayer { SessionManager sessionManager = castContext.getSessionManager(); sessionManager.addSessionManagerListener(statusListener, CastSession.class); - CastSession session = sessionManager.getCurrentCastSession(); - setRemoteMediaClient(session != null ? session.getRemoteMediaClient() : null); + setCastSession(sessionManager.getCurrentCastSession()); updateInternalStateAndNotifyIfChanged(); if (SDK_INT >= 30 && context != null) { api30Impl = new Api30Impl(context); @@ -829,16 +854,14 @@ public final class CastPlayer extends BasePlayer { return deviceInfo; } - /** This method is not supported and always returns {@code 0}. */ @Override public int getDeviceVolume() { - return 0; + return deviceVolume; } - /** This method is not supported and always returns {@code false}. */ @Override public boolean isDeviceMuted() { - return false; + return isMuted; } /** @@ -846,44 +869,79 @@ public final class CastPlayer extends BasePlayer { */ @Deprecated @Override - public void setDeviceVolume(int volume) {} + public void setDeviceVolume(@IntRange(from = 0) int volume) { + setDeviceVolume(volume, /* flags= */ 0); + } - /** This method is not supported and does nothing. */ @Override - public void setDeviceVolume(int volume, @C.VolumeFlags int flags) {} + public void setDeviceVolume(@IntRange(from = 0) int volume, @C.VolumeFlags int flags) { + if (castSession == null) { + return; + } + volume = VOLUME_RANGE.clamp(volume); + try { + // See [Internal ref: b/399691860] for context on why we don't use + // RemoteMediaClient.setStreamVolume. + castSession.setVolume((float) volume / MAX_VOLUME); + } catch (IOException e) { + Log.w(TAG, "Ignoring setDeviceVolume due to exception", e); + return; + } + setDeviceVolumeAndNotifyIfChanged(volume, isMuted); + listeners.flushEvents(); + } /** * @deprecated Use {@link #increaseDeviceVolume(int)} instead. */ @Deprecated @Override - public void increaseDeviceVolume() {} + public void increaseDeviceVolume() { + increaseDeviceVolume(/* flags= */ 0); + } - /** This method is not supported and does nothing. */ @Override - public void increaseDeviceVolume(@C.VolumeFlags int flags) {} + public void increaseDeviceVolume(@C.VolumeFlags int flags) { + setDeviceVolume(getDeviceVolume() + 1, flags); + } /** - * @deprecated Use {@link #decreaseDeviceVolume(int)} instead. + * @deprecated Use {@link #decreaseDeviceVolume(int)} (int)} instead. */ @Deprecated @Override - public void decreaseDeviceVolume() {} + public void decreaseDeviceVolume() { + decreaseDeviceVolume(/* flags= */ 0); + } - /** This method is not supported and does nothing. */ @Override - public void decreaseDeviceVolume(@C.VolumeFlags int flags) {} + public void decreaseDeviceVolume(@C.VolumeFlags int flags) { + setDeviceVolume(getDeviceVolume() - 1, flags); + } /** * @deprecated Use {@link #setDeviceMuted(boolean, int)} instead. */ @Deprecated @Override - public void setDeviceMuted(boolean muted) {} + public void setDeviceMuted(boolean muted) { + setDeviceMuted(muted, /* flags= */ 0); + } - /** This method is not supported and does nothing. */ @Override - public void setDeviceMuted(boolean muted, @C.VolumeFlags int flags) {} + public void setDeviceMuted(boolean muted, @C.VolumeFlags int flags) { + if (castSession == null) { + return; + } + try { + castSession.setMute(muted); + } catch (IOException e) { + Log.w(TAG, "Ignoring setDeviceMuted due to exception", e); + return; + } + setDeviceVolumeAndNotifyIfChanged(deviceVolume, muted); + listeners.flushEvents(); + } /** This method is not supported and does nothing. */ @Override @@ -906,6 +964,7 @@ public final class CastPlayer extends BasePlayer { ? getCurrentTimeline().getPeriod(oldWindowIndex, period, /* setIds= */ true).uid : null; updatePlayerStateAndNotifyIfChanged(/* resultCallback= */ null); + updateVolumeAndNotifyIfChanged(); updateRepeatModeAndNotifyIfChanged(/* resultCallback= */ null); updatePlaybackRateAndNotifyIfChanged(/* resultCallback= */ null); boolean playingPeriodChangedByTimelineChange = updateTimelineAndNotifyIfChanged(); @@ -1014,6 +1073,14 @@ public final class CastPlayer extends BasePlayer { } } + @RequiresNonNull("castSession") + private void updateVolumeAndNotifyIfChanged() { + if (castSession != null) { + int deviceVolume = VOLUME_RANGE.clamp((int) Math.round(castSession.getVolume() * MAX_VOLUME)); + setDeviceVolumeAndNotifyIfChanged(deviceVolume, castSession.isMute()); + } + } + @RequiresNonNull("remoteMediaClient") private void updateRepeatModeAndNotifyIfChanged(@Nullable ResultCallback resultCallback) { if (repeatMode.acceptsUpdate(resultCallback)) { @@ -1255,6 +1322,17 @@ public final class CastPlayer extends BasePlayer { /* adIndexInAdGroup= */ C.INDEX_UNSET); } + private void setDeviceVolumeAndNotifyIfChanged( + @IntRange(from = 0) int deviceVolume, boolean isMuted) { + if (this.deviceVolume != deviceVolume || this.isMuted != isMuted) { + this.deviceVolume = deviceVolume; + this.isMuted = isMuted; + listeners.queueEvent( + Player.EVENT_DEVICE_VOLUME_CHANGED, + listener -> listener.onDeviceVolumeChanged(deviceVolume, isMuted)); + } + } + private void setRepeatModeAndNotifyIfChanged(@Player.RepeatMode int repeatMode) { if (this.repeatMode.value != repeatMode) { this.repeatMode.value = repeatMode; @@ -1307,7 +1385,16 @@ public final class CastPlayer extends BasePlayer { } } - private void setRemoteMediaClient(@Nullable RemoteMediaClient remoteMediaClient) { + private void setCastSession(@Nullable CastSession castSession) { + if (this.castSession != null) { + this.castSession.removeCastListener(castListener); + } + if (castSession != null) { + castSession.addCastListener(castListener); + } + this.castSession = castSession; + RemoteMediaClient remoteMediaClient = + castSession != null ? castSession.getRemoteMediaClient() : null; if (this.remoteMediaClient == remoteMediaClient) { // Do nothing. return; @@ -1468,22 +1555,22 @@ public final class CastPlayer extends BasePlayer { @Override public void onSessionStarted(CastSession castSession, String s) { - setRemoteMediaClient(castSession.getRemoteMediaClient()); + setCastSession(castSession); } @Override public void onSessionResumed(CastSession castSession, boolean b) { - setRemoteMediaClient(castSession.getRemoteMediaClient()); + setCastSession(castSession); } @Override public void onSessionEnded(CastSession castSession, int i) { - setRemoteMediaClient(null); + setCastSession(null); } @Override public void onSessionSuspended(CastSession castSession, int i) { - setRemoteMediaClient(null); + setCastSession(null); } @Override @@ -1645,6 +1732,7 @@ public final class CastPlayer extends BasePlayer { // TODO b/364580007 - Populate volume information, and implement Player volume-related // methods. return new DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE) + .setMaxVolume(MAX_VOLUME) .setRoutingControllerId(remoteController.getId()) .build(); } @@ -1676,4 +1764,13 @@ public final class CastPlayer extends BasePlayer { } } } + + private final class CastListener extends Cast.Listener { + + @Override + public void onVolumeChanged() { + updateVolumeAndNotifyIfChanged(); + listeners.flushEvents(); + } + } } 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 7eeb29b27b..7a6d755742 100644 --- a/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java +++ b/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java @@ -72,6 +72,7 @@ import androidx.media3.common.Player.Listener; import androidx.media3.common.Timeline; import androidx.media3.common.util.Assertions; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.gms.cast.Cast; import com.google.android.gms.cast.MediaInfo; import com.google.android.gms.cast.MediaQueueItem; import com.google.android.gms.cast.MediaStatus; @@ -83,6 +84,7 @@ import com.google.android.gms.cast.framework.media.RemoteMediaClient; import com.google.android.gms.common.api.PendingResult; import com.google.android.gms.common.api.ResultCallback; import com.google.common.collect.ImmutableList; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -102,6 +104,7 @@ public class CastPlayerTest { private CastPlayer castPlayer; private DefaultMediaItemConverter mediaItemConverter; + private Cast.Listener castListener; private RemoteMediaClient.Callback remoteMediaClientCallback; @Mock private RemoteMediaClient mockRemoteMediaClient; @@ -117,6 +120,7 @@ public class CastPlayerTest { private ArgumentCaptor> setResultCallbackArgumentCaptor; + @Captor private ArgumentCaptor castListenerArgumentCaptor; @Captor private ArgumentCaptor callbackArgumentCaptor; @Captor private ArgumentCaptor queueItemsArgumentCaptor; @Captor private ArgumentCaptor mediaItemCaptor; @@ -139,6 +143,8 @@ public class CastPlayerTest { mediaItemConverter = new DefaultMediaItemConverter(); castPlayer = new CastPlayer(mockCastContext, mediaItemConverter); castPlayer.addListener(mockListener); + verify(mockCastSession).addCastListener(castListenerArgumentCaptor.capture()); + castListener = castListenerArgumentCaptor.getValue(); verify(mockRemoteMediaClient).registerCallback(callbackArgumentCaptor.capture()); remoteMediaClientCallback = callbackArgumentCaptor.getValue(); } @@ -1398,10 +1404,10 @@ public class CastPlayerTest { assertThat(castPlayer.isCommandAvailable(COMMAND_SET_MEDIA_ITEM)).isTrue(); assertThat(castPlayer.isCommandAvailable(COMMAND_GET_AUDIO_ATTRIBUTES)).isFalse(); assertThat(castPlayer.isCommandAvailable(COMMAND_GET_VOLUME)).isFalse(); - assertThat(castPlayer.isCommandAvailable(COMMAND_GET_DEVICE_VOLUME)).isFalse(); + assertThat(castPlayer.isCommandAvailable(COMMAND_GET_DEVICE_VOLUME)).isTrue(); assertThat(castPlayer.isCommandAvailable(COMMAND_SET_VOLUME)).isFalse(); - assertThat(castPlayer.isCommandAvailable(COMMAND_SET_DEVICE_VOLUME)).isFalse(); - assertThat(castPlayer.isCommandAvailable(COMMAND_ADJUST_DEVICE_VOLUME)).isFalse(); + assertThat(castPlayer.isCommandAvailable(COMMAND_SET_DEVICE_VOLUME)).isTrue(); + assertThat(castPlayer.isCommandAvailable(COMMAND_ADJUST_DEVICE_VOLUME)).isTrue(); assertThat(castPlayer.isCommandAvailable(COMMAND_SET_VIDEO_SURFACE)).isFalse(); assertThat(castPlayer.isCommandAvailable(COMMAND_GET_TEXT)).isFalse(); assertThat(castPlayer.isCommandAvailable(Player.COMMAND_RELEASE)).isTrue(); @@ -1935,6 +1941,40 @@ public class CastPlayerTest { assertThat(deviceInfo.playbackType).isEqualTo(DeviceInfo.PLAYBACK_TYPE_REMOTE); } + @Test + public void setDeviceVolume_updatesCastSessionVolume() throws IOException { + int maxVolume = castPlayer.getDeviceInfo().maxVolume; + int volumeToSet = 10; + castPlayer.addListener(mockListener); + castPlayer.setDeviceVolume(volumeToSet, /* flags= */ 0); + + verify(mockListener, times(1)).onDeviceVolumeChanged(volumeToSet, /* muted= */ false); + verify(mockCastSession).setVolume((double) volumeToSet / maxVolume); + assertThat(castPlayer.getDeviceVolume()).isEqualTo(volumeToSet); + + double newCastSessionVolume = .25; + int expectedDeviceVolume = (int) (newCastSessionVolume * maxVolume); + when(mockCastSession.getVolume()).thenReturn(newCastSessionVolume); + castListener.onVolumeChanged(); + assertThat(castPlayer.getDeviceVolume()).isEqualTo(expectedDeviceVolume); + verify(mockListener, times(1)).onDeviceVolumeChanged(volumeToSet, /* muted= */ false); + } + + @Test + public void setDeviceMuted_mutesCastSession() throws IOException { + castPlayer.addListener(mockListener); + castPlayer.setDeviceMuted(true, /* flags= */ 0); + + verify(mockListener, times(1)).onDeviceVolumeChanged(0, /* muted= */ true); + verify(mockCastSession).setMute(true); + assertThat(castPlayer.isDeviceMuted()).isEqualTo(true); + + when(mockCastSession.isMute()).thenReturn(false); + castListener.onVolumeChanged(); + assertThat(castPlayer.isDeviceMuted()).isEqualTo(false); + verify(mockListener, times(1)).onDeviceVolumeChanged(0, /* muted= */ false); + } + private int[] createMediaQueueItemIds(int numberOfIds) { int[] mediaQueueItemIds = new int[numberOfIds]; for (int i = 0; i < numberOfIds; i++) {