diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 769304f485..72ad4efbc7 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -19,12 +19,9 @@ `ExoPlayer.Builder.setSuppressPlaybackOnUnsuitableOutput`. The playback suppression reason will be updated as `Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT` if playback - is attempted when no suitable audio outputs are available. - * Add handling for auto-resume or auto-pause of playback when audio output - devices are added or removed dynamically during suppressed or ongoing - playback when the playback suppression due to no suitable output has - been enabled via - `ExoPlayer.Builder.setSuppressPlaybackOnUnsuitableOutput`. + is attempted when no suitable audio outputs are available, or if all + suitable outputs are disconnected during playback. The suppression + reason will be removed when a suitable output is connected. * Fix issue in `PlaybackStatsListener` where spurious `PlaybackStats` are created after the playlist is cleared. * Transformer: @@ -100,12 +97,6 @@ tests and Compose UI tests. This fixes a bug where playback advances non-deterministically during Espresso or Compose view interactions. * Remove deprecated symbols: - * Remove - `TransformationRequest.Builder.setEnableRequestSdrToneMapping(boolean)` - and - `TransformationRequest.Builder.experimental_setEnableHdrEditing(boolean)`. - Use `Composition.Builder.setHdrMode(int)` and pass the `Composition` to - `Transformer.start(Composition, String)` instead. ## 1.1 diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java index be93e754aa..9beab13c4f 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java @@ -723,6 +723,9 @@ public interface ExoPlayer extends Player { * Player.Listener#onPlaybackSuppressionReasonChanged(int)} with the value {@link * Player#PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT}. * + *

Callers of this may also want to enable {@link #setHandleAudioBecomingNoisy(boolean)} to + * prevent playback from continuing on the built-in speaker when a headset is disconnected. + * * @param suppressPlaybackOnUnsuitableOutput Whether the player should suppress the playback * when it is attempted on an unsuitable output. * @return This builder. diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java index 2cd0aee364..ae37756eca 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java @@ -2748,6 +2748,14 @@ import java.util.concurrent.TimeoutException; && playbackInfo.playbackSuppressionReason == playbackSuppressionReason) { return; } + updatePlaybackInfoForPlayWhenReadyAndSuppressionReasonStates( + playWhenReady, playWhenReadyChangeReason, playbackSuppressionReason); + } + + private void updatePlaybackInfoForPlayWhenReadyAndSuppressionReasonStates( + boolean playWhenReady, + @Player.PlayWhenReadyChangeReason int playWhenReadyChangeReason, + @PlaybackSuppressionReason int playbackSuppressionReason) { pendingOperationAcks++; // Position estimation and copy must occur before changing/masking playback state. PlaybackInfo newPlaybackInfo = @@ -3368,14 +3376,20 @@ import java.util.concurrent.TimeoutException; if (hasSupportedAudioOutput() && playbackInfo.playbackSuppressionReason == Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT) { - play(); + updatePlaybackInfoForPlayWhenReadyAndSuppressionReasonStates( + playbackInfo.playWhenReady, + PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + Player.PLAYBACK_SUPPRESSION_REASON_NONE); } } @Override public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) { if (!hasSupportedAudioOutput()) { - pause(); + updatePlaybackInfoForPlayWhenReadyAndSuppressionReasonStates( + playbackInfo.playWhenReady, + PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT); } } } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java index 14cdcbd9e3..78eff1f235 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java @@ -13313,151 +13313,119 @@ public final class ExoPlayerTest { player.release(); } + /** + * Tests removal of playback suppression reason as {@link + * Player#PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT} when a suitable device is added. + */ @Test - public void - onAudioDeviceAdded_addSuitableDevicesWhenPlaybackSuppressed_shouldResumeSuppressedPlayback() - throws Exception { + public void addSuitableDevicesWhenPlaybackSuppressed_shouldRemovePlaybackSuppression() + throws Exception { addWatchAsSystemFeature(); setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); ExoPlayer player = new TestExoPlayerBuilder(context).setSuppressPlaybackOnUnsuitableOutput(true).build(); player.setMediaItem( MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); + List playbackSuppressionList = new ArrayList<>(); + player.addListener( + new Player.Listener() { + @Override + public void onPlaybackSuppressionReasonChanged(int playbackSuppressionReason) { + playbackSuppressionList.add(playbackSuppressionReason); + } + }); player.prepare(); player.play(); player.pause(); runUntilPlaybackState(player, Player.STATE_READY); - AtomicBoolean isPlaybackResumed = new AtomicBoolean(false); - player.addListener( - new Player.Listener() { - @Override - public void onPlayWhenReadyChanged( - boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { - if (playWhenReady - && player.getPlaybackSuppressionReason() - != Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT) { - isPlaybackResumed.set(true); - } - } - }); addConnectedAudioOutput( AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, /* notifyAudioDeviceCallbacks= */ true); player.stop(); runUntilPlaybackState(player, Player.STATE_IDLE); - assertThat(isPlaybackResumed.get()).isTrue(); + assertThat(playbackSuppressionList) + .containsExactly( + Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT, + Player.PLAYBACK_SUPPRESSION_REASON_NONE); player.release(); } + /** + * Tests no change in the playback suppression reason when an unsuitable device is connected while + * playback was suppressed earlier. + */ @Test - public void - onAudioDeviceAdded_addUnsuitableDevicesWithPlaybackSuppressed_shouldNotResumePlayback() - throws Exception { + public void addUnsuitableDevicesWithPlaybackSuppressed_shouldNotRemovePlaybackSuppression() + throws Exception { addWatchAsSystemFeature(); setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); ExoPlayer player = new TestExoPlayerBuilder(context).setSuppressPlaybackOnUnsuitableOutput(true).build(); player.setMediaItem( MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); - player.prepare(); - runUntilPlaybackState(player, Player.STATE_READY); - AtomicBoolean isPlaybackResumed = new AtomicBoolean(false); + List playbackSuppressionList = new ArrayList<>(); player.addListener( new Player.Listener() { @Override - public void onPlayWhenReadyChanged( - boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { - if (playWhenReady - && player.getPlaybackSuppressionReason() - != Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT) { - isPlaybackResumed.set(true); - } + public void onPlaybackSuppressionReasonChanged(int playbackSuppressionReason) { + playbackSuppressionList.add(playbackSuppressionReason); } }); + player.prepare(); + player.play(); + runUntilPlaybackState(player, Player.STATE_READY); addConnectedAudioOutput(AudioDeviceInfo.TYPE_UNKNOWN, /* notifyAudioDeviceCallbacks= */ true); player.stop(); runUntilPlaybackState(player, Player.STATE_IDLE); - assertThat(isPlaybackResumed.get()).isFalse(); + assertThat(playbackSuppressionList) + .containsExactly(Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT); player.release(); } + /** + * Tests no change in the playback suppression reason when a suitable device is added but playback + * was not suppressed earlier. + */ @Test - public void - onAudioDeviceAdded_addSuitableDevicesWhenPlaybackNotSuppressed_shouldNotResumePlayback() - throws Exception { + public void addSuitableDevicesWhenPlaybackNotSuppressed_shouldNotRemovePlaybackSuppression() + throws Exception { addWatchAsSystemFeature(); setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); ExoPlayer player = new TestExoPlayerBuilder(context).setSuppressPlaybackOnUnsuitableOutput(true).build(); player.setMediaItem( MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); - player.prepare(); - runUntilPlaybackState(player, Player.STATE_READY); - AtomicBoolean isPlaybackResumed = new AtomicBoolean(false); + List playbackSuppressionList = new ArrayList<>(); player.addListener( new Player.Listener() { @Override - public void onPlayWhenReadyChanged( - boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { - if (playWhenReady - && player.getPlaybackSuppressionReason() - != Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT) { - isPlaybackResumed.set(true); - } + public void onPlaybackSuppressionReasonChanged(int playbackSuppressionReason) { + playbackSuppressionList.add(playbackSuppressionReason); } }); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); addConnectedAudioOutput( AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, /* notifyAudioDeviceCallbacks= */ true); player.stop(); runUntilPlaybackState(player, Player.STATE_IDLE); - assertThat(isPlaybackResumed.get()).isFalse(); + assertThat(playbackSuppressionList).isEmpty(); player.release(); } + /** + * Tests change in the playback suppression reason as {@link + * Player#PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT} when all the suitable audio outputs + * have been removed during an ongoing playback. + */ @Test - public void onAudioDeviceAdded_addSuitableDevicesOnNonWearSurface_shouldResumeSuppressedPlayback() + public void removeAllSuitableDevicesWhenPlaybackOngoing_shouldSetPlaybackSuppression() throws Exception { - setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); - ExoPlayer player = - new TestExoPlayerBuilder(context).setSuppressPlaybackOnUnsuitableOutput(true).build(); - player.setMediaItem( - MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); - player.prepare(); - player.play(); - player.pause(); - runUntilPlaybackState(player, Player.STATE_READY); - AtomicBoolean isPlaybackResumed = new AtomicBoolean(false); - player.addListener( - new Player.Listener() { - @Override - public void onPlayWhenReadyChanged( - boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { - if (playWhenReady - && player.getPlaybackSuppressionReason() - != Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT) { - isPlaybackResumed.set(true); - } - } - }); - - addConnectedAudioOutput( - AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, /* notifyAudioDeviceCallbacks= */ true); - player.stop(); - runUntilPlaybackState(player, Player.STATE_IDLE); - - assertThat(isPlaybackResumed.get()).isFalse(); - player.release(); - } - - @Test - public void - onAudioDeviceRemoved_removeSuitableDeviceWhenPlaybackOngoing_shouldPauseOngoingPlayback() - throws Exception { addWatchAsSystemFeature(); setupConnectedAudioOutput( AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP); @@ -13468,15 +13436,12 @@ public final class ExoPlayerTest { player.prepare(); player.play(); runUntilPlaybackState(player, Player.STATE_READY); - AtomicBoolean isPlaybackPaused = new AtomicBoolean(false); + List playbackSuppressionList = new ArrayList<>(); player.addListener( new Player.Listener() { @Override - public void onPlayWhenReadyChanged( - boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { - if (!playWhenReady) { - isPlaybackPaused.set(true); - } + public void onPlaybackSuppressionReasonChanged(int playbackSuppressionReason) { + playbackSuppressionList.add(playbackSuppressionReason); } }); @@ -13484,14 +13449,18 @@ public final class ExoPlayerTest { player.stop(); runUntilPlaybackState(player, Player.STATE_IDLE); - assertThat(isPlaybackPaused.get()).isTrue(); + assertThat(playbackSuppressionList) + .containsExactly(Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT); player.release(); } + /** + * Tests no change in the playback suppression reason when any unsuitable audio outputs has been + * removed during an ongoing playback. + */ @Test - public void - onAudioDeviceRemoved_removeUnsuitableDeviceLeavingOneSuitableDevice_shouldNotPausePlayback() - throws Exception { + public void removeAnyUnsuitableDevicesWhenPlaybackOngoing_shouldNotSetPlaybackSuppression() + throws Exception { addWatchAsSystemFeature(); setupConnectedAudioOutput( AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, @@ -13505,15 +13474,12 @@ public final class ExoPlayerTest { player.prepare(); player.play(); runUntilPlaybackState(player, Player.STATE_READY); - AtomicBoolean isPlaybackPaused = new AtomicBoolean(false); + List playbackSuppressionList = new ArrayList<>(); player.addListener( new Player.Listener() { @Override - public void onPlayWhenReadyChanged( - boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { - if (!playWhenReady) { - isPlaybackPaused.set(true); - } + public void onPlaybackSuppressionReasonChanged(int playbackSuppressionReason) { + playbackSuppressionList.add(playbackSuppressionReason); } }); @@ -13522,13 +13488,18 @@ public final class ExoPlayerTest { player.stop(); runUntilPlaybackState(player, Player.STATE_IDLE); - assertThat(isPlaybackPaused.get()).isFalse(); + assertThat(playbackSuppressionList).isEmpty(); player.release(); } + /** + * Tests no change in the playback suppression reason when any suitable audio outputs has been + * removed during an ongoing playback but at least one suitable audio output is still connected to + * the device. + */ @Test public void - onAudioDeviceRemoved_removeSuitableDeviceLeavingOneSuitableDevice_shouldNotPausePlayback() + removeAnySuitableDeviceButOneSuitableDeviceStillConnected_shouldNotSetPlaybackSuppression() throws Exception { addWatchAsSystemFeature(); setupConnectedAudioOutput( @@ -13542,15 +13513,12 @@ public final class ExoPlayerTest { player.prepare(); player.play(); runUntilPlaybackState(player, Player.STATE_READY); - AtomicBoolean isPlaybackPaused = new AtomicBoolean(false); + List playbackSuppressionList = new ArrayList<>(); player.addListener( new Player.Listener() { @Override - public void onPlayWhenReadyChanged( - boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { - if (!playWhenReady) { - isPlaybackPaused.set(true); - } + public void onPlaybackSuppressionReasonChanged(int playbackSuppressionReason) { + playbackSuppressionList.add(playbackSuppressionReason); } }); @@ -13558,40 +13526,7 @@ public final class ExoPlayerTest { player.stop(); runUntilPlaybackState(player, Player.STATE_IDLE); - assertThat(isPlaybackPaused.get()).isFalse(); - player.release(); - } - - @Test - public void - onAudioDeviceRemoved_removeSuitableDeviceOnNonWearSurface_shouldNotPauseOngoingPlayback() - throws Exception { - setupConnectedAudioOutput( - AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP); - ExoPlayer player = - new TestExoPlayerBuilder(context).setSuppressPlaybackOnUnsuitableOutput(true).build(); - player.setMediaItem( - MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); - player.prepare(); - player.play(); - runUntilPlaybackState(player, Player.STATE_READY); - AtomicBoolean isPlaybackPaused = new AtomicBoolean(false); - player.addListener( - new Player.Listener() { - @Override - public void onPlayWhenReadyChanged( - boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { - if (!playWhenReady) { - isPlaybackPaused.set(true); - } - } - }); - - removeConnectedAudioOutput(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP); - player.stop(); - runUntilPlaybackState(player, Player.STATE_IDLE); - - assertThat(isPlaybackPaused.get()).isFalse(); + assertThat(playbackSuppressionList).isEmpty(); player.release(); }