From 605af62d00b0047516d41efda7fe2ab9b2715eef Mon Sep 17 00:00:00 2001 From: Googler Date: Thu, 8 Jun 2023 19:56:56 +0000 Subject: [PATCH] Add playback suppression for the attempted playbacks on an unsuitable output. This CL introduces the new public API setSuppressPlaybackWhenUnsuitableOutput which if set to TRUE will cause suppression of a requested playback if that is going to happen on an unsuitable audio output (e.g. builtin speaker on a WearOS device). PiperOrigin-RevId: 538867212 --- RELEASENOTES.md | 6 + api.txt | 5 +- .../java/androidx/media3/common/Player.java | 18 +- .../androidx/media3/exoplayer/ExoPlayer.java | 26 +++ .../media3/exoplayer/ExoPlayerImpl.java | 94 +++++++- .../media3/exoplayer/ExoPlayerTest.java | 206 +++++++++++++++++- .../test/utils/TestExoPlayerBuilder.java | 19 +- 7 files changed, 354 insertions(+), 20 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 809ddc229c..ef317fd3e6 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -13,6 +13,12 @@ * Add additional action to manifest of main demo for making it easier to start the demo app with a custom `*.exolist.json` file ([#439](https://github.com/androidx/media/pull/439)). + * Add suppression of playback on unsuitable audio output devices (e.g. the + built-in speaker on Wear OS devices) when this feature is enabled via + `ExoPlayer.Builder.setSuppressPlaybackWhenNoSuitableOutputAvailable`. + 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. * Transformer: * Parse EXIF rotation data for image inputs. * Track Selection: diff --git a/api.txt b/api.txt index ea09a96cfb..48953e6dc8 100644 --- a/api.txt +++ b/api.txt @@ -862,7 +862,8 @@ package androidx.media3.common { field public static final int MEDIA_ITEM_TRANSITION_REASON_SEEK = 2; // 0x2 field public static final int PLAYBACK_SUPPRESSION_REASON_NONE = 0; // 0x0 field public static final int PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS = 1; // 0x1 - field public static final int PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE = 2; // 0x2 + field public static final int PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT = 3; // 0x3 + field @Deprecated public static final int PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE = 2; // 0x2 field public static final int PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY = 3; // 0x3 field public static final int PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS = 2; // 0x2 field public static final int PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM = 5; // 0x5 @@ -944,7 +945,7 @@ package androidx.media3.common { @IntDef({androidx.media3.common.Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, androidx.media3.common.Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS, androidx.media3.common.Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY, androidx.media3.common.Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE, androidx.media3.common.Player.PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM, androidx.media3.common.Player.PLAY_WHEN_READY_CHANGE_REASON_SUPPRESSED_TOO_LONG}) @java.lang.annotation.Documented @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) @java.lang.annotation.Target({java.lang.annotation.ElementType.FIELD, java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.PARAMETER, java.lang.annotation.ElementType.LOCAL_VARIABLE, java.lang.annotation.ElementType.TYPE_USE}) public static @interface Player.PlayWhenReadyChangeReason { } - @IntDef({androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_NONE, androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS, androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE}) @java.lang.annotation.Documented @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) @java.lang.annotation.Target({java.lang.annotation.ElementType.FIELD, java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.PARAMETER, java.lang.annotation.ElementType.LOCAL_VARIABLE, java.lang.annotation.ElementType.TYPE_USE}) public static @interface Player.PlaybackSuppressionReason { + @IntDef({androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_NONE, androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS, androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE, androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT}) @java.lang.annotation.Documented @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) @java.lang.annotation.Target({java.lang.annotation.ElementType.FIELD, java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.PARAMETER, java.lang.annotation.ElementType.LOCAL_VARIABLE, java.lang.annotation.ElementType.TYPE_USE}) public static @interface Player.PlaybackSuppressionReason { } public static final class Player.PositionInfo { diff --git a/libraries/common/src/main/java/androidx/media3/common/Player.java b/libraries/common/src/main/java/androidx/media3/common/Player.java index 7d68ab2ba1..3e93c30161 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Player.java +++ b/libraries/common/src/main/java/androidx/media3/common/Player.java @@ -1221,8 +1221,9 @@ public interface Player { /** * Reason why playback is suppressed even though {@link #getPlayWhenReady()} is {@code true}. One * of {@link #PLAYBACK_SUPPRESSION_REASON_NONE}, {@link - * #PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS} or {@link - * #PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE}. + * #PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS}, {@link + * #PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE} or {@link + * #PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT}. */ // @Target list includes both 'default' targets and TYPE_USE, to ensure backwards compatibility // with Kotlin usages from before TYPE_USE was added. @@ -1232,7 +1233,8 @@ public interface Player { @IntDef({ PLAYBACK_SUPPRESSION_REASON_NONE, PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS, - PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE + PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE, + PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT }) @interface PlaybackSuppressionReason {} /** Playback is not suppressed. */ @@ -1240,10 +1242,14 @@ public interface Player { /** Playback is suppressed due to transient audio focus loss. */ int PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS = 1; /** - * Playback is suppressed due to no suitable audio route, such as an attempt to use an internal - * speaker instead of bluetooth headphones on Wear OS. + * @deprecated Use {@link #PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT} instead. */ - int PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE = 2; + @Deprecated int PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE = 2; + /** + * Playback is suppressed due to attempt to play on an unsuitable audio output (e.g. attempt to + * play on built-in speaker on a Wear OS device). + */ + int PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT = 3; /** * Repeat modes for playback. One of {@link #REPEAT_MODE_OFF}, {@link #REPEAT_MODE_ONE} or {@link 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 27022555c1..7e160316a7 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java @@ -493,6 +493,7 @@ public interface ExoPlayer extends Player { /* package */ boolean usePlatformDiagnostics; @Nullable /* package */ Looper playbackLooper; /* package */ boolean buildCalled; + /* package */ boolean suppressPlaybackWhenNoSuitableOutputAvailable; /** * Creates a builder. @@ -712,6 +713,31 @@ public interface ExoPlayer extends Player { return this; } + /** + * Sets whether the player should suppress playback when a suitable audio output is not + * available. An example of an unsuitable audio output is the built-in speaker on a Wear OS + * device (unless it is explicitly selected by the user). + * + *

If called with {@code suppressPlaybackWhenNoSuitableOutputAvailable = true}, then a + * playback attempt while no suitable output is available will result in calls to {@link + * Player.Listener#onPlaybackSuppressionReasonChanged(int)} with the value {@link + * Player#PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT}. + * + * @param suppressPlaybackWhenNoSuitableOutputAvailable Whether the player should suppress the + * playback when suitable media route is not available. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + @CanIgnoreReturnValue + @UnstableApi + public Builder setSuppressPlaybackWhenNoSuitableOutputAvailable( + boolean suppressPlaybackWhenNoSuitableOutputAvailable) { + checkState(!buildCalled); + this.suppressPlaybackWhenNoSuitableOutputAvailable = + suppressPlaybackWhenNoSuitableOutputAvailable; + return this; + } + /** * Sets the {@link RenderersFactory} that will be used by the player. * 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 a4c28f6922..2d0f8aef44 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java @@ -40,10 +40,12 @@ import static java.lang.Math.min; import android.annotation.SuppressLint; import android.content.Context; +import android.content.pm.PackageManager; import android.graphics.Rect; import android.graphics.SurfaceTexture; import android.media.AudioDeviceInfo; import android.media.AudioFormat; +import android.media.AudioManager; import android.media.AudioTrack; import android.media.MediaFormat; import android.media.metrics.LogSessionId; @@ -172,6 +174,8 @@ import java.util.concurrent.TimeoutException; private final WakeLockManager wakeLockManager; private final WifiLockManager wifiLockManager; private final long detachSurfaceTimeoutMs; + @Nullable private AudioManager audioManager; + private final boolean suppressPlaybackWhenNoSuitableOutputAvailable; private @RepeatMode int repeatMode; private boolean shuffleModeEnabled; @@ -274,6 +278,8 @@ import java.util.concurrent.TimeoutException; this.applicationLooper = builder.looper; this.clock = builder.clock; this.wrappingPlayer = wrappingPlayer == null ? this : wrappingPlayer; + this.suppressPlaybackWhenNoSuitableOutputAvailable = + builder.suppressPlaybackWhenNoSuitableOutputAvailable; listeners = new ListenerSet<>( applicationLooper, @@ -382,6 +388,9 @@ import java.util.concurrent.TimeoutException; audioBecomingNoisyManager.setEnabled(builder.handleAudioBecomingNoisy); audioFocusManager = new AudioFocusManager(builder.context, eventHandler, componentListener); audioFocusManager.setAudioAttributes(builder.handleAudioFocus ? audioAttributes : null); + if (suppressPlaybackWhenNoSuitableOutputAvailable) { + audioManager = (AudioManager) applicationContext.getSystemService(Context.AUDIO_SERVICE); + } if (builder.deviceVolumeControlEnabled) { streamVolumeManager = new StreamVolumeManager(builder.context, eventHandler, componentListener); @@ -2731,26 +2740,22 @@ import java.util.concurrent.TimeoutException; @Player.PlayWhenReadyChangeReason int playWhenReadyChangeReason) { playWhenReady = playWhenReady && playerCommand != AudioFocusManager.PLAYER_COMMAND_DO_NOT_PLAY; @PlaybackSuppressionReason - int playbackSuppressionReason = - playWhenReady && playerCommand != AudioFocusManager.PLAYER_COMMAND_PLAY_WHEN_READY - ? Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS - : Player.PLAYBACK_SUPPRESSION_REASON_NONE; + int playbackSuppressionReason = computePlaybackSuppressionReason(playWhenReady, playerCommand); if (playbackInfo.playWhenReady == playWhenReady && playbackInfo.playbackSuppressionReason == playbackSuppressionReason) { return; } pendingOperationAcks++; - // Position estimation and copy must occur before changing/masking playback state. - PlaybackInfo playbackInfo = + PlaybackInfo newPlaybackInfo = this.playbackInfo.sleepingForOffload ? this.playbackInfo.copyWithEstimatedPosition() : this.playbackInfo; - playbackInfo = playbackInfo.copyWithPlayWhenReady(playWhenReady, playbackSuppressionReason); - + newPlaybackInfo = + newPlaybackInfo.copyWithPlayWhenReady(playWhenReady, playbackSuppressionReason); internalPlayer.setPlayWhenReady(playWhenReady, playbackSuppressionReason); updatePlaybackInfo( - playbackInfo, + newPlaybackInfo, /* ignored */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, playWhenReadyChangeReason, /* positionDiscontinuity= */ false, @@ -2760,6 +2765,35 @@ import java.util.concurrent.TimeoutException; /* repeatCurrentMediaItem= */ false); } + @PlaybackSuppressionReason + private int computePlaybackSuppressionReason( + boolean playWhenReady, @AudioFocusManager.PlayerCommand int playerCommand) { + if (playWhenReady && playerCommand != AudioFocusManager.PLAYER_COMMAND_PLAY_WHEN_READY) { + return Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS; + } + if (suppressPlaybackWhenNoSuitableOutputAvailable) { + if (playWhenReady && !hasSupportedAudioOutput()) { + return Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT; + } + if (!playWhenReady + && playbackInfo.playbackSuppressionReason + == PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT) { + return Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT; + } + } + return Player.PLAYBACK_SUPPRESSION_REASON_NONE; + } + + private boolean hasSupportedAudioOutput() { + if (audioManager == null || Util.SDK_INT < 23) { + // The Audio Manager API to determine the list of connected audio devices is available only in + // API >= 23. + return true; + } + return Api23.isSuitableAudioOutputPresentInAudioDeviceInfoList( + applicationContext, audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)); + } + private void updateWakeAndWifiLock() { @State int playbackState = getPlaybackState(); switch (playbackState) { @@ -3281,4 +3315,46 @@ import java.util.concurrent.TimeoutException; return new PlayerId(listener.getLogSessionId()); } } + + @RequiresApi(23) + private static final class Api23 { + private Api23() {} + + public static boolean isSuitableAudioOutputPresentInAudioDeviceInfoList( + Context context, AudioDeviceInfo[] audioDeviceInfos) { + if (!isRunningOnWear(context)) { + return true; + } + for (AudioDeviceInfo device : audioDeviceInfos) { + if (device.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP + || device.getType() == AudioDeviceInfo.TYPE_LINE_ANALOG + || device.getType() == AudioDeviceInfo.TYPE_LINE_DIGITAL + || device.getType() == AudioDeviceInfo.TYPE_USB_DEVICE + || device.getType() == AudioDeviceInfo.TYPE_WIRED_HEADPHONES + || device.getType() == AudioDeviceInfo.TYPE_WIRED_HEADSET) { + return true; + } + if (Util.SDK_INT >= 26 && device.getType() == AudioDeviceInfo.TYPE_USB_HEADSET) { + return true; + } + if (Util.SDK_INT >= 28 && device.getType() == AudioDeviceInfo.TYPE_HEARING_AID) { + return true; + } + if (Util.SDK_INT >= 31 + && (device.getType() == AudioDeviceInfo.TYPE_BLE_HEADSET + || device.getType() == AudioDeviceInfo.TYPE_BLE_SPEAKER)) { + return true; + } + if (Util.SDK_INT >= 33 && device.getType() == AudioDeviceInfo.TYPE_BLE_BROADCAST) { + return true; + } + } + return false; + } + + private static boolean isRunningOnWear(Context context) { + PackageManager packageManager = context.getPackageManager(); + return packageManager.hasSystemFeature(PackageManager.FEATURE_WATCH); + } + } } 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 b9b76ef9ea..6e53764912 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java @@ -90,7 +90,9 @@ import static org.robolectric.Shadows.shadowOf; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; import android.graphics.SurfaceTexture; +import android.media.AudioDeviceInfo; import android.media.AudioManager; import android.net.Uri; import android.os.Handler; @@ -112,6 +114,7 @@ import androidx.media3.common.PlaybackParameters; import androidx.media3.common.Player; import androidx.media3.common.Player.DiscontinuityReason; import androidx.media3.common.Player.Listener; +import androidx.media3.common.Player.PlayWhenReadyChangeReason; import androidx.media3.common.Player.PositionInfo; import androidx.media3.common.Timeline; import androidx.media3.common.Timeline.Window; @@ -201,8 +204,10 @@ import org.mockito.ArgumentMatchers; import org.mockito.InOrder; import org.mockito.Mockito; import org.robolectric.annotation.Config; +import org.robolectric.shadows.AudioDeviceInfoBuilder; import org.robolectric.shadows.ShadowAudioManager; import org.robolectric.shadows.ShadowLooper; +import org.robolectric.shadows.ShadowPackageManager; /** Unit test for {@link ExoPlayer}. */ @RunWith(AndroidJUnit4.class) @@ -8564,7 +8569,8 @@ public final class ExoPlayerTest { Player.Listener playerListener1 = new Player.Listener() { @Override - public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { + public void onPlayWhenReadyChanged( + boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { events.add(playWhenReadyChange1); } @@ -8576,7 +8582,8 @@ public final class ExoPlayerTest { Player.Listener playerListener2 = new Player.Listener() { @Override - public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { + public void onPlayWhenReadyChanged( + boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { events.add(playWhenReadyChange2); } @@ -12807,8 +12814,203 @@ public final class ExoPlayerTest { player.release(); } + /** + * Tests playback suppression for playback with only unsuitable route (e.g. builtin speaker) on + * Wear OS. + */ + @Test + public void play_withNoSuitableMediaRouteOnWear_shouldSuppressPlayback() throws Exception { + addWatchAsSystemFeature(); + setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + List playbackSuppressionList = new ArrayList<>(); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setSuppressOutputWhenNoSuitableOutputAvailable(true) + .build(); + player.setMediaItem( + MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); + player.addListener( + new Player.Listener() { + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { + if (playWhenReady) { + playbackSuppressionList.add(player.getPlaybackSuppressionReason()); + } + } + }); + player.prepare(); + + player.play(); + player.stop(); + runUntilPlaybackState(player, Player.STATE_IDLE); + + assertThat(playbackSuppressionList) + .containsExactly(Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT); + player.release(); + } + + /** + * Tests playback suppression for playback with suitable route (e.g. BluetoothA2DP) on Wear OS. + */ + @Test + public void play_withNoSuitableMediaRouteOnWear_shouldNotSuppressPlayback() throws Exception { + addWatchAsSystemFeature(); + setupConnectedAudioOutput( + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP); + List playbackSuppressionList = new ArrayList<>(); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setSuppressOutputWhenNoSuitableOutputAvailable(true) + .build(); + player.setMediaItem( + MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); + player.addListener( + new Player.Listener() { + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { + if (playWhenReady) { + playbackSuppressionList.add(player.getPlaybackSuppressionReason()); + } + } + }); + player.prepare(); + + player.play(); + player.stop(); + runUntilPlaybackState(player, Player.STATE_IDLE); + + assertThat(playbackSuppressionList).containsExactly(Player.PLAYBACK_SUPPRESSION_REASON_NONE); + player.release(); + } + + /** + * Tests playback suppression for multiple play calls with only unsuitable route (e.g. builtin + * speaker) on Wear OS. + */ + @Test + public void + play_call2TimesWithSubsequentPauseWithNoSuitableRouteOnWear_shouldSuppressionPlayback2Times() + throws Exception { + addWatchAsSystemFeature(); + setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + List playbackSuppressionList = new ArrayList<>(); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setSuppressOutputWhenNoSuitableOutputAvailable(true) + .build(); + player.setMediaItem( + MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); + player.addListener( + new Player.Listener() { + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { + player.pause(); + if (playWhenReady) { + playbackSuppressionList.add(player.getPlaybackSuppressionReason()); + } + } + }); + player.prepare(); + + player.play(); + player.play(); + player.stop(); + runUntilPlaybackState(player, Player.STATE_IDLE); + + assertThat(playbackSuppressionList) + .containsExactly( + Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT, + Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT); + player.release(); + } + + /** Tests playback suppression for playback on the built-speaker on non-Wear OS surfaces. */ + @Test + public void play_onBuiltinSpeakerWithoutWearSystemFeature_shouldNotSuppressPlayback() + throws Exception { + setupConnectedAudioOutput( + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP); + List playbackSuppressionList = new ArrayList<>(); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setSuppressOutputWhenNoSuitableOutputAvailable(true) + .build(); + player.setMediaItem( + MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); + player.addListener( + new Player.Listener() { + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { + if (playWhenReady) { + playbackSuppressionList.add(player.getPlaybackSuppressionReason()); + } + } + }); + player.prepare(); + + player.play(); + player.stop(); + runUntilPlaybackState(player, Player.STATE_IDLE); + + assertThat(playbackSuppressionList).containsExactly(Player.PLAYBACK_SUPPRESSION_REASON_NONE); + player.release(); + } + + /** + * Tests playback suppression for playback with only unsuitable route (e.g. builtin speaker) on + * Wear OS but {@link ExoPlayer.Builder#setSuppressPlaybackWhenNoSuitableOutputAvailable(boolean)} + * is not called with parameter as TRUE. + */ + @Test + public void + play_withOnlyUnsuitableRoutesWithoutEnablingPlaybackSuppression_shouldNotSuppressPlayback() + throws Exception { + addWatchAsSystemFeature(); + setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + List playbackSuppressionList = new ArrayList<>(); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.setMediaItem( + MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); + player.addListener( + new Player.Listener() { + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { + if (playWhenReady) { + playbackSuppressionList.add(player.getPlaybackSuppressionReason()); + } + } + }); + player.prepare(); + + player.play(); + player.stop(); + runUntilPlaybackState(player, Player.STATE_IDLE); + + assertThat(playbackSuppressionList).containsExactly(Player.PLAYBACK_SUPPRESSION_REASON_NONE); + player.release(); + } + // Internal methods. + private void addWatchAsSystemFeature() { + ShadowPackageManager shadowPackageManager = shadowOf(context.getPackageManager()); + shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true); + } + + private void setupConnectedAudioOutput(int... deviceTypes) { + ShadowAudioManager shadowAudioManager = shadowOf(context.getSystemService(AudioManager.class)); + ImmutableList.Builder deviceListBuilder = ImmutableList.builder(); + for (int deviceType : deviceTypes) { + deviceListBuilder.add(AudioDeviceInfoBuilder.newBuilder().setType(deviceType).build()); + } + shadowAudioManager.setOutputDevices(deviceListBuilder.build()); + } + 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)); diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestExoPlayerBuilder.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestExoPlayerBuilder.java index 2f9dda142c..77bda41f20 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestExoPlayerBuilder.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestExoPlayerBuilder.java @@ -55,6 +55,7 @@ public class TestExoPlayerBuilder { private long seekBackIncrementMs; private long seekForwardIncrementMs; private boolean deviceVolumeControlEnabled; + private boolean suppressPlaybackWhenUnsuitableOutput; public TestExoPlayerBuilder(Context context) { this.context = context; @@ -301,6 +302,21 @@ public class TestExoPlayerBuilder { return seekForwardIncrementMs; } + /** + * See {@link ExoPlayer.Builder#setSuppressPlaybackWhenNoSuitableOutputAvailable(boolean)} for + * details. + * + * @param suppressPlaybackWhenNoSuitableOutputAvailable Whether the player should suppress the + * playback when suitable media route is not available. + * @return This builder. + */ + @CanIgnoreReturnValue + public TestExoPlayerBuilder setSuppressOutputWhenNoSuitableOutputAvailable( + boolean suppressPlaybackWhenNoSuitableOutputAvailable) { + this.suppressPlaybackWhenUnsuitableOutput = suppressPlaybackWhenNoSuitableOutputAvailable; + return this; + } + /** Builds an {@link ExoPlayer} using the provided values or their defaults. */ public ExoPlayer build() { Assertions.checkNotNull( @@ -337,7 +353,8 @@ public class TestExoPlayerBuilder { .setLooper(looper) .setSeekBackIncrementMs(seekBackIncrementMs) .setSeekForwardIncrementMs(seekForwardIncrementMs) - .setDeviceVolumeControlEnabled(deviceVolumeControlEnabled); + .setDeviceVolumeControlEnabled(deviceVolumeControlEnabled) + .setSuppressPlaybackWhenNoSuitableOutputAvailable(suppressPlaybackWhenUnsuitableOutput); if (mediaSourceFactory != null) { builder.setMediaSourceFactory(mediaSourceFactory); }