From d93e42fa1f6703a9910746f07992892a719a7e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Thu, 27 Mar 2025 15:31:06 +0100 Subject: [PATCH 1/4] Implement `getVolume()`/`setVolume()` on `CastPlayer` --- .../java/androidx/media3/cast/CastPlayer.java | 77 +++++++++++-- .../androidx/media3/cast/CastPlayerTest.java | 101 +++++++++++++++++- 2 files changed, 170 insertions(+), 8 deletions(-) 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 c88c8e1b11..6ef0d20e41 100644 --- a/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java +++ b/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java @@ -35,6 +35,7 @@ import android.view.SurfaceView; import android.view.TextureView; import androidx.annotation.IntRange; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.VisibleForTesting; @@ -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,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 pendingResult = remoteMediaClient.setStreamVolume(volume); + this.volume.pendingResultCallback = + new ResultCallback() { + @Override + public void onResult(@NonNull 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 +993,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 +1117,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,7 +1260,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 +1273,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 +1393,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 +1525,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 +1798,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()) 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 33bdaf7d33..8dc396bdfa 100644 --- a/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java +++ b/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java @@ -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 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 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 From b6717f91c8bc3d6222d54f354b510633900fa1fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Thu, 27 Mar 2025 19:28:18 +0100 Subject: [PATCH 2/4] Update release notes --- RELEASENOTES.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 5f9cf162c2..46f51148e7 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -193,8 +193,11 @@ 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)). + * Add support for `setVolume()`, and `getVolume()` + ([#2279](https://github.com/androidx/media/pull/2279)). * Test Utilities: * Removed `transformer.TestUtil.addAudioDecoders(String...)`, `transformer.TestUtil.addAudioEncoders(String...)`, and From 814bef644c3afcef25e325f5b312a15038791b4b Mon Sep 17 00:00:00 2001 From: Marc Baechinger Date: Wed, 9 Apr 2025 17:08:19 +0200 Subject: [PATCH 3/4] remove redundant @NonNull annotation --- .../cast/src/main/java/androidx/media3/cast/CastPlayer.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 6ef0d20e41..7050909018 100644 --- a/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java +++ b/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java @@ -35,7 +35,6 @@ import android.view.SurfaceView; import android.view.TextureView; import androidx.annotation.IntRange; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.VisibleForTesting; @@ -798,7 +797,7 @@ public void setVolume(float volume) { this.volume.pendingResultCallback = new ResultCallback() { @Override - public void onResult(@NonNull MediaChannelResult result) { + public void onResult(MediaChannelResult result) { if (remoteMediaClient != null) { updateVolumeAndNotifyIfChanged(this); listeners.flushEvents(); From 631177a1b6e2c29873695d626f0097b16dc7afff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Thu, 15 May 2025 16:11:08 +0200 Subject: [PATCH 4/4] Move release notes to unreleased --- RELEASENOTES.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 46f51148e7..8b8a8cd129 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -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: @@ -196,8 +198,6 @@ This release includes the following changes since [1.6.1](#161-2025-04-14): `increaseDeviceVolume()`, `decreaseDeviceVolume()`, `isDeviceMuted()`, and `setDeviceMuted()` ([#2089](https://github.com/androidx/media/issues/2089)). - * Add support for `setVolume()`, and `getVolume()` - ([#2279](https://github.com/androidx/media/pull/2279)). * Test Utilities: * Removed `transformer.TestUtil.addAudioDecoders(String...)`, `transformer.TestUtil.addAudioEncoders(String...)`, and