diff --git a/RELEASENOTES.md b/RELEASENOTES.md index e0094ef1977..6b51c746ebb 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -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 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 c88c8e1b113..9aa5d250c5c 100644 --- a/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java +++ b/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java @@ -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 VOLUME_RANGE = new Range<>(0, MAX_VOLUME); + private static final Range RANGE_DEVICE_VOLUME = new Range<>(0, MAX_VOLUME); + private static final Range RANGE_VOLUME = new Range<>(0.f, 1.f); static { MediaLibraryInfo.registerModule("media3.cast"); @@ -178,6 +179,7 @@ public final class CastPlayer extends BasePlayer { private final StateHolder repeatMode; private boolean isMuted; private int deviceVolume; + private final StateHolder volume; private final StateHolder playbackParameters; @Nullable private CastSession castSession; @Nullable private RemoteMediaClient remoteMediaClient; @@ -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; @@ -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 pendingResult = remoteMediaClient.setStreamVolume(volume); + this.volume.pendingResultCallback = + new ResultCallback() { + @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. */ @@ -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. @@ -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(); @@ -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)) { @@ -1229,7 +1263,12 @@ 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, @@ -1237,6 +1276,16 @@ private void updateAvailableCommandsAndNotifyIfChanged() { } } + 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 mediaItems, int startIndex, @@ -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; @@ -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) { @@ -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()) @@ -1774,7 +1838,7 @@ private final class CastListener extends Cast.Listener { @Override public void onVolumeChanged() { - updateVolumeAndNotifyIfChanged(); + updateDeviceVolumeAndNotifyIfChanged(); 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 33bdaf7d33d..161e072db85 100644 --- a/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java +++ b/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java @@ -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; @@ -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; @@ -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; @@ -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; @@ -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); @@ -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 mediaItems = new ArrayList<>(); @@ -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 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