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 * Add additional action to manifest of main demo for making it easier to
start the demo app with a custom `*.exolist.json` file start the demo app with a custom `*.exolist.json` file
([#439](https://github.com/androidx/media/pull/439)). ([#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: * Transformer:
* Parse EXIF rotation data for image inputs. * Parse EXIF rotation data for image inputs.
* Track Selection: * 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 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_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_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_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_AUDIO_FOCUS_LOSS = 2; // 0x2
field public static final int PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM = 5; // 0x5 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.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 { 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 * Reason why playback is suppressed even though {@link #getPlayWhenReady()} is {@code true}. One
* of {@link #PLAYBACK_SUPPRESSION_REASON_NONE}, {@link * of {@link #PLAYBACK_SUPPRESSION_REASON_NONE}, {@link
* #PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS} or {@link * #PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS}, {@link
* #PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE}. * #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 // @Target list includes both 'default' targets and TYPE_USE, to ensure backwards compatibility
// with Kotlin usages from before TYPE_USE was added. // with Kotlin usages from before TYPE_USE was added.
@ -1232,7 +1233,8 @@ public interface Player {
@IntDef({ @IntDef({
PLAYBACK_SUPPRESSION_REASON_NONE, PLAYBACK_SUPPRESSION_REASON_NONE,
PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS, 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 {} @interface PlaybackSuppressionReason {}
/** Playback is not suppressed. */ /** Playback is not suppressed. */
@ -1240,10 +1242,14 @@ public interface Player {
/** Playback is suppressed due to transient audio focus loss. */ /** Playback is suppressed due to transient audio focus loss. */
int PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS = 1; 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 * @deprecated Use {@link #PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT} instead.
* speaker instead of bluetooth headphones on Wear OS.
*/ */
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 * 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; /* package */ boolean usePlatformDiagnostics;
@Nullable /* package */ Looper playbackLooper; @Nullable /* package */ Looper playbackLooper;
/* package */ boolean buildCalled; /* package */ boolean buildCalled;
/* package */ boolean suppressPlaybackWhenNoSuitableOutputAvailable;
/** /**
* Creates a builder. * Creates a builder.
@ -712,6 +713,31 @@ public interface ExoPlayer extends Player {
return this; 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. * 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.annotation.SuppressLint;
import android.content.Context; import android.content.Context;
import android.content.pm.PackageManager;
import android.graphics.Rect; import android.graphics.Rect;
import android.graphics.SurfaceTexture; import android.graphics.SurfaceTexture;
import android.media.AudioDeviceInfo; import android.media.AudioDeviceInfo;
import android.media.AudioFormat; import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioTrack; import android.media.AudioTrack;
import android.media.MediaFormat; import android.media.MediaFormat;
import android.media.metrics.LogSessionId; import android.media.metrics.LogSessionId;
@ -172,6 +174,8 @@ import java.util.concurrent.TimeoutException;
private final WakeLockManager wakeLockManager; private final WakeLockManager wakeLockManager;
private final WifiLockManager wifiLockManager; private final WifiLockManager wifiLockManager;
private final long detachSurfaceTimeoutMs; private final long detachSurfaceTimeoutMs;
@Nullable private AudioManager audioManager;
private final boolean suppressPlaybackWhenNoSuitableOutputAvailable;
private @RepeatMode int repeatMode; private @RepeatMode int repeatMode;
private boolean shuffleModeEnabled; private boolean shuffleModeEnabled;
@ -274,6 +278,8 @@ import java.util.concurrent.TimeoutException;
this.applicationLooper = builder.looper; this.applicationLooper = builder.looper;
this.clock = builder.clock; this.clock = builder.clock;
this.wrappingPlayer = wrappingPlayer == null ? this : wrappingPlayer; this.wrappingPlayer = wrappingPlayer == null ? this : wrappingPlayer;
this.suppressPlaybackWhenNoSuitableOutputAvailable =
builder.suppressPlaybackWhenNoSuitableOutputAvailable;
listeners = listeners =
new ListenerSet<>( new ListenerSet<>(
applicationLooper, applicationLooper,
@ -382,6 +388,9 @@ import java.util.concurrent.TimeoutException;
audioBecomingNoisyManager.setEnabled(builder.handleAudioBecomingNoisy); audioBecomingNoisyManager.setEnabled(builder.handleAudioBecomingNoisy);
audioFocusManager = new AudioFocusManager(builder.context, eventHandler, componentListener); audioFocusManager = new AudioFocusManager(builder.context, eventHandler, componentListener);
audioFocusManager.setAudioAttributes(builder.handleAudioFocus ? audioAttributes : null); audioFocusManager.setAudioAttributes(builder.handleAudioFocus ? audioAttributes : null);
if (suppressPlaybackWhenNoSuitableOutputAvailable) {
audioManager = (AudioManager) applicationContext.getSystemService(Context.AUDIO_SERVICE);
}
if (builder.deviceVolumeControlEnabled) { if (builder.deviceVolumeControlEnabled) {
streamVolumeManager = streamVolumeManager =
new StreamVolumeManager(builder.context, eventHandler, componentListener); new StreamVolumeManager(builder.context, eventHandler, componentListener);
@ -2731,26 +2740,22 @@ import java.util.concurrent.TimeoutException;
@Player.PlayWhenReadyChangeReason int playWhenReadyChangeReason) { @Player.PlayWhenReadyChangeReason int playWhenReadyChangeReason) {
playWhenReady = playWhenReady && playerCommand != AudioFocusManager.PLAYER_COMMAND_DO_NOT_PLAY; playWhenReady = playWhenReady && playerCommand != AudioFocusManager.PLAYER_COMMAND_DO_NOT_PLAY;
@PlaybackSuppressionReason @PlaybackSuppressionReason
int playbackSuppressionReason = int playbackSuppressionReason = computePlaybackSuppressionReason(playWhenReady, playerCommand);
playWhenReady && playerCommand != AudioFocusManager.PLAYER_COMMAND_PLAY_WHEN_READY
? Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS
: Player.PLAYBACK_SUPPRESSION_REASON_NONE;
if (playbackInfo.playWhenReady == playWhenReady if (playbackInfo.playWhenReady == playWhenReady
&& playbackInfo.playbackSuppressionReason == playbackSuppressionReason) { && playbackInfo.playbackSuppressionReason == playbackSuppressionReason) {
return; return;
} }
pendingOperationAcks++; pendingOperationAcks++;
// Position estimation and copy must occur before changing/masking playback state. // Position estimation and copy must occur before changing/masking playback state.
PlaybackInfo playbackInfo = PlaybackInfo newPlaybackInfo =
this.playbackInfo.sleepingForOffload this.playbackInfo.sleepingForOffload
? this.playbackInfo.copyWithEstimatedPosition() ? this.playbackInfo.copyWithEstimatedPosition()
: this.playbackInfo; : this.playbackInfo;
playbackInfo = playbackInfo.copyWithPlayWhenReady(playWhenReady, playbackSuppressionReason); newPlaybackInfo =
newPlaybackInfo.copyWithPlayWhenReady(playWhenReady, playbackSuppressionReason);
internalPlayer.setPlayWhenReady(playWhenReady, playbackSuppressionReason); internalPlayer.setPlayWhenReady(playWhenReady, playbackSuppressionReason);
updatePlaybackInfo( updatePlaybackInfo(
playbackInfo, newPlaybackInfo,
/* ignored */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, /* ignored */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
playWhenReadyChangeReason, playWhenReadyChangeReason,
/* positionDiscontinuity= */ false, /* positionDiscontinuity= */ false,
@ -2760,6 +2765,35 @@ import java.util.concurrent.TimeoutException;
/* repeatCurrentMediaItem= */ false); /* 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() { private void updateWakeAndWifiLock() {
@State int playbackState = getPlaybackState(); @State int playbackState = getPlaybackState();
switch (playbackState) { switch (playbackState) {
@ -3281,4 +3315,46 @@ import java.util.concurrent.TimeoutException;
return new PlayerId(listener.getLogSessionId()); 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.Context;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.SurfaceTexture; import android.graphics.SurfaceTexture;
import android.media.AudioDeviceInfo;
import android.media.AudioManager; import android.media.AudioManager;
import android.net.Uri; import android.net.Uri;
import android.os.Handler; import android.os.Handler;
@ -112,6 +114,7 @@ import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.Player; import androidx.media3.common.Player;
import androidx.media3.common.Player.DiscontinuityReason; import androidx.media3.common.Player.DiscontinuityReason;
import androidx.media3.common.Player.Listener; import androidx.media3.common.Player.Listener;
import androidx.media3.common.Player.PlayWhenReadyChangeReason;
import androidx.media3.common.Player.PositionInfo; import androidx.media3.common.Player.PositionInfo;
import androidx.media3.common.Timeline; import androidx.media3.common.Timeline;
import androidx.media3.common.Timeline.Window; import androidx.media3.common.Timeline.Window;
@ -201,8 +204,10 @@ import org.mockito.ArgumentMatchers;
import org.mockito.InOrder; import org.mockito.InOrder;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.robolectric.annotation.Config; import org.robolectric.annotation.Config;
import org.robolectric.shadows.AudioDeviceInfoBuilder;
import org.robolectric.shadows.ShadowAudioManager; import org.robolectric.shadows.ShadowAudioManager;
import org.robolectric.shadows.ShadowLooper; import org.robolectric.shadows.ShadowLooper;
import org.robolectric.shadows.ShadowPackageManager;
/** Unit test for {@link ExoPlayer}. */ /** Unit test for {@link ExoPlayer}. */
@RunWith(AndroidJUnit4.class) @RunWith(AndroidJUnit4.class)
@ -8564,7 +8569,8 @@ public final class ExoPlayerTest {
Player.Listener playerListener1 = Player.Listener playerListener1 =
new Player.Listener() { new Player.Listener() {
@Override @Override
public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { public void onPlayWhenReadyChanged(
boolean playWhenReady, @PlayWhenReadyChangeReason int reason) {
events.add(playWhenReadyChange1); events.add(playWhenReadyChange1);
} }
@ -8576,7 +8582,8 @@ public final class ExoPlayerTest {
Player.Listener playerListener2 = Player.Listener playerListener2 =
new Player.Listener() { new Player.Listener() {
@Override @Override
public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { public void onPlayWhenReadyChanged(
boolean playWhenReady, @PlayWhenReadyChangeReason int reason) {
events.add(playWhenReadyChange2); events.add(playWhenReadyChange2);
} }
@ -12807,8 +12814,203 @@ public final class ExoPlayerTest {
player.release(); 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. // 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) { private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) {
final Surface surface1 = new Surface(new SurfaceTexture(/* texName= */ 0)); final Surface surface1 = new Surface(new SurfaceTexture(/* texName= */ 0));
final Surface surface2 = new Surface(new SurfaceTexture(/* texName= */ 1)); final Surface surface2 = new Surface(new SurfaceTexture(/* texName= */ 1));

View File

@ -55,6 +55,7 @@ public class TestExoPlayerBuilder {
private long seekBackIncrementMs; private long seekBackIncrementMs;
private long seekForwardIncrementMs; private long seekForwardIncrementMs;
private boolean deviceVolumeControlEnabled; private boolean deviceVolumeControlEnabled;
private boolean suppressPlaybackWhenUnsuitableOutput;
public TestExoPlayerBuilder(Context context) { public TestExoPlayerBuilder(Context context) {
this.context = context; this.context = context;
@ -301,6 +302,21 @@ public class TestExoPlayerBuilder {
return seekForwardIncrementMs; 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. */ /** Builds an {@link ExoPlayer} using the provided values or their defaults. */
public ExoPlayer build() { public ExoPlayer build() {
Assertions.checkNotNull( Assertions.checkNotNull(
@ -337,7 +353,8 @@ public class TestExoPlayerBuilder {
.setLooper(looper) .setLooper(looper)
.setSeekBackIncrementMs(seekBackIncrementMs) .setSeekBackIncrementMs(seekBackIncrementMs)
.setSeekForwardIncrementMs(seekForwardIncrementMs) .setSeekForwardIncrementMs(seekForwardIncrementMs)
.setDeviceVolumeControlEnabled(deviceVolumeControlEnabled); .setDeviceVolumeControlEnabled(deviceVolumeControlEnabled)
.setSuppressPlaybackWhenNoSuitableOutputAvailable(suppressPlaybackWhenUnsuitableOutput);
if (mediaSourceFactory != null) { if (mediaSourceFactory != null) {
builder.setMediaSourceFactory(mediaSourceFactory); builder.setMediaSourceFactory(mediaSourceFactory);
} }