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
This commit is contained in:
Googler 2023-06-12 06:39:11 +00:00 committed by Tofunmi Adigun-Hameed
parent 5c29abbbf4
commit 6e46234589
3 changed files with 349 additions and 0 deletions

View file

@ -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:

View file

@ -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();
}
}
}
}

View file

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