Add initial version of SimpleBasePlayer

This base class will simplify the implementation of custom
Player classes. The current version only supports
available commands and playWhenReady handling.

PiperOrigin-RevId: 467618021
(cherry picked from commit 9a7fde8fde6e194b92ec67095b7b889ee23a3d77)
This commit is contained in:
tonihei 2022-08-15 08:54:27 +00:00 committed by microkatz
parent 9b6d997703
commit d2000fd25f
3 changed files with 1258 additions and 0 deletions

View File

@ -0,0 +1,59 @@
/*
* Copyright 2022 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 com.google.android.exoplayer2;
import android.media.MediaPlayer;
import android.os.Looper;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
/** A {@link Player} wrapper for the legacy Android platform {@link MediaPlayer}. */
public final class LegacyMediaPlayerWrapper extends SimpleBasePlayer {
private final MediaPlayer player;
private boolean playWhenReady;
/**
* Creates the {@link MediaPlayer} wrapper.
*
* @param looper The {@link Looper} used to call all methods on.
*/
public LegacyMediaPlayerWrapper(Looper looper) {
super(looper);
this.player = new MediaPlayer();
}
@Override
protected State getState() {
return new State.Builder()
.setAvailableCommands(new Commands.Builder().addAll(Player.COMMAND_PLAY_PAUSE).build())
.setPlayWhenReady(playWhenReady, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
.build();
}
@Override
protected ListenableFuture<?> handleSetPlayWhenReady(boolean playWhenReady) {
this.playWhenReady = playWhenReady;
// TODO: Only call these methods if the player is in Started or Paused state.
if (playWhenReady) {
player.start();
} else {
player.pause();
}
return Futures.immediateVoidFuture();
}
}

View File

@ -0,0 +1,793 @@
/*
* Copyright 2022 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 com.google.android.exoplayer2;
import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
import static com.google.android.exoplayer2.util.Util.castNonNull;
import android.os.Looper;
import android.view.Surface;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.TextureView;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.text.CueGroup;
import com.google.android.exoplayer2.util.Clock;
import com.google.android.exoplayer2.util.HandlerWrapper;
import com.google.android.exoplayer2.util.ListenerSet;
import com.google.android.exoplayer2.util.Util;
import com.google.common.base.Supplier;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.errorprone.annotations.ForOverride;
import java.util.HashSet;
import java.util.List;
import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;
/**
* A base implementation for {@link Player} that reduces the number of methods to implement to a
* minimum.
*
* <p>Implementation notes:
*
* <ul>
* <li>Subclasses must override {@link #getState()} to populate the current player state on
* request.
* <li>The {@link State} should set the {@linkplain State.Builder#setAvailableCommands available
* commands} to indicate which {@link Player} methods are supported.
* <li>All setter-like player methods (for example, {@link #setPlayWhenReady}) forward to
* overridable methods (for example, {@link #handleSetPlayWhenReady}) that can be used to
* handle these requests. These methods return a {@link ListenableFuture} to indicate when the
* request has been handled and is fully reflected in the values returned from {@link
* #getState}. This class will automatically request a state update once the request is done.
* If the state changes can be handled synchronously, these methods can return Guava's {@link
* Futures#immediateVoidFuture()}.
* <li>Subclasses can manually trigger state updates with {@link #invalidateState}, for example if
* something changes independent of {@link Player} method calls.
* </ul>
*
* This base class handles various aspects of the player implementation to simplify the subclass:
*
* <ul>
* <li>The {@link State} can only be created with allowed combinations of state values, avoiding
* any invalid player states.
* <li>Only functionality that is declared as {@linkplain Player.Command available} needs to be
* implemented. Other methods are automatically ignored.
* <li>Listener handling and informing listeners of state changes is handled automatically.
* <li>The base class provides a framework for asynchronous handling of method calls. It changes
* the visible playback state immediately to the most likely outcome to ensure the
* user-visible state changes look like synchronous operations. The state is then updated
* again once the asynchronous method calls have been fully handled.
* </ul>
*/
public abstract class SimpleBasePlayer extends BasePlayer {
/** An immutable state description of the player. */
protected static final class State {
/** A builder for {@link State} objects. */
public static final class Builder {
private Commands availableCommands;
private boolean playWhenReady;
private @PlayWhenReadyChangeReason int playWhenReadyChangeReason;
/** Creates the builder. */
public Builder() {
availableCommands = Commands.EMPTY;
playWhenReady = false;
playWhenReadyChangeReason = Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST;
}
private Builder(State state) {
this.availableCommands = state.availableCommands;
this.playWhenReady = state.playWhenReady;
this.playWhenReadyChangeReason = state.playWhenReadyChangeReason;
}
/**
* Sets the available {@link Commands}.
*
* @param availableCommands The available {@link Commands}.
* @return This builder.
*/
@CanIgnoreReturnValue
public Builder setAvailableCommands(Commands availableCommands) {
this.availableCommands = availableCommands;
return this;
}
/**
* Sets whether playback should proceed when ready and not suppressed.
*
* @param playWhenReady Whether playback should proceed when ready and not suppressed.
* @param playWhenReadyChangeReason The {@linkplain PlayWhenReadyChangeReason reason} for
* changing the value.
* @return This builder.
*/
@CanIgnoreReturnValue
public Builder setPlayWhenReady(
boolean playWhenReady, @PlayWhenReadyChangeReason int playWhenReadyChangeReason) {
this.playWhenReady = playWhenReady;
this.playWhenReadyChangeReason = playWhenReadyChangeReason;
return this;
}
/** Builds the {@link State}. */
public State build() {
return new State(this);
}
}
/** The available {@link Commands}. */
public final Commands availableCommands;
/** Whether playback should proceed when ready and not suppressed. */
public final boolean playWhenReady;
/** The last reason for changing {@link #playWhenReady}. */
public final @PlayWhenReadyChangeReason int playWhenReadyChangeReason;
private State(Builder builder) {
this.availableCommands = builder.availableCommands;
this.playWhenReady = builder.playWhenReady;
this.playWhenReadyChangeReason = builder.playWhenReadyChangeReason;
}
/** Returns a {@link Builder} pre-populated with the current state values. */
public Builder buildUpon() {
return new Builder(this);
}
@Override
public boolean equals(@Nullable Object o) {
if (this == o) {
return true;
}
if (!(o instanceof State)) {
return false;
}
State state = (State) o;
return playWhenReady == state.playWhenReady
&& playWhenReadyChangeReason == state.playWhenReadyChangeReason
&& availableCommands.equals(state.availableCommands);
}
@Override
public int hashCode() {
int result = 7;
result = 31 * result + availableCommands.hashCode();
result = 31 * result + (playWhenReady ? 1 : 0);
result = 31 * result + playWhenReadyChangeReason;
return result;
}
}
private final ListenerSet<Listener> listeners;
private final Looper applicationLooper;
private final HandlerWrapper applicationHandler;
private final HashSet<ListenableFuture<?>> pendingOperations;
private @MonotonicNonNull State state;
/**
* Creates the base class.
*
* @param applicationLooper The {@link Looper} that must be used for all calls to the player and
* that is used to call listeners on.
*/
protected SimpleBasePlayer(Looper applicationLooper) {
this(applicationLooper, Clock.DEFAULT);
}
/**
* Creates the base class.
*
* @param applicationLooper The {@link Looper} that must be used for all calls to the player and
* that is used to call listeners on.
* @param clock The {@link Clock} that will be used by the player.
*/
protected SimpleBasePlayer(Looper applicationLooper, Clock clock) {
this.applicationLooper = applicationLooper;
applicationHandler = clock.createHandler(applicationLooper, /* callback= */ null);
pendingOperations = new HashSet<>();
@SuppressWarnings("nullness:argument.type.incompatible") // Using this in constructor.
ListenerSet<Player.Listener> listenerSet =
new ListenerSet<>(
applicationLooper,
clock,
(listener, flags) -> listener.onEvents(/* player= */ this, new Events(flags)));
listeners = listenerSet;
}
@Override
public final void addListener(Listener listener) {
// Don't verify application thread. We allow calls to this method from any thread.
listeners.add(checkNotNull(listener));
}
@Override
public final void removeListener(Listener listener) {
// Don't verify application thread. We allow calls to this method from any thread.
checkNotNull(listener);
listeners.remove(listener);
}
@Override
public final Looper getApplicationLooper() {
// Don't verify application thread. We allow calls to this method from any thread.
return applicationLooper;
}
@Override
public final Commands getAvailableCommands() {
verifyApplicationThreadAndInitState();
return state.availableCommands;
}
@Override
public final void setPlayWhenReady(boolean playWhenReady) {
verifyApplicationThreadAndInitState();
State state = this.state;
if (!state.availableCommands.contains(Player.COMMAND_PLAY_PAUSE)) {
return;
}
updateStateForPendingOperation(
/* pendingOperation= */ handleSetPlayWhenReady(playWhenReady),
/* placeholderStateSupplier= */ () ->
state
.buildUpon()
.setPlayWhenReady(playWhenReady, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
.build());
}
@Override
public final boolean getPlayWhenReady() {
verifyApplicationThreadAndInitState();
return state.playWhenReady;
}
@Override
public final void setMediaItems(List<MediaItem> mediaItems, boolean resetPosition) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void setMediaItems(
List<MediaItem> mediaItems, int startIndex, long startPositionMs) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void addMediaItems(int index, List<MediaItem> mediaItems) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void moveMediaItems(int fromIndex, int toIndex, int newIndex) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void removeMediaItems(int fromIndex, int toIndex) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void prepare() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final int getPlaybackState() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final int getPlaybackSuppressionReason() {
// TODO: implement.
throw new IllegalStateException();
}
@Nullable
@Override
public final PlaybackException getPlayerError() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void setRepeatMode(int repeatMode) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final int getRepeatMode() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void setShuffleModeEnabled(boolean shuffleModeEnabled) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final boolean getShuffleModeEnabled() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final boolean isLoading() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void seekTo(int mediaItemIndex, long positionMs) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final long getSeekBackIncrement() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final long getSeekForwardIncrement() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final long getMaxSeekToPreviousPosition() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void setPlaybackParameters(PlaybackParameters playbackParameters) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final PlaybackParameters getPlaybackParameters() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void stop() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void stop(boolean reset) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void release() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final Tracks getCurrentTracks() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final TrackSelectionParameters getTrackSelectionParameters() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void setTrackSelectionParameters(TrackSelectionParameters parameters) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final MediaMetadata getMediaMetadata() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final MediaMetadata getPlaylistMetadata() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void setPlaylistMetadata(MediaMetadata mediaMetadata) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final Timeline getCurrentTimeline() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final int getCurrentPeriodIndex() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final int getCurrentMediaItemIndex() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final long getDuration() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final long getCurrentPosition() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final long getBufferedPosition() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final long getTotalBufferedDuration() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final boolean isPlayingAd() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final int getCurrentAdGroupIndex() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final int getCurrentAdIndexInAdGroup() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final long getContentPosition() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final long getContentBufferedPosition() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final AudioAttributes getAudioAttributes() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void setVolume(float volume) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final float getVolume() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void clearVideoSurface() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void clearVideoSurface(@Nullable Surface surface) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void setVideoSurface(@Nullable Surface surface) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void setVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void clearVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void setVideoSurfaceView(@Nullable SurfaceView surfaceView) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void clearVideoSurfaceView(@Nullable SurfaceView surfaceView) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void setVideoTextureView(@Nullable TextureView textureView) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void clearVideoTextureView(@Nullable TextureView textureView) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final VideoSize getVideoSize() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final CueGroup getCurrentCues() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final DeviceInfo getDeviceInfo() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final int getDeviceVolume() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final boolean isDeviceMuted() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void setDeviceVolume(int volume) {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void increaseDeviceVolume() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void decreaseDeviceVolume() {
// TODO: implement.
throw new IllegalStateException();
}
@Override
public final void setDeviceMuted(boolean muted) {
// TODO: implement.
throw new IllegalStateException();
}
/**
* Invalidates the current state.
*
* <p>Triggers a call to {@link #getState()} and informs listeners if the state changed.
*
* <p>Note that this may not have an immediate effect while there are still player methods being
* handled asynchronously. The state will be invalidated automatically once these pending
* synchronous operations are finished and there is no need to call this method again.
*/
protected final void invalidateState() {
verifyApplicationThreadAndInitState();
if (!pendingOperations.isEmpty()) {
return;
}
updateStateAndInformListeners(getState());
}
/**
* Returns the current {@link State} of the player.
*
* <p>The {@link State} should include all {@linkplain
* State.Builder#setAvailableCommands(Commands) available commands} indicating which player
* methods are allowed to be called.
*
* <p>Note that this method won't be called while asynchronous handling of player methods is in
* progress. This means that the implementation doesn't need to handle state changes caused by
* these asynchronous operations until they are done and can return the currently known state
* directly. The placeholder state used while these asynchronous operations are in progress can be
* customized by overriding {@link #getPlaceholderState(State)} if required.
*/
@ForOverride
protected abstract State getState();
/**
* Returns the placeholder state used while a player method is handled asynchronously.
*
* <p>The {@code suggestedPlaceholderState} already contains the most likely state update, for
* example setting {@link State#playWhenReady} to true if {@code player.setPlayWhenReady(true)} is
* called, and an implementations only needs to override this method if it can determine a more
* accurate placeholder state.
*
* @param suggestedPlaceholderState The suggested placeholder {@link State}, including the most
* likely outcome of handling all pending asynchronous operations.
* @return The placeholder {@link State} to use while asynchronous operations are pending.
*/
@ForOverride
protected State getPlaceholderState(State suggestedPlaceholderState) {
return suggestedPlaceholderState;
}
/**
* Handles calls to set {@link State#playWhenReady}.
*
* <p>Will only be called if {@link Player.Command#COMMAND_PLAY_PAUSE} is available.
*
* @param playWhenReady The requested {@link State#playWhenReady}
* @return A {@link ListenableFuture} indicating the completion of all immediate {@link State}
* changes caused by this call.
* @see Player#setPlayWhenReady(boolean)
* @see Player#play()
* @see Player#pause()
*/
@ForOverride
protected ListenableFuture<?> handleSetPlayWhenReady(boolean playWhenReady) {
throw new IllegalStateException();
}
@SuppressWarnings("deprecation") // Calling deprecated listener methods.
@RequiresNonNull("state")
private void updateStateAndInformListeners(State newState) {
State previousState = state;
// Assign new state immediately such that all getters return the right values, but use a
// snapshot of the previous and new state so that listener invocations are triggered correctly.
this.state = newState;
boolean playWhenReadyChanged = previousState.playWhenReady != newState.playWhenReady;
if (playWhenReadyChanged /* TODO: || playbackStateChanged */) {
listeners.queueEvent(
/* eventFlag= */ C.INDEX_UNSET,
listener ->
listener.onPlayerStateChanged(newState.playWhenReady, /* TODO */ Player.STATE_IDLE));
}
if (playWhenReadyChanged
|| previousState.playWhenReadyChangeReason != newState.playWhenReadyChangeReason) {
listeners.queueEvent(
Player.EVENT_PLAY_WHEN_READY_CHANGED,
listener ->
listener.onPlayWhenReadyChanged(
newState.playWhenReady, newState.playWhenReadyChangeReason));
}
if (isPlaying(previousState) != isPlaying(newState)) {
listeners.queueEvent(
Player.EVENT_IS_PLAYING_CHANGED,
listener -> listener.onIsPlayingChanged(isPlaying(newState)));
}
if (!previousState.availableCommands.equals(newState.availableCommands)) {
listeners.queueEvent(
Player.EVENT_AVAILABLE_COMMANDS_CHANGED,
listener -> listener.onAvailableCommandsChanged(newState.availableCommands));
}
listeners.flushEvents();
}
@EnsuresNonNull("state")
private void verifyApplicationThreadAndInitState() {
if (Thread.currentThread() != applicationLooper.getThread()) {
String message =
Util.formatInvariant(
"Player is accessed on the wrong thread.\n"
+ "Current thread: '%s'\n"
+ "Expected thread: '%s'\n"
+ "See https://exoplayer.dev/issues/player-accessed-on-wrong-thread",
Thread.currentThread().getName(), applicationLooper.getThread().getName());
throw new IllegalStateException(message);
}
if (state == null) {
// First time accessing state.
state = getState();
}
}
@RequiresNonNull("state")
private void updateStateForPendingOperation(
ListenableFuture<?> pendingOperation, Supplier<State> placeholderStateSupplier) {
if (pendingOperation.isDone() && pendingOperations.isEmpty()) {
updateStateAndInformListeners(getState());
} else {
pendingOperations.add(pendingOperation);
State suggestedPlaceholderState = placeholderStateSupplier.get();
updateStateAndInformListeners(getPlaceholderState(suggestedPlaceholderState));
pendingOperation.addListener(
() -> {
castNonNull(state); // Already check by method @RequiresNonNull pre-condition.
pendingOperations.remove(pendingOperation);
if (pendingOperations.isEmpty()) {
updateStateAndInformListeners(getState());
}
},
this::postOrRunOnApplicationHandler);
}
}
private void postOrRunOnApplicationHandler(Runnable runnable) {
if (applicationHandler.getLooper() == Looper.myLooper()) {
runnable.run();
} else {
applicationHandler.post(runnable);
}
}
private static boolean isPlaying(State state) {
return state.playWhenReady && false;
// TODO: && state.playbackState == Player.STATE_READY
// && state.playbackSuppressionReason == PLAYBACK_SUPPRESSION_REASON_NONE
}
}

View File

@ -0,0 +1,406 @@
/*
* Copyright 2022 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 com.google.android.exoplayer2;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import android.os.Looper;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.Player.Commands;
import com.google.android.exoplayer2.Player.Listener;
import com.google.android.exoplayer2.SimpleBasePlayer.State;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Unit test for {@link SimpleBasePlayer}. */
@RunWith(AndroidJUnit4.class)
public class SimpleBasePlayerTest {
@Test
public void allPlayerInterfaceMethods_declaredFinal() throws Exception {
for (Method method : Player.class.getDeclaredMethods()) {
assertThat(
SimpleBasePlayer.class
.getMethod(method.getName(), method.getParameterTypes())
.getModifiers()
& Modifier.FINAL)
.isNotEqualTo(0);
}
}
@Test
public void stateBuildUpon_build_isEqual() {
State state =
new State.Builder()
.setAvailableCommands(new Commands.Builder().addAllCommands().build())
.setPlayWhenReady(
/* playWhenReady= */ true,
/* playWhenReadyChangeReason= */ Player
.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS)
.build();
State newState = state.buildUpon().build();
assertThat(newState).isEqualTo(state);
assertThat(newState.hashCode()).isEqualTo(state.hashCode());
}
@Test
public void stateBuilderSetAvailableCommands_setsAvailableCommands() {
Commands commands =
new Commands.Builder()
.addAll(Player.COMMAND_GET_DEVICE_VOLUME, Player.COMMAND_GET_TIMELINE)
.build();
State state = new State.Builder().setAvailableCommands(commands).build();
assertThat(state.availableCommands).isEqualTo(commands);
}
@Test
public void stateBuilderSetPlayWhenReady_setsStatePlayWhenReadyAndReason() {
State state =
new State.Builder()
.setPlayWhenReady(
/* playWhenReady= */ true,
/* playWhenReadyChangeReason= */ Player
.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS)
.build();
assertThat(state.playWhenReady).isTrue();
assertThat(state.playWhenReadyChangeReason)
.isEqualTo(Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS);
}
@Test
public void getterMethods_noOtherMethodCalls_returnCurrentState() {
Commands commands =
new Commands.Builder()
.addAll(Player.COMMAND_GET_DEVICE_VOLUME, Player.COMMAND_GET_TIMELINE)
.build();
State state =
new State.Builder()
.setAvailableCommands(commands)
.setPlayWhenReady(
/* playWhenReady= */ true,
/* playWhenReadyChangeReason= */ Player
.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS)
.build();
SimpleBasePlayer player =
new SimpleBasePlayer(Looper.myLooper()) {
@Override
protected State getState() {
return state;
}
};
assertThat(player.getApplicationLooper()).isEqualTo(Looper.myLooper());
assertThat(player.getAvailableCommands()).isEqualTo(commands);
assertThat(player.getPlayWhenReady()).isTrue();
}
@SuppressWarnings("deprecation") // Verifying deprecated listener call.
@Test
public void invalidateState_updatesStateAndInformsListeners() {
State state1 =
new State.Builder()
.setAvailableCommands(new Commands.Builder().addAllCommands().build())
.setPlayWhenReady(
/* playWhenReady= */ true,
/* playWhenReadyChangeReason= */ Player
.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS)
.build();
Commands commands = new Commands.Builder().add(Player.COMMAND_GET_TEXT).build();
State state2 =
new State.Builder()
.setAvailableCommands(commands)
.setPlayWhenReady(
/* playWhenReady= */ false,
/* playWhenReadyChangeReason= */ Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
.build();
AtomicBoolean returnState2 = new AtomicBoolean();
SimpleBasePlayer player =
new SimpleBasePlayer(Looper.myLooper()) {
@Override
protected State getState() {
return returnState2.get() ? state2 : state1;
}
};
Listener listener = mock(Listener.class);
player.addListener(listener);
// Verify state1 is used.
assertThat(player.getPlayWhenReady()).isTrue();
returnState2.set(true);
player.invalidateState();
// Verify updated state.
assertThat(player.getAvailableCommands()).isEqualTo(commands);
assertThat(player.getPlayWhenReady()).isFalse();
// Verify listener calls.
verify(listener).onAvailableCommandsChanged(commands);
verify(listener)
.onPlayWhenReadyChanged(
/* playWhenReady= */ false, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE);
verify(listener)
.onPlayerStateChanged(/* playWhenReady= */ false, /* playbackState= */ Player.STATE_IDLE);
verifyNoMoreInteractions(listener);
}
@Test
public void invalidateState_duringAsyncMethodHandling_isIgnored() {
State state1 =
new State.Builder()
.setAvailableCommands(new Commands.Builder().addAllCommands().build())
.setPlayWhenReady(
/* playWhenReady= */ true,
/* playWhenReadyChangeReason= */ Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
.build();
State state2 =
state1
.buildUpon()
.setPlayWhenReady(
/* playWhenReady= */ false,
/* playWhenReadyChangeReason= */ Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
.build();
AtomicReference<State> currentState = new AtomicReference<>(state1);
SettableFuture<?> asyncFuture = SettableFuture.create();
SimpleBasePlayer player =
new SimpleBasePlayer(Looper.myLooper()) {
@Override
protected State getState() {
return currentState.get();
}
@Override
protected ListenableFuture<?> handleSetPlayWhenReady(boolean playWhenReady) {
return asyncFuture;
}
};
Listener listener = mock(Listener.class);
player.addListener(listener);
// Verify state1 is used trigger async method.
assertThat(player.getPlayWhenReady()).isTrue();
player.setPlayWhenReady(true);
currentState.set(state2);
player.invalidateState();
// Verify placeholder state is used (and not state2).
assertThat(player.getPlayWhenReady()).isTrue();
// Finish async operation and verify no listeners are informed.
currentState.set(state1);
asyncFuture.set(null);
assertThat(player.getPlayWhenReady()).isTrue();
verifyNoMoreInteractions(listener);
}
@Test
public void overlappingAsyncMethodHandling_onlyUpdatesStateAfterAllDone() {
State state1 =
new State.Builder()
.setAvailableCommands(new Commands.Builder().addAllCommands().build())
.setPlayWhenReady(
/* playWhenReady= */ true,
/* playWhenReadyChangeReason= */ Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
.build();
State state2 =
state1
.buildUpon()
.setPlayWhenReady(
/* playWhenReady= */ false,
/* playWhenReadyChangeReason= */ Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
.build();
AtomicReference<State> currentState = new AtomicReference<>(state1);
ArrayList<SettableFuture<?>> asyncFutures = new ArrayList<>();
SimpleBasePlayer player =
new SimpleBasePlayer(Looper.myLooper()) {
@Override
protected State getState() {
return currentState.get();
}
@Override
protected ListenableFuture<?> handleSetPlayWhenReady(boolean playWhenReady) {
SettableFuture<?> future = SettableFuture.create();
asyncFutures.add(future);
return future;
}
};
Listener listener = mock(Listener.class);
player.addListener(listener);
// Verify state1 is used.
assertThat(player.getPlayWhenReady()).isTrue();
// Trigger multiple parallel async calls and set state2 (which should never be used).
player.setPlayWhenReady(true);
currentState.set(state2);
assertThat(player.getPlayWhenReady()).isTrue();
player.setPlayWhenReady(true);
assertThat(player.getPlayWhenReady()).isTrue();
player.setPlayWhenReady(true);
assertThat(player.getPlayWhenReady()).isTrue();
// Finish async operation and verify state2 is not used while operations are pending.
asyncFutures.get(1).set(null);
assertThat(player.getPlayWhenReady()).isTrue();
asyncFutures.get(2).set(null);
assertThat(player.getPlayWhenReady()).isTrue();
verifyNoMoreInteractions(listener);
// Finish last async operation and verify updated state and listener calls.
asyncFutures.get(0).set(null);
assertThat(player.getPlayWhenReady()).isFalse();
verify(listener)
.onPlayWhenReadyChanged(
/* playWhenReady= */ false, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE);
}
@SuppressWarnings("deprecation") // Verifying deprecated listener call.
@Test
public void setPlayWhenReady_immediateHandling_updatesStateAndInformsListeners() {
State state =
new State.Builder()
.setAvailableCommands(new Commands.Builder().addAllCommands().build())
.setPlayWhenReady(
/* playWhenReady= */ false, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
.build();
State updatedState =
state
.buildUpon()
.setPlayWhenReady(
/* playWhenReady= */ true, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
.build();
AtomicBoolean stateUpdated = new AtomicBoolean();
SimpleBasePlayer player =
new SimpleBasePlayer(Looper.myLooper()) {
@Override
protected State getState() {
return stateUpdated.get() ? updatedState : state;
}
@Override
protected ListenableFuture<?> handleSetPlayWhenReady(boolean playWhenReady) {
stateUpdated.set(true);
return Futures.immediateVoidFuture();
}
};
Listener listener = mock(Listener.class);
player.addListener(listener);
// Intentionally use parameter that doesn't match final result.
player.setPlayWhenReady(false);
assertThat(player.getPlayWhenReady()).isTrue();
verify(listener)
.onPlayWhenReadyChanged(
/* playWhenReady= */ true, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE);
verify(listener)
.onPlayerStateChanged(/* playWhenReady= */ true, /* playbackState= */ Player.STATE_IDLE);
verifyNoMoreInteractions(listener);
}
@SuppressWarnings("deprecation") // Verifying deprecated listener call.
@Test
public void setPlayWhenReady_asyncHandling_usesPlaceholderStateAndInformsListeners() {
State state =
new State.Builder()
.setAvailableCommands(new Commands.Builder().addAllCommands().build())
.setPlayWhenReady(
/* playWhenReady= */ false, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
.build();
State updatedState =
state
.buildUpon()
.setPlayWhenReady(
/* playWhenReady= */ true, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
.build();
SettableFuture<?> future = SettableFuture.create();
SimpleBasePlayer player =
new SimpleBasePlayer(Looper.myLooper()) {
@Override
protected State getState() {
return future.isDone() ? updatedState : state;
}
@Override
protected ListenableFuture<?> handleSetPlayWhenReady(boolean playWhenReady) {
return future;
}
};
Listener listener = mock(Listener.class);
player.addListener(listener);
player.setPlayWhenReady(true);
// Verify placeholder state and listener calls.
assertThat(player.getPlayWhenReady()).isTrue();
verify(listener)
.onPlayWhenReadyChanged(
/* playWhenReady= */ true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST);
verify(listener)
.onPlayerStateChanged(/* playWhenReady= */ true, /* playbackState= */ Player.STATE_IDLE);
verifyNoMoreInteractions(listener);
future.set(null);
// Verify actual state update.
assertThat(player.getPlayWhenReady()).isTrue();
verify(listener)
.onPlayWhenReadyChanged(
/* playWhenReady= */ true, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE);
verifyNoMoreInteractions(listener);
}
@Test
public void setPlayWhenReady_withoutAvailableCommand_isNotForwarded() {
State state =
new State.Builder()
.setAvailableCommands(
new Commands.Builder().addAllCommands().remove(Player.COMMAND_PLAY_PAUSE).build())
.build();
AtomicBoolean callForwarded = new AtomicBoolean();
SimpleBasePlayer player =
new SimpleBasePlayer(Looper.myLooper()) {
@Override
protected State getState() {
return state;
}
@Override
protected ListenableFuture<?> handleSetPlayWhenReady(boolean playWhenReady) {
callForwarded.set(true);
return Futures.immediateVoidFuture();
}
};
player.setPlayWhenReady(true);
assertThat(callForwarded.get()).isFalse();
}
}