diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 35781a9cd9..1287385418 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -26,6 +26,8 @@ timeout ([#1540](https://github.com/androidx/media/issues/1540)). * Remove `MediaCodecAdapter.Configuration.flags` as the field was always zero. + * Allow the user to select the built-in speaker for playback on Wear OS + API 35+ (where the device advertises support for this). * Transformer: * Add `SurfaceAssetLoader`, which supports queueing video data to Transformer via a `Surface`. diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultSuitableOutputChecker.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultSuitableOutputChecker.java new file mode 100644 index 0000000000..0801594f3e --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultSuitableOutputChecker.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer; + +import static androidx.media3.common.util.Assertions.checkState; + +import android.content.Context; +import android.media.MediaRoute2Info; +import android.media.MediaRouter2; +import android.media.MediaRouter2.RouteCallback; +import android.media.RouteDiscoveryPreference; +import android.media.RoutingSessionInfo; +import android.os.Handler; +import androidx.annotation.RequiresApi; +import androidx.media3.common.util.Util; +import com.google.common.collect.ImmutableList; +import java.util.concurrent.Executor; + +/** Default implementation for {@link SuitableOutputChecker}. */ +@RequiresApi(35) +/* package */ final class DefaultSuitableOutputChecker implements SuitableOutputChecker { + + private static final RouteDiscoveryPreference EMPTY_DISCOVERY_PREFERENCE = + new RouteDiscoveryPreference.Builder( + /* preferredFeatures= */ ImmutableList.of(), /* activeScan= */ false) + .build(); + + private final MediaRouter2 router; + private final RouteCallback routeCallback; + private final Executor executor; + + private boolean isEnabled; + + public DefaultSuitableOutputChecker(Context context, Handler eventHandler) { + this.router = MediaRouter2.getInstance(context); + this.routeCallback = new RouteCallback() {}; + this.executor = + new Executor() { + @Override + public void execute(Runnable command) { + Util.postOrRun(eventHandler, command); + } + }; + } + + @Override + public void setEnabled(boolean isEnabled) { + if (isEnabled && !this.isEnabled) { + router.registerRouteCallback(executor, routeCallback, EMPTY_DISCOVERY_PREFERENCE); + this.isEnabled = true; + } else if (!isEnabled && this.isEnabled) { + router.unregisterRouteCallback(routeCallback); + this.isEnabled = false; + } + } + + @Override + public boolean isSelectedRouteSuitableForPlayback() { + checkState(isEnabled, "SuitableOutputChecker is not enabled"); + int transferReason = router.getSystemController().getRoutingSessionInfo().getTransferReason(); + boolean wasTransferInitiatedBySelf = router.getSystemController().wasTransferInitiatedBySelf(); + for (MediaRoute2Info routeInfo : router.getSystemController().getSelectedRoutes()) { + if (isRouteSuitableForMediaPlayback(routeInfo, transferReason, wasTransferInitiatedBySelf)) { + return true; + } + } + return false; + } + + private static boolean isRouteSuitableForMediaPlayback( + MediaRoute2Info routeInfo, int transferReason, boolean wasTransferInitiatedBySelf) { + int suitabilityStatus = routeInfo.getSuitabilityStatus(); + + if (suitabilityStatus == MediaRoute2Info.SUITABILITY_STATUS_SUITABLE_FOR_MANUAL_TRANSFER) { + return (transferReason == RoutingSessionInfo.TRANSFER_REASON_SYSTEM_REQUEST + || transferReason == RoutingSessionInfo.TRANSFER_REASON_APP) + && wasTransferInitiatedBySelf; + } + + return suitabilityStatus == MediaRoute2Info.SUITABILITY_STATUS_SUITABLE_FOR_DEFAULT_TRANSFER; + } +} 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 d4b75b3184..a055a695ff 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java @@ -15,6 +15,7 @@ */ package androidx.media3.exoplayer; +import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkState; @@ -23,6 +24,7 @@ import android.content.Context; import android.media.AudioDeviceInfo; import android.media.AudioTrack; import android.media.MediaCodec; +import android.os.Handler; import android.os.Looper; import android.os.Process; import android.view.Surface; @@ -32,6 +34,7 @@ import android.view.TextureView; import androidx.annotation.IntRange; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; import androidx.annotation.VisibleForTesting; import androidx.media3.common.AudioAttributes; import androidx.media3.common.AuxEffectInfo; @@ -506,6 +509,7 @@ public interface ExoPlayer extends Player { /* package */ boolean suppressPlaybackOnUnsuitableOutput; /* package */ String playerName; /* package */ boolean dynamicSchedulingEnabled; + @Nullable /* package */ SuitableOutputChecker suitableOutputChecker; /** * Creates a builder. @@ -1260,6 +1264,27 @@ public interface ExoPlayer extends Player { return this; } + /** + * Sets the {@link SuitableOutputChecker} to check the suitability of the selected outputs for + * playback. + * + *
If this method is not called, the library uses a default implementation based on framework + * APIs. + * + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + @CanIgnoreReturnValue + @UnstableApi + @RestrictTo(LIBRARY_GROUP) + @VisibleForTesting + @RequiresApi(35) + public Builder setSuitableOutputChecker(SuitableOutputChecker suitableOutputChecker) { + checkState(!buildCalled); + this.suitableOutputChecker = suitableOutputChecker; + return this; + } + /** * Sets the {@link Looper} that will be used for playback. * @@ -1304,6 +1329,11 @@ public interface ExoPlayer extends Player { public ExoPlayer build() { checkState(!buildCalled); buildCalled = true; + if (suitableOutputChecker == null + && Util.SDK_INT >= 35 + && suppressPlaybackOnUnsuitableOutput) { + suitableOutputChecker = new DefaultSuitableOutputChecker(context, new Handler(looper)); + } return new ExoPlayerImpl(/* builder= */ this, /* wrappingPlayer= */ null); } 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 76cb12a6cc..1365e60eda 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java @@ -184,6 +184,7 @@ import java.util.concurrent.TimeoutException; private final long detachSurfaceTimeoutMs; @Nullable private AudioManager audioManager; private final boolean suppressPlaybackOnUnsuitableOutput; + @Nullable private final SuitableOutputChecker suitableOutputChecker; private @RepeatMode int repeatMode; private boolean shuffleModeEnabled; @@ -400,13 +401,18 @@ import java.util.concurrent.TimeoutException; audioBecomingNoisyManager.setEnabled(builder.handleAudioBecomingNoisy); audioFocusManager = new AudioFocusManager(builder.context, eventHandler, componentListener); audioFocusManager.setAudioAttributes(builder.handleAudioFocus ? audioAttributes : null); - if (suppressPlaybackOnUnsuitableOutput && Util.SDK_INT >= 23) { + + suitableOutputChecker = builder.suitableOutputChecker; + if (suitableOutputChecker != null && Util.SDK_INT >= 35) { + suitableOutputChecker.setEnabled(true); + } else if (suppressPlaybackOnUnsuitableOutput && Util.SDK_INT >= 23) { audioManager = (AudioManager) applicationContext.getSystemService(Context.AUDIO_SERVICE); Api23.registerAudioDeviceCallback( audioManager, new NoSuitableOutputPlaybackSuppressionAudioDeviceCallback(), new Handler(applicationLooper)); } + if (builder.deviceVolumeControlEnabled) { streamVolumeManager = new StreamVolumeManager(builder.context, eventHandler, componentListener); @@ -1066,6 +1072,9 @@ import java.util.concurrent.TimeoutException; if (playbackInfo.sleepingForOffload) { playbackInfo = playbackInfo.copyWithEstimatedPosition(); } + if (suitableOutputChecker != null && Util.SDK_INT >= 35) { + suitableOutputChecker.setEnabled(false); + } playbackInfo = playbackInfo.copyWithPlaybackState(Player.STATE_IDLE); playbackInfo = playbackInfo.copyWithLoadingMediaPeriodId(playbackInfo.periodId); playbackInfo.bufferedPositionUs = playbackInfo.positionUs; @@ -2831,13 +2840,16 @@ import java.util.concurrent.TimeoutException; } private boolean hasSupportedAudioOutput() { - if (audioManager == null || Util.SDK_INT < 23) { + if (Util.SDK_INT >= 35 && suitableOutputChecker != null) { + return suitableOutputChecker.isSelectedRouteSuitableForPlayback(); + } else if (Util.SDK_INT >= 23 && audioManager != null) { + return Api23.isSuitableExternalAudioOutputPresentInAudioDeviceInfoList( + applicationContext, audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)); + } else { // 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() { @@ -3390,7 +3402,7 @@ import java.util.concurrent.TimeoutException; private Api23() {} @DoNotInline - public static boolean isSuitableAudioOutputPresentInAudioDeviceInfoList( + public static boolean isSuitableExternalAudioOutputPresentInAudioDeviceInfoList( Context context, AudioDeviceInfo[] audioDeviceInfos) { if (!Util.isWear(context)) { return true; diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/SuitableOutputChecker.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/SuitableOutputChecker.java new file mode 100644 index 0000000000..04c7698d32 --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/SuitableOutputChecker.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; + +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; +import androidx.media3.common.util.UnstableApi; + +/** Provides methods to check the suitability of media outputs. */ +@RequiresApi(35) +@RestrictTo(LIBRARY_GROUP) +@UnstableApi +public interface SuitableOutputChecker { + /** + * Enables the current instance to receive updates on the suitable media outputs. + * + *
When the caller no longer requires updated information, they must call this method with + * {@code false}. + * + * @param isEnabled True if this instance should receive the updates. + */ + void setEnabled(boolean isEnabled); + + /** + * Returns whether any audio output is suitable for the media playback. + * + * @throws IllegalStateException if this instance is not enabled to receive the updates on + * suitable media outputs. + */ + boolean isSelectedRouteSuitableForPlayback(); +} diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeSuitableOutputChecker.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeSuitableOutputChecker.java new file mode 100644 index 0000000000..d44496ce8e --- /dev/null +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeSuitableOutputChecker.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.test.utils; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; +import static androidx.media3.common.util.Assertions.checkState; + +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.SuitableOutputChecker; +import com.google.errorprone.annotations.CanIgnoreReturnValue; + +/** Fake implementation for {@link SuitableOutputChecker}. */ +@RestrictTo(LIBRARY_GROUP) +@UnstableApi +@RequiresApi(35) +public final class FakeSuitableOutputChecker implements SuitableOutputChecker { + + /** Builder for {@link FakeSuitableOutputChecker} instance. */ + public static final class Builder { + + private boolean isSuitableOutputAvailable; + + /** + * Sets the initial value to be returned from {@link + * SuitableOutputChecker#isSelectedRouteSuitableForPlayback()}. The default value is false. + */ + @CanIgnoreReturnValue + public Builder setIsSuitableExternalOutputAvailable(boolean isSuitableOutputAvailable) { + this.isSuitableOutputAvailable = isSuitableOutputAvailable; + return this; + } + + /** + * Builds a {@link FakeSuitableOutputChecker} with the builder's current values. + * + * @return The built {@link FakeSuitableOutputChecker}. + */ + public FakeSuitableOutputChecker build() { + return new FakeSuitableOutputChecker(isSuitableOutputAvailable); + } + } + + private final boolean isSuitableOutputAvailable; + private boolean isEnabled; + + public FakeSuitableOutputChecker(boolean isSuitableOutputAvailable) { + this.isSuitableOutputAvailable = isSuitableOutputAvailable; + } + + @Override + public void setEnabled(boolean isEnabled) { + this.isEnabled = isEnabled; + } + + @Override + public boolean isSelectedRouteSuitableForPlayback() { + checkState(isEnabled, "SuitableOutputChecker is not enabled"); + return isSuitableOutputAvailable; + } +} 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 61cf20e26a..d9f233df4c 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 @@ -18,8 +18,10 @@ package androidx.media3.test.utils; import static com.google.common.truth.Truth.assertThat; import android.content.Context; +import android.os.Build.VERSION; import android.os.Looper; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.media3.common.C; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.Clock; @@ -30,6 +32,7 @@ import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.exoplayer.LoadControl; import androidx.media3.exoplayer.Renderer; import androidx.media3.exoplayer.RenderersFactory; +import androidx.media3.exoplayer.SuitableOutputChecker; import androidx.media3.exoplayer.analytics.DefaultAnalyticsCollector; import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; @@ -52,6 +55,7 @@ public class TestExoPlayerBuilder { @Nullable private MediaSource.Factory mediaSourceFactory; private boolean useLazyPreparation; private @MonotonicNonNull Looper looper; + @Nullable private SuitableOutputChecker suitableOutputChecker; private long seekBackIncrementMs; private long seekForwardIncrementMs; private long maxSeekToPreviousPositionMs; @@ -242,6 +246,23 @@ public class TestExoPlayerBuilder { return this; } + /** + * Sets the {@link SuitableOutputChecker} to check the suitability of the selected outputs for + * playback. + * + *
If this method is not called, the library uses a default implementation based on framework + * APIs. + * + * @return This builder. + */ + @CanIgnoreReturnValue + @RequiresApi(35) + public TestExoPlayerBuilder setSuitableOutputChecker( + SuitableOutputChecker suitableOutputChecker) { + this.suitableOutputChecker = suitableOutputChecker; + return this; + } + /** * Returns the {@link Looper} that will be used by the player, or null if no {@link Looper} has * been set yet and no default is available. @@ -402,6 +423,9 @@ public class TestExoPlayerBuilder { .setDeviceVolumeControlEnabled(deviceVolumeControlEnabled) .setSuppressPlaybackOnUnsuitableOutput(suppressPlaybackWhenUnsuitableOutput) .experimentalSetDynamicSchedulingEnabled(dynamicSchedulingEnabled); + if (VERSION.SDK_INT >= 35 && suitableOutputChecker != null) { + builder.setSuitableOutputChecker(suitableOutputChecker); + } if (mediaSourceFactory != null) { builder.setMediaSourceFactory(mediaSourceFactory); }