mirror of
https://github.com/samsonjs/media.git
synced 2026-04-03 10:55:48 +00:00
Implement AudioDeviceCallbacks to auto-resume & auto-pause when suitable devices are added or removed respectively with playback suppression feature enabled.
With playback suppression in place, the devices can be added when the playback on ExoPlayer is in the suppression state. Also, it is quite possible that a suitable audio output device on which playback is ongoing gets removed requiring the Player to pause the playback. These requirements can be fullfilled using AudioDeviceCallbacks which has been implemented with this change. PiperOrigin-RevId: 539559193
This commit is contained in:
parent
5c29abbbf4
commit
6e46234589
3 changed files with 349 additions and 0 deletions
|
|
@ -33,6 +33,11 @@
|
|||
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.
|
||||
* Add handling for auto-resume or auto-pause of playback when audio output
|
||||
devices are added or removed dynamically during suppressed or ongoing
|
||||
playback when the playback suppression due to no suitable output has
|
||||
been enabled via
|
||||
`ExoPlayer.Builder.setSuppressPlaybackWhenNoSuitableOutputAvailable`.
|
||||
* Transformer:
|
||||
* Parse EXIF rotation data for image inputs.
|
||||
* Track Selection:
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import android.content.Context;
|
|||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.SurfaceTexture;
|
||||
import android.media.AudioDeviceCallback;
|
||||
import android.media.AudioDeviceInfo;
|
||||
import android.media.AudioFormat;
|
||||
import android.media.AudioManager;
|
||||
|
|
@ -390,6 +391,8 @@ import java.util.concurrent.TimeoutException;
|
|||
audioFocusManager.setAudioAttributes(builder.handleAudioFocus ? audioAttributes : null);
|
||||
if (suppressPlaybackWhenNoSuitableOutputAvailable) {
|
||||
audioManager = (AudioManager) applicationContext.getSystemService(Context.AUDIO_SERVICE);
|
||||
audioManager.registerAudioDeviceCallback(
|
||||
new NoSuitableOutputPlaybackSuppressionAudioDeviceCallback(), /* handler= */ null);
|
||||
}
|
||||
if (builder.deviceVolumeControlEnabled) {
|
||||
streamVolumeManager =
|
||||
|
|
@ -3357,4 +3360,28 @@ import java.util.concurrent.TimeoutException;
|
|||
return packageManager.hasSystemFeature(PackageManager.FEATURE_WATCH);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link AudioDeviceCallback} to handle auto-resume and auto-pause for playback suppression due
|
||||
* to no suitable audio output.
|
||||
*/
|
||||
private final class NoSuitableOutputPlaybackSuppressionAudioDeviceCallback
|
||||
extends AudioDeviceCallback {
|
||||
|
||||
@Override
|
||||
public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) {
|
||||
if (hasSupportedAudioOutput()
|
||||
&& playbackInfo.playbackSuppressionReason
|
||||
== Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT) {
|
||||
play();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) {
|
||||
if (!hasSupportedAudioOutput()) {
|
||||
pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUnti
|
|||
import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilSleepingForOffload;
|
||||
import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilTimelineChanged;
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
import static java.util.Arrays.stream;
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.fail;
|
||||
|
|
@ -13139,6 +13140,304 @@ public final class ExoPlayerTest {
|
|||
player.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void
|
||||
onAudioDeviceAdded_addSuitableDevicesWhenPlaybackSuppressed_shouldResumeSuppressedPlayback()
|
||||
throws Exception {
|
||||
addWatchAsSystemFeature();
|
||||
setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
|
||||
ExoPlayer player =
|
||||
new TestExoPlayerBuilder(context)
|
||||
.setSuppressOutputWhenNoSuitableOutputAvailable(true)
|
||||
.build();
|
||||
player.setMediaItem(
|
||||
MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
|
||||
player.prepare();
|
||||
player.play();
|
||||
player.pause();
|
||||
runUntilPlaybackState(player, Player.STATE_READY);
|
||||
AtomicBoolean isPlaybackResumed = new AtomicBoolean(false);
|
||||
player.addListener(
|
||||
new Player.Listener() {
|
||||
@Override
|
||||
public void onPlayWhenReadyChanged(
|
||||
boolean playWhenReady, @PlayWhenReadyChangeReason int reason) {
|
||||
if (playWhenReady
|
||||
&& player.getPlaybackSuppressionReason()
|
||||
!= Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT) {
|
||||
isPlaybackResumed.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
addConnectedAudioOutput(
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, /* notifyAudioDeviceCallbacks= */ true);
|
||||
player.stop();
|
||||
runUntilPlaybackState(player, Player.STATE_IDLE);
|
||||
|
||||
assertThat(isPlaybackResumed.get()).isTrue();
|
||||
player.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void
|
||||
onAudioDeviceAdded_addUnsuitableDevicesWithPlaybackSuppressed_shouldNotResumePlayback()
|
||||
throws Exception {
|
||||
addWatchAsSystemFeature();
|
||||
setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
|
||||
ExoPlayer player =
|
||||
new TestExoPlayerBuilder(context)
|
||||
.setSuppressOutputWhenNoSuitableOutputAvailable(true)
|
||||
.build();
|
||||
player.setMediaItem(
|
||||
MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
|
||||
player.prepare();
|
||||
runUntilPlaybackState(player, Player.STATE_READY);
|
||||
AtomicBoolean isPlaybackResumed = new AtomicBoolean(false);
|
||||
player.addListener(
|
||||
new Player.Listener() {
|
||||
@Override
|
||||
public void onPlayWhenReadyChanged(
|
||||
boolean playWhenReady, @PlayWhenReadyChangeReason int reason) {
|
||||
if (playWhenReady
|
||||
&& player.getPlaybackSuppressionReason()
|
||||
!= Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT) {
|
||||
isPlaybackResumed.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
addConnectedAudioOutput(AudioDeviceInfo.TYPE_UNKNOWN, /* notifyAudioDeviceCallbacks= */ true);
|
||||
player.stop();
|
||||
runUntilPlaybackState(player, Player.STATE_IDLE);
|
||||
|
||||
assertThat(isPlaybackResumed.get()).isFalse();
|
||||
player.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void
|
||||
onAudioDeviceAdded_addSuitableDevicesWhenPlaybackNotSuppressed_shouldNotResumePlayback()
|
||||
throws Exception {
|
||||
addWatchAsSystemFeature();
|
||||
setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
|
||||
ExoPlayer player =
|
||||
new TestExoPlayerBuilder(context)
|
||||
.setSuppressOutputWhenNoSuitableOutputAvailable(true)
|
||||
.build();
|
||||
player.setMediaItem(
|
||||
MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
|
||||
player.prepare();
|
||||
runUntilPlaybackState(player, Player.STATE_READY);
|
||||
AtomicBoolean isPlaybackResumed = new AtomicBoolean(false);
|
||||
player.addListener(
|
||||
new Player.Listener() {
|
||||
@Override
|
||||
public void onPlayWhenReadyChanged(
|
||||
boolean playWhenReady, @PlayWhenReadyChangeReason int reason) {
|
||||
if (playWhenReady
|
||||
&& player.getPlaybackSuppressionReason()
|
||||
!= Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT) {
|
||||
isPlaybackResumed.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
addConnectedAudioOutput(
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, /* notifyAudioDeviceCallbacks= */ true);
|
||||
player.stop();
|
||||
runUntilPlaybackState(player, Player.STATE_IDLE);
|
||||
|
||||
assertThat(isPlaybackResumed.get()).isFalse();
|
||||
player.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onAudioDeviceAdded_addSuitableDevicesOnNonWearSurface_shouldResumeSuppressedPlayback()
|
||||
throws Exception {
|
||||
setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
|
||||
ExoPlayer player =
|
||||
new TestExoPlayerBuilder(context)
|
||||
.setSuppressOutputWhenNoSuitableOutputAvailable(true)
|
||||
.build();
|
||||
player.setMediaItem(
|
||||
MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
|
||||
player.prepare();
|
||||
player.play();
|
||||
player.pause();
|
||||
runUntilPlaybackState(player, Player.STATE_READY);
|
||||
AtomicBoolean isPlaybackResumed = new AtomicBoolean(false);
|
||||
player.addListener(
|
||||
new Player.Listener() {
|
||||
@Override
|
||||
public void onPlayWhenReadyChanged(
|
||||
boolean playWhenReady, @PlayWhenReadyChangeReason int reason) {
|
||||
if (playWhenReady
|
||||
&& player.getPlaybackSuppressionReason()
|
||||
!= Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT) {
|
||||
isPlaybackResumed.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
addConnectedAudioOutput(
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, /* notifyAudioDeviceCallbacks= */ true);
|
||||
player.stop();
|
||||
runUntilPlaybackState(player, Player.STATE_IDLE);
|
||||
|
||||
assertThat(isPlaybackResumed.get()).isFalse();
|
||||
player.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void
|
||||
onAudioDeviceRemoved_removeSuitableDeviceWhenPlaybackOngoing_shouldPauseOngoingPlayback()
|
||||
throws Exception {
|
||||
addWatchAsSystemFeature();
|
||||
setupConnectedAudioOutput(
|
||||
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP);
|
||||
ExoPlayer player =
|
||||
new TestExoPlayerBuilder(context)
|
||||
.setSuppressOutputWhenNoSuitableOutputAvailable(true)
|
||||
.build();
|
||||
player.setMediaItem(
|
||||
MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
|
||||
player.prepare();
|
||||
player.play();
|
||||
runUntilPlaybackState(player, Player.STATE_READY);
|
||||
AtomicBoolean isPlaybackPaused = new AtomicBoolean(false);
|
||||
player.addListener(
|
||||
new Player.Listener() {
|
||||
@Override
|
||||
public void onPlayWhenReadyChanged(
|
||||
boolean playWhenReady, @PlayWhenReadyChangeReason int reason) {
|
||||
if (!playWhenReady) {
|
||||
isPlaybackPaused.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
removeConnectedAudioOutput(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP);
|
||||
player.stop();
|
||||
runUntilPlaybackState(player, Player.STATE_IDLE);
|
||||
|
||||
assertThat(isPlaybackPaused.get()).isTrue();
|
||||
player.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void
|
||||
onAudioDeviceRemoved_removeUnsuitableDeviceLeavingOneSuitableDevice_shouldNotPausePlayback()
|
||||
throws Exception {
|
||||
addWatchAsSystemFeature();
|
||||
setupConnectedAudioOutput(
|
||||
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
|
||||
AudioDeviceInfo.TYPE_UNKNOWN,
|
||||
AudioDeviceInfo.TYPE_BUS,
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP);
|
||||
ExoPlayer player =
|
||||
new TestExoPlayerBuilder(context)
|
||||
.setSuppressOutputWhenNoSuitableOutputAvailable(true)
|
||||
.build();
|
||||
player.setMediaItem(
|
||||
MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
|
||||
player.prepare();
|
||||
player.play();
|
||||
runUntilPlaybackState(player, Player.STATE_READY);
|
||||
AtomicBoolean isPlaybackPaused = new AtomicBoolean(false);
|
||||
player.addListener(
|
||||
new Player.Listener() {
|
||||
@Override
|
||||
public void onPlayWhenReadyChanged(
|
||||
boolean playWhenReady, @PlayWhenReadyChangeReason int reason) {
|
||||
if (!playWhenReady) {
|
||||
isPlaybackPaused.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
removeConnectedAudioOutput(AudioDeviceInfo.TYPE_UNKNOWN);
|
||||
removeConnectedAudioOutput(AudioDeviceInfo.TYPE_BUS);
|
||||
player.stop();
|
||||
runUntilPlaybackState(player, Player.STATE_IDLE);
|
||||
|
||||
assertThat(isPlaybackPaused.get()).isFalse();
|
||||
player.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void
|
||||
onAudioDeviceRemoved_removeSuitableDeviceLeavingOneSuitableDevice_shouldNotPausePlayback()
|
||||
throws Exception {
|
||||
addWatchAsSystemFeature();
|
||||
setupConnectedAudioOutput(
|
||||
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
|
||||
AudioDeviceInfo.TYPE_BLE_SPEAKER,
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP);
|
||||
ExoPlayer player =
|
||||
new TestExoPlayerBuilder(context)
|
||||
.setSuppressOutputWhenNoSuitableOutputAvailable(true)
|
||||
.build();
|
||||
player.setMediaItem(
|
||||
MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
|
||||
player.prepare();
|
||||
player.play();
|
||||
runUntilPlaybackState(player, Player.STATE_READY);
|
||||
AtomicBoolean isPlaybackPaused = new AtomicBoolean(false);
|
||||
player.addListener(
|
||||
new Player.Listener() {
|
||||
@Override
|
||||
public void onPlayWhenReadyChanged(
|
||||
boolean playWhenReady, @PlayWhenReadyChangeReason int reason) {
|
||||
if (!playWhenReady) {
|
||||
isPlaybackPaused.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
removeConnectedAudioOutput(AudioDeviceInfo.TYPE_BLE_SPEAKER);
|
||||
player.stop();
|
||||
runUntilPlaybackState(player, Player.STATE_IDLE);
|
||||
|
||||
assertThat(isPlaybackPaused.get()).isFalse();
|
||||
player.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void
|
||||
onAudioDeviceRemoved_removeSuitableDeviceOnNonWearSurface_shouldNotPauseOngoingPlayback()
|
||||
throws Exception {
|
||||
setupConnectedAudioOutput(
|
||||
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP);
|
||||
ExoPlayer player =
|
||||
new TestExoPlayerBuilder(context)
|
||||
.setSuppressOutputWhenNoSuitableOutputAvailable(true)
|
||||
.build();
|
||||
player.setMediaItem(
|
||||
MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
|
||||
player.prepare();
|
||||
player.play();
|
||||
runUntilPlaybackState(player, Player.STATE_READY);
|
||||
AtomicBoolean isPlaybackPaused = new AtomicBoolean(false);
|
||||
player.addListener(
|
||||
new Player.Listener() {
|
||||
@Override
|
||||
public void onPlayWhenReadyChanged(
|
||||
boolean playWhenReady, @PlayWhenReadyChangeReason int reason) {
|
||||
if (!playWhenReady) {
|
||||
isPlaybackPaused.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
removeConnectedAudioOutput(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP);
|
||||
player.stop();
|
||||
runUntilPlaybackState(player, Player.STATE_IDLE);
|
||||
|
||||
assertThat(isPlaybackPaused.get()).isFalse();
|
||||
player.release();
|
||||
}
|
||||
|
||||
// Internal methods.
|
||||
|
||||
private void addWatchAsSystemFeature() {
|
||||
|
|
@ -13155,6 +13454,24 @@ public final class ExoPlayerTest {
|
|||
shadowAudioManager.setOutputDevices(deviceListBuilder.build());
|
||||
}
|
||||
|
||||
private void addConnectedAudioOutput(int deviceTypes, boolean notifyAudioDeviceCallbacks) {
|
||||
ShadowAudioManager shadowAudioManager = shadowOf(context.getSystemService(AudioManager.class));
|
||||
shadowAudioManager.addOutputDevice(
|
||||
AudioDeviceInfoBuilder.newBuilder().setType(deviceTypes).build(),
|
||||
notifyAudioDeviceCallbacks);
|
||||
}
|
||||
|
||||
private void removeConnectedAudioOutput(int deviceType) {
|
||||
ShadowAudioManager shadowAudioManager = shadowOf(context.getSystemService(AudioManager.class));
|
||||
stream(shadowAudioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS))
|
||||
.filter(audioDeviceInfo -> deviceType == audioDeviceInfo.getType())
|
||||
.findFirst()
|
||||
.ifPresent(
|
||||
filteredAudioDeviceInfo ->
|
||||
shadowAudioManager.removeOutputDevice(
|
||||
filteredAudioDeviceInfo, /* notifyAudioDeviceCallbacks= */ true));
|
||||
}
|
||||
|
||||
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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue