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