Skip to content

Add volume management to CastPlayer #2279

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
May 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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