From 9a7fde8fde6e194b92ec67095b7b889ee23a3d77 Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 15 Aug 2022 08:54:27 +0000 Subject: [PATCH] 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 --- .../exoplayer2/LegacyMediaPlayerWrapper.java | 59 ++ .../android/exoplayer2/SimpleBasePlayer.java | 793 ++++++++++++++++++ .../exoplayer2/SimpleBasePlayerTest.java | 406 +++++++++ 3 files changed, 1258 insertions(+) create mode 100644 library/common/src/main/java/com/google/android/exoplayer2/LegacyMediaPlayerWrapper.java create mode 100644 library/common/src/main/java/com/google/android/exoplayer2/SimpleBasePlayer.java create mode 100644 library/common/src/test/java/com/google/android/exoplayer2/SimpleBasePlayerTest.java diff --git a/library/common/src/main/java/com/google/android/exoplayer2/LegacyMediaPlayerWrapper.java b/library/common/src/main/java/com/google/android/exoplayer2/LegacyMediaPlayerWrapper.java new file mode 100644 index 00000000000..a2ff1d63465 --- /dev/null +++ b/library/common/src/main/java/com/google/android/exoplayer2/LegacyMediaPlayerWrapper.java @@ -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(); + } +} diff --git a/library/common/src/main/java/com/google/android/exoplayer2/SimpleBasePlayer.java b/library/common/src/main/java/com/google/android/exoplayer2/SimpleBasePlayer.java new file mode 100644 index 00000000000..38167d00b51 --- /dev/null +++ b/library/common/src/main/java/com/google/android/exoplayer2/SimpleBasePlayer.java @@ -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. + * + *

Implementation notes: + * + *

+ * + * This base class handles various aspects of the player implementation to simplify the subclass: + * + * + */ +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 listeners; + private final Looper applicationLooper; + private final HandlerWrapper applicationHandler; + private final HashSet> 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 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 mediaItems, boolean resetPosition) { + // TODO: implement. + throw new IllegalStateException(); + } + + @Override + public final void setMediaItems( + List mediaItems, int startIndex, long startPositionMs) { + // TODO: implement. + throw new IllegalStateException(); + } + + @Override + public final void addMediaItems(int index, List 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. + * + *

Triggers a call to {@link #getState()} and informs listeners if the state changed. + * + *

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. + * + *

The {@link State} should include all {@linkplain + * State.Builder#setAvailableCommands(Commands) available commands} indicating which player + * methods are allowed to be called. + * + *

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. + * + *

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}. + * + *

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 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 + } +} diff --git a/library/common/src/test/java/com/google/android/exoplayer2/SimpleBasePlayerTest.java b/library/common/src/test/java/com/google/android/exoplayer2/SimpleBasePlayerTest.java new file mode 100644 index 00000000000..7c9fcf1afec --- /dev/null +++ b/library/common/src/test/java/com/google/android/exoplayer2/SimpleBasePlayerTest.java @@ -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 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 currentState = new AtomicReference<>(state1); + ArrayList> 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(); + } +}