From 6e46234589d700b1efc00439d0fc909a6ccdcd66 Mon Sep 17 00:00:00 2001 From: Googler Date: Mon, 12 Jun 2023 06:39:11 +0000 Subject: [PATCH] Implement AudioDeviceCallbacks to auto-resume & auto-pause when suitable devices are added or removed respectively with playback suppression feature enabled. With playback suppression in place, the devices can be added when the playback on ExoPlayer is in the suppression state. Also, it is quite possible that a suitable audio output device on which playback is ongoing gets removed requiring the Player to pause the playback. These requirements can be fullfilled using AudioDeviceCallbacks which has been implemented with this change. PiperOrigin-RevId: 539559193 --- RELEASENOTES.md | 5 + .../media3/exoplayer/ExoPlayerImpl.java | 27 ++ .../media3/exoplayer/ExoPlayerTest.java | 317 ++++++++++++++++++ 3 files changed, 349 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ceeeab2d9f..d9d4111e34 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -33,6 +33,11 @@ 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.setSuppressPlaybackWhenNoSuitableOutputAvailable`. * Transformer: * Parse EXIF rotation data for image inputs. * Track Selection: 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 2d0f8aef44..feb15eaf80 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java @@ -43,6 +43,7 @@ import android.content.Context; import android.content.pm.PackageManager; import android.graphics.Rect; import android.graphics.SurfaceTexture; +import android.media.AudioDeviceCallback; import android.media.AudioDeviceInfo; import android.media.AudioFormat; import android.media.AudioManager; @@ -390,6 +391,8 @@ import java.util.concurrent.TimeoutException; audioFocusManager.setAudioAttributes(builder.handleAudioFocus ? audioAttributes : null); if (suppressPlaybackWhenNoSuitableOutputAvailable) { audioManager = (AudioManager) applicationContext.getSystemService(Context.AUDIO_SERVICE); + audioManager.registerAudioDeviceCallback( + new NoSuitableOutputPlaybackSuppressionAudioDeviceCallback(), /* handler= */ null); } if (builder.deviceVolumeControlEnabled) { streamVolumeManager = @@ -3357,4 +3360,28 @@ import java.util.concurrent.TimeoutException; return packageManager.hasSystemFeature(PackageManager.FEATURE_WATCH); } } + + /** + * A {@link AudioDeviceCallback} to handle auto-resume and auto-pause for playback suppression due + * to no suitable audio output. + */ + private final class NoSuitableOutputPlaybackSuppressionAudioDeviceCallback + extends AudioDeviceCallback { + + @Override + public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) { + if (hasSupportedAudioOutput() + && playbackInfo.playbackSuppressionReason + == Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT) { + play(); + } + } + + @Override + public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) { + if (!hasSupportedAudioOutput()) { + pause(); + } + } + } } 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 f00997e160..a87a610ad0 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java @@ -67,6 +67,7 @@ import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUnti import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilSleepingForOffload; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilTimelineChanged; import static com.google.common.truth.Truth.assertThat; +import static java.util.Arrays.stream; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; @@ -13139,6 +13140,304 @@ public final class ExoPlayerTest { player.release(); } + @Test + public void + onAudioDeviceAdded_addSuitableDevicesWhenPlaybackSuppressed_shouldResumeSuppressedPlayback() + throws Exception { + addWatchAsSystemFeature(); + setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setSuppressOutputWhenNoSuitableOutputAvailable(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()).isTrue(); + player.release(); + } + + @Test + public void + onAudioDeviceAdded_addUnsuitableDevicesWithPlaybackSuppressed_shouldNotResumePlayback() + throws Exception { + addWatchAsSystemFeature(); + setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setSuppressOutputWhenNoSuitableOutputAvailable(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); + 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_UNKNOWN, /* notifyAudioDeviceCallbacks= */ true); + player.stop(); + runUntilPlaybackState(player, Player.STATE_IDLE); + + assertThat(isPlaybackResumed.get()).isFalse(); + player.release(); + } + + @Test + public void + onAudioDeviceAdded_addSuitableDevicesWhenPlaybackNotSuppressed_shouldNotResumePlayback() + throws Exception { + addWatchAsSystemFeature(); + setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setSuppressOutputWhenNoSuitableOutputAvailable(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); + 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 onAudioDeviceAdded_addSuitableDevicesOnNonWearSurface_shouldResumeSuppressedPlayback() + throws Exception { + setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setSuppressOutputWhenNoSuitableOutputAvailable(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); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setSuppressOutputWhenNoSuitableOutputAvailable(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()).isTrue(); + player.release(); + } + + @Test + public void + onAudioDeviceRemoved_removeUnsuitableDeviceLeavingOneSuitableDevice_shouldNotPausePlayback() + throws Exception { + addWatchAsSystemFeature(); + setupConnectedAudioOutput( + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, + AudioDeviceInfo.TYPE_UNKNOWN, + AudioDeviceInfo.TYPE_BUS, + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setSuppressOutputWhenNoSuitableOutputAvailable(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_UNKNOWN); + removeConnectedAudioOutput(AudioDeviceInfo.TYPE_BUS); + player.stop(); + runUntilPlaybackState(player, Player.STATE_IDLE); + + assertThat(isPlaybackPaused.get()).isFalse(); + player.release(); + } + + @Test + public void + onAudioDeviceRemoved_removeSuitableDeviceLeavingOneSuitableDevice_shouldNotPausePlayback() + throws Exception { + addWatchAsSystemFeature(); + setupConnectedAudioOutput( + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, + AudioDeviceInfo.TYPE_BLE_SPEAKER, + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setSuppressOutputWhenNoSuitableOutputAvailable(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_BLE_SPEAKER); + 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) + .setSuppressOutputWhenNoSuitableOutputAvailable(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(); + player.release(); + } + // Internal methods. private void addWatchAsSystemFeature() { @@ -13155,6 +13454,24 @@ public final class ExoPlayerTest { shadowAudioManager.setOutputDevices(deviceListBuilder.build()); } + private void addConnectedAudioOutput(int deviceTypes, boolean notifyAudioDeviceCallbacks) { + ShadowAudioManager shadowAudioManager = shadowOf(context.getSystemService(AudioManager.class)); + shadowAudioManager.addOutputDevice( + AudioDeviceInfoBuilder.newBuilder().setType(deviceTypes).build(), + notifyAudioDeviceCallbacks); + } + + private void removeConnectedAudioOutput(int deviceType) { + ShadowAudioManager shadowAudioManager = shadowOf(context.getSystemService(AudioManager.class)); + stream(shadowAudioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) + .filter(audioDeviceInfo -> deviceType == audioDeviceInfo.getType()) + .findFirst() + .ifPresent( + filteredAudioDeviceInfo -> + shadowAudioManager.removeOutputDevice( + filteredAudioDeviceInfo, /* notifyAudioDeviceCallbacks= */ true)); + } + private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { final Surface surface1 = new Surface(new SurfaceTexture(/* texName= */ 0)); final Surface surface2 = new Surface(new SurfaceTexture(/* texName= */ 1));