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));