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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
5 changes: 4 additions & 1 deletion RELEASENOTES.md
Original file line number Diff line number Diff line change
@@ -34,6 +34,8 @@
* MIDI extension:
* Leanback extension:
* Cast extension:
* Add support for `setVolume()`, and `getVolume()`
([#2279](https://github.com/androidx/media/pull/2279)).
* Test Utilities:
* Remove deprecated symbols:

@@ -193,7 +195,8 @@ This release includes the following changes since [1.6.1](#161-2025-04-14):
([#2357](https://github.com/androidx/media/issues/2357)).
* Cast extension:
* Add support for `getDeviceVolume()`, `setDeviceVolume()`,
`getDeviceMuted()`, and `setDeviceMuted()`
`increaseDeviceVolume()`, `decreaseDeviceVolume()`,
`isDeviceMuted()`, and `setDeviceMuted()`
([#2089](https://github.com/androidx/media/issues/2089)).
* Test Utilities:
* Removed `transformer.TestUtil.addAudioDecoders(String...)`,
76 changes: 69 additions & 7 deletions libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java
Original file line number Diff line number Diff line change
@@ -178,6 +178,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;
@@ -293,6 +294,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;
@@ -781,14 +783,33 @@ 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.
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. */
@@ -971,6 +992,7 @@ private void updateInternalStateAndNotifyIfChanged() {
updatePlayerStateAndNotifyIfChanged(/* resultCallback= */ null);
updateVolumeAndNotifyIfChanged();
updateRepeatModeAndNotifyIfChanged(/* resultCallback= */ null);
updateVolumeAndNotifyIfChanged(/* resultCallback= */ null);
updatePlaybackRateAndNotifyIfChanged(/* resultCallback= */ null);
boolean playingPeriodChangedByTimelineChange = updateTimelineAndNotifyIfChanged();
Timeline currentTimeline = getCurrentTimeline();
@@ -1094,6 +1116,14 @@ private void updateRepeatModeAndNotifyIfChanged(@Nullable ResultCallback<?> resu
}
}

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

/**
* Updates the timeline and notifies {@link Player.Listener event listeners} if required.
*
@@ -1229,14 +1259,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,
@@ -1347,6 +1392,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;
@@ -1470,6 +1524,15 @@ private static int fetchPlaybackState(RemoteMediaClient remoteMediaClient) {
}
}

private static float fetchVolume(RemoteMediaClient remoteMediaClient) {
MediaStatus mediaStatus = remoteMediaClient.getMediaStatus();
if (mediaStatus == null) {
// No media session active, yet.
return 1f;
}
return (float) mediaStatus.getStreamVolume();
}

private static int fetchCurrentWindowIndex(
@Nullable RemoteMediaClient remoteMediaClient, Timeline timeline) {
if (remoteMediaClient == null) {
@@ -1734,8 +1797,7 @@ 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.
// TODO b/364580007 - Populate min volume information.
return new DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE)
.setMaxVolume(MAX_VOLUME)
.setRoutingControllerId(remoteController.getId())
101 changes: 100 additions & 1 deletion libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java
Original file line number Diff line number Diff line change
@@ -26,6 +26,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;
@@ -48,6 +49,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;
@@ -139,6 +141,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);
@@ -390,6 +393,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<>();
@@ -1410,7 +1467,49 @@ 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_PLAY_PAUSE)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_PREPARE)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_STOP)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SEEK_TO_DEFAULT_POSITION)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)).isFalse();
assertThat(castPlayer.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SEEK_TO_NEXT)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SEEK_TO_MEDIA_ITEM)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SEEK_BACK)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SEEK_FORWARD)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SET_SPEED_AND_PITCH)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SET_SHUFFLE_MODE)).isFalse();
assertThat(castPlayer.isCommandAvailable(COMMAND_SET_REPEAT_MODE)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_GET_CURRENT_MEDIA_ITEM)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_GET_TIMELINE)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_GET_METADATA)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SET_PLAYLIST_METADATA)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_CHANGE_MEDIA_ITEMS)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SET_MEDIA_ITEM)).isTrue();
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_ADJUST_DEVICE_VOLUME)).isTrue();
assertThat(castPlayer.isCommandAvailable(COMMAND_SET_VIDEO_SURFACE)).isFalse();
assertThat(castPlayer.isCommandAvailable(COMMAND_GET_TEXT)).isFalse();
assertThat(castPlayer.isCommandAvailable(COMMAND_RELEASE)).isTrue();
}

@Test