Skip to content
2 changes: 2 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
* MIDI extension:
* Leanback extension:
* Cast extension:
* Add support for `setVolume()`, and `getVolume()`
([#2279](https://github.com/androidx/media/pull/2279)).
* Test Utilities:
* Add `advance(player).untilPositionAtLeast` and `untilMediaItemIndex` to
`TestPlayerRunHelper` in order to advance the player until a specified
Expand Down
90 changes: 77 additions & 13 deletions libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ public final class CastPlayer extends BasePlayer {
public static final DeviceInfo DEVICE_INFO_REMOTE_EMPTY =
new DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE).setMaxVolume(MAX_VOLUME).build();

private static final Range<Integer> VOLUME_RANGE = new Range<>(0, MAX_VOLUME);
private static final Range<Integer> RANGE_DEVICE_VOLUME = new Range<>(0, MAX_VOLUME);
private static final Range<Float> RANGE_VOLUME = new Range<>(0.f, 1.f);

static {
MediaLibraryInfo.registerModule("media3.cast");
Expand Down Expand Up @@ -178,6 +179,7 @@ public final class CastPlayer extends BasePlayer {
private final StateHolder<Integer> repeatMode;
private boolean isMuted;
private int deviceVolume;
private final StateHolder<Float> volume;
private final StateHolder<PlaybackParameters> playbackParameters;
@Nullable private CastSession castSession;
@Nullable private RemoteMediaClient remoteMediaClient;
Expand Down Expand Up @@ -293,6 +295,7 @@ public CastPlayer(
playWhenReady = new StateHolder<>(false);
repeatMode = new StateHolder<>(REPEAT_MODE_OFF);
deviceVolume = MAX_VOLUME;
volume = new StateHolder<>(1f);
playbackParameters = new StateHolder<>(PlaybackParameters.DEFAULT);
playbackState = STATE_IDLE;
currentTimeline = CastTimeline.EMPTY_CAST_TIMELINE;
Expand Down Expand Up @@ -781,14 +784,34 @@ public AudioAttributes getAudioAttributes() {
return AudioAttributes.DEFAULT;
}

/** This method is not supported and does nothing. */
@Override
public void setVolume(float volume) {}
public void setVolume(float volume) {
if (remoteMediaClient == null) {
return;
}
// We update the local state and send the message to the receiver app, which will cause the
// operation to be perceived as synchronous by the user. When the operation reports a result,
// the local state will be updated to reflect the state reported by the Cast SDK.
volume = RANGE_VOLUME.clamp(volume);
setVolumeAndNotifyIfChanged(volume);
listeners.flushEvents();
PendingResult<MediaChannelResult> pendingResult = remoteMediaClient.setStreamVolume(volume);
this.volume.pendingResultCallback =
new ResultCallback<MediaChannelResult>() {
@Override
public void onResult(MediaChannelResult result) {
if (remoteMediaClient != null) {
updateVolumeAndNotifyIfChanged(this);
listeners.flushEvents();
}
}
};
pendingResult.setResultCallback(this.volume.pendingResultCallback);
}

/** This method is not supported and returns 1. */
@Override
public float getVolume() {
return 1;
return volume.value;
}

/** This method is not supported and does nothing. */
Expand Down Expand Up @@ -880,7 +903,7 @@ public void setDeviceVolume(@IntRange(from = 0) int volume, @C.VolumeFlags int f
if (castSession == null) {
return;
}
volume = VOLUME_RANGE.clamp(volume);
volume = RANGE_DEVICE_VOLUME.clamp(volume);
try {
// See [Internal ref: b/399691860] for context on why we don't use
// RemoteMediaClient.setStreamVolume.
Expand Down Expand Up @@ -969,8 +992,9 @@ private void updateInternalStateAndNotifyIfChanged() {
? getCurrentTimeline().getPeriod(oldWindowIndex, period, /* setIds= */ true).uid
: null;
updatePlayerStateAndNotifyIfChanged(/* resultCallback= */ null);
updateVolumeAndNotifyIfChanged();
updateDeviceVolumeAndNotifyIfChanged();
updateRepeatModeAndNotifyIfChanged(/* resultCallback= */ null);
updateVolumeAndNotifyIfChanged(/* resultCallback= */ null);
updatePlaybackRateAndNotifyIfChanged(/* resultCallback= */ null);
boolean playingPeriodChangedByTimelineChange = updateTimelineAndNotifyIfChanged();
Timeline currentTimeline = getCurrentTimeline();
Expand Down Expand Up @@ -1079,13 +1103,23 @@ private void updatePlaybackRateAndNotifyIfChanged(@Nullable ResultCallback<?> re
}

@RequiresNonNull("castSession")
private void updateVolumeAndNotifyIfChanged() {
private void updateDeviceVolumeAndNotifyIfChanged() {
if (castSession != null) {
int deviceVolume = VOLUME_RANGE.clamp((int) Math.round(castSession.getVolume() * MAX_VOLUME));
int deviceVolume =
RANGE_DEVICE_VOLUME.clamp((int) Math.round(castSession.getVolume() * MAX_VOLUME));
setDeviceVolumeAndNotifyIfChanged(deviceVolume, castSession.isMute());
}
}

@RequiresNonNull("remoteMediaClient")
private void updateVolumeAndNotifyIfChanged(@Nullable ResultCallback<?> resultCallback) {
if (volume.acceptsUpdate(resultCallback)) {
float remoteVolume = RANGE_VOLUME.clamp(fetchVolume(remoteMediaClient));
setVolumeAndNotifyIfChanged(remoteVolume);
volume.clearPendingResultCallback();
}
}

@RequiresNonNull("remoteMediaClient")
private void updateRepeatModeAndNotifyIfChanged(@Nullable ResultCallback<?> resultCallback) {
if (repeatMode.acceptsUpdate(resultCallback)) {
Expand Down Expand Up @@ -1229,14 +1263,29 @@ private boolean updateTracksAndSelectionsAndNotifyIfChanged() {

private void updateAvailableCommandsAndNotifyIfChanged() {
Commands previousAvailableCommands = availableCommands;
availableCommands = Util.getAvailableCommands(/* player= */ this, PERMANENT_AVAILABLE_COMMANDS);
availableCommands =
Util.getAvailableCommands(/* player= */ this, PERMANENT_AVAILABLE_COMMANDS)
.buildUpon()
.addIf(COMMAND_GET_VOLUME, isSetVolumeCommandAvailable())
.addIf(COMMAND_SET_VOLUME, isSetVolumeCommandAvailable())
.build();
if (!availableCommands.equals(previousAvailableCommands)) {
listeners.queueEvent(
Player.EVENT_AVAILABLE_COMMANDS_CHANGED,
listener -> listener.onAvailableCommandsChanged(availableCommands));
}
}

private boolean isSetVolumeCommandAvailable() {
if (remoteMediaClient != null) {
MediaStatus mediaStatus = remoteMediaClient.getMediaStatus();
if (mediaStatus != null) {
return mediaStatus.isMediaCommandSupported(MediaStatus.COMMAND_SET_VOLUME);
}
}
return false;
}

private void setMediaItemsInternal(
List<MediaItem> mediaItems,
int startIndex,
Expand Down Expand Up @@ -1347,6 +1396,15 @@ private void setRepeatModeAndNotifyIfChanged(@Player.RepeatMode int repeatMode)
}
}

private void setVolumeAndNotifyIfChanged(float volume) {
if (this.volume.value != volume) {
this.volume.value = volume;
listeners.queueEvent(
Player.EVENT_VOLUME_CHANGED, listener -> listener.onVolumeChanged(volume));
updateAvailableCommandsAndNotifyIfChanged();
}
}

private void setPlaybackParametersAndNotifyIfChanged(PlaybackParameters playbackParameters) {
if (this.playbackParameters.value.equals(playbackParameters)) {
return;
Expand Down Expand Up @@ -1470,6 +1528,14 @@ private static int fetchPlaybackState(RemoteMediaClient remoteMediaClient) {
}
}

private static float fetchVolume(RemoteMediaClient remoteMediaClient) {
MediaStatus mediaStatus = remoteMediaClient.getMediaStatus();
if (mediaStatus == null) {
return 1f;
}
return (float) mediaStatus.getStreamVolume();
}

private static int fetchCurrentWindowIndex(
@Nullable RemoteMediaClient remoteMediaClient, Timeline timeline) {
if (remoteMediaClient == null) {
Expand Down Expand Up @@ -1734,8 +1800,6 @@ public DeviceInfo fetchDeviceInfo() {
// 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)
.setMaxVolume(MAX_VOLUME)
.setRoutingControllerId(remoteController.getId())
Expand Down Expand Up @@ -1774,7 +1838,7 @@ private final class CastListener extends Cast.Listener {

@Override
public void onVolumeChanged() {
updateVolumeAndNotifyIfChanged();
updateDeviceVolumeAndNotifyIfChanged();
listeners.flushEvents();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package androidx.media3.cast;

import static androidx.media3.common.Player.COMMAND_ADJUST_DEVICE_VOLUME;
import static androidx.media3.common.Player.COMMAND_ADJUST_DEVICE_VOLUME_WITH_FLAGS;
import static androidx.media3.common.Player.COMMAND_CHANGE_MEDIA_ITEMS;
import static androidx.media3.common.Player.COMMAND_GET_AUDIO_ATTRIBUTES;
import static androidx.media3.common.Player.COMMAND_GET_CURRENT_MEDIA_ITEM;
Expand All @@ -26,6 +27,7 @@
import static androidx.media3.common.Player.COMMAND_GET_VOLUME;
import static androidx.media3.common.Player.COMMAND_PLAY_PAUSE;
import static androidx.media3.common.Player.COMMAND_PREPARE;
import static androidx.media3.common.Player.COMMAND_RELEASE;
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_IN_CURRENT_MEDIA_ITEM;
Expand All @@ -36,6 +38,7 @@
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM;
import static androidx.media3.common.Player.COMMAND_SET_DEVICE_VOLUME;
import static androidx.media3.common.Player.COMMAND_SET_DEVICE_VOLUME_WITH_FLAGS;
import static androidx.media3.common.Player.COMMAND_SET_MEDIA_ITEM;
import static androidx.media3.common.Player.COMMAND_SET_PLAYLIST_METADATA;
import static androidx.media3.common.Player.COMMAND_SET_REPEAT_MODE;
Expand All @@ -48,6 +51,7 @@
import static androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
Expand Down Expand Up @@ -139,6 +143,7 @@ public void setUp() {
// Make the remote media client present the same default values as ExoPlayer:
when(mockRemoteMediaClient.isPaused()).thenReturn(true);
when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_OFF);
when(mockMediaStatus.getStreamVolume()).thenReturn(1.0);
when(mockMediaStatus.getPlaybackRate()).thenReturn(1.0d);
mediaItemConverter = new DefaultMediaItemConverter();
castPlayer = new CastPlayer(mockCastContext, mediaItemConverter);
Expand Down Expand Up @@ -390,6 +395,60 @@ public void repeatMode_changesOnStatusUpdates() {
assertThat(castPlayer.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ONE);
}

@Test
public void setVolume_masksRemoteState() {
when(mockRemoteMediaClient.setStreamVolume(anyDouble())).thenReturn(mockPendingResult);
assertThat(castPlayer.getVolume()).isEqualTo(1f);

castPlayer.setVolume(0.5f);
verify(mockPendingResult).setResultCallback(setResultCallbackArgumentCaptor.capture());
assertThat(castPlayer.getVolume()).isEqualTo(0.5f);
verify(mockListener).onVolumeChanged(0.5f);

// There is a status update in the middle, which should be hidden by masking.
when(mockMediaStatus.getStreamVolume()).thenReturn(0.75);
remoteMediaClientCallback.onStatusUpdated();
verifyNoMoreInteractions(mockListener);

// Upon result, the mediaStatus now exposes the new volume.
when(mockMediaStatus.getStreamVolume()).thenReturn(0.5);
setResultCallbackArgumentCaptor
.getValue()
.onResult(mock(RemoteMediaClient.MediaChannelResult.class));
verifyNoMoreInteractions(mockListener);
}

@Test
public void setVolume_updatesUponResultChange() {
when(mockRemoteMediaClient.setStreamVolume(anyDouble())).thenReturn(mockPendingResult);

castPlayer.setVolume(0.5f);
verify(mockPendingResult).setResultCallback(setResultCallbackArgumentCaptor.capture());
assertThat(castPlayer.getVolume()).isEqualTo(0.5f);
verify(mockListener).onVolumeChanged(0.5f);

// There is a status update in the middle, which should be hidden by masking.
when(mockMediaStatus.getStreamVolume()).thenReturn(0.75);
remoteMediaClientCallback.onStatusUpdated();
verifyNoMoreInteractions(mockListener);

// Upon result, the volume is 0.75. The state should reflect that.
setResultCallbackArgumentCaptor
.getValue()
.onResult(mock(RemoteMediaClient.MediaChannelResult.class));
verify(mockListener).onVolumeChanged(0.75f);
assertThat(castPlayer.getVolume()).isEqualTo(0.75f);
}

@Test
public void volume_changesOnStatusUpdates() {
assertThat(castPlayer.getVolume()).isEqualTo(1f);
when(mockMediaStatus.getStreamVolume()).thenReturn(0.75);
remoteMediaClientCallback.onStatusUpdated();
verify(mockListener).onVolumeChanged(0.75f);
assertThat(castPlayer.getVolume()).isEqualTo(0.75f);
}

@Test
public void setMediaItems_callsRemoteMediaClient() {
List<MediaItem> mediaItems = new ArrayList<>();
Expand Down Expand Up @@ -1410,7 +1469,27 @@ public void isCommandAvailable_isTrueForAvailableCommands() {
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();
assertThat(castPlayer.isCommandAvailable(COMMAND_RELEASE)).isTrue();
}

@Test
public void isCommandAvailable_setVolumeIsSupported() {
when(mockMediaStatus.isMediaCommandSupported(MediaStatus.COMMAND_SET_VOLUME)).thenReturn(true);

int[] mediaQueueItemIds = new int[] {1, 2};
List<MediaItem> mediaItems = createMediaItems(mediaQueueItemIds);

castPlayer.addMediaItems(mediaItems);
updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1);

assertThat(castPlayer.isCommandAvailable(COMMAND_GET_AUDIO_ATTRIBUTES)).isFalse();
assertThat(castPlayer.isCommandAvailable(COMMAND_GET_VOLUME)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_GET_DEVICE_VOLUME)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SET_VOLUME)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SET_DEVICE_VOLUME)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SET_DEVICE_VOLUME_WITH_FLAGS)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_ADJUST_DEVICE_VOLUME)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_ADJUST_DEVICE_VOLUME_WITH_FLAGS)).isTrue();
}

@Test
Expand Down