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++) {