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
This commit is contained in:
Googler 2023-06-08 19:56:56 +00:00 committed by Tofunmi Adigun-Hameed
parent 28b8fb706a
commit 605af62d00
7 changed files with 354 additions and 20 deletions

View File

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

View File

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

View File

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

View File

@ -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).
*
* <p>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.
*

View File

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

View File

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

View File

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