entry : info.getAllHeadersAsList()) {
if (entry.getKey().equalsIgnoreCase("Content-Encoding")) {
@@ -833,7 +860,7 @@ private static long getContentLength(UrlResponseInfo info) {
// would increase it.
Log.w(TAG, "Inconsistent headers [" + contentLengthHeader + "] [" + contentRangeHeader
+ "]");
- contentLength = Math.max(contentLength, contentLengthFromRange);
+ contentLength = max(contentLength, contentLengthFromRange);
}
} catch (NumberFormatException e) {
Log.e(TAG, "Unexpected Content-Range [" + contentRangeHeader + "]");
@@ -876,7 +903,7 @@ private static boolean isEmpty(@Nullable List> list) {
// Copy as much as possible from the src buffer into dst buffer.
// Returns the number of bytes copied.
private static int copyByteBuffer(ByteBuffer src, ByteBuffer dst) {
- int remaining = Math.min(src.remaining(), dst.remaining());
+ int remaining = min(src.remaining(), dst.remaining());
int limit = src.limit();
src.limit(src.position() + remaining);
dst.put(src);
@@ -900,7 +927,11 @@ public synchronized void onRedirectReceived(
if (responseCode == 307 || responseCode == 308) {
exception =
new InvalidResponseCodeException(
- responseCode, info.getHttpStatusText(), info.getAllHeaders(), dataSpec);
+ responseCode,
+ info.getHttpStatusText(),
+ info.getAllHeaders(),
+ dataSpec,
+ /* responseBody= */ Util.EMPTY_BYTE_ARRAY);
operation.open();
return;
}
diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java
index 4086011b4f3..85c9d09a791 100644
--- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java
+++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java
@@ -15,6 +15,8 @@
*/
package com.google.android.exoplayer2.ext.cronet;
+import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT;
+
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
import com.google.android.exoplayer2.upstream.HttpDataSource;
@@ -50,14 +52,13 @@ public final class CronetDataSourceFactory extends BaseFactory {
private final HttpDataSource.Factory fallbackFactory;
/**
- * Constructs a CronetDataSourceFactory.
+ * Creates an instance.
*
* If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided
* fallback {@link HttpDataSource.Factory} will be used instead.
*
*
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
- * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables
- * cross-protocol redirects.
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout.
*
* @param cronetEngineWrapper A {@link CronetEngineWrapper}.
* @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
@@ -79,23 +80,36 @@ public CronetDataSourceFactory(
}
/**
- * Constructs a CronetDataSourceFactory.
+ * Creates an instance.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
+ * DefaultHttpDataSourceFactory} will be used instead.
+ *
+ *
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ */
+ public CronetDataSourceFactory(CronetEngineWrapper cronetEngineWrapper, Executor executor) {
+ this(cronetEngineWrapper, executor, DEFAULT_USER_AGENT);
+ }
+
+ /**
+ * Creates an instance.
*
*
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
* DefaultHttpDataSourceFactory} will be used instead.
*
*
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
- * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables
- * cross-protocol redirects.
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout.
*
* @param cronetEngineWrapper A {@link CronetEngineWrapper}.
* @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
* @param userAgent A user agent used to create a fallback HttpDataSource if needed.
*/
public CronetDataSourceFactory(
- CronetEngineWrapper cronetEngineWrapper,
- Executor executor,
- String userAgent) {
+ CronetEngineWrapper cronetEngineWrapper, Executor executor, String userAgent) {
this(
cronetEngineWrapper,
executor,
@@ -112,7 +126,7 @@ public CronetDataSourceFactory(
}
/**
- * Constructs a CronetDataSourceFactory.
+ * Creates an instance.
*
*
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
* DefaultHttpDataSourceFactory} will be used instead.
@@ -147,7 +161,7 @@ public CronetDataSourceFactory(
}
/**
- * Constructs a CronetDataSourceFactory.
+ * Creates an instance.
*
*
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided
* fallback {@link HttpDataSource.Factory} will be used instead.
@@ -178,14 +192,13 @@ public CronetDataSourceFactory(
}
/**
- * Constructs a CronetDataSourceFactory.
+ * Creates an instance.
*
*
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided
* fallback {@link HttpDataSource.Factory} will be used instead.
*
*
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
- * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables
- * cross-protocol redirects.
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout.
*
* @param cronetEngineWrapper A {@link CronetEngineWrapper}.
* @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
@@ -209,14 +222,33 @@ public CronetDataSourceFactory(
}
/**
- * Constructs a CronetDataSourceFactory.
+ * Creates an instance.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
+ * DefaultHttpDataSourceFactory} will be used instead.
+ *
+ *
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @param transferListener An optional listener.
+ */
+ public CronetDataSourceFactory(
+ CronetEngineWrapper cronetEngineWrapper,
+ Executor executor,
+ @Nullable TransferListener transferListener) {
+ this(cronetEngineWrapper, executor, transferListener, DEFAULT_USER_AGENT);
+ }
+
+ /**
+ * Creates an instance.
*
*
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
* DefaultHttpDataSourceFactory} will be used instead.
*
*
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
- * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables
- * cross-protocol redirects.
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout.
*
* @param cronetEngineWrapper A {@link CronetEngineWrapper}.
* @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
@@ -244,7 +276,7 @@ public CronetDataSourceFactory(
}
/**
- * Constructs a CronetDataSourceFactory.
+ * Creates an instance.
*
*
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
* DefaultHttpDataSourceFactory} will be used instead.
@@ -277,7 +309,7 @@ public CronetDataSourceFactory(
}
/**
- * Constructs a CronetDataSourceFactory.
+ * Creates an instance.
*
*
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided
* fallback {@link HttpDataSource.Factory} will be used instead.
diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java
index 2c25c32269f..9f709b14d03 100644
--- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java
+++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java
@@ -15,6 +15,8 @@
*/
package com.google.android.exoplayer2.ext.cronet;
+import static java.lang.Math.min;
+
import android.content.Context;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
@@ -166,7 +168,7 @@ private static class CronetProviderComparator implements Comparator> responseHeaderList = new ArrayList<>();
- responseHeaderList.addAll(testResponseHeader.entrySet());
- return new UrlResponseInfoImpl(
- Collections.singletonList(url),
- statusCode,
- null, // httpStatusText
- responseHeaderList,
- false, // wasCached
- null, // negotiatedProtocol
- null); // proxyServer
+ Map> responseHeaderMap = new HashMap<>();
+ for (Map.Entry entry : testResponseHeader.entrySet()) {
+ responseHeaderList.add(entry);
+ responseHeaderMap.put(entry.getKey(), Collections.singletonList(entry.getValue()));
+ }
+ return new UrlResponseInfo() {
+ @Override
+ public String getUrl() {
+ return url;
+ }
+
+ @Override
+ public List getUrlChain() {
+ return Collections.singletonList(url);
+ }
+
+ @Override
+ public int getHttpStatusCode() {
+ return statusCode;
+ }
+
+ @Override
+ public String getHttpStatusText() {
+ return null;
+ }
+
+ @Override
+ public List> getAllHeadersAsList() {
+ return responseHeaderList;
+ }
+
+ @Override
+ public Map> getAllHeaders() {
+ return responseHeaderMap;
+ }
+
+ @Override
+ public boolean wasCached() {
+ return false;
+ }
+
+ @Override
+ public String getNegotiatedProtocol() {
+ return null;
+ }
+
+ @Override
+ public String getProxyServer() {
+ return null;
+ }
+
+ @Override
+ public long getReceivedByteCount() {
+ return 0;
+ }
+ };
}
@Test
@@ -284,7 +330,7 @@ public void requestOpenFail() {
fail("HttpDataSource.HttpDataSourceException expected");
} catch (HttpDataSourceException e) {
// Check for connection not automatically closed.
- assertThat(e.getCause() instanceof UnknownHostException).isFalse();
+ assertThat(e).hasCauseThat().isNotInstanceOf(UnknownHostException.class);
verify(mockUrlRequest, never()).cancel();
verify(mockTransferListener, never())
.onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
@@ -322,7 +368,7 @@ public void requestOpenFailDueToDnsFailure() {
fail("HttpDataSource.HttpDataSourceException expected");
} catch (HttpDataSourceException e) {
// Check for connection not automatically closed.
- assertThat(e.getCause() instanceof UnknownHostException).isTrue();
+ assertThat(e).hasCauseThat().isInstanceOf(UnknownHostException.class);
verify(mockUrlRequest, never()).cancel();
verify(mockTransferListener, never())
.onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
@@ -330,15 +376,18 @@ public void requestOpenFailDueToDnsFailure() {
}
@Test
- public void requestOpenValidatesStatusCode() {
+ public void requestOpenPropagatesFailureResponseBody() throws Exception {
mockResponseStartSuccess();
- testUrlResponseInfo = createUrlResponseInfo(500); // statusCode
+ // Use a size larger than CronetDataSource.READ_BUFFER_SIZE_BYTES
+ int responseLength = 40 * 1024;
+ mockReadSuccess(/* position= */ 0, /* length= */ responseLength);
+ testUrlResponseInfo = createUrlResponseInfo(/* statusCode= */ 500);
try {
dataSourceUnderTest.open(testDataSpec);
- fail("HttpDataSource.HttpDataSourceException expected");
- } catch (HttpDataSourceException e) {
- assertThat(e instanceof HttpDataSource.InvalidResponseCodeException).isTrue();
+ fail("HttpDataSource.InvalidResponseCodeException expected");
+ } catch (HttpDataSource.InvalidResponseCodeException e) {
+ assertThat(e.responseBody).isEqualTo(buildTestDataArray(0, responseLength));
// Check for connection not automatically closed.
verify(mockUrlRequest, never()).cancel();
verify(mockTransferListener, never())
@@ -361,7 +410,7 @@ public void requestOpenValidatesContentTypePredicate() {
dataSourceUnderTest.open(testDataSpec);
fail("HttpDataSource.HttpDataSourceException expected");
} catch (HttpDataSourceException e) {
- assertThat(e instanceof HttpDataSource.InvalidContentTypeException).isTrue();
+ assertThat(e).isInstanceOf(HttpDataSource.InvalidContentTypeException.class);
// Check for connection not automatically closed.
verify(mockUrlRequest, never()).cancel();
assertThat(testedContentTypes).hasSize(1);
@@ -892,8 +941,8 @@ public void run() {
fail();
} catch (HttpDataSourceException e) {
// Expected.
- assertThat(e instanceof CronetDataSource.OpenException).isTrue();
- assertThat(e.getCause() instanceof SocketTimeoutException).isTrue();
+ assertThat(e).isInstanceOf(CronetDataSource.OpenException.class);
+ assertThat(e).hasCauseThat().isInstanceOf(SocketTimeoutException.class);
assertThat(((CronetDataSource.OpenException) e).cronetConnectionStatus)
.isEqualTo(TEST_CONNECTION_STATUS);
timedOutLatch.countDown();
@@ -932,8 +981,8 @@ public void run() {
fail();
} catch (HttpDataSourceException e) {
// Expected.
- assertThat(e instanceof CronetDataSource.OpenException).isTrue();
- assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue();
+ assertThat(e).isInstanceOf(CronetDataSource.OpenException.class);
+ assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class);
assertThat(((CronetDataSource.OpenException) e).cronetConnectionStatus)
.isEqualTo(TEST_INVALID_CONNECTION_STATUS);
timedOutLatch.countDown();
@@ -1005,8 +1054,8 @@ public void run() {
fail();
} catch (HttpDataSourceException e) {
// Expected.
- assertThat(e instanceof CronetDataSource.OpenException).isTrue();
- assertThat(e.getCause() instanceof SocketTimeoutException).isTrue();
+ assertThat(e).isInstanceOf(CronetDataSource.OpenException.class);
+ assertThat(e).hasCauseThat().isInstanceOf(SocketTimeoutException.class);
openExceptions.getAndIncrement();
timedOutLatch.countDown();
}
@@ -1231,7 +1280,7 @@ public void run() {
fail();
} catch (HttpDataSourceException e) {
// Expected.
- assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue();
+ assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class);
timedOutLatch.countDown();
}
}
@@ -1262,7 +1311,7 @@ public void run() {
fail();
} catch (HttpDataSourceException e) {
// Expected.
- assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue();
+ assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class);
timedOutLatch.countDown();
}
}
@@ -1375,7 +1424,7 @@ private void mockReadSuccess(int position, int length) {
mockUrlRequest, testUrlResponseInfo);
} else {
ByteBuffer inputBuffer = (ByteBuffer) invocation.getArguments()[0];
- int readLength = Math.min(positionAndRemaining[1], inputBuffer.remaining());
+ int readLength = min(positionAndRemaining[1], inputBuffer.remaining());
inputBuffer.put(buildTestDataBuffer(positionAndRemaining[0], readLength));
positionAndRemaining[0] += readLength;
positionAndRemaining[1] -= readLength;
diff --git a/extensions/ffmpeg/README.md b/extensions/ffmpeg/README.md
index f6e39445728..639d1f6d6c7 100644
--- a/extensions/ffmpeg/README.md
+++ b/extensions/ffmpeg/README.md
@@ -18,14 +18,15 @@ its modules locally. Instructions for doing this can be found in ExoPlayer's
[top level README][]. The extension is not provided via JCenter (see [#2781][]
for more information).
-In addition, it's necessary to build the extension's native components as
-follows:
+In addition, it's necessary to manually build the FFmpeg library, so that gradle
+can bundle the FFmpeg binaries in the APK:
* Set the following shell variable:
```
cd ""
-FFMPEG_EXT_PATH="$(pwd)/extensions/ffmpeg/src/main/jni"
+EXOPLAYER_ROOT="$(pwd)"
+FFMPEG_EXT_PATH="${EXOPLAYER_ROOT}/extensions/ffmpeg/src/main"
```
* Download the [Android NDK][] and set its location in a shell variable.
@@ -41,6 +42,17 @@ NDK_PATH=""
HOST_PLATFORM="linux-x86_64"
```
+* Fetch FFmpeg and checkout an appropriate branch. We cannot guarantee
+ compatibility with all versions of FFmpeg. We currently recommend version 4.2:
+
+```
+cd "" && \
+git clone git://source.ffmpeg.org/ffmpeg && \
+cd ffmpeg && \
+git checkout release/4.2 && \
+FFMPEG_PATH="$(pwd)"
+```
+
* Configure the decoders to include. See the [Supported formats][] page for
details of the available decoders, and which formats they support.
@@ -48,22 +60,21 @@ HOST_PLATFORM="linux-x86_64"
ENABLED_DECODERS=(vorbis opus flac)
```
-* Fetch and build FFmpeg. Executing `build_ffmpeg.sh` will fetch and build
- FFmpeg 4.2 for `armeabi-v7a`, `arm64-v8a`, `x86` and `x86_64`. The script can
- be edited if you need to build for different architectures.
+* Add a link to the FFmpeg source code in the FFmpeg extension `jni` directory.
```
-cd "${FFMPEG_EXT_PATH}" && \
-./build_ffmpeg.sh \
- "${FFMPEG_EXT_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ENABLED_DECODERS[@]}"
+cd "${FFMPEG_EXT_PATH}/jni" && \
+ln -s "$FFMPEG_PATH" ffmpeg
```
-* Build the JNI native libraries, setting `APP_ABI` to include the architectures
- built in the previous step. For example:
+* Execute `build_ffmpeg.sh` to build FFmpeg for `armeabi-v7a`, `arm64-v8a`,
+ `x86` and `x86_64`. The script can be edited if you need to build for
+ different architectures:
```
-cd "${FFMPEG_EXT_PATH}" && \
-${NDK_PATH}/ndk-build APP_ABI="armeabi-v7a arm64-v8a x86 x86_64" -j4
+cd "${FFMPEG_EXT_PATH}/jni" && \
+./build_ffmpeg.sh \
+ "${FFMPEG_EXT_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ENABLED_DECODERS[@]}"
```
## Build instructions (Windows) ##
diff --git a/extensions/ffmpeg/build.gradle b/extensions/ffmpeg/build.gradle
index 4fe753427ac..a9edeaff6bd 100644
--- a/extensions/ffmpeg/build.gradle
+++ b/extensions/ffmpeg/build.gradle
@@ -11,65 +11,13 @@
// 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.
-apply from: '../../constants.gradle'
-apply plugin: 'com.android.library'
+apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle"
-android {
- compileSdkVersion project.ext.compileSdkVersion
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- defaultConfig {
- minSdkVersion project.ext.minSdkVersion
- targetSdkVersion project.ext.targetSdkVersion
- consumerProguardFiles 'proguard-rules.txt'
-
- externalNativeBuild {
- cmake {
- // Debug CMake build type causes video frames to drop,
- // so native library should always use Release build type.
- arguments "-DCMAKE_BUILD_TYPE=Release"
- targets "ffmpeg"
- }
- }
- }
-
- externalNativeBuild {
- cmake {
- version '3.10.2'
- path "src/main/jni/CMakeLists.txt"
- }
- }
-
- buildTypes {
- debug {
- ndk {
- abiFilters 'arm64-v8a'/*, 'x86_64'*/
- }
- }
- }
-
- // This option resolves the problem of finding libffmpeg.so
- // on multiple paths. The first one found is picked.
- packagingOptions {
- pickFirst 'lib/arm64-v8a/libffmpeg.so'
- pickFirst 'lib/armeabi-v7a/libffmpeg.so'
- pickFirst 'lib/x86/libffmpeg.so'
- pickFirst 'lib/x86_64/libffmpeg.so'
- }
-
- sourceSets.main {
- // As native JNI library build is invoked from gradle, this is
- // not needed. However, it exposes the built library and keeps
- // consistency with the other extensions.
- jniLibs.srcDir 'src/main/libs'
- jni.srcDirs = [] // Disable the automatic ndk-build call by Android Studio.
- }
-
- testOptions.unitTests.includeAndroidResources = true
+// Configure the native build only if ffmpeg is present to avoid gradle sync
+// failures if ffmpeg hasn't been built according to the README instructions.
+if (project.file('src/main/jni/ffmpeg').exists()) {
+ android.externalNativeBuild.cmake.path = 'src/main/jni/CMakeLists.txt'
+ android.externalNativeBuild.cmake.version = '3.7.1+'
}
dependencies {
diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java
index c5072a3398e..d6980f28019 100644
--- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java
+++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java
@@ -52,10 +52,10 @@
private volatile int sampleRate;
public FfmpegAudioDecoder(
+ Format format,
int numInputBuffers,
int numOutputBuffers,
int initialInputBufferSize,
- Format format,
boolean outputFloat)
throws FfmpegDecoderException {
super(new DecoderInputBuffer[numInputBuffers], new SimpleOutputBuffer[numOutputBuffers]);
@@ -82,7 +82,9 @@ public String getName() {
@Override
protected DecoderInputBuffer createInputBuffer() {
- return new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT);
+ return new DecoderInputBuffer(
+ DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT,
+ FfmpegLibrary.getInputBufferPaddingSize());
}
@Override
diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java
index 4bb64cbaa02..0718dc2c5c7 100644
--- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java
+++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java
@@ -15,6 +15,10 @@
*/
package com.google.android.exoplayer2.ext.ffmpeg;
+import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY;
+import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_SUPPORTED_WITH_TRANSCODING;
+import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_UNSUPPORTED;
+
import android.os.Handler;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
@@ -22,16 +26,17 @@
import com.google.android.exoplayer2.audio.AudioProcessor;
import com.google.android.exoplayer2.audio.AudioRendererEventListener;
import com.google.android.exoplayer2.audio.AudioSink;
+import com.google.android.exoplayer2.audio.AudioSink.SinkFormatSupport;
import com.google.android.exoplayer2.audio.DecoderAudioRenderer;
import com.google.android.exoplayer2.audio.DefaultAudioSink;
import com.google.android.exoplayer2.drm.ExoMediaCrypto;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.util.TraceUtil;
-import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import com.google.android.exoplayer2.util.Util;
/** Decodes and renders audio using FFmpeg. */
-public final class FfmpegAudioRenderer extends DecoderAudioRenderer {
+public final class FfmpegAudioRenderer extends DecoderAudioRenderer {
private static final String TAG = "FfmpegAudioRenderer";
@@ -40,10 +45,6 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer {
/** The default input buffer size. */
private static final int DEFAULT_INPUT_BUFFER_SIZE = 960 * 6;
- private final boolean enableFloatOutput;
-
- private @MonotonicNonNull FfmpegAudioDecoder decoder;
-
public FfmpegAudioRenderer() {
this(/* eventHandler= */ null, /* eventListener= */ null);
}
@@ -63,8 +64,7 @@ public FfmpegAudioRenderer(
this(
eventHandler,
eventListener,
- new DefaultAudioSink(/* audioCapabilities= */ null, audioProcessors),
- /* enableFloatOutput= */ false);
+ new DefaultAudioSink(/* audioCapabilities= */ null, audioProcessors));
}
/**
@@ -74,21 +74,15 @@ public FfmpegAudioRenderer(
* null if delivery of events is not required.
* @param eventListener A listener of events. May be null if delivery of events is not required.
* @param audioSink The sink to which audio will be output.
- * @param enableFloatOutput Whether to enable 32-bit float audio format, if supported on the
- * device/build and if the input format may have bit depth higher than 16-bit. When using
- * 32-bit float output, any audio processing will be disabled, including playback speed/pitch
- * adjustment.
*/
public FfmpegAudioRenderer(
@Nullable Handler eventHandler,
@Nullable AudioRendererEventListener eventListener,
- AudioSink audioSink,
- boolean enableFloatOutput) {
+ AudioSink audioSink) {
super(
eventHandler,
eventListener,
audioSink);
- this.enableFloatOutput = enableFloatOutput;
}
@Override
@@ -102,9 +96,11 @@ protected int supportsFormatInternal(Format format) {
String mimeType = Assertions.checkNotNull(format.sampleMimeType);
if (!FfmpegLibrary.isAvailable() || !MimeTypes.isAudio(mimeType)) {
return FORMAT_UNSUPPORTED_TYPE;
- } else if (!FfmpegLibrary.supportsFormat(mimeType) || !isOutputSupported(format)) {
+ } else if (!FfmpegLibrary.supportsFormat(mimeType)
+ || (!sinkSupportsFormat(format, C.ENCODING_PCM_16BIT)
+ && !sinkSupportsFormat(format, C.ENCODING_PCM_FLOAT))) {
return FORMAT_UNSUPPORTED_SUBTYPE;
- } else if (format.drmInitData != null && format.exoMediaCryptoType == null) {
+ } else if (format.exoMediaCryptoType != null) {
return FORMAT_UNSUPPORTED_DRM;
} else {
return FORMAT_HANDLED;
@@ -123,15 +119,15 @@ protected FfmpegAudioDecoder createDecoder(Format format, @Nullable ExoMediaCryp
TraceUtil.beginSection("createFfmpegAudioDecoder");
int initialInputBufferSize =
format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE;
- decoder =
+ FfmpegAudioDecoder decoder =
new FfmpegAudioDecoder(
- NUM_BUFFERS, NUM_BUFFERS, initialInputBufferSize, format, shouldUseFloatOutput(format));
+ format, NUM_BUFFERS, NUM_BUFFERS, initialInputBufferSize, shouldOutputFloat(format));
TraceUtil.endSection();
return decoder;
}
@Override
- public Format getOutputFormat() {
+ public Format getOutputFormat(FfmpegAudioDecoder decoder) {
Assertions.checkNotNull(decoder);
return new Format.Builder()
.setSampleMimeType(MimeTypes.AUDIO_RAW)
@@ -141,29 +137,36 @@ public Format getOutputFormat() {
.build();
}
- private boolean isOutputSupported(Format inputFormat) {
- return shouldUseFloatOutput(inputFormat)
- || supportsOutput(inputFormat.channelCount, C.ENCODING_PCM_16BIT);
+ /**
+ * Returns whether the renderer's {@link AudioSink} supports the PCM format that will be output
+ * from the decoder for the given input format and requested output encoding.
+ */
+ private boolean sinkSupportsFormat(Format inputFormat, @C.PcmEncoding int pcmEncoding) {
+ return sinkSupportsFormat(
+ Util.getPcmFormat(pcmEncoding, inputFormat.channelCount, inputFormat.sampleRate));
}
- private boolean shouldUseFloatOutput(Format inputFormat) {
- Assertions.checkNotNull(inputFormat.sampleMimeType);
- if (!enableFloatOutput || !supportsOutput(inputFormat.channelCount, C.ENCODING_PCM_FLOAT)) {
- return false;
+ private boolean shouldOutputFloat(Format inputFormat) {
+ if (!sinkSupportsFormat(inputFormat, C.ENCODING_PCM_16BIT)) {
+ // We have no choice because the sink doesn't support 16-bit integer PCM.
+ return true;
}
- switch (inputFormat.sampleMimeType) {
- case MimeTypes.AUDIO_RAW:
- // For raw audio, output in 32-bit float encoding if the bit depth is > 16-bit.
- return inputFormat.pcmEncoding == C.ENCODING_PCM_24BIT
- || inputFormat.pcmEncoding == C.ENCODING_PCM_32BIT
- || inputFormat.pcmEncoding == C.ENCODING_PCM_FLOAT;
- case MimeTypes.AUDIO_AC3:
- // AC-3 is always 16-bit, so there is no point outputting in 32-bit float encoding.
- return false;
+
+ @SinkFormatSupport
+ int formatSupport =
+ getSinkFormatSupport(
+ Util.getPcmFormat(
+ C.ENCODING_PCM_FLOAT, inputFormat.channelCount, inputFormat.sampleRate));
+ switch (formatSupport) {
+ case SINK_FORMAT_SUPPORTED_DIRECTLY:
+ // AC-3 is always 16-bit, so there's no point using floating point. Assume that it's worth
+ // using for all other formats.
+ return !MimeTypes.AUDIO_AC3.equals(inputFormat.sampleMimeType);
+ case SINK_FORMAT_UNSUPPORTED:
+ case SINK_FORMAT_SUPPORTED_WITH_TRANSCODING:
default:
- // For all other formats, assume that it's worth using 32-bit float encoding.
- return true;
+ // Always prefer 16-bit PCM if the sink does not provide direct support for floating point.
+ return false;
}
}
-
}
diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java
index e3752aad5c9..71912aea2f9 100644
--- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java
+++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java
@@ -16,10 +16,12 @@
package com.google.android.exoplayer2.ext.ffmpeg;
import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
import com.google.android.exoplayer2.util.LibraryLoader;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.MimeTypes;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/**
* Configures and queries the underlying native library.
@@ -33,7 +35,10 @@ public final class FfmpegLibrary {
private static final String TAG = "FfmpegLibrary";
private static final LibraryLoader LOADER =
- new LibraryLoader("avutil", "swresample", "avcodec", "ffmpeg");
+ new LibraryLoader("avutil", "swresample", "avcodec", "ffmpeg_jni");
+
+ private static @MonotonicNonNull String version;
+ private static int inputBufferPaddingSize = C.LENGTH_UNSET;
private FfmpegLibrary() {}
@@ -56,8 +61,29 @@ public static boolean isAvailable() {
}
/** Returns the version of the underlying library if available, or null otherwise. */
- public static @Nullable String getVersion() {
- return isAvailable() ? ffmpegGetVersion() : null;
+ @Nullable
+ public static String getVersion() {
+ if (!isAvailable()) {
+ return null;
+ }
+ if (version == null) {
+ version = ffmpegGetVersion();
+ }
+ return version;
+ }
+
+ /**
+ * Returns the required amount of padding for input buffers in bytes, or {@link C#LENGTH_UNSET} if
+ * the underlying library is not available.
+ */
+ public static int getInputBufferPaddingSize() {
+ if (!isAvailable()) {
+ return C.LENGTH_UNSET;
+ }
+ if (inputBufferPaddingSize == C.LENGTH_UNSET) {
+ inputBufferPaddingSize = ffmpegGetInputBufferPaddingSize();
+ }
+ return inputBufferPaddingSize;
}
/**
@@ -69,7 +95,7 @@ public static boolean supportsFormat(String mimeType) {
if (!isAvailable()) {
return false;
}
- String codecName = getCodecName(mimeType);
+ @Nullable String codecName = getCodecName(mimeType);
if (codecName == null) {
return false;
}
@@ -84,7 +110,8 @@ public static boolean supportsFormat(String mimeType) {
* Returns the name of the FFmpeg decoder that could be used to decode the format, or {@code null}
* if it's unsupported.
*/
- /* package */ static @Nullable String getCodecName(String mimeType) {
+ @Nullable
+ /* package */ static String getCodecName(String mimeType) {
switch (mimeType) {
case MimeTypes.AUDIO_AAC:
return "aac";
@@ -128,6 +155,8 @@ public static boolean supportsFormat(String mimeType) {
}
private static native String ffmpegGetVersion();
- private static native boolean ffmpegHasDecoder(String codecName);
+ private static native int ffmpegGetInputBufferPaddingSize();
+
+ private static native boolean ffmpegHasDecoder(String codecName);
}
diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegVideoRenderer.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegVideoRenderer.java
index e1071b9fef5..a72f458936a 100644
--- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegVideoRenderer.java
+++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegVideoRenderer.java
@@ -129,7 +129,7 @@ public final int supportsFormat(Format format) {
return FORMAT_UNSUPPORTED_TYPE;
} else if (!FfmpegLibrary.supportsFormat(format.sampleMimeType)) {
return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE);
- } else if (format.drmInitData != null && format.exoMediaCryptoType == null) {
+ } else if (format.exoMediaCryptoType != null) {
return RendererCapabilities.create(FORMAT_UNSUPPORTED_DRM);
} else {
return RendererCapabilities.create(
@@ -170,4 +170,9 @@ protected void setDecoderOutputMode(@C.VideoOutputMode int outputMode) {
decoder.setOutputMode(outputMode);
}
}
+
+ @Override
+ protected boolean canKeepCodec(Format oldFormat, Format newFormat) {
+ return Util.areEqual(oldFormat.sampleMimeType, newFormat.sampleMimeType);
+ }
}
diff --git a/extensions/ffmpeg/src/main/jni/Android.mk b/extensions/ffmpeg/src/main/jni/Android.mk
deleted file mode 100644
index 01de61ccc1e..00000000000
--- a/extensions/ffmpeg/src/main/jni/Android.mk
+++ /dev/null
@@ -1,40 +0,0 @@
-#
-# Copyright (C) 2016 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.
-#
-
-LOCAL_PATH := $(call my-dir)
-
-include $(CLEAR_VARS)
-LOCAL_MODULE := libavcodec
-LOCAL_SRC_FILES := ffmpeg/android-libs/$(TARGET_ARCH_ABI)/$(LOCAL_MODULE).so
-include $(PREBUILT_SHARED_LIBRARY)
-
-include $(CLEAR_VARS)
-LOCAL_MODULE := libswresample
-LOCAL_SRC_FILES := ffmpeg/android-libs/$(TARGET_ARCH_ABI)/$(LOCAL_MODULE).so
-include $(PREBUILT_SHARED_LIBRARY)
-
-include $(CLEAR_VARS)
-LOCAL_MODULE := libavutil
-LOCAL_SRC_FILES := ffmpeg/android-libs/$(TARGET_ARCH_ABI)/$(LOCAL_MODULE).so
-include $(PREBUILT_SHARED_LIBRARY)
-
-include $(CLEAR_VARS)
-LOCAL_MODULE := ffmpeg
-LOCAL_SRC_FILES := ffmpeg_jni.cc
-LOCAL_C_INCLUDES := ffmpeg
-LOCAL_SHARED_LIBRARIES := libavcodec libswresample libavutil
-LOCAL_LDLIBS := -Lffmpeg/android-libs/$(TARGET_ARCH_ABI) -llog -landroid
-include $(BUILD_SHARED_LIBRARY)
diff --git a/extensions/ffmpeg/src/main/jni/Application.mk b/extensions/ffmpeg/src/main/jni/Application.mk
deleted file mode 100644
index 7d6f7325489..00000000000
--- a/extensions/ffmpeg/src/main/jni/Application.mk
+++ /dev/null
@@ -1,20 +0,0 @@
-#
-# Copyright (C) 2016 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.
-#
-
-APP_OPTIM := release
-APP_STL := c++_static
-APP_CPPFLAGS := -frtti
-APP_PLATFORM := android-9
diff --git a/extensions/ffmpeg/src/main/jni/CMakeLists.txt b/extensions/ffmpeg/src/main/jni/CMakeLists.txt
index adab7d4f540..b60af4fa18a 100644
--- a/extensions/ffmpeg/src/main/jni/CMakeLists.txt
+++ b/extensions/ffmpeg/src/main/jni/CMakeLists.txt
@@ -1,61 +1,36 @@
-# libffmpegJNI requires modern CMake.
cmake_minimum_required(VERSION 3.7.1 FATAL_ERROR)
-# libffmpegJNI requires C++11.
+# Enable C++11 features.
set(CMAKE_CXX_STANDARD 11)
-project(libffmpeg C CXX)
+project(libffmpeg_jni C CXX)
-set(libgffmpeg_jni_root "${CMAKE_CURRENT_SOURCE_DIR}")
-set(libgffmpeg_jni_build "${CMAKE_BINARY_DIR}")
-set(libgffmpeg_jni_output_directory
- ${libgffmpeg_jni_root}/../libs/${ANDROID_ABI}/)
+set(ffmpeg_location "${CMAKE_CURRENT_SOURCE_DIR}/ffmpeg")
+set(ffmpeg_binaries "${ffmpeg_location}/android-libs/${ANDROID_ABI}")
-add_library(
- avutil
- SHARED
- IMPORTED)
-set_target_properties(
- avutil PROPERTIES
- IMPORTED_LOCATION
- ${libgffmpeg_jni_output_directory}/libavutil.so)
-
-add_library(
- swresample
- SHARED
- IMPORTED)
-set_target_properties(
- swresample PROPERTIES
- IMPORTED_LOCATION
- ${libgffmpeg_jni_output_directory}/libswresample.so)
-
-add_library(
- avcodec
- SHARED
- IMPORTED)
-set_target_properties(
- avcodec PROPERTIES
- IMPORTED_LOCATION
- ${libgffmpeg_jni_output_directory}/libavcodec.so)
+foreach(ffmpeg_lib avutil swresample avcodec)
+ set(ffmpeg_lib_filename lib${ffmpeg_lib}.so)
+ set(ffmpeg_lib_file_path ${ffmpeg_binaries}/${ffmpeg_lib_filename})
+ add_library(
+ ${ffmpeg_lib}
+ SHARED
+ IMPORTED)
+ set_target_properties(
+ ${ffmpeg_lib} PROPERTIES
+ IMPORTED_LOCATION
+ ${ffmpeg_lib_file_path})
+endforeach()
+
+include_directories(${ffmpeg_location})
+find_library(android_log_lib log)
-# Build libgffmpegJNI.
-add_library(ffmpeg
+add_library(ffmpeg_jni
SHARED
ffmpeg_jni.cc)
-include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)
-# Locate NDK log library.
-find_library(android_log_lib log)
-
-# Link libgffmpegJNI against used libraries.
-target_link_libraries(ffmpeg
+target_link_libraries(ffmpeg_jni
PRIVATE android
PRIVATE avutil
PRIVATE swresample
PRIVATE avcodec
PRIVATE ${android_log_lib})
-
-# Specify output directory for libgffmpegJNI.
-set_target_properties(ffmpeg PROPERTIES
- LIBRARY_OUTPUT_DIRECTORY
- ${libgffmpeg_jni_output_directory})
diff --git a/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh b/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh
index 833ea189b23..4660669a336 100755
--- a/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh
+++ b/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh
@@ -41,10 +41,7 @@ for decoder in "${ENABLED_DECODERS[@]}"
do
COMMON_OPTIONS="${COMMON_OPTIONS} --enable-decoder=${decoder}"
done
-cd "${FFMPEG_EXT_PATH}"
-(git -C ffmpeg pull || git clone git://source.ffmpeg.org/ffmpeg ffmpeg)
-cd ffmpeg
-git checkout release/4.2
+cd "${FFMPEG_EXT_PATH}/jni/ffmpeg"
./configure \
--libdir=android-libs/armeabi-v7a \
--arch=arm \
diff --git a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc
index 44d086e93f4..fe5e4126cf2 100644
--- a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc
+++ b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc
@@ -134,6 +134,10 @@ LIBRARY_FUNC(jstring, ffmpegGetVersion) {
return env->NewStringUTF(LIBAVCODEC_IDENT);
}
+LIBRARY_FUNC(jint, ffmpegGetInputBufferPaddingSize) {
+ return (jint)AV_INPUT_BUFFER_PADDING_SIZE;
+}
+
LIBRARY_FUNC(jboolean, ffmpegHasDecoder, jstring codecName) {
return getCodecByName(env, codecName) != NULL;
}
diff --git a/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java b/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java
index a52d1b1d7ad..cc8ca5487e0 100644
--- a/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java
+++ b/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java
@@ -21,13 +21,22 @@
import org.junit.Test;
import org.junit.runner.RunWith;
-/** Unit test for {@link DefaultRenderersFactoryTest} with {@link FfmpegAudioRenderer}. */
+/**
+ * Unit test for {@link DefaultRenderersFactoryTest} with {@link FfmpegAudioRenderer} and {@link
+ * FfmpegVideoRenderer}.
+ */
@RunWith(AndroidJUnit4.class)
public final class DefaultRenderersFactoryTest {
@Test
- public void createRenderers_instantiatesVpxRenderer() {
+ public void createRenderers_instantiatesFfmpegAudioRenderer() {
DefaultRenderersFactoryAsserts.assertExtensionRendererCreated(
FfmpegAudioRenderer.class, C.TRACK_TYPE_AUDIO);
}
+
+ @Test
+ public void createRenderers_instantiatesFfmpegVideoRenderer() {
+ DefaultRenderersFactoryAsserts.assertExtensionRendererCreated(
+ FfmpegVideoRenderer.class, C.TRACK_TYPE_VIDEO);
+ }
}
diff --git a/extensions/flac/build.gradle b/extensions/flac/build.gradle
index f220d21106b..9aeeb83eb3f 100644
--- a/extensions/flac/build.gradle
+++ b/extensions/flac/build.gradle
@@ -11,24 +11,9 @@
// 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.
-apply from: '../../constants.gradle'
-apply plugin: 'com.android.library'
+apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle"
android {
- compileSdkVersion project.ext.compileSdkVersion
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- defaultConfig {
- minSdkVersion project.ext.minSdkVersion
- targetSdkVersion project.ext.targetSdkVersion
- consumerProguardFiles 'proguard-rules.txt'
- testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
- }
-
sourceSets {
main {
jniLibs.srcDir 'src/main/libs'
@@ -36,8 +21,6 @@ android {
}
androidTest.assets.srcDir '../../testdata/src/test/assets/'
}
-
- testOptions.unitTests.includeAndroidResources = true
}
dependencies {
diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java
index 1c0c450a30f..e6e66fbe29b 100644
--- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java
+++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java
@@ -37,16 +37,16 @@
@RunWith(AndroidJUnit4.class)
public final class FlacExtractorSeekTest {
- private static final String TEST_FILE_SEEK_TABLE = "flac/bear.flac";
- private static final String TEST_FILE_BINARY_SEARCH = "flac/bear_one_metadata_block.flac";
- private static final String TEST_FILE_UNSEEKABLE = "flac/bear_no_seek_table_no_num_samples.flac";
+ private static final String TEST_FILE_SEEK_TABLE = "media/flac/bear.flac";
+ private static final String TEST_FILE_BINARY_SEARCH = "media/flac/bear_one_metadata_block.flac";
+ private static final String TEST_FILE_UNSEEKABLE =
+ "media/flac/bear_no_seek_table_no_num_samples.flac";
private static final int DURATION_US = 2_741_000;
private FlacExtractor extractor = new FlacExtractor();
private FakeExtractorOutput extractorOutput = new FakeExtractorOutput();
private DefaultDataSource dataSource =
- new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent")
- .createDataSource();
+ new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()).createDataSource();
@Test
public void flacExtractorReads_seekTable_returnSeekableSeekMap() throws IOException {
diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java
index e203849bb9e..d260a58e5d5 100644
--- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java
+++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java
@@ -17,7 +17,6 @@
import static org.junit.Assert.fail;
-import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.testutil.ExtractorAsserts;
import org.junit.Before;
@@ -25,6 +24,8 @@
import org.junit.runner.RunWith;
/** Unit test for {@link FlacExtractor}. */
+// TODO(internal: b/26110951): Use org.junit.runners.Parameterized (and corresponding methods on
+// ExtractorAsserts) when it's supported by our testing infrastructure.
@RunWith(AndroidJUnit4.class)
public class FlacExtractorTest {
@@ -37,91 +38,81 @@ public void setUp() {
@Test
public void sample() throws Exception {
- ExtractorAsserts.assertBehavior(
+ ExtractorAsserts.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear.flac",
- ApplicationProvider.getApplicationContext(),
- /* dumpFilesPrefix= */ "flac/bear_raw");
+ /* file= */ "media/flac/bear.flac",
+ /* dumpFilesPrefix= */ "extractordumps/flac/bear_raw");
}
@Test
public void sampleWithId3HeaderAndId3Enabled() throws Exception {
- ExtractorAsserts.assertBehavior(
+ ExtractorAsserts.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_with_id3.flac",
- ApplicationProvider.getApplicationContext(),
- /* dumpFilesPrefix= */ "flac/bear_with_id3_enabled_raw");
+ /* file= */ "media/flac/bear_with_id3.flac",
+ /* dumpFilesPrefix= */ "extractordumps/flac/bear_with_id3_enabled_raw");
}
@Test
public void sampleWithId3HeaderAndId3Disabled() throws Exception {
- ExtractorAsserts.assertBehavior(
+ ExtractorAsserts.assertAllBehaviors(
() -> new FlacExtractor(FlacExtractor.FLAG_DISABLE_ID3_METADATA),
- /* file= */ "flac/bear_with_id3.flac",
- ApplicationProvider.getApplicationContext(),
- /* dumpFilesPrefix= */ "flac/bear_with_id3_disabled_raw");
+ /* file= */ "media/flac/bear_with_id3.flac",
+ /* dumpFilesPrefix= */ "extractordumps/flac/bear_with_id3_disabled_raw");
}
@Test
public void sampleUnseekable() throws Exception {
- ExtractorAsserts.assertBehavior(
+ ExtractorAsserts.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_no_seek_table_no_num_samples.flac",
- ApplicationProvider.getApplicationContext(),
- /* dumpFilesPrefix= */ "flac/bear_no_seek_table_no_num_samples_raw");
+ /* file= */ "media/flac/bear_no_seek_table_no_num_samples.flac",
+ /* dumpFilesPrefix= */ "extractordumps/flac/bear_no_seek_table_no_num_samples_raw");
}
@Test
public void sampleWithVorbisComments() throws Exception {
- ExtractorAsserts.assertBehavior(
+ ExtractorAsserts.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_with_vorbis_comments.flac",
- ApplicationProvider.getApplicationContext(),
- /* dumpFilesPrefix= */ "flac/bear_with_vorbis_comments_raw");
+ /* file= */ "media/flac/bear_with_vorbis_comments.flac",
+ /* dumpFilesPrefix= */ "extractordumps/flac/bear_with_vorbis_comments_raw");
}
@Test
public void sampleWithPicture() throws Exception {
- ExtractorAsserts.assertBehavior(
+ ExtractorAsserts.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_with_picture.flac",
- ApplicationProvider.getApplicationContext(),
- /* dumpFilesPrefix= */ "flac/bear_with_picture_raw");
+ /* file= */ "media/flac/bear_with_picture.flac",
+ /* dumpFilesPrefix= */ "extractordumps/flac/bear_with_picture_raw");
}
@Test
public void oneMetadataBlock() throws Exception {
- ExtractorAsserts.assertBehavior(
+ ExtractorAsserts.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_one_metadata_block.flac",
- ApplicationProvider.getApplicationContext(),
- /* dumpFilesPrefix= */ "flac/bear_one_metadata_block_raw");
+ /* file= */ "media/flac/bear_one_metadata_block.flac",
+ /* dumpFilesPrefix= */ "extractordumps/flac/bear_one_metadata_block_raw");
}
@Test
public void noMinMaxFrameSize() throws Exception {
- ExtractorAsserts.assertBehavior(
+ ExtractorAsserts.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_no_min_max_frame_size.flac",
- ApplicationProvider.getApplicationContext(),
- /* dumpFilesPrefix= */ "flac/bear_no_min_max_frame_size_raw");
+ /* file= */ "media/flac/bear_no_min_max_frame_size.flac",
+ /* dumpFilesPrefix= */ "extractordumps/flac/bear_no_min_max_frame_size_raw");
}
@Test
public void noNumSamples() throws Exception {
- ExtractorAsserts.assertBehavior(
+ ExtractorAsserts.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_no_num_samples.flac",
- ApplicationProvider.getApplicationContext(),
- /* dumpFilesPrefix= */ "flac/bear_no_num_samples_raw");
+ /* file= */ "media/flac/bear_no_num_samples.flac",
+ /* dumpFilesPrefix= */ "extractordumps/flac/bear_no_num_samples_raw");
}
@Test
public void uncommonSampleRate() throws Exception {
- ExtractorAsserts.assertBehavior(
+ ExtractorAsserts.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_uncommon_sample_rate.flac",
- ApplicationProvider.getApplicationContext(),
- /* dumpFilesPrefix= */ "flac/bear_uncommon_sample_rate_raw");
+ /* file= */ "media/flac/bear_uncommon_sample_rate.flac",
+ /* dumpFilesPrefix= */ "extractordumps/flac/bear_uncommon_sample_rate_raw");
}
}
diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java
index e9b1fd10193..bbcc26fb64f 100644
--- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java
+++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java
@@ -25,6 +25,7 @@
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.ExoPlayer;
+import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.audio.AudioProcessor;
import com.google.android.exoplayer2.audio.AudioSink;
@@ -33,6 +34,7 @@
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.testutil.CapturingAudioSink;
+import com.google.android.exoplayer2.testutil.DumpFileAsserts;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import org.junit.Before;
import org.junit.Test;
@@ -69,7 +71,7 @@ private static void playAndAssertAudioSinkInput(String fileName) throws Exceptio
TestPlaybackRunnable testPlaybackRunnable =
new TestPlaybackRunnable(
- Uri.parse("asset:///" + fileName),
+ Uri.parse("asset:///media/" + fileName),
ApplicationProvider.getApplicationContext(),
audioSink);
Thread thread = new Thread(testPlaybackRunnable);
@@ -79,8 +81,10 @@ private static void playAndAssertAudioSinkInput(String fileName) throws Exceptio
throw testPlaybackRunnable.playbackException;
}
- audioSink.assertOutput(
- ApplicationProvider.getApplicationContext(), fileName + ".audiosink.dump");
+ DumpFileAsserts.assertOutput(
+ ApplicationProvider.getApplicationContext(),
+ audioSink,
+ "audiosinkdumps/" + fileName + ".audiosink.dump");
}
private static class TestPlaybackRunnable implements Player.EventListener, Runnable {
@@ -107,9 +111,8 @@ public void run() {
player.addListener(this);
MediaSource mediaSource =
new ProgressiveMediaSource.Factory(
- new DefaultDataSourceFactory(context, "ExoPlayerExtFlacTest"),
- MatroskaExtractor.FACTORY)
- .createMediaSource(uri);
+ new DefaultDataSourceFactory(context), MatroskaExtractor.FACTORY)
+ .createMediaSource(MediaItem.fromUri(uri));
player.setMediaSource(mediaSource);
player.prepare();
player.play();
diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java
index 742ade214de..b736c4d7434 100644
--- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java
+++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java
@@ -15,6 +15,8 @@
*/
package com.google.android.exoplayer2.ext.flac;
+import static java.lang.Math.max;
+
import com.google.android.exoplayer2.extractor.BinarySearchSeeker;
import com.google.android.exoplayer2.extractor.ExtractorInput;
import com.google.android.exoplayer2.extractor.FlacStreamMetadata;
@@ -74,7 +76,7 @@ public FlacBinarySearchSeeker(
/* floorBytePosition= */ firstFramePosition,
/* ceilingBytePosition= */ inputLength,
/* approxBytesPerFrame= */ streamMetadata.getApproxBytesPerFrame(),
- /* minimumSearchRange= */ Math.max(
+ /* minimumSearchRange= */ max(
FlacConstants.MIN_FRAME_HEADER_SIZE, streamMetadata.minFrameSize));
this.decoderJni = Assertions.checkNotNull(decoderJni);
}
diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java
index daf45849485..af4e5710249 100644
--- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java
+++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java
@@ -15,6 +15,8 @@
*/
package com.google.android.exoplayer2.ext.flac;
+import static java.lang.Math.min;
+
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ParserException;
@@ -118,7 +120,7 @@ public void clearData() {
public int read(ByteBuffer target) throws IOException {
int byteCount = target.remaining();
if (byteBufferData != null) {
- byteCount = Math.min(byteCount, byteBufferData.remaining());
+ byteCount = min(byteCount, byteBufferData.remaining());
int originalLimit = byteBufferData.limit();
byteBufferData.limit(byteBufferData.position() + byteCount);
target.put(byteBufferData);
@@ -126,7 +128,7 @@ public int read(ByteBuffer target) throws IOException {
} else if (extractorInput != null) {
ExtractorInput extractorInput = this.extractorInput;
byte[] tempBuffer = Util.castNonNull(this.tempBuffer);
- byteCount = Math.min(byteCount, TEMP_BUFFER_SIZE);
+ byteCount = min(byteCount, TEMP_BUFFER_SIZE);
int read = readFromExtractorInput(extractorInput, tempBuffer, /* offset= */ 0, byteCount);
if (read < 4) {
// Reading less than 4 bytes, most of the time, happens because of getting the bytes left in
diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java
index 364cf80ef8e..0ac4dbeffa2 100644
--- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java
+++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java
@@ -53,6 +53,11 @@ public final class FlacExtractor implements Extractor {
/** Factory that returns one extractor which is a {@link FlacExtractor}. */
public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new FlacExtractor()};
+ // LINT.IfChange
+ /*
+ * Flags in the two FLAC extractors should be kept in sync. If we ever change this then
+ * DefaultExtractorsFactory will need modifying, because it currently assumes this is the case.
+ */
/**
* Flags controlling the behavior of the extractor. Possible flag value is {@link
* #FLAG_DISABLE_ID3_METADATA}.
@@ -68,7 +73,9 @@ public final class FlacExtractor implements Extractor {
* Flag to disable parsing of ID3 metadata. Can be set to save memory if ID3 metadata is not
* required.
*/
- public static final int FLAG_DISABLE_ID3_METADATA = 1;
+ public static final int FLAG_DISABLE_ID3_METADATA =
+ com.google.android.exoplayer2.extractor.flac.FlacExtractor.FLAG_DISABLE_ID3_METADATA;
+ // LINT.ThenChange(../../../../../../../../../../../library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java)
private final ParsableByteArray outputBuffer;
private final boolean id3MetadataDisabled;
@@ -203,7 +210,7 @@ private void decodeStreamMetadata(ExtractorInput input) throws IOException {
if (this.streamMetadata == null) {
this.streamMetadata = streamMetadata;
outputBuffer.reset(streamMetadata.getMaxDecodedFrameSize());
- outputFrameHolder = new OutputFrameHolder(ByteBuffer.wrap(outputBuffer.data));
+ outputFrameHolder = new OutputFrameHolder(ByteBuffer.wrap(outputBuffer.getData()));
binarySearchSeeker =
outputSeekMap(
flacDecoderJni,
diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java
index cbdf42dbaff..df511866a38 100644
--- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java
+++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java
@@ -25,26 +25,24 @@
import com.google.android.exoplayer2.audio.DecoderAudioRenderer;
import com.google.android.exoplayer2.drm.ExoMediaCrypto;
import com.google.android.exoplayer2.extractor.FlacStreamMetadata;
-import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.FlacConstants;
import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.util.TraceUtil;
import com.google.android.exoplayer2.util.Util;
-import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/** Decodes and renders audio using the native Flac decoder. */
-public final class LibflacAudioRenderer extends DecoderAudioRenderer {
+public final class LibflacAudioRenderer extends DecoderAudioRenderer {
private static final String TAG = "LibflacAudioRenderer";
private static final int NUM_BUFFERS = 16;
- private @MonotonicNonNull FlacStreamMetadata streamMetadata;
-
public LibflacAudioRenderer() {
this(/* eventHandler= */ null, /* eventListener= */ null);
}
/**
+ * Creates an instance.
+ *
* @param eventHandler A handler to use when delivering events to {@code eventListener}. May be
* null if delivery of events is not required.
* @param eventListener A listener of events. May be null if delivery of events is not required.
@@ -58,6 +56,8 @@ public LibflacAudioRenderer(
}
/**
+ * Creates an instance.
+ *
* @param eventHandler A handler to use when delivering events to {@code eventListener}. May be
* null if delivery of events is not required.
* @param eventListener A listener of events. May be null if delivery of events is not required.
@@ -85,24 +85,25 @@ protected int supportsFormatInternal(Format format) {
|| !MimeTypes.AUDIO_FLAC.equalsIgnoreCase(format.sampleMimeType)) {
return FORMAT_UNSUPPORTED_TYPE;
}
- // Compute the PCM encoding that the FLAC decoder will output.
- @C.PcmEncoding int pcmEncoding;
+ // Compute the format that the FLAC decoder will output.
+ Format outputFormat;
if (format.initializationData.isEmpty()) {
// The initialization data might not be set if the format was obtained from a manifest (e.g.
// for DASH playbacks) rather than directly from the media. In this case we assume
// ENCODING_PCM_16BIT. If the actual encoding is different then playback will still succeed as
// long as the AudioSink supports it, which will always be true when using DefaultAudioSink.
- pcmEncoding = C.ENCODING_PCM_16BIT;
+ outputFormat =
+ Util.getPcmFormat(C.ENCODING_PCM_16BIT, format.channelCount, format.sampleRate);
} else {
int streamMetadataOffset =
FlacConstants.STREAM_MARKER_SIZE + FlacConstants.METADATA_BLOCK_HEADER_SIZE;
FlacStreamMetadata streamMetadata =
new FlacStreamMetadata(format.initializationData.get(0), streamMetadataOffset);
- pcmEncoding = Util.getPcmEncoding(streamMetadata.bitsPerSample);
+ outputFormat = getOutputFormat(streamMetadata);
}
- if (!supportsOutput(format.channelCount, pcmEncoding)) {
+ if (!sinkSupportsFormat(outputFormat)) {
return FORMAT_UNSUPPORTED_SUBTYPE;
- } else if (format.drmInitData != null && format.exoMediaCryptoType == null) {
+ } else if (format.exoMediaCryptoType != null) {
return FORMAT_UNSUPPORTED_DRM;
} else {
return FORMAT_HANDLED;
@@ -115,19 +116,19 @@ protected FlacDecoder createDecoder(Format format, @Nullable ExoMediaCrypto medi
TraceUtil.beginSection("createFlacDecoder");
FlacDecoder decoder =
new FlacDecoder(NUM_BUFFERS, NUM_BUFFERS, format.maxInputSize, format.initializationData);
- streamMetadata = decoder.getStreamMetadata();
TraceUtil.endSection();
return decoder;
}
@Override
- protected Format getOutputFormat() {
- Assertions.checkNotNull(streamMetadata);
- return new Format.Builder()
- .setSampleMimeType(MimeTypes.AUDIO_RAW)
- .setChannelCount(streamMetadata.channels)
- .setSampleRate(streamMetadata.sampleRate)
- .setPcmEncoding(Util.getPcmEncoding(streamMetadata.bitsPerSample))
- .build();
+ protected Format getOutputFormat(FlacDecoder decoder) {
+ return getOutputFormat(decoder.getStreamMetadata());
+ }
+
+ private static Format getOutputFormat(FlacStreamMetadata streamMetadata) {
+ return Util.getPcmFormat(
+ Util.getPcmEncoding(streamMetadata.bitsPerSample),
+ streamMetadata.channels,
+ streamMetadata.sampleRate);
}
}
diff --git a/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java b/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java
index fb20ff1114a..3fb8f2cece4 100644
--- a/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java
+++ b/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java
@@ -26,7 +26,7 @@
public final class DefaultRenderersFactoryTest {
@Test
- public void createRenderers_instantiatesVpxRenderer() {
+ public void createRenderers_instantiatesFlacRenderer() {
DefaultRenderersFactoryAsserts.assertExtensionRendererCreated(
LibflacAudioRenderer.class, C.TRACK_TYPE_AUDIO);
}
diff --git a/extensions/gvr/build.gradle b/extensions/gvr/build.gradle
index 4e6bd76cb41..891888a0d25 100644
--- a/extensions/gvr/build.gradle
+++ b/extensions/gvr/build.gradle
@@ -11,24 +11,9 @@
// 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.
-apply from: '../../constants.gradle'
-apply plugin: 'com.android.library'
+apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle"
-android {
- compileSdkVersion project.ext.compileSdkVersion
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- defaultConfig {
- minSdkVersion 19
- targetSdkVersion project.ext.targetSdkVersion
- }
-
- testOptions.unitTests.includeAndroidResources = true
-}
+android.defaultConfig.minSdkVersion 19
dependencies {
implementation project(modulePrefix + 'library-core')
diff --git a/extensions/ima/README.md b/extensions/ima/README.md
index f28ba2977e2..c67dfdbb5d5 100644
--- a/extensions/ima/README.md
+++ b/extensions/ima/README.md
@@ -26,35 +26,29 @@ locally. Instructions for doing this can be found in ExoPlayer's
## Using the extension ##
-To play ads alongside a single-window content `MediaSource`, prepare the player
-with an `AdsMediaSource` constructed using an `ImaAdsLoader`, the content
-`MediaSource` and an overlay `ViewGroup` on top of the player. Pass an ad tag
-URI from your ad campaign when creating the `ImaAdsLoader`. The IMA
-documentation includes some [sample ad tags][] for testing. Note that the IMA
+To use the extension, follow the instructions on the
+[Ad insertion page](https://exoplayer.dev/ad-insertion.html#declarative-ad-support)
+of the developer guide. The `AdsLoaderProvider` passed to the player's
+`DefaultMediaSourceFactory` should return an `ImaAdsLoader`. Note that the IMA
extension only supports players which are accessed on the application's main
thread.
Resuming the player after entering the background requires some special handling
when playing ads. The player and its media source are released on entering the
-background, and are recreated when the player returns to the foreground. When
-playing ads it is necessary to persist ad playback state while in the background
-by keeping a reference to the `ImaAdsLoader`. Reuse it when resuming playback of
-the same content/ads by passing it in when constructing the new
-`AdsMediaSource`. It is also important to persist the player position when
+background, and are recreated when returning to the foreground. When playing ads
+it is necessary to persist ad playback state while in the background by keeping
+a reference to the `ImaAdsLoader`. When re-entering the foreground, pass the
+same instance back when `AdsLoaderProvider.getAdsLoader(Uri adTagUri)` is called
+to restore the state. It is also important to persist the player position when
entering the background by storing the value of `player.getContentPosition()`.
On returning to the foreground, seek to that position before preparing the new
player instance. Finally, it is important to call `ImaAdsLoader.release()` when
-playback of the content/ads has finished and will not be resumed.
+playback has finished and will not be resumed.
-You can try the IMA extension in the ExoPlayer demo app. To do this you must
-select and build one of the `withExtensions` build variants of the demo app in
-Android Studio. You can find IMA test content in the "IMA sample ad tags"
-section of the app. The demo app's `PlayerActivity` also shows how to persist
-the `ImaAdsLoader` instance and the player position when backgrounded during ad
-playback.
-
-[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
-[sample ad tags]: https://developers.google.com/interactive-media-ads/docs/sdks/android/tags
+You can try the IMA extension in the ExoPlayer demo app, which has test content
+in the "IMA sample ad tags" section of the sample chooser. The demo app's
+`PlayerActivity` also shows how to persist the `ImaAdsLoader` instance and the
+player position when backgrounded during ad playback.
## Links ##
diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle
index 1bd0b8dbd46..f7b2b3f77c0 100644
--- a/extensions/ima/build.gradle
+++ b/extensions/ima/build.gradle
@@ -11,22 +11,10 @@
// 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.
-apply from: '../../constants.gradle'
-apply plugin: 'com.android.library'
+apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle"
android {
- compileSdkVersion project.ext.compileSdkVersion
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
defaultConfig {
- minSdkVersion project.ext.minSdkVersion
- targetSdkVersion project.ext.targetSdkVersion
- consumerProguardFiles 'proguard-rules.txt'
- testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
// Enable multidex for androidTests.
multiDexEnabled true
}
@@ -34,22 +22,42 @@ android {
sourceSets {
androidTest.assets.srcDir '../../testdata/src/test/assets/'
}
-
- testOptions.unitTests.includeAndroidResources = true
}
dependencies {
- api 'com.google.ads.interactivemedia.v3:interactivemedia:3.11.3'
+ api 'com.google.ads.interactivemedia.v3:interactivemedia:3.19.4'
implementation project(modulePrefix + 'library-core')
implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
implementation 'com.google.android.gms:play-services-ads-identifier:17.0.0'
+ implementation ('com.google.guava:guava:' + guavaVersion) {
+ exclude group: 'com.google.code.findbugs', module: 'jsr305'
+ exclude group: 'org.checkerframework', module: 'checker-compat-qual'
+ exclude group: 'com.google.errorprone', module: 'error_prone_annotations'
+ exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
+ exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations'
+ }
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion
androidTestImplementation project(modulePrefix + 'testutils')
+ androidTestImplementation 'androidx.multidex:multidex:' + androidxMultidexVersion
androidTestImplementation 'androidx.test:rules:' + androidxTestRulesVersion
androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion
- androidTestImplementation 'com.android.support:multidex:1.0.3'
+ androidTestImplementation ('com.google.guava:guava:' + guavaVersion) {
+ exclude group: 'com.google.code.findbugs', module: 'jsr305'
+ exclude group: 'org.checkerframework', module: 'checker-compat-qual'
+ exclude group: 'com.google.errorprone', module: 'error_prone_annotations'
+ exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
+ exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations'
+ }
androidTestCompileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
testImplementation project(modulePrefix + 'testutils')
+ testImplementation ('com.google.guava:guava:' + guavaVersion) {
+ exclude group: 'com.google.code.findbugs', module: 'jsr305'
+ exclude group: 'org.checkerframework', module: 'checker-compat-qual'
+ exclude group: 'com.google.errorprone', module: 'error_prone_annotations'
+ exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
+ exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations'
+ }
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
}
diff --git a/extensions/ima/src/androidTest/java/com/google/android/exoplayer2/ext/ima/ImaPlaybackTest.java b/extensions/ima/src/androidTest/java/com/google/android/exoplayer2/ext/ima/ImaPlaybackTest.java
index ed9130bc721..88bc4e14c53 100644
--- a/extensions/ima/src/androidTest/java/com/google/android/exoplayer2/ext/ima/ImaPlaybackTest.java
+++ b/extensions/ima/src/androidTest/java/com/google/android/exoplayer2/ext/ima/ImaPlaybackTest.java
@@ -20,7 +20,6 @@
import android.content.Context;
import android.net.Uri;
import android.view.Surface;
-import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import androidx.annotation.Nullable;
@@ -28,6 +27,7 @@
import androidx.test.rule.ActivityTestRule;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.Player.DiscontinuityReason;
import com.google.android.exoplayer2.Player.EventListener;
import com.google.android.exoplayer2.Player.TimelineChangeReason;
@@ -38,8 +38,10 @@
import com.google.android.exoplayer2.drm.DrmSessionManager;
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory;
import com.google.android.exoplayer2.source.MediaSource;
+import com.google.android.exoplayer2.source.ads.AdsLoader;
import com.google.android.exoplayer2.source.ads.AdsLoader.AdViewProvider;
import com.google.android.exoplayer2.source.ads.AdsMediaSource;
+import com.google.android.exoplayer2.testutil.ActionSchedule;
import com.google.android.exoplayer2.testutil.ExoHostedTest;
import com.google.android.exoplayer2.testutil.HostActivity;
import com.google.android.exoplayer2.testutil.TestUtil;
@@ -47,11 +49,12 @@
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import com.google.android.exoplayer2.util.Assertions;
-import com.google.android.exoplayer2.util.Util;
+import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -60,39 +63,86 @@
@RunWith(AndroidJUnit4.class)
public final class ImaPlaybackTest {
+ private static final String TAG = "ImaPlaybackTest";
+
private static final long TIMEOUT_MS = 5 * 60 * C.MILLIS_PER_SECOND;
- private static final String CONTENT_URI =
+ private static final String CONTENT_URI_SHORT =
"https://storage.googleapis.com/exoplayer-test-media-1/mp4/android-screens-10s.mp4";
- private static final String PREROLL_ADS_RESPONSE_FILE_NAME = "ad-responses/preroll.xml";
- private static final String MIDROLL_ADS_RESPONSE_FILE_NAME = "ad-responses/midroll.xml";
-
+ private static final String CONTENT_URI_LONG =
+ "https://storage.googleapis.com/exoplayer-test-media-1/mp4/android-screens-25s.mp4";
private static final AdId CONTENT = new AdId(C.INDEX_UNSET, C.INDEX_UNSET);
@Rule public ActivityTestRule testRule = new ActivityTestRule<>(HostActivity.class);
@Test
public void playbackWithPrerollAdTag_playsAdAndContent() throws Exception {
- AdId[] expectedAdIds = new AdId[] {ad(0), CONTENT};
String adsResponse =
- TestUtil.getString(/* context= */ testRule.getActivity(), PREROLL_ADS_RESPONSE_FILE_NAME);
+ TestUtil.getString(/* context= */ testRule.getActivity(), "ad-responses/preroll.xml");
+ AdId[] expectedAdIds = new AdId[] {ad(0), CONTENT};
ImaHostedTest hostedTest =
- new ImaHostedTest(Uri.parse(CONTENT_URI), adsResponse, expectedAdIds);
+ new ImaHostedTest(Uri.parse(CONTENT_URI_SHORT), adsResponse, expectedAdIds);
testRule.getActivity().runTest(hostedTest, TIMEOUT_MS);
}
@Test
public void playbackWithMidrolls_playsAdAndContent() throws Exception {
+ String adsResponse =
+ TestUtil.getString(
+ /* context= */ testRule.getActivity(), "ad-responses/preroll_midroll6s_postroll.xml");
AdId[] expectedAdIds = new AdId[] {ad(0), CONTENT, ad(1), CONTENT, ad(2), CONTENT};
+ ImaHostedTest hostedTest =
+ new ImaHostedTest(Uri.parse(CONTENT_URI_SHORT), adsResponse, expectedAdIds);
+
+ testRule.getActivity().runTest(hostedTest, TIMEOUT_MS);
+ }
+
+ @Test
+ public void playbackWithMidrolls1And7_playsAdsAndContent() throws Exception {
String adsResponse =
- TestUtil.getString(/* context= */ testRule.getActivity(), MIDROLL_ADS_RESPONSE_FILE_NAME);
+ TestUtil.getString(
+ /* context= */ testRule.getActivity(), "ad-responses/midroll1s_midroll7s.xml");
+ AdId[] expectedAdIds = new AdId[] {CONTENT, ad(0), CONTENT, ad(1), CONTENT};
ImaHostedTest hostedTest =
- new ImaHostedTest(Uri.parse(CONTENT_URI), adsResponse, expectedAdIds);
+ new ImaHostedTest(Uri.parse(CONTENT_URI_SHORT), adsResponse, expectedAdIds);
testRule.getActivity().runTest(hostedTest, TIMEOUT_MS);
}
+ @Test
+ public void playbackWithMidrolls10And20WithSeekTo12_playsAdsAndContent() throws Exception {
+ String adsResponse =
+ TestUtil.getString(
+ /* context= */ testRule.getActivity(), "ad-responses/midroll10s_midroll20s.xml");
+ AdId[] expectedAdIds = new AdId[] {CONTENT, ad(0), CONTENT, ad(1), CONTENT};
+ ImaHostedTest hostedTest =
+ new ImaHostedTest(Uri.parse(CONTENT_URI_LONG), adsResponse, expectedAdIds);
+ hostedTest.setSchedule(
+ new ActionSchedule.Builder(TAG)
+ .waitForPlaybackState(Player.STATE_READY)
+ .seek(12 * C.MILLIS_PER_SECOND)
+ .build());
+ testRule.getActivity().runTest(hostedTest, TIMEOUT_MS);
+ }
+
+ @Ignore("The second ad doesn't preload so playback gets stuck. See [internal: b/155615925].")
+ @Test
+ public void playbackWithMidrolls10And20WithSeekTo18_playsAdsAndContent() throws Exception {
+ String adsResponse =
+ TestUtil.getString(
+ /* context= */ testRule.getActivity(), "ad-responses/midroll10s_midroll20s.xml");
+ AdId[] expectedAdIds = new AdId[] {CONTENT, ad(0), CONTENT, ad(1), CONTENT};
+ ImaHostedTest hostedTest =
+ new ImaHostedTest(Uri.parse(CONTENT_URI_LONG), adsResponse, expectedAdIds);
+ hostedTest.setSchedule(
+ new ActionSchedule.Builder(TAG)
+ .waitForPlaybackState(Player.STATE_READY)
+ .seek(18 * C.MILLIS_PER_SECOND)
+ .build());
+ testRule.getActivity().runTest(hostedTest, TIMEOUT_MS);
+ }
+
private static AdId ad(int groupIndex) {
return new AdId(groupIndex, /* indexInGroup= */ 0);
}
@@ -170,7 +220,9 @@ public void onTimelineChanged(EventTime eventTime, @TimelineChangeReason int rea
@Override
public void onPositionDiscontinuity(
EventTime eventTime, @DiscontinuityReason int reason) {
- maybeUpdateSeenAdIdentifiers();
+ if (reason != Player.DISCONTINUITY_REASON_SEEK) {
+ maybeUpdateSeenAdIdentifiers();
+ }
}
});
Context context = host.getApplicationContext();
@@ -182,29 +234,26 @@ public void onPositionDiscontinuity(
@Override
protected MediaSource buildSource(
HostActivity host,
- String userAgent,
DrmSessionManager drmSessionManager,
FrameLayout overlayFrameLayout) {
Context context = host.getApplicationContext();
- DataSource.Factory dataSourceFactory =
- new DefaultDataSourceFactory(
- context, Util.getUserAgent(context, ImaPlaybackTest.class.getSimpleName()));
+ DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(context);
MediaSource contentMediaSource =
- DefaultMediaSourceFactory.newInstance(context)
- .createMediaSource(MediaItem.fromUri(contentUri));
+ new DefaultMediaSourceFactory(context).createMediaSource(MediaItem.fromUri(contentUri));
return new AdsMediaSource(
contentMediaSource,
dataSourceFactory,
Assertions.checkNotNull(imaAdsLoader),
new AdViewProvider() {
+
@Override
public ViewGroup getAdViewGroup() {
return overlayFrameLayout;
}
@Override
- public View[] getAdOverlayViews() {
- return new View[0];
+ public ImmutableList getAdOverlayInfos() {
+ return ImmutableList.of();
}
});
}
diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java
new file mode 100644
index 00000000000..a97307a4195
--- /dev/null
+++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2020 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.ext.ima;
+
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.source.ads.AdPlaybackState;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Static utility class for constructing {@link AdPlaybackState} instances from IMA-specific data.
+ */
+/* package */ final class AdPlaybackStateFactory {
+ private AdPlaybackStateFactory() {}
+
+ /**
+ * Construct an {@link AdPlaybackState} from the provided {@code cuePoints}.
+ *
+ * @param cuePoints The cue points of the ads in seconds.
+ * @return The {@link AdPlaybackState}.
+ */
+ public static AdPlaybackState fromCuePoints(List cuePoints) {
+ if (cuePoints.isEmpty()) {
+ // If no cue points are specified, there is a preroll ad.
+ return new AdPlaybackState(/* adGroupTimesUs...= */ 0);
+ }
+
+ int count = cuePoints.size();
+ long[] adGroupTimesUs = new long[count];
+ int adGroupIndex = 0;
+ for (int i = 0; i < count; i++) {
+ double cuePoint = cuePoints.get(i);
+ if (cuePoint == -1.0) {
+ adGroupTimesUs[count - 1] = C.TIME_END_OF_SOURCE;
+ } else {
+ adGroupTimesUs[adGroupIndex++] = Math.round(C.MICROS_PER_SECOND * cuePoint);
+ }
+ }
+ // Cue points may be out of order, so sort them.
+ Arrays.sort(adGroupTimesUs, 0, adGroupIndex);
+ return new AdPlaybackState(adGroupTimesUs);
+ }
+}
diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java
index 04a12e5c022..88b0daac493 100644
--- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java
+++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java
@@ -15,8 +15,15 @@
*/
package com.google.android.exoplayer2.ext.ima;
+import static com.google.android.exoplayer2.util.Assertions.checkArgument;
+import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
+import static com.google.android.exoplayer2.util.Assertions.checkState;
+import static com.google.android.exoplayer2.util.Util.castNonNull;
+import static java.lang.Math.max;
+
import android.content.Context;
import android.net.Uri;
+import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.view.View;
@@ -24,7 +31,6 @@
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
-import com.google.ads.interactivemedia.v3.api.Ad;
import com.google.ads.interactivemedia.v3.api.AdDisplayContainer;
import com.google.ads.interactivemedia.v3.api.AdError;
import com.google.ads.interactivemedia.v3.api.AdError.AdErrorCode;
@@ -34,15 +40,19 @@
import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventListener;
import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventType;
import com.google.ads.interactivemedia.v3.api.AdPodInfo;
+import com.google.ads.interactivemedia.v3.api.AdsLoader;
import com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener;
import com.google.ads.interactivemedia.v3.api.AdsManager;
import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent;
import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings;
import com.google.ads.interactivemedia.v3.api.AdsRequest;
import com.google.ads.interactivemedia.v3.api.CompanionAdSlot;
+import com.google.ads.interactivemedia.v3.api.FriendlyObstruction;
+import com.google.ads.interactivemedia.v3.api.FriendlyObstructionPurpose;
import com.google.ads.interactivemedia.v3.api.ImaSdkFactory;
import com.google.ads.interactivemedia.v3.api.ImaSdkSettings;
import com.google.ads.interactivemedia.v3.api.UiElement;
+import com.google.ads.interactivemedia.v3.api.player.AdMediaInfo;
import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider;
import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer;
import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate;
@@ -52,15 +62,16 @@
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.source.ads.AdPlaybackState;
-import com.google.android.exoplayer2.source.ads.AdPlaybackState.AdState;
-import com.google.android.exoplayer2.source.ads.AdsLoader;
import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException;
import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
import com.google.android.exoplayer2.upstream.DataSpec;
-import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.util.Util;
+import com.google.common.collect.BiMap;
+import com.google.common.collect.HashBiMap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import java.io.IOException;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
@@ -69,30 +80,28 @@
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
-import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/**
- * {@link AdsLoader} using the IMA SDK. All methods must be called on the main thread.
+ * {@link com.google.android.exoplayer2.source.ads.AdsLoader} using the IMA SDK. All methods must be
+ * called on the main thread.
*
* The player instance that will play the loaded ads must be set before playback using {@link
* #setPlayer(Player)}. If the ads loader is no longer required, it must be released by calling
* {@link #release()}.
*
- *
The IMA SDK can take into account video control overlay views when calculating ad viewability.
- * For more details see {@link AdDisplayContainer#registerVideoControlsOverlay(View)} and {@link
- * AdViewProvider#getAdOverlayViews()}.
+ *
The IMA SDK can report obstructions to the ad view for accurate viewability measurement. This
+ * means that any overlay views that obstruct the ad overlay but are essential for playback need to
+ * be registered via the {@link AdViewProvider} passed to the {@link
+ * com.google.android.exoplayer2.source.ads.AdsMediaSource}. See the
+ * IMA SDK Open Measurement documentation for more information.
*/
public final class ImaAdsLoader
- implements Player.EventListener,
- AdsLoader,
- VideoAdPlayer,
- ContentProgressProvider,
- AdErrorListener,
- AdsLoadedListener,
- AdEventListener {
+ implements Player.EventListener, com.google.android.exoplayer2.source.ads.AdsLoader {
static {
ExoPlayerLibraryInfo.registerModule("goog.exo.ima");
@@ -101,15 +110,30 @@ public final class ImaAdsLoader
/** Builder for {@link ImaAdsLoader}. */
public static final class Builder {
+ /**
+ * The default duration in milliseconds for which the player must buffer while preloading an ad
+ * group before that ad group is skipped and marked as having failed to load.
+ *
+ *
This value should be large enough not to trigger discarding the ad when it actually might
+ * load soon, but small enough so that user is not waiting for too long.
+ *
+ * @see #setAdPreloadTimeoutMs(long)
+ */
+ public static final long DEFAULT_AD_PRELOAD_TIMEOUT_MS = 10 * C.MILLIS_PER_SECOND;
+
private final Context context;
@Nullable private ImaSdkSettings imaSdkSettings;
+ @Nullable private AdErrorListener adErrorListener;
@Nullable private AdEventListener adEventListener;
@Nullable private Set adUiElements;
+ @Nullable private Collection companionAdSlots;
+ private long adPreloadTimeoutMs;
private int vastLoadTimeoutMs;
private int mediaLoadTimeoutMs;
private int mediaBitrate;
private boolean focusSkipButtonWhenAvailable;
+ private boolean playAdBeforeStartPosition;
private ImaFactory imaFactory;
/**
@@ -118,11 +142,13 @@ public static final class Builder {
* @param context The context;
*/
public Builder(Context context) {
- this.context = Assertions.checkNotNull(context);
+ this.context = checkNotNull(context);
+ adPreloadTimeoutMs = DEFAULT_AD_PRELOAD_TIMEOUT_MS;
vastLoadTimeoutMs = TIMEOUT_UNSET;
mediaLoadTimeoutMs = TIMEOUT_UNSET;
mediaBitrate = BITRATE_UNSET;
focusSkipButtonWhenAvailable = true;
+ playAdBeforeStartPosition = true;
imaFactory = new DefaultImaFactory();
}
@@ -136,7 +162,20 @@ public Builder(Context context) {
* @return This builder, for convenience.
*/
public Builder setImaSdkSettings(ImaSdkSettings imaSdkSettings) {
- this.imaSdkSettings = Assertions.checkNotNull(imaSdkSettings);
+ this.imaSdkSettings = checkNotNull(imaSdkSettings);
+ return this;
+ }
+
+ /**
+ * Sets a listener for ad errors that will be passed to {@link
+ * AdsLoader#addAdErrorListener(AdErrorListener)} and {@link
+ * AdsManager#addAdErrorListener(AdErrorListener)}.
+ *
+ * @param adErrorListener The ad error listener.
+ * @return This builder, for convenience.
+ */
+ public Builder setAdErrorListener(AdErrorListener adErrorListener) {
+ this.adErrorListener = checkNotNull(adErrorListener);
return this;
}
@@ -148,7 +187,7 @@ public Builder setImaSdkSettings(ImaSdkSettings imaSdkSettings) {
* @return This builder, for convenience.
*/
public Builder setAdEventListener(AdEventListener adEventListener) {
- this.adEventListener = Assertions.checkNotNull(adEventListener);
+ this.adEventListener = checkNotNull(adEventListener);
return this;
}
@@ -160,7 +199,38 @@ public Builder setAdEventListener(AdEventListener adEventListener) {
* @see AdsRenderingSettings#setUiElements(Set)
*/
public Builder setAdUiElements(Set adUiElements) {
- this.adUiElements = new HashSet<>(Assertions.checkNotNull(adUiElements));
+ this.adUiElements = ImmutableSet.copyOf(checkNotNull(adUiElements));
+ return this;
+ }
+
+ /**
+ * Sets the slots to use for companion ads, if they are present in the loaded ad.
+ *
+ * @param companionAdSlots The slots to use for companion ads.
+ * @return This builder, for convenience.
+ * @see AdDisplayContainer#setCompanionSlots(Collection)
+ */
+ public Builder setCompanionAdSlots(Collection companionAdSlots) {
+ this.companionAdSlots = ImmutableList.copyOf(checkNotNull(companionAdSlots));
+ return this;
+ }
+
+ /**
+ * Sets the duration in milliseconds for which the player must buffer while preloading an ad
+ * group before that ad group is skipped and marked as having failed to load. Pass {@link
+ * C#TIME_UNSET} if there should be no such timeout. The default value is {@value
+ * #DEFAULT_AD_PRELOAD_TIMEOUT_MS} ms.
+ *
+ * The purpose of this timeout is to avoid playback getting stuck in the unexpected case that
+ * the IMA SDK does not load an ad break based on the player's reported content position.
+ *
+ * @param adPreloadTimeoutMs The timeout buffering duration in milliseconds, or {@link
+ * C#TIME_UNSET} for no timeout.
+ * @return This builder, for convenience.
+ */
+ public Builder setAdPreloadTimeoutMs(long adPreloadTimeoutMs) {
+ checkArgument(adPreloadTimeoutMs == C.TIME_UNSET || adPreloadTimeoutMs > 0);
+ this.adPreloadTimeoutMs = adPreloadTimeoutMs;
return this;
}
@@ -172,7 +242,7 @@ public Builder setAdUiElements(Set adUiElements) {
* @see AdsRequest#setVastLoadTimeout(float)
*/
public Builder setVastLoadTimeoutMs(int vastLoadTimeoutMs) {
- Assertions.checkArgument(vastLoadTimeoutMs > 0);
+ checkArgument(vastLoadTimeoutMs > 0);
this.vastLoadTimeoutMs = vastLoadTimeoutMs;
return this;
}
@@ -185,7 +255,7 @@ public Builder setVastLoadTimeoutMs(int vastLoadTimeoutMs) {
* @see AdsRenderingSettings#setLoadVideoTimeout(int)
*/
public Builder setMediaLoadTimeoutMs(int mediaLoadTimeoutMs) {
- Assertions.checkArgument(mediaLoadTimeoutMs > 0);
+ checkArgument(mediaLoadTimeoutMs > 0);
this.mediaLoadTimeoutMs = mediaLoadTimeoutMs;
return this;
}
@@ -198,7 +268,7 @@ public Builder setMediaLoadTimeoutMs(int mediaLoadTimeoutMs) {
* @see AdsRenderingSettings#setBitrateKbps(int)
*/
public Builder setMaxMediaBitrate(int bitrate) {
- Assertions.checkArgument(bitrate > 0);
+ checkArgument(bitrate > 0);
this.mediaBitrate = bitrate;
return this;
}
@@ -217,9 +287,24 @@ public Builder setFocusSkipButtonWhenAvailable(boolean focusSkipButtonWhenAvaila
return this;
}
+ /**
+ * Sets whether to play an ad before the start position when beginning playback. If {@code
+ * true}, an ad will be played if there is one at or before the start position. If {@code
+ * false}, an ad will be played only if there is one exactly at the start position. The default
+ * setting is {@code true}.
+ *
+ * @param playAdBeforeStartPosition Whether to play an ad before the start position when
+ * beginning playback.
+ * @return This builder, for convenience.
+ */
+ public Builder setPlayAdBeforeStartPosition(boolean playAdBeforeStartPosition) {
+ this.playAdBeforeStartPosition = playAdBeforeStartPosition;
+ return this;
+ }
+
@VisibleForTesting
/* package */ Builder setImaFactory(ImaFactory imaFactory) {
- this.imaFactory = Assertions.checkNotNull(imaFactory);
+ this.imaFactory = checkNotNull(imaFactory);
return this;
}
@@ -236,12 +321,16 @@ public ImaAdsLoader buildForAdTag(Uri adTagUri) {
context,
adTagUri,
imaSdkSettings,
- null,
+ /* adsResponse= */ null,
+ adPreloadTimeoutMs,
vastLoadTimeoutMs,
mediaLoadTimeoutMs,
mediaBitrate,
focusSkipButtonWhenAvailable,
+ playAdBeforeStartPosition,
adUiElements,
+ companionAdSlots,
+ adErrorListener,
adEventListener,
imaFactory);
}
@@ -256,14 +345,18 @@ public ImaAdsLoader buildForAdTag(Uri adTagUri) {
public ImaAdsLoader buildForAdsResponse(String adsResponse) {
return new ImaAdsLoader(
context,
- null,
+ /* adTagUri= */ null,
imaSdkSettings,
adsResponse,
+ adPreloadTimeoutMs,
vastLoadTimeoutMs,
mediaLoadTimeoutMs,
mediaBitrate,
focusSkipButtonWhenAvailable,
+ playAdBeforeStartPosition,
adUiElements,
+ companionAdSlots,
+ adErrorListener,
adEventListener,
imaFactory);
}
@@ -275,6 +368,14 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) {
private static final String IMA_SDK_SETTINGS_PLAYER_TYPE = "google/exo.ext.ima";
private static final String IMA_SDK_SETTINGS_PLAYER_VERSION = ExoPlayerLibraryInfo.VERSION;
+ /**
+ * Interval at which ad progress updates are provided to the IMA SDK, in milliseconds. 100 ms is
+ * the interval recommended by the IMA documentation.
+ *
+ * @see VideoAdPlayer.VideoAdPlayerCallback
+ */
+ private static final int AD_PROGRESS_UPDATE_INTERVAL_MS = 100;
+
/** The value used in {@link VideoProgressUpdate}s to indicate an unset duration. */
private static final long IMA_DURATION_UNSET = -1L;
@@ -282,10 +383,14 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) {
* Threshold before the end of content at which IMA is notified that content is complete if the
* player buffers, in milliseconds.
*/
- private static final long END_OF_CONTENT_POSITION_THRESHOLD_MS = 5000;
-
- /** The maximum duration before an ad break that IMA may start preloading the next ad. */
- private static final long MAXIMUM_PRELOAD_DURATION_MS = 8000;
+ private static final long THRESHOLD_END_OF_CONTENT_MS = 5000;
+ /**
+ * Threshold before the start of an ad at which IMA is expected to be able to preload the ad, in
+ * milliseconds.
+ */
+ private static final long THRESHOLD_AD_PRELOAD_MS = 4000;
+ /** The threshold below which ad cue points are treated as matching, in microseconds. */
+ private static final long THRESHOLD_AD_MATCH_US = 1000;
private static final int TIMEOUT_UNSET = -1;
private static final int BITRATE_UNSET = -1;
@@ -295,85 +400,95 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) {
@Retention(RetentionPolicy.SOURCE)
@IntDef({IMA_AD_STATE_NONE, IMA_AD_STATE_PLAYING, IMA_AD_STATE_PAUSED})
private @interface ImaAdState {}
- /**
- * The ad playback state when IMA is not playing an ad.
- */
+ /** The ad playback state when IMA is not playing an ad. */
private static final int IMA_AD_STATE_NONE = 0;
/**
- * The ad playback state when IMA has called {@link #playAd()} and not {@link #pauseAd()}.
+ * The ad playback state when IMA has called {@link ComponentListener#playAd(AdMediaInfo)} and not
+ * {@link ComponentListener##pauseAd(AdMediaInfo)}.
*/
private static final int IMA_AD_STATE_PLAYING = 1;
/**
- * The ad playback state when IMA has called {@link #pauseAd()} while playing an ad.
+ * The ad playback state when IMA has called {@link ComponentListener#pauseAd(AdMediaInfo)} while
+ * playing an ad.
*/
private static final int IMA_AD_STATE_PAUSED = 2;
+ private final Context context;
@Nullable private final Uri adTagUri;
@Nullable private final String adsResponse;
+ private final long adPreloadTimeoutMs;
private final int vastLoadTimeoutMs;
private final int mediaLoadTimeoutMs;
private final boolean focusSkipButtonWhenAvailable;
+ private final boolean playAdBeforeStartPosition;
private final int mediaBitrate;
@Nullable private final Set adUiElements;
+ @Nullable private final Collection companionAdSlots;
+ @Nullable private final AdErrorListener adErrorListener;
@Nullable private final AdEventListener adEventListener;
private final ImaFactory imaFactory;
+ private final ImaSdkSettings imaSdkSettings;
private final Timeline.Period period;
- private final List adCallbacks;
- private final AdDisplayContainer adDisplayContainer;
- private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader;
-
+ private final Handler handler;
+ private final ComponentListener componentListener;
+ private final List adCallbacks;
+ private final Runnable updateAdProgressRunnable;
+ private final BiMap adInfoByAdMediaInfo;
+
+ private @MonotonicNonNull AdDisplayContainer adDisplayContainer;
+ private @MonotonicNonNull AdsLoader adsLoader;
private boolean wasSetPlayerCalled;
@Nullable private Player nextPlayer;
- private Object pendingAdRequestContext;
+ @Nullable private Object pendingAdRequestContext;
private List supportedMimeTypes;
@Nullable private EventListener eventListener;
@Nullable private Player player;
private VideoProgressUpdate lastContentProgress;
private VideoProgressUpdate lastAdProgress;
- private int lastVolumePercentage;
+ private int lastVolumePercent;
@Nullable private AdsManager adsManager;
- private boolean initializedAdsManager;
- private AdLoadException pendingAdLoadError;
+ private boolean isAdsManagerInitialized;
+ private boolean hasAdPlaybackState;
+ @Nullable private AdLoadException pendingAdLoadError;
private Timeline timeline;
private long contentDurationMs;
- private int podIndexOffset;
private AdPlaybackState adPlaybackState;
// Fields tracking IMA's state.
- /** The expected ad group index that IMA should load next. */
- private int expectedAdGroupIndex;
- /** The index of the current ad group that IMA is loading. */
- private int adGroupIndex;
/** Whether IMA has sent an ad event to pause content since the last resume content event. */
private boolean imaPausedContent;
/** The current ad playback state. */
private @ImaAdState int imaAdState;
- /**
- * Whether {@link com.google.ads.interactivemedia.v3.api.AdsLoader#contentComplete()} has been
- * called since starting ad playback.
- */
+ /** The current ad media info, or {@code null} if in state {@link #IMA_AD_STATE_NONE}. */
+ @Nullable private AdMediaInfo imaAdMediaInfo;
+ /** The current ad info, or {@code null} if in state {@link #IMA_AD_STATE_NONE}. */
+ @Nullable private AdInfo imaAdInfo;
+ /** Whether IMA has been notified that playback of content has finished. */
private boolean sentContentComplete;
// Fields tracking the player/loader state.
/** Whether the player is playing an ad. */
private boolean playingAd;
+ /** Whether the player is buffering an ad. */
+ private boolean bufferingAd;
/**
* If the player is playing an ad, stores the ad index in its ad group. {@link C#INDEX_UNSET}
* otherwise.
*/
private int playingAdIndexInAdGroup;
/**
- * Whether there's a pending ad preparation error which IMA needs to be notified of when it
- * transitions from playing content to playing the ad.
+ * The ad info for a pending ad for which the media failed preparation, or {@code null} if no
+ * pending ads have failed to prepare.
*/
- private boolean shouldNotifyAdPrepareError;
+ @Nullable private AdInfo pendingAdPrepareErrorAdInfo;
/**
- * If a content period has finished but IMA has not yet called {@link #playAd()}, stores the value
- * of {@link SystemClock#elapsedRealtime()} when the content stopped playing. This can be used to
- * determine a fake, increasing content position. {@link C#TIME_UNSET} otherwise.
+ * If a content period has finished but IMA has not yet called {@link
+ * ComponentListener#playAd(AdMediaInfo)}, stores the value of {@link
+ * SystemClock#elapsedRealtime()} when the content stopped playing. This can be used to determine
+ * a fake, increasing content position. {@link C#TIME_UNSET} otherwise.
*/
private long fakeContentProgressElapsedRealtimeMs;
/**
@@ -383,8 +498,16 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) {
private long fakeContentProgressOffsetMs;
/** Stores the pending content position when a seek operation was intercepted to play an ad. */
private long pendingContentPositionMs;
- /** Whether {@link #getContentProgress()} has sent {@link #pendingContentPositionMs} to IMA. */
+ /**
+ * Whether {@link ComponentListener#getContentProgress()} has sent {@link
+ * #pendingContentPositionMs} to IMA.
+ */
private boolean sentPendingContentPositionMs;
+ /**
+ * Stores the real time in milliseconds at which the player started buffering, possibly due to not
+ * having preloaded an ad, or {@link C#TIME_UNSET} if not applicable.
+ */
+ private long waitingForPreloadElapsedRealtimeMs;
/**
* Creates a new IMA ads loader.
@@ -402,62 +525,49 @@ public ImaAdsLoader(Context context, Uri adTagUri) {
adTagUri,
/* imaSdkSettings= */ null,
/* adsResponse= */ null,
+ /* adPreloadTimeoutMs= */ Builder.DEFAULT_AD_PRELOAD_TIMEOUT_MS,
/* vastLoadTimeoutMs= */ TIMEOUT_UNSET,
/* mediaLoadTimeoutMs= */ TIMEOUT_UNSET,
/* mediaBitrate= */ BITRATE_UNSET,
/* focusSkipButtonWhenAvailable= */ true,
+ /* playAdBeforeStartPosition= */ true,
/* adUiElements= */ null,
+ /* companionAdSlots= */ null,
+ /* adErrorListener= */ null,
/* adEventListener= */ null,
/* imaFactory= */ new DefaultImaFactory());
}
- /**
- * Creates a new IMA ads loader.
- *
- * @param context The context.
- * @param adTagUri The {@link Uri} of an ad tag compatible with the Android IMA SDK. See
- * https://developers.google.com/interactive-media-ads/docs/sdks/android/compatibility for
- * more information.
- * @param imaSdkSettings {@link ImaSdkSettings} used to configure the IMA SDK, or {@code null} to
- * use the default settings. If set, the player type and version fields may be overwritten.
- * @deprecated Use {@link ImaAdsLoader.Builder}.
- */
- @Deprecated
- public ImaAdsLoader(Context context, Uri adTagUri, @Nullable ImaSdkSettings imaSdkSettings) {
- this(
- context,
- adTagUri,
- imaSdkSettings,
- /* adsResponse= */ null,
- /* vastLoadTimeoutMs= */ TIMEOUT_UNSET,
- /* mediaLoadTimeoutMs= */ TIMEOUT_UNSET,
- /* mediaBitrate= */ BITRATE_UNSET,
- /* focusSkipButtonWhenAvailable= */ true,
- /* adUiElements= */ null,
- /* adEventListener= */ null,
- /* imaFactory= */ new DefaultImaFactory());
- }
-
+ @SuppressWarnings({"nullness:argument.type.incompatible", "methodref.receiver.bound.invalid"})
private ImaAdsLoader(
Context context,
@Nullable Uri adTagUri,
@Nullable ImaSdkSettings imaSdkSettings,
@Nullable String adsResponse,
+ long adPreloadTimeoutMs,
int vastLoadTimeoutMs,
int mediaLoadTimeoutMs,
int mediaBitrate,
boolean focusSkipButtonWhenAvailable,
+ boolean playAdBeforeStartPosition,
@Nullable Set adUiElements,
+ @Nullable Collection companionAdSlots,
+ @Nullable AdErrorListener adErrorListener,
@Nullable AdEventListener adEventListener,
ImaFactory imaFactory) {
- Assertions.checkArgument(adTagUri != null || adsResponse != null);
+ checkArgument(adTagUri != null || adsResponse != null);
+ this.context = context.getApplicationContext();
this.adTagUri = adTagUri;
this.adsResponse = adsResponse;
+ this.adPreloadTimeoutMs = adPreloadTimeoutMs;
this.vastLoadTimeoutMs = vastLoadTimeoutMs;
this.mediaLoadTimeoutMs = mediaLoadTimeoutMs;
this.mediaBitrate = mediaBitrate;
this.focusSkipButtonWhenAvailable = focusSkipButtonWhenAvailable;
+ this.playAdBeforeStartPosition = playAdBeforeStartPosition;
this.adUiElements = adUiElements;
+ this.companionAdSlots = companionAdSlots;
+ this.adErrorListener = adErrorListener;
this.adEventListener = adEventListener;
this.imaFactory = imaFactory;
if (imaSdkSettings == null) {
@@ -468,56 +578,50 @@ private ImaAdsLoader(
}
imaSdkSettings.setPlayerType(IMA_SDK_SETTINGS_PLAYER_TYPE);
imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION);
+ this.imaSdkSettings = imaSdkSettings;
period = new Timeline.Period();
+ handler = Util.createHandler(getImaLooper(), /* callback= */ null);
+ componentListener = new ComponentListener();
adCallbacks = new ArrayList<>(/* initialCapacity= */ 1);
- adDisplayContainer = imaFactory.createAdDisplayContainer();
- adDisplayContainer.setPlayer(/* videoAdPlayer= */ this);
- adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer);
- adsLoader.addAdErrorListener(/* adErrorListener= */ this);
- adsLoader.addAdsLoadedListener(/* adsLoadedListener= */ this);
+ updateAdProgressRunnable = this::updateAdProgress;
+ adInfoByAdMediaInfo = HashBiMap.create();
+ supportedMimeTypes = Collections.emptyList();
+ lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
+ lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET;
fakeContentProgressOffsetMs = C.TIME_UNSET;
pendingContentPositionMs = C.TIME_UNSET;
- adGroupIndex = C.INDEX_UNSET;
+ waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET;
contentDurationMs = C.TIME_UNSET;
timeline = Timeline.EMPTY;
+ adPlaybackState = AdPlaybackState.NONE;
}
/**
- * Returns the underlying {@code com.google.ads.interactivemedia.v3.api.AdsLoader} wrapped by
- * this instance.
+ * Returns the underlying {@link AdsLoader} wrapped by this instance, or {@code null} if ads have
+ * not been requested yet.
*/
- public com.google.ads.interactivemedia.v3.api.AdsLoader getAdsLoader() {
+ @Nullable
+ public AdsLoader getAdsLoader() {
return adsLoader;
}
/**
- * Returns the {@link AdDisplayContainer} used by this loader.
+ * Returns the {@link AdDisplayContainer} used by this loader, or {@code null} if ads have not
+ * been requested yet.
*
* Note: any video controls overlays registered via {@link
- * AdDisplayContainer#registerVideoControlsOverlay(View)} will be unregistered automatically when
- * the media source detaches from this instance. It is therefore necessary to re-register views
- * each time the ads loader is reused. Alternatively, provide overlay views via the {@link
- * AdsLoader.AdViewProvider} when creating the media source to benefit from automatic
- * registration.
+ * AdDisplayContainer#registerFriendlyObstruction(FriendlyObstruction)} will be unregistered
+ * automatically when the media source detaches from this instance. It is therefore necessary to
+ * re-register views each time the ads loader is reused. Alternatively, provide overlay views via
+ * the {@link com.google.android.exoplayer2.source.ads.AdsLoader.AdViewProvider} when creating the
+ * media source to benefit from automatic registration.
*/
+ @Nullable
public AdDisplayContainer getAdDisplayContainer() {
return adDisplayContainer;
}
- /**
- * Sets the slots for displaying companion ads. Individual slots can be created using {@link
- * ImaSdkFactory#createCompanionAdSlot()}.
- *
- * @param companionSlots Slots for displaying companion ads.
- * @see AdDisplayContainer#setCompanionSlots(Collection)
- * @deprecated Use {@code getAdDisplayContainer().setCompanionSlots(...)}.
- */
- @Deprecated
- public void setCompanionSlots(Collection companionSlots) {
- adDisplayContainer.setCompanionSlots(companionSlots);
- }
-
/**
* Requests ads, if they have not already been requested. Must be called on the main thread.
*
@@ -525,36 +629,64 @@ public void setCompanionSlots(Collection companionSlots) {
* called, so it is only necessary to call this method if you want to request ads before preparing
* the player.
*
- * @param adViewGroup A {@link ViewGroup} on top of the player that will show any ad UI.
+ * @param adViewGroup A {@link ViewGroup} on top of the player that will show any ad UI, or {@code
+ * null} if playing audio-only ads.
*/
- public void requestAds(ViewGroup adViewGroup) {
- if (adPlaybackState != null || adsManager != null || pendingAdRequestContext != null) {
+ public void requestAds(@Nullable ViewGroup adViewGroup) {
+ if (hasAdPlaybackState || adsManager != null || pendingAdRequestContext != null) {
// Ads have already been requested.
return;
}
- adDisplayContainer.setAdContainer(adViewGroup);
- pendingAdRequestContext = new Object();
+ if (adViewGroup != null) {
+ adDisplayContainer =
+ imaFactory.createAdDisplayContainer(adViewGroup, /* player= */ componentListener);
+ } else {
+ adDisplayContainer =
+ imaFactory.createAudioAdDisplayContainer(context, /* player= */ componentListener);
+ }
+ if (companionAdSlots != null) {
+ adDisplayContainer.setCompanionSlots(companionAdSlots);
+ }
+ adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer);
+ adsLoader.addAdErrorListener(componentListener);
+ if (adErrorListener != null) {
+ adsLoader.addAdErrorListener(adErrorListener);
+ }
+ adsLoader.addAdsLoadedListener(componentListener);
AdsRequest request = imaFactory.createAdsRequest();
if (adTagUri != null) {
request.setAdTagUrl(adTagUri.toString());
- } else /* adsResponse != null */ {
- request.setAdsResponse(adsResponse);
+ } else {
+ request.setAdsResponse(castNonNull(adsResponse));
}
if (vastLoadTimeoutMs != TIMEOUT_UNSET) {
request.setVastLoadTimeout(vastLoadTimeoutMs);
}
- request.setContentProgressProvider(this);
+ request.setContentProgressProvider(componentListener);
+ pendingAdRequestContext = new Object();
request.setUserRequestContext(pendingAdRequestContext);
adsLoader.requestAds(request);
}
- // AdsLoader implementation.
+ /**
+ * Skips the current ad.
+ *
+ * This method is intended for apps that play audio-only ads and so need to provide their own
+ * UI for users to skip skippable ads. Apps showing video ads should not call this method, as the
+ * IMA SDK provides the UI to skip ads in the ad view group passed via {@link AdViewProvider}.
+ */
+ public void skipAd() {
+ if (adsManager != null) {
+ adsManager.skip();
+ }
+ }
+
+ // com.google.android.exoplayer2.source.ads.AdsLoader implementation.
@Override
public void setPlayer(@Nullable Player player) {
- Assertions.checkState(Looper.getMainLooper() == Looper.myLooper());
- Assertions.checkState(
- player == null || player.getApplicationLooper() == Looper.getMainLooper());
+ checkState(Looper.myLooper() == getImaLooper());
+ checkState(player == null || player.getApplicationLooper() == getImaLooper());
nextPlayer = player;
wasSetPlayerCalled = true;
}
@@ -563,6 +695,7 @@ public void setPlayer(@Nullable Player player) {
public void setSupportedContentTypes(@C.ContentType int... contentTypes) {
List supportedMimeTypes = new ArrayList<>();
for (@C.ContentType int contentType : contentTypes) {
+ // IMA does not support Smooth Streaming ad media.
if (contentType == C.TYPE_DASH) {
supportedMimeTypes.add(MimeTypes.APPLICATION_MPD);
} else if (contentType == C.TYPE_HLS) {
@@ -575,8 +708,6 @@ public void setSupportedContentTypes(@C.ContentType int... contentTypes) {
MimeTypes.VIDEO_H263,
MimeTypes.AUDIO_MP4,
MimeTypes.AUDIO_MPEG));
- } else if (contentType == C.TYPE_SS) {
- // IMA does not support Smooth Streaming ad media.
}
}
this.supportedMimeTypes = Collections.unmodifiableList(supportedMimeTypes);
@@ -584,80 +715,104 @@ public void setSupportedContentTypes(@C.ContentType int... contentTypes) {
@Override
public void start(EventListener eventListener, AdViewProvider adViewProvider) {
- Assertions.checkState(
+ checkState(
wasSetPlayerCalled, "Set player using adsLoader.setPlayer before preparing the player.");
player = nextPlayer;
if (player == null) {
return;
}
- this.eventListener = eventListener;
- lastVolumePercentage = 0;
- lastAdProgress = null;
- lastContentProgress = null;
- ViewGroup adViewGroup = adViewProvider.getAdViewGroup();
- adDisplayContainer.setAdContainer(adViewGroup);
- View[] adOverlayViews = adViewProvider.getAdOverlayViews();
- for (View view : adOverlayViews) {
- adDisplayContainer.registerVideoControlsOverlay(view);
- }
player.addListener(this);
+ boolean playWhenReady = player.getPlayWhenReady();
+ this.eventListener = eventListener;
+ lastVolumePercent = 0;
+ lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
+ lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
maybeNotifyPendingAdLoadError();
- if (adPlaybackState != null) {
+ if (hasAdPlaybackState) {
// Pass the ad playback state to the player, and resume ads if necessary.
eventListener.onAdPlaybackState(adPlaybackState);
- if (imaPausedContent && player.getPlayWhenReady()) {
+ if (adsManager != null && imaPausedContent && playWhenReady) {
adsManager.resume();
}
} else if (adsManager != null) {
- adPlaybackState = new AdPlaybackState(getAdGroupTimesUs(adsManager.getAdCuePoints()));
+ adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints());
updateAdPlaybackState();
} else {
// Ads haven't loaded yet, so request them.
- requestAds(adViewGroup);
+ requestAds(adViewProvider.getAdViewGroup());
+ }
+ if (adDisplayContainer != null) {
+ for (OverlayInfo overlayInfo : adViewProvider.getAdOverlayInfos()) {
+ adDisplayContainer.registerFriendlyObstruction(
+ imaFactory.createFriendlyObstruction(
+ overlayInfo.view,
+ getFriendlyObstructionPurpose(overlayInfo.purpose),
+ overlayInfo.reasonDetail));
+ }
}
}
@Override
public void stop() {
+ @Nullable Player player = this.player;
if (player == null) {
return;
}
if (adsManager != null && imaPausedContent) {
+ adsManager.pause();
adPlaybackState =
adPlaybackState.withAdResumePositionUs(
playingAd ? C.msToUs(player.getCurrentPosition()) : 0);
- adsManager.pause();
}
- lastVolumePercentage = getVolume();
- lastAdProgress = getAdProgress();
- lastContentProgress = getContentProgress();
- adDisplayContainer.unregisterAllVideoControlsOverlays();
+ lastVolumePercent = getPlayerVolumePercent();
+ lastAdProgress = getAdVideoProgressUpdate();
+ lastContentProgress = getContentVideoProgressUpdate();
+ if (adDisplayContainer != null) {
+ adDisplayContainer.unregisterAllFriendlyObstructions();
+ }
player.removeListener(this);
- player = null;
+ this.player = null;
eventListener = null;
}
@Override
public void release() {
pendingAdRequestContext = null;
- if (adsManager != null) {
- adsManager.removeAdErrorListener(this);
- adsManager.removeAdEventListener(this);
- if (adEventListener != null) {
- adsManager.removeAdEventListener(adEventListener);
+ destroyAdsManager();
+ if (adsLoader != null) {
+ adsLoader.removeAdsLoadedListener(componentListener);
+ adsLoader.removeAdErrorListener(componentListener);
+ if (adErrorListener != null) {
+ adsLoader.removeAdErrorListener(adErrorListener);
}
- adsManager.destroy();
- adsManager = null;
}
- adsLoader.removeAdsLoadedListener(/* adsLoadedListener= */ this);
- adsLoader.removeAdErrorListener(/* adErrorListener= */ this);
imaPausedContent = false;
imaAdState = IMA_AD_STATE_NONE;
+ imaAdMediaInfo = null;
+ stopUpdatingAdProgress();
+ imaAdInfo = null;
pendingAdLoadError = null;
adPlaybackState = AdPlaybackState.NONE;
+ hasAdPlaybackState = true;
updateAdPlaybackState();
}
+ @Override
+ public void handlePrepareComplete(int adGroupIndex, int adIndexInAdGroup) {
+ AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup);
+ if (DEBUG) {
+ Log.d(TAG, "Prepared ad " + adInfo);
+ }
+ @Nullable AdMediaInfo adMediaInfo = adInfoByAdMediaInfo.inverse().get(adInfo);
+ if (adMediaInfo != null) {
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onLoaded(adMediaInfo);
+ }
+ } else {
+ Log.w(TAG, "Unexpected prepared ad " + adInfo);
+ }
+ }
+
@Override
public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception) {
if (player == null) {
@@ -665,87 +820,179 @@ public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOExcepti
}
try {
handleAdPrepareError(adGroupIndex, adIndexInAdGroup, exception);
- } catch (Exception e) {
+ } catch (RuntimeException e) {
maybeNotifyInternalError("handlePrepareError", e);
}
}
- // com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener implementation.
+ // Player.EventListener implementation.
@Override
- public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) {
- AdsManager adsManager = adsManagerLoadedEvent.getAdsManager();
- if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) {
- adsManager.destroy();
+ public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) {
+ if (timeline.isEmpty()) {
+ // The player is being reset or contains no media.
return;
}
- pendingAdRequestContext = null;
- this.adsManager = adsManager;
- adsManager.addAdErrorListener(this);
- adsManager.addAdEventListener(this);
- if (adEventListener != null) {
- adsManager.addAdEventListener(adEventListener);
- }
- if (player != null) {
- // If a player is attached already, start playback immediately.
- try {
- adPlaybackState = new AdPlaybackState(getAdGroupTimesUs(adsManager.getAdCuePoints()));
- updateAdPlaybackState();
- } catch (Exception e) {
- maybeNotifyInternalError("onAdsManagerLoaded", e);
+ checkArgument(timeline.getPeriodCount() == 1);
+ this.timeline = timeline;
+ long contentDurationUs = timeline.getPeriod(/* periodIndex= */ 0, period).durationUs;
+ contentDurationMs = C.usToMs(contentDurationUs);
+ if (contentDurationUs != C.TIME_UNSET) {
+ adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs);
+ }
+ @Nullable AdsManager adsManager = this.adsManager;
+ if (!isAdsManagerInitialized && adsManager != null) {
+ isAdsManagerInitialized = true;
+ @Nullable AdsRenderingSettings adsRenderingSettings = setupAdsRendering();
+ if (adsRenderingSettings == null) {
+ // There are no ads to play.
+ destroyAdsManager();
+ } else {
+ adsManager.init(adsRenderingSettings);
+ adsManager.start();
+ if (DEBUG) {
+ Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings);
+ }
}
+ updateAdPlaybackState();
}
+ handleTimelineOrPositionChanged();
}
- // AdEvent.AdEventListener implementation.
+ @Override
+ public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) {
+ handleTimelineOrPositionChanged();
+ }
@Override
- public void onAdEvent(AdEvent adEvent) {
- AdEventType adEventType = adEvent.getType();
- if (DEBUG) {
- Log.d(TAG, "onAdEvent: " + adEventType);
- }
- if (adsManager == null) {
- Log.w(TAG, "Ignoring AdEvent after release: " + adEvent);
+ public void onPlaybackStateChanged(@Player.State int playbackState) {
+ @Nullable Player player = this.player;
+ if (adsManager == null || player == null) {
return;
}
- try {
- handleAdEvent(adEvent);
- } catch (Exception e) {
- maybeNotifyInternalError("onAdEvent", e);
+
+ if (playbackState == Player.STATE_BUFFERING && !player.isPlayingAd()) {
+ // Check whether we are waiting for an ad to preload.
+ int adGroupIndex = getLoadingAdGroupIndex();
+ if (adGroupIndex == C.INDEX_UNSET) {
+ return;
+ }
+ AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex];
+ if (adGroup.count != C.LENGTH_UNSET
+ && adGroup.count != 0
+ && adGroup.states[0] != AdPlaybackState.AD_STATE_UNAVAILABLE) {
+ // An ad is available already so we must be buffering for some other reason.
+ return;
+ }
+ long adGroupTimeMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]);
+ long contentPositionMs = getContentPeriodPositionMs(player, timeline, period);
+ long timeUntilAdMs = adGroupTimeMs - contentPositionMs;
+ if (timeUntilAdMs < adPreloadTimeoutMs) {
+ waitingForPreloadElapsedRealtimeMs = SystemClock.elapsedRealtime();
+ }
+ } else if (playbackState == Player.STATE_READY) {
+ waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET;
}
- }
- // AdErrorEvent.AdErrorListener implementation.
+ handlePlayerStateChanged(player.getPlayWhenReady(), playbackState);
+ }
@Override
- public void onAdError(AdErrorEvent adErrorEvent) {
- AdError error = adErrorEvent.getError();
- if (DEBUG) {
- Log.d(TAG, "onAdError", error);
+ public void onPlayWhenReadyChanged(
+ boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) {
+ if (adsManager == null || player == null) {
+ return;
}
- if (adsManager == null) {
- // No ads were loaded, so allow playback to start without any ads.
- pendingAdRequestContext = null;
- adPlaybackState = new AdPlaybackState();
- updateAdPlaybackState();
- } else if (isAdGroupLoadError(error)) {
- try {
- handleAdGroupLoadError(error);
- } catch (Exception e) {
- maybeNotifyInternalError("onAdError", e);
- }
+
+ if (imaAdState == IMA_AD_STATE_PLAYING && !playWhenReady) {
+ adsManager.pause();
+ return;
}
- if (pendingAdLoadError == null) {
- pendingAdLoadError = AdLoadException.createForAllAds(error);
+
+ if (imaAdState == IMA_AD_STATE_PAUSED && playWhenReady) {
+ adsManager.resume();
+ return;
}
- maybeNotifyPendingAdLoadError();
+ handlePlayerStateChanged(playWhenReady, player.getPlaybackState());
}
- // ContentProgressProvider implementation.
-
@Override
- public VideoProgressUpdate getContentProgress() {
+ public void onPlayerError(ExoPlaybackException error) {
+ if (imaAdState != IMA_AD_STATE_NONE) {
+ AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onError(adMediaInfo);
+ }
+ }
+ }
+
+ // Internal methods.
+
+ /**
+ * Configures ads rendering for starting playback, returning the settings for the IMA SDK or
+ * {@code null} if no ads should play.
+ */
+ @Nullable
+ private AdsRenderingSettings setupAdsRendering() {
+ AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings();
+ adsRenderingSettings.setEnablePreloading(true);
+ adsRenderingSettings.setMimeTypes(supportedMimeTypes);
+ if (mediaLoadTimeoutMs != TIMEOUT_UNSET) {
+ adsRenderingSettings.setLoadVideoTimeout(mediaLoadTimeoutMs);
+ }
+ if (mediaBitrate != BITRATE_UNSET) {
+ adsRenderingSettings.setBitrateKbps(mediaBitrate / 1000);
+ }
+ adsRenderingSettings.setFocusSkipButtonWhenAvailable(focusSkipButtonWhenAvailable);
+ if (adUiElements != null) {
+ adsRenderingSettings.setUiElements(adUiElements);
+ }
+
+ // Skip ads based on the start position as required.
+ long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs;
+ long contentPositionMs = getContentPeriodPositionMs(checkNotNull(player), timeline, period);
+ int adGroupForPositionIndex =
+ adPlaybackState.getAdGroupIndexForPositionUs(
+ C.msToUs(contentPositionMs), C.msToUs(contentDurationMs));
+ if (adGroupForPositionIndex != C.INDEX_UNSET) {
+ boolean playAdWhenStartingPlayback =
+ playAdBeforeStartPosition
+ || adGroupTimesUs[adGroupForPositionIndex] == C.msToUs(contentPositionMs);
+ if (!playAdWhenStartingPlayback) {
+ adGroupForPositionIndex++;
+ } else if (hasMidrollAdGroups(adGroupTimesUs)) {
+ // Provide the player's initial position to trigger loading and playing the ad. If there are
+ // no midrolls, we are playing a preroll and any pending content position wouldn't be
+ // cleared.
+ pendingContentPositionMs = contentPositionMs;
+ }
+ if (adGroupForPositionIndex > 0) {
+ for (int i = 0; i < adGroupForPositionIndex; i++) {
+ adPlaybackState = adPlaybackState.withSkippedAdGroup(i);
+ }
+ if (adGroupForPositionIndex == adGroupTimesUs.length) {
+ // We don't need to play any ads. Because setPlayAdsAfterTime does not discard non-VMAP
+ // ads, we signal that no ads will render so the caller can destroy the ads manager.
+ return null;
+ }
+ long adGroupForPositionTimeUs = adGroupTimesUs[adGroupForPositionIndex];
+ long adGroupBeforePositionTimeUs = adGroupTimesUs[adGroupForPositionIndex - 1];
+ if (adGroupForPositionTimeUs == C.TIME_END_OF_SOURCE) {
+ // Play the postroll by offsetting the start position just past the last non-postroll ad.
+ adsRenderingSettings.setPlayAdsAfterTime(
+ (double) adGroupBeforePositionTimeUs / C.MICROS_PER_SECOND + 1d);
+ } else {
+ // Play ads after the midpoint between the ad to play and the one before it, to avoid
+ // issues with rounding one of the two ad times.
+ double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforePositionTimeUs) / 2d;
+ adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND);
+ }
+ }
+ }
+ return adsRenderingSettings;
+ }
+
+ private VideoProgressUpdate getContentVideoProgressUpdate() {
if (player == null) {
return lastContentProgress;
}
@@ -754,32 +1001,11 @@ public VideoProgressUpdate getContentProgress() {
if (pendingContentPositionMs != C.TIME_UNSET) {
sentPendingContentPositionMs = true;
contentPositionMs = pendingContentPositionMs;
- expectedAdGroupIndex =
- adPlaybackState.getAdGroupIndexForPositionUs(
- C.msToUs(contentPositionMs), C.msToUs(contentDurationMs));
} else if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) {
long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs;
contentPositionMs = fakeContentProgressOffsetMs + elapsedSinceEndMs;
- expectedAdGroupIndex =
- adPlaybackState.getAdGroupIndexForPositionUs(
- C.msToUs(contentPositionMs), C.msToUs(contentDurationMs));
} else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) {
- contentPositionMs = getContentPeriodPositionMs();
- // Update the expected ad group index for the current content position. The update is delayed
- // until MAXIMUM_PRELOAD_DURATION_MS before the ad so that an ad group load error delivered
- // just after an ad group isn't incorrectly attributed to the next ad group.
- int nextAdGroupIndex =
- adPlaybackState.getAdGroupIndexAfterPositionUs(
- C.msToUs(contentPositionMs), C.msToUs(contentDurationMs));
- if (nextAdGroupIndex != expectedAdGroupIndex && nextAdGroupIndex != C.INDEX_UNSET) {
- long nextAdGroupTimeMs = C.usToMs(adPlaybackState.adGroupTimesUs[nextAdGroupIndex]);
- if (nextAdGroupTimeMs == C.TIME_END_OF_SOURCE) {
- nextAdGroupTimeMs = contentDurationMs;
- }
- if (nextAdGroupTimeMs - contentPositionMs < MAXIMUM_PRELOAD_DURATION_MS) {
- expectedAdGroupIndex = nextAdGroupIndex;
- }
- }
+ contentPositionMs = getContentPeriodPositionMs(player, timeline, period);
} else {
return VideoProgressUpdate.VIDEO_TIME_NOT_READY;
}
@@ -787,28 +1013,40 @@ public VideoProgressUpdate getContentProgress() {
return new VideoProgressUpdate(contentPositionMs, contentDurationMs);
}
- // VideoAdPlayer implementation.
-
- @Override
- public VideoProgressUpdate getAdProgress() {
+ private VideoProgressUpdate getAdVideoProgressUpdate() {
if (player == null) {
return lastAdProgress;
} else if (imaAdState != IMA_AD_STATE_NONE && playingAd) {
long adDuration = player.getDuration();
- return adDuration == C.TIME_UNSET ? VideoProgressUpdate.VIDEO_TIME_NOT_READY
+ return adDuration == C.TIME_UNSET
+ ? VideoProgressUpdate.VIDEO_TIME_NOT_READY
: new VideoProgressUpdate(player.getCurrentPosition(), adDuration);
} else {
return VideoProgressUpdate.VIDEO_TIME_NOT_READY;
}
}
- @Override
- public int getVolume() {
+ private void updateAdProgress() {
+ VideoProgressUpdate videoProgressUpdate = getAdVideoProgressUpdate();
+ AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onAdProgress(adMediaInfo, videoProgressUpdate);
+ }
+ handler.removeCallbacks(updateAdProgressRunnable);
+ handler.postDelayed(updateAdProgressRunnable, AD_PROGRESS_UPDATE_INTERVAL_MS);
+ }
+
+ private void stopUpdatingAdProgress() {
+ handler.removeCallbacks(updateAdProgressRunnable);
+ }
+
+ private int getPlayerVolumePercent() {
+ @Nullable Player player = this.player;
if (player == null) {
- return lastVolumePercentage;
+ return lastVolumePercent;
}
- Player.AudioComponent audioComponent = player.getAudioComponent();
+ @Nullable Player.AudioComponent audioComponent = player.getAudioComponent();
if (audioComponent != null) {
return (int) (audioComponent.getVolume() * 100);
}
@@ -823,297 +1061,23 @@ public int getVolume() {
return 0;
}
- @Override
- public void loadAd(String adUriString) {
- try {
- if (DEBUG) {
- Log.d(TAG, "loadAd in ad group " + adGroupIndex);
- }
- if (adsManager == null) {
- Log.w(TAG, "Ignoring loadAd after release");
- return;
- }
- if (adGroupIndex == C.INDEX_UNSET) {
- Log.w(
- TAG,
- "Unexpected loadAd without LOADED event; assuming ad group index is actually "
- + expectedAdGroupIndex);
- adGroupIndex = expectedAdGroupIndex;
- adsManager.start();
- }
- int adIndexInAdGroup = getAdIndexInAdGroupToLoad(adGroupIndex);
- if (adIndexInAdGroup == C.INDEX_UNSET) {
- Log.w(TAG, "Unexpected loadAd in an ad group with no remaining unavailable ads");
- return;
- }
- adPlaybackState =
- adPlaybackState.withAdUri(adGroupIndex, adIndexInAdGroup, Uri.parse(adUriString));
- updateAdPlaybackState();
- } catch (Exception e) {
- maybeNotifyInternalError("loadAd", e);
- }
- }
-
- @Override
- public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) {
- adCallbacks.add(videoAdPlayerCallback);
- }
-
- @Override
- public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) {
- adCallbacks.remove(videoAdPlayerCallback);
- }
-
- @Override
- public void playAd() {
- if (DEBUG) {
- Log.d(TAG, "playAd");
- }
+ private void handleAdEvent(AdEvent adEvent) {
if (adsManager == null) {
- Log.w(TAG, "Ignoring playAd after release");
+ // Drop events after release.
return;
}
- switch (imaAdState) {
- case IMA_AD_STATE_PLAYING:
- // IMA does not always call stopAd before resuming content.
- // See [Internal: b/38354028, b/63320878].
- Log.w(TAG, "Unexpected playAd without stopAd");
- break;
- case IMA_AD_STATE_NONE:
- // IMA is requesting to play the ad, so stop faking the content position.
- fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET;
- fakeContentProgressOffsetMs = C.TIME_UNSET;
- imaAdState = IMA_AD_STATE_PLAYING;
- for (int i = 0; i < adCallbacks.size(); i++) {
- adCallbacks.get(i).onPlay();
- }
- if (shouldNotifyAdPrepareError) {
- shouldNotifyAdPrepareError = false;
- for (int i = 0; i < adCallbacks.size(); i++) {
- adCallbacks.get(i).onError();
- }
- }
- break;
- case IMA_AD_STATE_PAUSED:
- imaAdState = IMA_AD_STATE_PLAYING;
- for (int i = 0; i < adCallbacks.size(); i++) {
- adCallbacks.get(i).onResume();
- }
- break;
- default:
- throw new IllegalStateException();
- }
- if (player == null) {
- // Sometimes messages from IMA arrive after detaching the player. See [Internal: b/63801642].
- Log.w(TAG, "Unexpected playAd while detached");
- } else if (!player.getPlayWhenReady()) {
- adsManager.pause();
- }
- }
-
- @Override
- public void stopAd() {
- if (DEBUG) {
- Log.d(TAG, "stopAd");
- }
- if (adsManager == null) {
- Log.w(TAG, "Ignoring stopAd after release");
- return;
- }
- if (player == null) {
- // Sometimes messages from IMA arrive after detaching the player. See [Internal: b/63801642].
- Log.w(TAG, "Unexpected stopAd while detached");
- }
- if (imaAdState == IMA_AD_STATE_NONE) {
- Log.w(TAG, "Unexpected stopAd");
- return;
- }
- try {
- stopAdInternal();
- } catch (Exception e) {
- maybeNotifyInternalError("stopAd", e);
- }
- }
-
- @Override
- public void pauseAd() {
- if (DEBUG) {
- Log.d(TAG, "pauseAd");
- }
- if (imaAdState == IMA_AD_STATE_NONE) {
- // This method is called after content is resumed.
- return;
- }
- imaAdState = IMA_AD_STATE_PAUSED;
- for (int i = 0; i < adCallbacks.size(); i++) {
- adCallbacks.get(i).onPause();
- }
- }
-
- @Override
- public void resumeAd() {
- // This method is never called. See [Internal: b/18931719].
- maybeNotifyInternalError("resumeAd", new IllegalStateException("Unexpected call to resumeAd"));
- }
-
- // Player.EventListener implementation.
-
- @Override
- public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) {
- if (timeline.isEmpty()) {
- // The player is being reset or contains no media.
- return;
- }
- Assertions.checkArgument(timeline.getPeriodCount() == 1);
- this.timeline = timeline;
- long contentDurationUs = timeline.getPeriod(/* periodIndex= */ 0, period).durationUs;
- contentDurationMs = C.usToMs(contentDurationUs);
- if (contentDurationUs != C.TIME_UNSET) {
- adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs);
- }
- if (!initializedAdsManager && adsManager != null) {
- initializedAdsManager = true;
- initializeAdsManager();
- }
- checkForContentCompleteOrNewAdGroup();
- }
-
- @Override
- public void onPlaybackStateChanged(@Player.State int playbackState) {
- if (adsManager == null || player == null) {
- return;
- }
- handlePlayerStateChanged(player.getPlayWhenReady(), playbackState);
- }
-
- @Override
- public void onPlayWhenReadyChanged(
- boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) {
- if (adsManager == null || player == null) {
- return;
- }
-
- if (imaAdState == IMA_AD_STATE_PLAYING && !playWhenReady) {
- adsManager.pause();
- return;
- }
-
- if (imaAdState == IMA_AD_STATE_PAUSED && playWhenReady) {
- adsManager.resume();
- return;
- }
- handlePlayerStateChanged(playWhenReady, player.getPlaybackState());
- }
-
- @Override
- public void onPlayerError(ExoPlaybackException error) {
- if (imaAdState != IMA_AD_STATE_NONE) {
- for (int i = 0; i < adCallbacks.size(); i++) {
- adCallbacks.get(i).onError();
- }
- }
- }
-
- @Override
- public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) {
- checkForContentCompleteOrNewAdGroup();
- }
-
- // Internal methods.
-
- private void initializeAdsManager() {
- AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings();
- adsRenderingSettings.setEnablePreloading(true);
- adsRenderingSettings.setMimeTypes(supportedMimeTypes);
- if (mediaLoadTimeoutMs != TIMEOUT_UNSET) {
- adsRenderingSettings.setLoadVideoTimeout(mediaLoadTimeoutMs);
- }
- if (mediaBitrate != BITRATE_UNSET) {
- adsRenderingSettings.setBitrateKbps(mediaBitrate / 1000);
- }
- adsRenderingSettings.setFocusSkipButtonWhenAvailable(focusSkipButtonWhenAvailable);
- if (adUiElements != null) {
- adsRenderingSettings.setUiElements(adUiElements);
- }
-
- // Skip ads based on the start position as required.
- long[] adGroupTimesUs = getAdGroupTimesUs(adsManager.getAdCuePoints());
- long contentPositionMs = getContentPeriodPositionMs();
- int adGroupIndexForPosition =
- adPlaybackState.getAdGroupIndexForPositionUs(
- C.msToUs(contentPositionMs), C.msToUs(contentDurationMs));
- if (adGroupIndexForPosition > 0 && adGroupIndexForPosition != C.INDEX_UNSET) {
- // Skip any ad groups before the one at or immediately before the playback position.
- for (int i = 0; i < adGroupIndexForPosition; i++) {
- adPlaybackState = adPlaybackState.withSkippedAdGroup(i);
- }
- // Play ads after the midpoint between the ad to play and the one before it, to avoid issues
- // with rounding one of the two ad times.
- long adGroupForPositionTimeUs = adGroupTimesUs[adGroupIndexForPosition];
- long adGroupBeforeTimeUs = adGroupTimesUs[adGroupIndexForPosition - 1];
- double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforeTimeUs) / 2d;
- adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND);
- }
-
- // IMA indexes any remaining midroll ad pods from 1. A preroll (if present) has index 0.
- // Store an index offset as we want to index all ads (including skipped ones) from 0.
- if (adGroupIndexForPosition == 0 && adGroupTimesUs[0] == 0) {
- // We are playing a preroll.
- podIndexOffset = 0;
- } else if (adGroupIndexForPosition == C.INDEX_UNSET) {
- // There's no ad to play which means there's no preroll.
- podIndexOffset = -1;
- } else {
- // We are playing a midroll and any ads before it were skipped.
- podIndexOffset = adGroupIndexForPosition - 1;
- }
-
- if (adGroupIndexForPosition != C.INDEX_UNSET && hasMidrollAdGroups(adGroupTimesUs)) {
- // Provide the player's initial position to trigger loading and playing the ad.
- pendingContentPositionMs = contentPositionMs;
- }
-
- adsManager.init(adsRenderingSettings);
- updateAdPlaybackState();
- if (DEBUG) {
- Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings);
- }
- }
-
- private void handleAdEvent(AdEvent adEvent) {
- Ad ad = adEvent.getAd();
- switch (adEvent.getType()) {
- case LOADED:
- // The ad position is not always accurate when using preloading. See [Internal: b/62613240].
- AdPodInfo adPodInfo = ad.getAdPodInfo();
- int podIndex = adPodInfo.getPodIndex();
- adGroupIndex =
- podIndex == -1 ? (adPlaybackState.adGroupCount - 1) : (podIndex + podIndexOffset);
- int adPosition = adPodInfo.getAdPosition();
- int adCount = adPodInfo.getTotalAds();
- adsManager.start();
- if (DEBUG) {
- Log.d(TAG, "Loaded ad " + adPosition + " of " + adCount + " in group " + adGroupIndex);
- }
- int oldAdCount = adPlaybackState.adGroups[adGroupIndex].count;
- if (adCount != oldAdCount) {
- if (oldAdCount == C.LENGTH_UNSET) {
- adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, adCount);
- updateAdPlaybackState();
- } else {
- // IMA sometimes unexpectedly decreases the ad count in an ad group.
- Log.w(TAG, "Unexpected ad count in LOADED, " + adCount + ", expected " + oldAdCount);
- }
- }
- if (adGroupIndex != expectedAdGroupIndex) {
- Log.w(
- TAG,
- "Expected ad group index "
- + expectedAdGroupIndex
- + ", actual ad group index "
- + adGroupIndex);
- expectedAdGroupIndex = adGroupIndex;
- }
+ switch (adEvent.getType()) {
+ case AD_BREAK_FETCH_ERROR:
+ String adGroupTimeSecondsString = checkNotNull(adEvent.getAdData().get("adBreakTime"));
+ if (DEBUG) {
+ Log.d(TAG, "Fetch error for ad at " + adGroupTimeSecondsString + " seconds");
+ }
+ double adGroupTimeSeconds = Double.parseDouble(adGroupTimeSecondsString);
+ int adGroupIndex =
+ adGroupTimeSeconds == -1.0
+ ? adPlaybackState.adGroupCount - 1
+ : getAdGroupIndexForCuePointTimeSeconds(adGroupTimeSeconds);
+ handleAdGroupFetchError(adGroupIndex);
break;
case CONTENT_PAUSE_REQUESTED:
// After CONTENT_PAUSE_REQUESTED, IMA will playAd/pauseAd/stopAd to show one or more ads
@@ -1139,25 +1103,58 @@ private void handleAdEvent(AdEvent adEvent) {
Map adData = adEvent.getAdData();
String message = "AdEvent: " + adData;
Log.i(TAG, message);
- if ("adLoadError".equals(adData.get("type"))) {
- handleAdGroupLoadError(new IOException(message));
- }
break;
- case STARTED:
- case ALL_ADS_COMPLETED:
default:
break;
}
}
+ private void pauseContentInternal() {
+ imaAdState = IMA_AD_STATE_NONE;
+ if (sentPendingContentPositionMs) {
+ pendingContentPositionMs = C.TIME_UNSET;
+ sentPendingContentPositionMs = false;
+ }
+ }
+
+ private void resumeContentInternal() {
+ if (imaAdInfo != null) {
+ adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex);
+ updateAdPlaybackState();
+ } else if (adPlaybackState.adGroupCount == 1 && adPlaybackState.adGroupTimesUs[0] == 0) {
+ // For incompatible VPAID ads with one preroll, content is resumed immediately. In this case
+ // we haven't received ad info (the ad never loaded), but there is only one ad group to skip.
+ adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ 0);
+ updateAdPlaybackState();
+ }
+ }
+
private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) {
+ if (playingAd && imaAdState == IMA_AD_STATE_PLAYING) {
+ if (!bufferingAd && playbackState == Player.STATE_BUFFERING) {
+ AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onBuffering(adMediaInfo);
+ }
+ stopUpdatingAdProgress();
+ } else if (bufferingAd && playbackState == Player.STATE_READY) {
+ bufferingAd = false;
+ updateAdProgress();
+ }
+ }
+
if (imaAdState == IMA_AD_STATE_NONE
&& playbackState == Player.STATE_BUFFERING
&& playWhenReady) {
- checkForContentComplete();
+ ensureSentContentCompleteIfAtEndOfStream();
} else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) {
- for (int i = 0; i < adCallbacks.size(); i++) {
- adCallbacks.get(i).onEnded();
+ AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
+ if (adMediaInfo == null) {
+ Log.w(TAG, "onEnded without ad media info");
+ } else {
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onEnded(adMediaInfo);
+ }
}
if (DEBUG) {
Log.d(TAG, "VideoAdPlayerCallback.onEnded in onPlaybackStateChanged");
@@ -1165,36 +1162,24 @@ private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int p
}
}
- private void checkForContentCompleteOrNewAdGroup() {
+ private void handleTimelineOrPositionChanged() {
+ @Nullable Player player = this.player;
if (adsManager == null || player == null) {
return;
}
if (!playingAd && !player.isPlayingAd()) {
- checkForContentComplete();
- if (sentContentComplete) {
- for (int i = 0; i < adPlaybackState.adGroupCount; i++) {
- if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) {
- adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ i);
- }
- }
- updateAdPlaybackState();
- } else if (!timeline.isEmpty()) {
- long positionMs = getContentPeriodPositionMs();
+ ensureSentContentCompleteIfAtEndOfStream();
+ if (!sentContentComplete && !timeline.isEmpty()) {
+ long positionMs = getContentPeriodPositionMs(player, timeline, period);
timeline.getPeriod(/* periodIndex= */ 0, period);
int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs));
if (newAdGroupIndex != C.INDEX_UNSET) {
sentPendingContentPositionMs = false;
pendingContentPositionMs = positionMs;
- if (newAdGroupIndex != adGroupIndex) {
- shouldNotifyAdPrepareError = false;
- }
}
}
}
- updateImaStateForPlayerState();
- }
- private void updateImaStateForPlayerState() {
boolean wasPlayingAd = playingAd;
int oldPlayingAdIndexInAdGroup = playingAdIndexInAdGroup;
playingAd = player.isPlayingAd();
@@ -1203,8 +1188,13 @@ private void updateImaStateForPlayerState() {
if (adFinished) {
// IMA is waiting for the ad playback to finish so invoke the callback now.
// Either CONTENT_RESUME_REQUESTED will be passed next, or playAd will be called again.
- for (int i = 0; i < adCallbacks.size(); i++) {
- adCallbacks.get(i).onEnded();
+ @Nullable AdMediaInfo adMediaInfo = imaAdMediaInfo;
+ if (adMediaInfo == null) {
+ Log.w(TAG, "onEnded without ad media info");
+ } else {
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onEnded(adMediaInfo);
+ }
}
if (DEBUG) {
Log.d(TAG, "VideoAdPlayerCallback.onEnded in onTimelineChanged/onPositionDiscontinuity");
@@ -1212,60 +1202,200 @@ private void updateImaStateForPlayerState() {
}
if (!sentContentComplete && !wasPlayingAd && playingAd && imaAdState == IMA_AD_STATE_NONE) {
int adGroupIndex = player.getCurrentAdGroupIndex();
- // IMA hasn't called playAd yet, so fake the content position.
- fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime();
- fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]);
- if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) {
- fakeContentProgressOffsetMs = contentDurationMs;
+ if (adPlaybackState.adGroupTimesUs[adGroupIndex] == C.TIME_END_OF_SOURCE) {
+ sendContentComplete();
+ } else {
+ // IMA hasn't called playAd yet, so fake the content position.
+ fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime();
+ fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]);
+ if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) {
+ fakeContentProgressOffsetMs = contentDurationMs;
+ }
}
}
}
- private void resumeContentInternal() {
- if (imaAdState != IMA_AD_STATE_NONE) {
- imaAdState = IMA_AD_STATE_NONE;
+ private void loadAdInternal(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) {
+ if (adsManager == null) {
+ // Drop events after release.
if (DEBUG) {
- Log.d(TAG, "Unexpected CONTENT_RESUME_REQUESTED without stopAd");
+ Log.d(
+ TAG,
+ "loadAd after release " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo);
}
+ return;
}
- if (adGroupIndex != C.INDEX_UNSET) {
- adPlaybackState = adPlaybackState.withSkippedAdGroup(adGroupIndex);
- adGroupIndex = C.INDEX_UNSET;
- updateAdPlaybackState();
+
+ int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo);
+ int adIndexInAdGroup = adPodInfo.getAdPosition() - 1;
+ AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup);
+ adInfoByAdMediaInfo.put(adMediaInfo, adInfo);
+ if (DEBUG) {
+ Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo));
+ }
+ if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) {
+ // We have already marked this ad as having failed to load, so ignore the request. IMA will
+ // timeout after its media load timeout.
+ return;
}
+
+ // The ad count may increase on successive loads of ads in the same ad pod, for example, due to
+ // separate requests for ad tags with multiple ads within the ad pod completing after an earlier
+ // ad has loaded. See also https://github.com/google/ExoPlayer/issues/7477.
+ AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex];
+ adPlaybackState =
+ adPlaybackState.withAdCount(
+ adInfo.adGroupIndex, max(adPodInfo.getTotalAds(), adGroup.states.length));
+ adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex];
+ for (int i = 0; i < adIndexInAdGroup; i++) {
+ // Any preceding ads that haven't loaded are not going to load.
+ if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) {
+ adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, /* adIndexInAdGroup= */ i);
+ }
+ }
+
+ Uri adUri = Uri.parse(adMediaInfo.getUrl());
+ adPlaybackState =
+ adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri);
+ updateAdPlaybackState();
}
- private void pauseContentInternal() {
- imaAdState = IMA_AD_STATE_NONE;
- if (sentPendingContentPositionMs) {
- pendingContentPositionMs = C.TIME_UNSET;
- sentPendingContentPositionMs = false;
+ private void playAdInternal(AdMediaInfo adMediaInfo) {
+ if (DEBUG) {
+ Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo));
+ }
+ if (adsManager == null) {
+ // Drop events after release.
+ return;
+ }
+
+ if (imaAdState == IMA_AD_STATE_PLAYING) {
+ // IMA does not always call stopAd before resuming content.
+ // See [Internal: b/38354028].
+ Log.w(TAG, "Unexpected playAd without stopAd");
+ }
+
+ if (imaAdState == IMA_AD_STATE_NONE) {
+ // IMA is requesting to play the ad, so stop faking the content position.
+ fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET;
+ fakeContentProgressOffsetMs = C.TIME_UNSET;
+ imaAdState = IMA_AD_STATE_PLAYING;
+ imaAdMediaInfo = adMediaInfo;
+ imaAdInfo = checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo));
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onPlay(adMediaInfo);
+ }
+ if (pendingAdPrepareErrorAdInfo != null && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) {
+ pendingAdPrepareErrorAdInfo = null;
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onError(adMediaInfo);
+ }
+ }
+ updateAdProgress();
+ } else {
+ imaAdState = IMA_AD_STATE_PLAYING;
+ checkState(adMediaInfo.equals(imaAdMediaInfo));
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onResume(adMediaInfo);
+ }
+ }
+ if (!checkNotNull(player).getPlayWhenReady()) {
+ checkNotNull(adsManager).pause();
}
}
- private void stopAdInternal() {
+ private void pauseAdInternal(AdMediaInfo adMediaInfo) {
+ if (DEBUG) {
+ Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo));
+ }
+ if (adsManager == null) {
+ // Drop event after release.
+ return;
+ }
+ if (imaAdState == IMA_AD_STATE_NONE) {
+ // This method is called if loadAd has been called but the loaded ad won't play due to a seek
+ // to a different position, so drop the event. See also [Internal: b/159111848].
+ return;
+ }
+ checkState(adMediaInfo.equals(imaAdMediaInfo));
+ imaAdState = IMA_AD_STATE_PAUSED;
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onPause(adMediaInfo);
+ }
+ }
+
+ private void stopAdInternal(AdMediaInfo adMediaInfo) {
+ if (DEBUG) {
+ Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo));
+ }
+ if (adsManager == null) {
+ // Drop event after release.
+ return;
+ }
+ if (imaAdState == IMA_AD_STATE_NONE) {
+ // This method is called if loadAd has been called but the preloaded ad won't play due to a
+ // seek to a different position, so drop the event and discard the ad. See also [Internal:
+ // b/159111848].
+ @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo);
+ if (adInfo != null) {
+ adPlaybackState =
+ adPlaybackState.withSkippedAd(adInfo.adGroupIndex, adInfo.adIndexInAdGroup);
+ updateAdPlaybackState();
+ }
+ return;
+ }
+ checkNotNull(player);
imaAdState = IMA_AD_STATE_NONE;
- int adIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay();
+ stopUpdatingAdProgress();
// TODO: Handle the skipped event so the ad can be marked as skipped rather than played.
+ checkNotNull(imaAdInfo);
+ int adGroupIndex = imaAdInfo.adGroupIndex;
+ int adIndexInAdGroup = imaAdInfo.adIndexInAdGroup;
+ if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) {
+ // We have already marked this ad as having failed to load, so ignore the request.
+ return;
+ }
adPlaybackState =
adPlaybackState.withPlayedAd(adGroupIndex, adIndexInAdGroup).withAdResumePositionUs(0);
updateAdPlaybackState();
if (!playingAd) {
- adGroupIndex = C.INDEX_UNSET;
+ imaAdMediaInfo = null;
+ imaAdInfo = null;
+ }
+ }
+
+ private void handleAdGroupFetchError(int adGroupIndex) {
+ AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex];
+ if (adGroup.count == C.LENGTH_UNSET) {
+ adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, max(1, adGroup.states.length));
+ adGroup = adPlaybackState.adGroups[adGroupIndex];
+ }
+ for (int i = 0; i < adGroup.count; i++) {
+ if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) {
+ if (DEBUG) {
+ Log.d(TAG, "Removing ad " + i + " in ad group " + adGroupIndex);
+ }
+ adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, i);
+ }
}
+ updateAdPlaybackState();
}
private void handleAdGroupLoadError(Exception error) {
- int adGroupIndex =
- this.adGroupIndex == C.INDEX_UNSET ? expectedAdGroupIndex : this.adGroupIndex;
+ if (player == null) {
+ return;
+ }
+
+ // TODO: Once IMA signals which ad group failed to load, remove this call.
+ int adGroupIndex = getLoadingAdGroupIndex();
if (adGroupIndex == C.INDEX_UNSET) {
- // Drop the error, as we don't know which ad group it relates to.
+ Log.w(TAG, "Unable to determine ad group index for ad group load error", error);
return;
}
+
AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex];
if (adGroup.count == C.LENGTH_UNSET) {
- adPlaybackState =
- adPlaybackState.withAdCount(adGroupIndex, Math.max(1, adGroup.states.length));
+ adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, max(1, adGroup.states.length));
adGroup = adPlaybackState.adGroups[adGroupIndex];
}
for (int i = 0; i < adGroup.count; i++) {
@@ -1301,41 +1431,51 @@ private void handleAdPrepareError(int adGroupIndex, int adIndexInAdGroup, Except
if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) {
fakeContentProgressOffsetMs = contentDurationMs;
}
- shouldNotifyAdPrepareError = true;
+ pendingAdPrepareErrorAdInfo = new AdInfo(adGroupIndex, adIndexInAdGroup);
} else {
+ AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
// We're already playing an ad.
if (adIndexInAdGroup > playingAdIndexInAdGroup) {
// Mark the playing ad as ended so we can notify the error on the next ad and remove it,
// which means that the ad after will load (if any).
for (int i = 0; i < adCallbacks.size(); i++) {
- adCallbacks.get(i).onEnded();
+ adCallbacks.get(i).onEnded(adMediaInfo);
}
}
playingAdIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay();
for (int i = 0; i < adCallbacks.size(); i++) {
- adCallbacks.get(i).onError();
+ adCallbacks.get(i).onError(checkNotNull(adMediaInfo));
}
}
adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, adIndexInAdGroup);
updateAdPlaybackState();
}
- private void checkForContentComplete() {
- if (contentDurationMs != C.TIME_UNSET
+ private void ensureSentContentCompleteIfAtEndOfStream() {
+ if (!sentContentComplete
+ && contentDurationMs != C.TIME_UNSET
&& pendingContentPositionMs == C.TIME_UNSET
- && getContentPeriodPositionMs() + END_OF_CONTENT_POSITION_THRESHOLD_MS >= contentDurationMs
- && !sentContentComplete) {
- adsLoader.contentComplete();
- if (DEBUG) {
- Log.d(TAG, "adsLoader.contentComplete");
+ && getContentPeriodPositionMs(checkNotNull(player), timeline, period)
+ + THRESHOLD_END_OF_CONTENT_MS
+ >= contentDurationMs) {
+ sendContentComplete();
+ }
+ }
+
+ private void sendContentComplete() {
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onContentComplete();
+ }
+ sentContentComplete = true;
+ if (DEBUG) {
+ Log.d(TAG, "adsLoader.contentComplete");
+ }
+ for (int i = 0; i < adPlaybackState.adGroupCount; i++) {
+ if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) {
+ adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ i);
}
- sentContentComplete = true;
- // After sending content complete IMA will not poll the content position, so set the expected
- // ad group index.
- expectedAdGroupIndex =
- adPlaybackState.getAdGroupIndexForPositionUs(
- C.msToUs(contentDurationMs), C.msToUs(contentDurationMs));
}
+ updateAdPlaybackState();
}
private void updateAdPlaybackState() {
@@ -1345,24 +1485,9 @@ private void updateAdPlaybackState() {
}
}
- /**
- * Returns the next ad index in the specified ad group to load, or {@link C#INDEX_UNSET} if all
- * ads in the ad group have loaded.
- */
- private int getAdIndexInAdGroupToLoad(int adGroupIndex) {
- @AdState int[] states = adPlaybackState.adGroups[adGroupIndex].states;
- int adIndexInAdGroup = 0;
- // IMA loads ads in order.
- while (adIndexInAdGroup < states.length
- && states[adIndexInAdGroup] != AdPlaybackState.AD_STATE_UNAVAILABLE) {
- adIndexInAdGroup++;
- }
- return adIndexInAdGroup == states.length ? C.INDEX_UNSET : adIndexInAdGroup;
- }
-
private void maybeNotifyPendingAdLoadError() {
if (pendingAdLoadError != null && eventListener != null) {
- eventListener.onAdLoadError(pendingAdLoadError, new DataSpec(adTagUri));
+ eventListener.onAdLoadError(pendingAdLoadError, getAdsDataSpec(adTagUri));
pendingAdLoadError = null;
}
}
@@ -1371,47 +1496,90 @@ private void maybeNotifyInternalError(String name, Exception cause) {
String message = "Internal error in " + name;
Log.e(TAG, message, cause);
// We can't recover from an unexpected error in general, so skip all remaining ads.
- if (adPlaybackState == null) {
- adPlaybackState = AdPlaybackState.NONE;
- } else {
- for (int i = 0; i < adPlaybackState.adGroupCount; i++) {
- adPlaybackState = adPlaybackState.withSkippedAdGroup(i);
- }
+ for (int i = 0; i < adPlaybackState.adGroupCount; i++) {
+ adPlaybackState = adPlaybackState.withSkippedAdGroup(i);
}
updateAdPlaybackState();
if (eventListener != null) {
eventListener.onAdLoadError(
AdLoadException.createForUnexpected(new RuntimeException(message, cause)),
- new DataSpec(adTagUri));
+ getAdsDataSpec(adTagUri));
}
}
- private long getContentPeriodPositionMs() {
- long contentWindowPositionMs = player.getContentPosition();
- return contentWindowPositionMs
- - timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs();
+ private int getAdGroupIndexForAdPod(AdPodInfo adPodInfo) {
+ if (adPodInfo.getPodIndex() == -1) {
+ // This is a postroll ad.
+ return adPlaybackState.adGroupCount - 1;
+ }
+
+ // adPodInfo.podIndex may be 0-based or 1-based, so for now look up the cue point instead.
+ return getAdGroupIndexForCuePointTimeSeconds(adPodInfo.getTimeOffset());
}
- private static long[] getAdGroupTimesUs(List cuePoints) {
- if (cuePoints.isEmpty()) {
- // If no cue points are specified, there is a preroll ad.
- return new long[] {0};
+ /**
+ * Returns the index of the ad group that will preload next, or {@link C#INDEX_UNSET} if there is
+ * no such ad group.
+ */
+ private int getLoadingAdGroupIndex() {
+ long playerPositionUs =
+ C.msToUs(getContentPeriodPositionMs(checkNotNull(player), timeline, period));
+ int adGroupIndex =
+ adPlaybackState.getAdGroupIndexForPositionUs(playerPositionUs, C.msToUs(contentDurationMs));
+ if (adGroupIndex == C.INDEX_UNSET) {
+ adGroupIndex =
+ adPlaybackState.getAdGroupIndexAfterPositionUs(
+ playerPositionUs, C.msToUs(contentDurationMs));
}
+ return adGroupIndex;
+ }
- int count = cuePoints.size();
- long[] adGroupTimesUs = new long[count];
- int adGroupIndex = 0;
- for (int i = 0; i < count; i++) {
- double cuePoint = cuePoints.get(i);
- if (cuePoint == -1.0) {
- adGroupTimesUs[count - 1] = C.TIME_END_OF_SOURCE;
- } else {
- adGroupTimesUs[adGroupIndex++] = (long) (C.MICROS_PER_SECOND * cuePoint);
+ private int getAdGroupIndexForCuePointTimeSeconds(double cuePointTimeSeconds) {
+ // We receive initial cue points from IMA SDK as floats. This code replicates the same
+ // calculation used to populate adGroupTimesUs (having truncated input back to float, to avoid
+ // failures if the behavior of the IMA SDK changes to provide greater precision).
+ long adPodTimeUs = Math.round((float) cuePointTimeSeconds * C.MICROS_PER_SECOND);
+ for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) {
+ long adGroupTimeUs = adPlaybackState.adGroupTimesUs[adGroupIndex];
+ if (adGroupTimeUs != C.TIME_END_OF_SOURCE
+ && Math.abs(adGroupTimeUs - adPodTimeUs) < THRESHOLD_AD_MATCH_US) {
+ return adGroupIndex;
}
}
- // Cue points may be out of order, so sort them.
- Arrays.sort(adGroupTimesUs, 0, adGroupIndex);
- return adGroupTimesUs;
+ throw new IllegalStateException("Failed to find cue point");
+ }
+
+ private String getAdMediaInfoString(AdMediaInfo adMediaInfo) {
+ @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo);
+ return "AdMediaInfo[" + adMediaInfo.getUrl() + (adInfo != null ? ", " + adInfo : "") + "]";
+ }
+
+ private static FriendlyObstructionPurpose getFriendlyObstructionPurpose(
+ @OverlayInfo.Purpose int purpose) {
+ switch (purpose) {
+ case OverlayInfo.PURPOSE_CONTROLS:
+ return FriendlyObstructionPurpose.VIDEO_CONTROLS;
+ case OverlayInfo.PURPOSE_CLOSE_AD:
+ return FriendlyObstructionPurpose.CLOSE_AD;
+ case OverlayInfo.PURPOSE_NOT_VISIBLE:
+ return FriendlyObstructionPurpose.NOT_VISIBLE;
+ case OverlayInfo.PURPOSE_OTHER:
+ default:
+ return FriendlyObstructionPurpose.OTHER;
+ }
+ }
+
+ private static DataSpec getAdsDataSpec(@Nullable Uri adTagUri) {
+ return new DataSpec(adTagUri != null ? adTagUri : Uri.EMPTY);
+ }
+
+ private static long getContentPeriodPositionMs(
+ Player player, Timeline timeline, Timeline.Period period) {
+ long contentWindowPositionMs = player.getContentPosition();
+ return contentWindowPositionMs
+ - (timeline.isEmpty()
+ ? 0
+ : timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs());
}
private static boolean isAdGroupLoadError(AdError adError) {
@@ -1421,6 +1589,12 @@ private static boolean isAdGroupLoadError(AdError adError) {
|| adError.getErrorCode() == AdErrorCode.UNKNOWN_ERROR;
}
+ private static Looper getImaLooper() {
+ // IMA SDK callbacks occur on the main thread. This method can be used to check that the player
+ // is using the same looper, to ensure all interaction with this class is on the main thread.
+ return Looper.getMainLooper();
+ }
+
private static boolean hasMidrollAdGroups(long[] adGroupTimesUs) {
int count = adGroupTimesUs.length;
if (count == 1) {
@@ -1433,22 +1607,267 @@ private static boolean hasMidrollAdGroups(long[] adGroupTimesUs) {
}
}
+ private void destroyAdsManager() {
+ if (adsManager != null) {
+ adsManager.removeAdErrorListener(componentListener);
+ if (adErrorListener != null) {
+ adsManager.removeAdErrorListener(adErrorListener);
+ }
+ adsManager.removeAdEventListener(componentListener);
+ if (adEventListener != null) {
+ adsManager.removeAdEventListener(adEventListener);
+ }
+ adsManager.destroy();
+ adsManager = null;
+ }
+ }
+
/** Factory for objects provided by the IMA SDK. */
@VisibleForTesting
/* package */ interface ImaFactory {
- /** @see ImaSdkSettings */
+ /** Creates {@link ImaSdkSettings} for configuring the IMA SDK. */
ImaSdkSettings createImaSdkSettings();
- /** @see com.google.ads.interactivemedia.v3.api.ImaSdkFactory#createAdsRenderingSettings() */
+ /**
+ * Creates {@link AdsRenderingSettings} for giving the {@link AdsManager} parameters that
+ * control rendering of ads.
+ */
AdsRenderingSettings createAdsRenderingSettings();
- /** @see com.google.ads.interactivemedia.v3.api.ImaSdkFactory#createAdDisplayContainer() */
- AdDisplayContainer createAdDisplayContainer();
- /** @see com.google.ads.interactivemedia.v3.api.ImaSdkFactory#createAdsRequest() */
+ /**
+ * Creates an {@link AdDisplayContainer} to hold the player for video ads, a container for
+ * non-linear ads, and slots for companion ads.
+ */
+ AdDisplayContainer createAdDisplayContainer(ViewGroup container, VideoAdPlayer player);
+ /** Creates an {@link AdDisplayContainer} to hold the player for audio ads. */
+ AdDisplayContainer createAudioAdDisplayContainer(Context context, VideoAdPlayer player);
+ /**
+ * Creates a {@link FriendlyObstruction} to describe an obstruction considered "friendly" for
+ * viewability measurement purposes.
+ */
+ FriendlyObstruction createFriendlyObstruction(
+ View view,
+ FriendlyObstructionPurpose friendlyObstructionPurpose,
+ @Nullable String reasonDetail);
+ /** Creates an {@link AdsRequest} to contain the data used to request ads. */
AdsRequest createAdsRequest();
- /** @see ImaSdkFactory#createAdsLoader(Context, ImaSdkSettings, AdDisplayContainer) */
- com.google.ads.interactivemedia.v3.api.AdsLoader createAdsLoader(
+ /** Creates an {@link AdsLoader} for requesting ads using the specified settings. */
+ AdsLoader createAdsLoader(
Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer);
}
+ private final class ComponentListener
+ implements AdsLoadedListener,
+ ContentProgressProvider,
+ AdEventListener,
+ AdErrorListener,
+ VideoAdPlayer {
+
+ // AdsLoader.AdsLoadedListener implementation.
+
+ @Override
+ public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) {
+ AdsManager adsManager = adsManagerLoadedEvent.getAdsManager();
+ if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) {
+ adsManager.destroy();
+ return;
+ }
+ pendingAdRequestContext = null;
+ ImaAdsLoader.this.adsManager = adsManager;
+ adsManager.addAdErrorListener(this);
+ if (adErrorListener != null) {
+ adsManager.addAdErrorListener(adErrorListener);
+ }
+ adsManager.addAdEventListener(this);
+ if (adEventListener != null) {
+ adsManager.addAdEventListener(adEventListener);
+ }
+ if (player != null) {
+ // If a player is attached already, start playback immediately.
+ try {
+ adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints());
+ hasAdPlaybackState = true;
+ updateAdPlaybackState();
+ } catch (RuntimeException e) {
+ maybeNotifyInternalError("onAdsManagerLoaded", e);
+ }
+ }
+ }
+
+ // ContentProgressProvider implementation.
+
+ @Override
+ public VideoProgressUpdate getContentProgress() {
+ VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate();
+ if (DEBUG) {
+ if (VideoProgressUpdate.VIDEO_TIME_NOT_READY.equals(videoProgressUpdate)) {
+ Log.d(TAG, "Content progress: not ready");
+ } else {
+ Log.d(
+ TAG,
+ Util.formatInvariant(
+ "Content progress: %.1f of %.1f s",
+ videoProgressUpdate.getCurrentTime(), videoProgressUpdate.getDuration()));
+ }
+ }
+
+ if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) {
+ // IMA is polling the player position but we are buffering for an ad to preload, so playback
+ // may be stuck. Detect this case and signal an error if applicable.
+ long stuckElapsedRealtimeMs =
+ SystemClock.elapsedRealtime() - waitingForPreloadElapsedRealtimeMs;
+ if (stuckElapsedRealtimeMs >= THRESHOLD_AD_PRELOAD_MS) {
+ waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET;
+ handleAdGroupLoadError(new IOException("Ad preloading timed out"));
+ maybeNotifyPendingAdLoadError();
+ }
+ }
+
+ return videoProgressUpdate;
+ }
+
+ // AdEvent.AdEventListener implementation.
+
+ @Override
+ public void onAdEvent(AdEvent adEvent) {
+ AdEventType adEventType = adEvent.getType();
+ if (DEBUG && adEventType != AdEventType.AD_PROGRESS) {
+ Log.d(TAG, "onAdEvent: " + adEventType);
+ }
+ try {
+ handleAdEvent(adEvent);
+ } catch (RuntimeException e) {
+ maybeNotifyInternalError("onAdEvent", e);
+ }
+ }
+
+ // AdErrorEvent.AdErrorListener implementation.
+
+ @Override
+ public void onAdError(AdErrorEvent adErrorEvent) {
+ AdError error = adErrorEvent.getError();
+ if (DEBUG) {
+ Log.d(TAG, "onAdError", error);
+ }
+ if (adsManager == null) {
+ // No ads were loaded, so allow playback to start without any ads.
+ pendingAdRequestContext = null;
+ adPlaybackState = AdPlaybackState.NONE;
+ hasAdPlaybackState = true;
+ updateAdPlaybackState();
+ } else if (isAdGroupLoadError(error)) {
+ try {
+ handleAdGroupLoadError(error);
+ } catch (RuntimeException e) {
+ maybeNotifyInternalError("onAdError", e);
+ }
+ }
+ if (pendingAdLoadError == null) {
+ pendingAdLoadError = AdLoadException.createForAllAds(error);
+ }
+ maybeNotifyPendingAdLoadError();
+ }
+
+ // VideoAdPlayer implementation.
+
+ @Override
+ public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) {
+ adCallbacks.add(videoAdPlayerCallback);
+ }
+
+ @Override
+ public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) {
+ adCallbacks.remove(videoAdPlayerCallback);
+ }
+
+ @Override
+ public VideoProgressUpdate getAdProgress() {
+ throw new IllegalStateException("Unexpected call to getAdProgress when using preloading");
+ }
+
+ @Override
+ public int getVolume() {
+ return getPlayerVolumePercent();
+ }
+
+ @Override
+ public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) {
+ try {
+ loadAdInternal(adMediaInfo, adPodInfo);
+ } catch (RuntimeException e) {
+ maybeNotifyInternalError("loadAd", e);
+ }
+ }
+
+ @Override
+ public void playAd(AdMediaInfo adMediaInfo) {
+ try {
+ playAdInternal(adMediaInfo);
+ } catch (RuntimeException e) {
+ maybeNotifyInternalError("playAd", e);
+ }
+ }
+
+ @Override
+ public void pauseAd(AdMediaInfo adMediaInfo) {
+ try {
+ pauseAdInternal(adMediaInfo);
+ } catch (RuntimeException e) {
+ maybeNotifyInternalError("pauseAd", e);
+ }
+ }
+
+ @Override
+ public void stopAd(AdMediaInfo adMediaInfo) {
+ try {
+ stopAdInternal(adMediaInfo);
+ } catch (RuntimeException e) {
+ maybeNotifyInternalError("stopAd", e);
+ }
+ }
+
+ @Override
+ public void release() {
+ // Do nothing.
+ }
+ }
+
+ // TODO: Consider moving this into AdPlaybackState.
+ private static final class AdInfo {
+ public final int adGroupIndex;
+ public final int adIndexInAdGroup;
+
+ public AdInfo(int adGroupIndex, int adIndexInAdGroup) {
+ this.adGroupIndex = adGroupIndex;
+ this.adIndexInAdGroup = adIndexInAdGroup;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ AdInfo adInfo = (AdInfo) o;
+ if (adGroupIndex != adInfo.adGroupIndex) {
+ return false;
+ }
+ return adIndexInAdGroup == adInfo.adIndexInAdGroup;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = adGroupIndex;
+ result = 31 * result + adIndexInAdGroup;
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "(" + adGroupIndex + ", " + adIndexInAdGroup + ')';
+ }
+ }
+
/** Default {@link ImaFactory} for non-test usage, which delegates to {@link ImaSdkFactory}. */
private static final class DefaultImaFactory implements ImaFactory {
@Override
@@ -1462,8 +1881,25 @@ public AdsRenderingSettings createAdsRenderingSettings() {
}
@Override
- public AdDisplayContainer createAdDisplayContainer() {
- return ImaSdkFactory.getInstance().createAdDisplayContainer();
+ public AdDisplayContainer createAdDisplayContainer(ViewGroup container, VideoAdPlayer player) {
+ return ImaSdkFactory.createAdDisplayContainer(container, player);
+ }
+
+ @Override
+ public AdDisplayContainer createAudioAdDisplayContainer(Context context, VideoAdPlayer player) {
+ return ImaSdkFactory.createAudioAdDisplayContainer(context, player);
+ }
+
+ // The reasonDetail parameter to createFriendlyObstruction is annotated @Nullable but the
+ // annotation is not kept in the obfuscated dependency.
+ @SuppressWarnings("nullness:argument.type.incompatible")
+ @Override
+ public FriendlyObstruction createFriendlyObstruction(
+ View view,
+ FriendlyObstructionPurpose friendlyObstructionPurpose,
+ @Nullable String reasonDetail) {
+ return ImaSdkFactory.getInstance()
+ .createFriendlyObstruction(view, friendlyObstructionPurpose, reasonDetail);
}
@Override
@@ -1472,7 +1908,7 @@ public AdsRequest createAdsRequest() {
}
@Override
- public com.google.ads.interactivemedia.v3.api.AdsLoader createAdsLoader(
+ public AdsLoader createAdsLoader(
Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer) {
return ImaSdkFactory.getInstance()
.createAdsLoader(context, imaSdkSettings, adDisplayContainer);
diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java
deleted file mode 100644
index 59dfc6473cb..00000000000
--- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java
+++ /dev/null
@@ -1,211 +0,0 @@
-/*
- * Copyright (C) 2018 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.ext.ima;
-
-import com.google.ads.interactivemedia.v3.api.Ad;
-import com.google.ads.interactivemedia.v3.api.AdPodInfo;
-import com.google.ads.interactivemedia.v3.api.CompanionAd;
-import com.google.ads.interactivemedia.v3.api.UiElement;
-import java.util.List;
-import java.util.Set;
-
-/** A fake ad for testing. */
-/* package */ final class FakeAd implements Ad {
-
- private final boolean skippable;
- private final AdPodInfo adPodInfo;
-
- public FakeAd(boolean skippable, int podIndex, int totalAds, int adPosition) {
- this.skippable = skippable;
- adPodInfo =
- new AdPodInfo() {
- @Override
- public int getTotalAds() {
- return totalAds;
- }
-
- @Override
- public int getAdPosition() {
- return adPosition;
- }
-
- @Override
- public int getPodIndex() {
- return podIndex;
- }
-
- @Override
- public boolean isBumper() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public double getMaxDuration() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public double getTimeOffset() {
- throw new UnsupportedOperationException();
- }
- };
- }
-
- @Override
- public int getVastMediaWidth() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public int getVastMediaHeight() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public int getVastMediaBitrate() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public boolean isSkippable() {
- return skippable;
- }
-
- @Override
- public AdPodInfo getAdPodInfo() {
- return adPodInfo;
- }
-
- @Override
- public String getAdId() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String getCreativeId() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String getCreativeAdId() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String getUniversalAdIdValue() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String getUniversalAdIdRegistry() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String getAdSystem() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String[] getAdWrapperIds() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String[] getAdWrapperSystems() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String[] getAdWrapperCreativeIds() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public boolean isLinear() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public double getSkipTimeOffset() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public boolean isUiDisabled() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String getDescription() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String getTitle() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String getContentType() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String getAdvertiserName() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String getSurveyUrl() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String getDealId() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public int getWidth() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public int getHeight() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String getTraffickingParameters() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public double getDuration() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public Set getUiElements() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public List getCompanionAds() {
- throw new UnsupportedOperationException();
- }
-}
diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java
deleted file mode 100644
index a8f3daae335..00000000000
--- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
- * Copyright (C) 2018 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.ext.ima;
-
-import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener;
-import com.google.ads.interactivemedia.v3.api.AdsManager;
-import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent;
-import com.google.ads.interactivemedia.v3.api.AdsRequest;
-import com.google.ads.interactivemedia.v3.api.ImaSdkSettings;
-import com.google.ads.interactivemedia.v3.api.StreamManager;
-import com.google.ads.interactivemedia.v3.api.StreamRequest;
-import com.google.android.exoplayer2.util.Assertions;
-import java.util.ArrayList;
-
-/** Fake {@link com.google.ads.interactivemedia.v3.api.AdsLoader} implementation for tests. */
-public final class FakeAdsLoader implements com.google.ads.interactivemedia.v3.api.AdsLoader {
-
- private final ImaSdkSettings imaSdkSettings;
- private final AdsManager adsManager;
- private final ArrayList adsLoadedListeners;
- private final ArrayList adErrorListeners;
-
- public FakeAdsLoader(ImaSdkSettings imaSdkSettings, AdsManager adsManager) {
- this.imaSdkSettings = Assertions.checkNotNull(imaSdkSettings);
- this.adsManager = Assertions.checkNotNull(adsManager);
- adsLoadedListeners = new ArrayList<>();
- adErrorListeners = new ArrayList<>();
- }
-
- @Override
- public void contentComplete() {
- // Do nothing.
- }
-
- @Override
- public ImaSdkSettings getSettings() {
- return imaSdkSettings;
- }
-
- @Override
- public void requestAds(AdsRequest adsRequest) {
- for (AdsLoadedListener listener : adsLoadedListeners) {
- listener.onAdsManagerLoaded(
- new AdsManagerLoadedEvent() {
- @Override
- public AdsManager getAdsManager() {
- return adsManager;
- }
-
- @Override
- public StreamManager getStreamManager() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public Object getUserRequestContext() {
- return adsRequest.getUserRequestContext();
- }
- });
- }
- }
-
- @Override
- public String requestStream(StreamRequest streamRequest) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public void addAdsLoadedListener(AdsLoadedListener adsLoadedListener) {
- adsLoadedListeners.add(adsLoadedListener);
- }
-
- @Override
- public void removeAdsLoadedListener(AdsLoadedListener adsLoadedListener) {
- adsLoadedListeners.remove(adsLoadedListener);
- }
-
- @Override
- public void addAdErrorListener(AdErrorListener adErrorListener) {
- adErrorListeners.add(adErrorListener);
- }
-
- @Override
- public void removeAdErrorListener(AdErrorListener adErrorListener) {
- adErrorListeners.remove(adErrorListener);
- }
-}
diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java
deleted file mode 100644
index 7c2c8a6e0b1..00000000000
--- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java
+++ /dev/null
@@ -1,132 +0,0 @@
-/*
- * Copyright (C) 2018 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.ext.ima;
-
-import com.google.ads.interactivemedia.v3.api.AdDisplayContainer;
-import com.google.ads.interactivemedia.v3.api.AdsRequest;
-import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider;
-import java.util.List;
-import java.util.Map;
-
-/** Fake {@link AdsRequest} implementation for tests. */
-public final class FakeAdsRequest implements AdsRequest {
-
- private String adTagUrl;
- private String adsResponse;
- private Object userRequestContext;
- private AdDisplayContainer adDisplayContainer;
- private ContentProgressProvider contentProgressProvider;
-
- @Override
- public void setAdTagUrl(String adTagUrl) {
- this.adTagUrl = adTagUrl;
- }
-
- @Override
- public String getAdTagUrl() {
- return adTagUrl;
- }
-
- @Override
- public void setExtraParameter(String s, String s1) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public String getExtraParameter(String s) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public Map getExtraParameters() {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public void setUserRequestContext(Object userRequestContext) {
- this.userRequestContext = userRequestContext;
- }
-
- @Override
- public Object getUserRequestContext() {
- return userRequestContext;
- }
-
- @Override
- public AdDisplayContainer getAdDisplayContainer() {
- return adDisplayContainer;
- }
-
- @Override
- public void setAdDisplayContainer(AdDisplayContainer adDisplayContainer) {
- this.adDisplayContainer = adDisplayContainer;
- }
-
- @Override
- public ContentProgressProvider getContentProgressProvider() {
- return contentProgressProvider;
- }
-
- @Override
- public void setContentProgressProvider(ContentProgressProvider contentProgressProvider) {
- this.contentProgressProvider = contentProgressProvider;
- }
-
- @Override
- public String getAdsResponse() {
- return adsResponse;
- }
-
- @Override
- public void setAdsResponse(String adsResponse) {
- this.adsResponse = adsResponse;
- }
-
- @Override
- public void setAdWillAutoPlay(boolean b) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public void setAdWillPlayMuted(boolean b) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public void setContentDuration(float v) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public void setContentKeywords(List list) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public void setContentTitle(String s) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public void setVastLoadTimeout(float v) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public void setLiveStreamPrefetchSeconds(float v) {
- throw new UnsupportedOperationException();
- }
-}
diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java
index 44395fa0d1b..e32a1992006 100644
--- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java
+++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java
@@ -15,10 +15,16 @@
*/
package com.google.android.exoplayer2.ext.ima;
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -27,22 +33,29 @@
import android.view.ViewGroup;
import android.widget.FrameLayout;
import androidx.annotation.Nullable;
-import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.ads.interactivemedia.v3.api.Ad;
import com.google.ads.interactivemedia.v3.api.AdDisplayContainer;
import com.google.ads.interactivemedia.v3.api.AdEvent;
import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventType;
+import com.google.ads.interactivemedia.v3.api.AdPodInfo;
import com.google.ads.interactivemedia.v3.api.AdsManager;
+import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent;
import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings;
+import com.google.ads.interactivemedia.v3.api.AdsRequest;
+import com.google.ads.interactivemedia.v3.api.FriendlyObstruction;
import com.google.ads.interactivemedia.v3.api.ImaSdkSettings;
+import com.google.ads.interactivemedia.v3.api.player.AdMediaInfo;
+import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider;
+import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlaybackException;
+import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.Timeline.Period;
import com.google.android.exoplayer2.ext.ima.ImaAdsLoader.ImaFactory;
-import com.google.android.exoplayer2.source.MaskingMediaSource.DummyTimeline;
+import com.google.android.exoplayer2.source.MaskingMediaSource.PlaceholderTimeline;
import com.google.android.exoplayer2.source.ads.AdPlaybackState;
import com.google.android.exoplayer2.source.ads.AdsLoader;
import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException;
@@ -50,21 +63,31 @@
import com.google.android.exoplayer2.testutil.FakeTimeline;
import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition;
import com.google.android.exoplayer2.upstream.DataSpec;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
import java.io.IOException;
+import java.time.Duration;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.List;
import java.util.Map;
import org.junit.After;
import org.junit.Before;
+import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
-import org.mockito.MockitoAnnotations;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.mockito.stubbing.Answer;
+import org.robolectric.shadows.ShadowSystemClock;
-/** Test for {@link ImaAdsLoader}. */
+/** Tests for {@link ImaAdsLoader}. */
@RunWith(AndroidJUnit4.class)
-public class ImaAdsLoaderTest {
+public final class ImaAdsLoaderTest {
private static final long CONTENT_DURATION_US = 10 * C.MICROS_PER_SECOND;
private static final Timeline CONTENT_TIMELINE =
@@ -74,36 +97,39 @@ public class ImaAdsLoaderTest {
private static final long CONTENT_PERIOD_DURATION_US =
CONTENT_TIMELINE.getPeriod(/* periodIndex= */ 0, new Period()).durationUs;
private static final Uri TEST_URI = Uri.EMPTY;
+ private static final AdMediaInfo TEST_AD_MEDIA_INFO = new AdMediaInfo(TEST_URI.toString());
private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND;
- private static final long[][] PREROLL_ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}};
- private static final Float[] PREROLL_CUE_POINTS_SECONDS = new Float[] {0f};
- private static final FakeAd UNSKIPPABLE_AD =
- new FakeAd(/* skippable= */ false, /* podIndex= */ 0, /* totalAds= */ 1, /* adPosition= */ 1);
-
- @Mock private ImaSdkSettings imaSdkSettings;
- @Mock private AdsRenderingSettings adsRenderingSettings;
- @Mock private AdDisplayContainer adDisplayContainer;
- @Mock private AdsManager adsManager;
+ private static final ImmutableList PREROLL_CUE_POINTS_SECONDS = ImmutableList.of(0f);
+
+ @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+ @Mock private ImaSdkSettings mockImaSdkSettings;
+ @Mock private AdsRenderingSettings mockAdsRenderingSettings;
+ @Mock private AdDisplayContainer mockAdDisplayContainer;
+ @Mock private AdsManager mockAdsManager;
+ @Mock private AdsRequest mockAdsRequest;
+ @Mock private AdsManagerLoadedEvent mockAdsManagerLoadedEvent;
+ @Mock private com.google.ads.interactivemedia.v3.api.AdsLoader mockAdsLoader;
+ @Mock private FriendlyObstruction mockFriendlyObstruction;
@Mock private ImaFactory mockImaFactory;
+ @Mock private AdPodInfo mockAdPodInfo;
+ @Mock private Ad mockPrerollSingleAd;
+
private ViewGroup adViewGroup;
- private View adOverlayView;
private AdsLoader.AdViewProvider adViewProvider;
+ private AdsLoader.AdViewProvider audioAdsAdViewProvider;
+ private AdEvent.AdEventListener adEventListener;
+ private ContentProgressProvider contentProgressProvider;
+ private VideoAdPlayer videoAdPlayer;
private TestAdsLoaderListener adsLoaderListener;
private FakePlayer fakeExoPlayer;
private ImaAdsLoader imaAdsLoader;
@Before
public void setUp() {
- MockitoAnnotations.initMocks(this);
- FakeAdsRequest fakeAdsRequest = new FakeAdsRequest();
- FakeAdsLoader fakeAdsLoader = new FakeAdsLoader(imaSdkSettings, adsManager);
- when(mockImaFactory.createAdDisplayContainer()).thenReturn(adDisplayContainer);
- when(mockImaFactory.createAdsRenderingSettings()).thenReturn(adsRenderingSettings);
- when(mockImaFactory.createAdsRequest()).thenReturn(fakeAdsRequest);
- when(mockImaFactory.createImaSdkSettings()).thenReturn(imaSdkSettings);
- when(mockImaFactory.createAdsLoader(any(), any(), any())).thenReturn(fakeAdsLoader);
- adViewGroup = new FrameLayout(ApplicationProvider.getApplicationContext());
- adOverlayView = new View(ApplicationProvider.getApplicationContext());
+ setupMocks();
+ adViewGroup = new FrameLayout(getApplicationContext());
+ View adOverlayView = new View(getApplicationContext());
adViewProvider =
new AdsLoader.AdViewProvider() {
@Override
@@ -112,8 +138,21 @@ public ViewGroup getAdViewGroup() {
}
@Override
- public View[] getAdOverlayViews() {
- return new View[] {adOverlayView};
+ public ImmutableList getAdOverlayInfos() {
+ return ImmutableList.of(
+ new AdsLoader.OverlayInfo(adOverlayView, AdsLoader.OverlayInfo.PURPOSE_CLOSE_AD));
+ }
+ };
+ audioAdsAdViewProvider =
+ new AdsLoader.AdViewProvider() {
+ @Override
+ public ViewGroup getAdViewGroup() {
+ return null;
+ }
+
+ @Override
+ public ImmutableList getAdOverlayInfos() {
+ return ImmutableList.of();
}
};
}
@@ -127,25 +166,37 @@ public void teardown() {
@Test
public void builder_overridesPlayerType() {
- when(imaSdkSettings.getPlayerType()).thenReturn("test player type");
- setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+ when(mockImaSdkSettings.getPlayerType()).thenReturn("test player type");
+ setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS);
- verify(imaSdkSettings).setPlayerType("google/exo.ext.ima");
+ verify(mockImaSdkSettings).setPlayerType("google/exo.ext.ima");
}
@Test
public void start_setsAdUiViewGroup() {
- setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+ setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS);
imaAdsLoader.start(adsLoaderListener, adViewProvider);
- verify(adDisplayContainer, atLeastOnce()).setAdContainer(adViewGroup);
- verify(adDisplayContainer, atLeastOnce()).registerVideoControlsOverlay(adOverlayView);
+ verify(mockImaFactory, atLeastOnce()).createAdDisplayContainer(adViewGroup, videoAdPlayer);
+ verify(mockImaFactory, never()).createAudioAdDisplayContainer(any(), any());
+ verify(mockAdDisplayContainer).registerFriendlyObstruction(mockFriendlyObstruction);
+ }
+
+ @Test
+ public void startForAudioOnlyAds_createsAudioOnlyAdDisplayContainer() {
+ setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS);
+ imaAdsLoader.start(adsLoaderListener, audioAdsAdViewProvider);
+
+ verify(mockImaFactory, atLeastOnce())
+ .createAudioAdDisplayContainer(getApplicationContext(), videoAdPlayer);
+ verify(mockImaFactory, never()).createAdDisplayContainer(any(), any());
+ verify(mockAdDisplayContainer, never()).registerFriendlyObstruction(any());
}
@Test
public void start_withPlaceholderContent_initializedAdsLoader() {
- Timeline placeholderTimeline = new DummyTimeline(/* tag= */ null);
- setupPlayback(placeholderTimeline, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+ Timeline placeholderTimeline = new PlaceholderTimeline(MediaItem.fromUri(Uri.EMPTY));
+ setupPlayback(placeholderTimeline, PREROLL_CUE_POINTS_SECONDS);
imaAdsLoader.start(adsLoaderListener, adViewProvider);
// We'll only create the rendering settings when initializing the ads loader.
@@ -154,26 +205,27 @@ public void start_withPlaceholderContent_initializedAdsLoader() {
@Test
public void start_updatesAdPlaybackState() {
- setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+ setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS);
imaAdsLoader.start(adsLoaderListener, adViewProvider);
assertThat(adsLoaderListener.adPlaybackState)
.isEqualTo(
new AdPlaybackState(/* adGroupTimesUs...= */ 0)
- .withAdDurationsUs(PREROLL_ADS_DURATIONS_US)
.withContentDurationUs(CONTENT_PERIOD_DURATION_US));
}
@Test
public void startAfterRelease() {
- setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+ setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS);
imaAdsLoader.release();
imaAdsLoader.start(adsLoaderListener, adViewProvider);
}
@Test
public void startAndCallbacksAfterRelease() {
- setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+ setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS);
+ // Request ads in order to get a reference to the ad event listener.
+ imaAdsLoader.requestAds(adViewGroup);
imaAdsLoader.release();
imaAdsLoader.start(adsLoaderListener, adViewProvider);
fakeExoPlayer.setPlayingContentPosition(/* position= */ 0);
@@ -184,47 +236,47 @@ public void startAndCallbacksAfterRelease() {
// when using Robolectric and accessing VideoProgressUpdate.VIDEO_TIME_NOT_READY, due to the IMA
// SDK being proguarded.
imaAdsLoader.requestAds(adViewGroup);
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, UNSKIPPABLE_AD));
- imaAdsLoader.loadAd(TEST_URI.toString());
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, UNSKIPPABLE_AD));
- imaAdsLoader.playAd();
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, UNSKIPPABLE_AD));
- imaAdsLoader.pauseAd();
- imaAdsLoader.stopAd();
+ adEventListener.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd));
+ videoAdPlayer.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo);
+ adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd));
+ videoAdPlayer.playAd(TEST_AD_MEDIA_INFO);
+ adEventListener.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd));
+ videoAdPlayer.pauseAd(TEST_AD_MEDIA_INFO);
+ videoAdPlayer.stopAd(TEST_AD_MEDIA_INFO);
imaAdsLoader.onPlayerError(ExoPlaybackException.createForSource(new IOException()));
imaAdsLoader.onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK);
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null));
+ adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null));
imaAdsLoader.handlePrepareError(
/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, new IOException());
}
@Test
public void playback_withPrerollAd_marksAdAsPlayed() {
- setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+ setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS);
// Load the preroll ad.
imaAdsLoader.start(adsLoaderListener, adViewProvider);
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, UNSKIPPABLE_AD));
- imaAdsLoader.loadAd(TEST_URI.toString());
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, UNSKIPPABLE_AD));
+ adEventListener.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd));
+ videoAdPlayer.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo);
+ adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd));
// Play the preroll ad.
- imaAdsLoader.playAd();
+ videoAdPlayer.playAd(TEST_AD_MEDIA_INFO);
fakeExoPlayer.setPlayingAdPosition(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
/* position= */ 0,
/* contentPosition= */ 0);
fakeExoPlayer.setState(Player.STATE_READY, true);
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, UNSKIPPABLE_AD));
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, UNSKIPPABLE_AD));
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.MIDPOINT, UNSKIPPABLE_AD));
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, UNSKIPPABLE_AD));
+ adEventListener.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd));
+ adEventListener.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, mockPrerollSingleAd));
+ adEventListener.onAdEvent(getAdEvent(AdEventType.MIDPOINT, mockPrerollSingleAd));
+ adEventListener.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, mockPrerollSingleAd));
// Play the content.
fakeExoPlayer.setPlayingContentPosition(0);
- imaAdsLoader.stopAd();
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null));
+ videoAdPlayer.stopAd(TEST_AD_MEDIA_INFO);
+ adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null));
// Verify that the preroll ad has been marked as played.
assertThat(adsLoaderListener.adPlaybackState)
@@ -233,35 +285,556 @@ public void playback_withPrerollAd_marksAdAsPlayed() {
.withContentDurationUs(CONTENT_PERIOD_DURATION_US)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* uri= */ TEST_URI)
- .withAdDurationsUs(PREROLL_ADS_DURATIONS_US)
+ .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}})
.withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)
.withAdResumePositionUs(/* adResumePositionUs= */ 0));
}
+ @Test
+ public void playback_withMidrollFetchError_marksAdAsInErrorState() {
+ AdEvent mockMidrollFetchErrorAdEvent = mock(AdEvent.class);
+ when(mockMidrollFetchErrorAdEvent.getType()).thenReturn(AdEventType.AD_BREAK_FETCH_ERROR);
+ when(mockMidrollFetchErrorAdEvent.getAdData())
+ .thenReturn(ImmutableMap.of("adBreakTime", "20.5"));
+ setupPlayback(CONTENT_TIMELINE, ImmutableList.of(20.5f));
+
+ // Simulate loading an empty midroll ad.
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+ adEventListener.onAdEvent(mockMidrollFetchErrorAdEvent);
+
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ new AdPlaybackState(/* adGroupTimesUs...= */ 20_500_000)
+ .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
+ .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}})
+ .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
+ .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0));
+ }
+
+ @Test
+ public void playback_withPostrollFetchError_marksAdAsInErrorState() {
+ AdEvent mockPostrollFetchErrorAdEvent = mock(AdEvent.class);
+ when(mockPostrollFetchErrorAdEvent.getType()).thenReturn(AdEventType.AD_BREAK_FETCH_ERROR);
+ when(mockPostrollFetchErrorAdEvent.getAdData())
+ .thenReturn(ImmutableMap.of("adBreakTime", "-1"));
+ setupPlayback(CONTENT_TIMELINE, ImmutableList.of(-1f));
+
+ // Simulate loading an empty postroll ad.
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+ adEventListener.onAdEvent(mockPostrollFetchErrorAdEvent);
+
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ new AdPlaybackState(/* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE)
+ .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
+ .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}})
+ .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
+ .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0));
+ }
+
+ @Test
+ public void playback_withAdNotPreloadingBeforeTimeout_hasNoError() {
+ // Simulate an ad at 2 seconds.
+ long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND;
+ long adGroupTimeUs =
+ adGroupPositionInWindowUs
+ + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ ImmutableList cuePoints = ImmutableList.of((float) adGroupTimeUs / C.MICROS_PER_SECOND);
+ setupPlayback(CONTENT_TIMELINE, cuePoints);
+
+ // Advance playback to just before the midroll and simulate buffering.
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+ fakeExoPlayer.setPlayingContentPosition(C.usToMs(adGroupPositionInWindowUs));
+ fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true);
+ // Advance before the timeout and simulating polling content progress.
+ ShadowSystemClock.advanceBy(Duration.ofSeconds(1));
+ contentProgressProvider.getContentProgress();
+
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ AdPlaybackStateFactory.fromCuePoints(cuePoints)
+ .withContentDurationUs(CONTENT_PERIOD_DURATION_US));
+ }
+
+ @Test
+ public void playback_withAdNotPreloadingAfterTimeout_hasErrorAdGroup() {
+ // Simulate an ad at 2 seconds.
+ long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND;
+ long adGroupTimeUs =
+ adGroupPositionInWindowUs
+ + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ ImmutableList cuePoints = ImmutableList.of((float) adGroupTimeUs / C.MICROS_PER_SECOND);
+ setupPlayback(CONTENT_TIMELINE, cuePoints);
+
+ // Advance playback to just before the midroll and simulate buffering.
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+ fakeExoPlayer.setPlayingContentPosition(C.usToMs(adGroupPositionInWindowUs));
+ fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true);
+ // Advance past the timeout and simulate polling content progress.
+ ShadowSystemClock.advanceBy(Duration.ofSeconds(5));
+ contentProgressProvider.getContentProgress();
+
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ AdPlaybackStateFactory.fromCuePoints(cuePoints)
+ .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
+ .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}})
+ .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
+ .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0));
+ }
+
+ @Test
+ public void resumePlaybackBeforeMidroll_playsPreroll() {
+ long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND;
+ long midrollPeriodTimeUs =
+ midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ ImmutableList cuePoints =
+ ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND);
+ setupPlayback(CONTENT_TIMELINE, cuePoints);
+
+ fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) - 1_000);
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+
+ verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble());
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ AdPlaybackStateFactory.fromCuePoints(cuePoints)
+ .withContentDurationUs(CONTENT_PERIOD_DURATION_US));
+ }
+
+ @Test
+ public void resumePlaybackAtMidroll_skipsPreroll() {
+ long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND;
+ long midrollPeriodTimeUs =
+ midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ ImmutableList cuePoints =
+ ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND);
+ setupPlayback(CONTENT_TIMELINE, cuePoints);
+
+ fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs));
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+
+ ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class);
+ verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture());
+ double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d;
+ assertThat(playAdsAfterTimeCaptor.getValue())
+ .isWithin(0.1)
+ .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND);
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ AdPlaybackStateFactory.fromCuePoints(cuePoints)
+ .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
+ .withSkippedAdGroup(/* adGroupIndex= */ 0));
+ }
+
+ @Test
+ public void resumePlaybackAfterMidroll_skipsPreroll() {
+ long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND;
+ long midrollPeriodTimeUs =
+ midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ ImmutableList cuePoints =
+ ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND);
+ setupPlayback(CONTENT_TIMELINE, cuePoints);
+
+ fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) + 1_000);
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+
+ ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class);
+ verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture());
+ double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d;
+ assertThat(playAdsAfterTimeCaptor.getValue())
+ .isWithin(0.1)
+ .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND);
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ AdPlaybackStateFactory.fromCuePoints(cuePoints)
+ .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
+ .withSkippedAdGroup(/* adGroupIndex= */ 0));
+ }
+
+ @Test
+ public void resumePlaybackBeforeSecondMidroll_playsFirstMidroll() {
+ long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND;
+ long firstMidrollPeriodTimeUs =
+ firstMidrollWindowTimeUs
+ + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND;
+ long secondMidrollPeriodTimeUs =
+ secondMidrollWindowTimeUs
+ + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ ImmutableList cuePoints =
+ ImmutableList.of(
+ (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND,
+ (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND);
+ setupPlayback(CONTENT_TIMELINE, cuePoints);
+
+ fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs) - 1_000);
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+
+ verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble());
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ AdPlaybackStateFactory.fromCuePoints(cuePoints)
+ .withContentDurationUs(CONTENT_PERIOD_DURATION_US));
+ }
+
+ @Test
+ public void resumePlaybackAtSecondMidroll_skipsFirstMidroll() {
+ long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND;
+ long firstMidrollPeriodTimeUs =
+ firstMidrollWindowTimeUs
+ + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND;
+ long secondMidrollPeriodTimeUs =
+ secondMidrollWindowTimeUs
+ + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ ImmutableList cuePoints =
+ ImmutableList.of(
+ (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND,
+ (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND);
+ setupPlayback(CONTENT_TIMELINE, cuePoints);
+
+ fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs));
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+
+ ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class);
+ verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture());
+ double expectedPlayAdsAfterTimeUs = (firstMidrollPeriodTimeUs + secondMidrollPeriodTimeUs) / 2d;
+ assertThat(playAdsAfterTimeCaptor.getValue())
+ .isWithin(0.1)
+ .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND);
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ AdPlaybackStateFactory.fromCuePoints(cuePoints)
+ .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
+ .withSkippedAdGroup(/* adGroupIndex= */ 0));
+ }
+
+ @Test
+ public void resumePlaybackBeforeMidroll_withoutPlayAdBeforeStartPosition_skipsPreroll() {
+ long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND;
+ long midrollPeriodTimeUs =
+ midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ ImmutableList cuePoints =
+ ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND);
+ setupPlayback(
+ CONTENT_TIMELINE,
+ cuePoints,
+ new ImaAdsLoader.Builder(getApplicationContext())
+ .setPlayAdBeforeStartPosition(false)
+ .setImaFactory(mockImaFactory)
+ .setImaSdkSettings(mockImaSdkSettings)
+ .buildForAdTag(TEST_URI));
+
+ fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) - 1_000);
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+
+ ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class);
+ verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture());
+ double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d;
+ assertThat(playAdsAfterTimeCaptor.getValue())
+ .isWithin(0.1d)
+ .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND);
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ AdPlaybackStateFactory.fromCuePoints(cuePoints)
+ .withSkippedAdGroup(/* adGroupIndex= */ 0)
+ .withContentDurationUs(CONTENT_PERIOD_DURATION_US));
+ }
+
+ @Test
+ public void resumePlaybackAtMidroll_withoutPlayAdBeforeStartPosition_skipsPreroll() {
+ long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND;
+ long midrollPeriodTimeUs =
+ midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ ImmutableList cuePoints =
+ ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND);
+ setupPlayback(
+ CONTENT_TIMELINE,
+ cuePoints,
+ new ImaAdsLoader.Builder(getApplicationContext())
+ .setPlayAdBeforeStartPosition(false)
+ .setImaFactory(mockImaFactory)
+ .setImaSdkSettings(mockImaSdkSettings)
+ .buildForAdTag(TEST_URI));
+
+ fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs));
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+
+ ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class);
+ verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture());
+ double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d;
+ assertThat(playAdsAfterTimeCaptor.getValue())
+ .isWithin(0.1d)
+ .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND);
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ AdPlaybackStateFactory.fromCuePoints(cuePoints)
+ .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
+ .withSkippedAdGroup(/* adGroupIndex= */ 0));
+ }
+
+ @Test
+ public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMidroll() {
+ long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND;
+ long midrollPeriodTimeUs =
+ midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ ImmutableList cuePoints =
+ ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND);
+ setupPlayback(
+ CONTENT_TIMELINE,
+ cuePoints,
+ new ImaAdsLoader.Builder(getApplicationContext())
+ .setPlayAdBeforeStartPosition(false)
+ .setImaFactory(mockImaFactory)
+ .setImaSdkSettings(mockImaSdkSettings)
+ .buildForAdTag(TEST_URI));
+
+ fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) + 1_000);
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+
+ verify(mockAdsManager).destroy();
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ AdPlaybackStateFactory.fromCuePoints(cuePoints)
+ .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
+ .withSkippedAdGroup(/* adGroupIndex= */ 0)
+ .withSkippedAdGroup(/* adGroupIndex= */ 1));
+ }
+
+ @Test
+ public void
+ resumePlaybackBeforeSecondMidroll_withoutPlayAdBeforeStartPosition_skipsFirstMidroll() {
+ long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND;
+ long firstMidrollPeriodTimeUs =
+ firstMidrollWindowTimeUs
+ + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND;
+ long secondMidrollPeriodTimeUs =
+ secondMidrollWindowTimeUs
+ + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ ImmutableList cuePoints =
+ ImmutableList.of(
+ (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND,
+ (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND);
+ setupPlayback(
+ CONTENT_TIMELINE,
+ cuePoints,
+ new ImaAdsLoader.Builder(getApplicationContext())
+ .setPlayAdBeforeStartPosition(false)
+ .setImaFactory(mockImaFactory)
+ .setImaSdkSettings(mockImaSdkSettings)
+ .buildForAdTag(TEST_URI));
+
+ fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs) - 1_000);
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+
+ ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class);
+ verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture());
+ double expectedPlayAdsAfterTimeUs = (firstMidrollPeriodTimeUs + secondMidrollPeriodTimeUs) / 2d;
+ assertThat(playAdsAfterTimeCaptor.getValue())
+ .isWithin(0.1d)
+ .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND);
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ AdPlaybackStateFactory.fromCuePoints(cuePoints)
+ .withSkippedAdGroup(/* adGroupIndex= */ 0)
+ .withContentDurationUs(CONTENT_PERIOD_DURATION_US));
+ }
+
+ @Test
+ public void resumePlaybackAtSecondMidroll_withoutPlayAdBeforeStartPosition_skipsFirstMidroll() {
+ long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND;
+ long firstMidrollPeriodTimeUs =
+ firstMidrollWindowTimeUs
+ + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND;
+ long secondMidrollPeriodTimeUs =
+ secondMidrollWindowTimeUs
+ + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
+ ImmutableList cuePoints =
+ ImmutableList.of(
+ (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND,
+ (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND);
+ setupPlayback(
+ CONTENT_TIMELINE,
+ cuePoints,
+ new ImaAdsLoader.Builder(getApplicationContext())
+ .setPlayAdBeforeStartPosition(false)
+ .setImaFactory(mockImaFactory)
+ .setImaSdkSettings(mockImaSdkSettings)
+ .buildForAdTag(TEST_URI));
+
+ fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs));
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+
+ ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class);
+ verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture());
+ double expectedPlayAdsAfterTimeUs = (firstMidrollPeriodTimeUs + secondMidrollPeriodTimeUs) / 2d;
+ assertThat(playAdsAfterTimeCaptor.getValue())
+ .isWithin(0.1d)
+ .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND);
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ AdPlaybackStateFactory.fromCuePoints(cuePoints)
+ .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
+ .withSkippedAdGroup(/* adGroupIndex= */ 0));
+ }
+
@Test
public void stop_unregistersAllVideoControlOverlays() {
- setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+ setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS);
imaAdsLoader.start(adsLoaderListener, adViewProvider);
imaAdsLoader.requestAds(adViewGroup);
imaAdsLoader.stop();
- InOrder inOrder = inOrder(adDisplayContainer);
- inOrder.verify(adDisplayContainer).registerVideoControlsOverlay(adOverlayView);
- inOrder.verify(adDisplayContainer).unregisterAllVideoControlsOverlays();
+ InOrder inOrder = inOrder(mockAdDisplayContainer);
+ inOrder.verify(mockAdDisplayContainer).registerFriendlyObstruction(mockFriendlyObstruction);
+ inOrder.verify(mockAdDisplayContainer).unregisterAllFriendlyObstructions();
}
- private void setupPlayback(Timeline contentTimeline, long[][] adDurationsUs, Float[] cuePoints) {
- fakeExoPlayer = new FakePlayer();
- adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline, adDurationsUs);
- when(adsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints));
- imaAdsLoader =
- new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext())
+ @Test
+ public void loadAd_withLargeAdCuePoint_updatesAdPlaybackStateWithLoadedAd() {
+ float midrollTimeSecs = 1_765f;
+ ImmutableList cuePoints = ImmutableList.of(midrollTimeSecs);
+ setupPlayback(CONTENT_TIMELINE, cuePoints);
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+ videoAdPlayer.loadAd(
+ TEST_AD_MEDIA_INFO,
+ new AdPodInfo() {
+ @Override
+ public int getTotalAds() {
+ return 1;
+ }
+
+ @Override
+ public int getAdPosition() {
+ return 1;
+ }
+
+ @Override
+ public boolean isBumper() {
+ return false;
+ }
+
+ @Override
+ public double getMaxDuration() {
+ return 0;
+ }
+
+ @Override
+ public int getPodIndex() {
+ return 0;
+ }
+
+ @Override
+ public double getTimeOffset() {
+ return midrollTimeSecs;
+ }
+ });
+
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ AdPlaybackStateFactory.fromCuePoints(cuePoints)
+ .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
+ .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
+ .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, TEST_URI)
+ .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}));
+ }
+
+ private void setupPlayback(Timeline contentTimeline, List cuePoints) {
+ setupPlayback(
+ contentTimeline,
+ cuePoints,
+ new ImaAdsLoader.Builder(getApplicationContext())
.setImaFactory(mockImaFactory)
- .setImaSdkSettings(imaSdkSettings)
- .buildForAdTag(TEST_URI);
+ .setImaSdkSettings(mockImaSdkSettings)
+ .buildForAdTag(TEST_URI));
+ }
+
+ private void setupPlayback(
+ Timeline contentTimeline, List cuePoints, ImaAdsLoader imaAdsLoader) {
+ fakeExoPlayer = new FakePlayer();
+ adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline);
+ when(mockAdsManager.getAdCuePoints()).thenReturn(cuePoints);
+ this.imaAdsLoader = imaAdsLoader;
imaAdsLoader.setPlayer(fakeExoPlayer);
}
+ private void setupMocks() {
+ ArgumentCaptor userRequestContextCaptor = ArgumentCaptor.forClass(Object.class);
+ doNothing().when(mockAdsRequest).setUserRequestContext(userRequestContextCaptor.capture());
+ when(mockAdsRequest.getUserRequestContext())
+ .thenAnswer(invocation -> userRequestContextCaptor.getValue());
+ List adsLoadedListeners =
+ new ArrayList<>();
+ // Deliberately don't handle removeAdsLoadedListener to allow testing behavior if the IMA SDK
+ // invokes callbacks after release.
+ doAnswer(
+ invocation -> {
+ adsLoadedListeners.add(invocation.getArgument(0));
+ return null;
+ })
+ .when(mockAdsLoader)
+ .addAdsLoadedListener(any());
+ when(mockAdsManagerLoadedEvent.getAdsManager()).thenReturn(mockAdsManager);
+ when(mockAdsManagerLoadedEvent.getUserRequestContext())
+ .thenAnswer(invocation -> mockAdsRequest.getUserRequestContext());
+ doAnswer(
+ (Answer)
+ invocation -> {
+ for (com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener listener :
+ adsLoadedListeners) {
+ listener.onAdsManagerLoaded(mockAdsManagerLoadedEvent);
+ }
+ return null;
+ })
+ .when(mockAdsLoader)
+ .requestAds(mockAdsRequest);
+
+ doAnswer(
+ invocation -> {
+ adEventListener = invocation.getArgument(0);
+ return null;
+ })
+ .when(mockAdsManager)
+ .addAdEventListener(any());
+
+ doAnswer(
+ invocation -> {
+ contentProgressProvider = invocation.getArgument(0);
+ return null;
+ })
+ .when(mockAdsRequest)
+ .setContentProgressProvider(any());
+
+ doAnswer(
+ invocation -> {
+ videoAdPlayer = invocation.getArgument(1);
+ return mockAdDisplayContainer;
+ })
+ .when(mockImaFactory)
+ .createAdDisplayContainer(any(), any());
+ doAnswer(
+ invocation -> {
+ videoAdPlayer = invocation.getArgument(1);
+ return mockAdDisplayContainer;
+ })
+ .when(mockImaFactory)
+ .createAudioAdDisplayContainer(any(), any());
+ when(mockImaFactory.createAdsRenderingSettings()).thenReturn(mockAdsRenderingSettings);
+ when(mockImaFactory.createAdsRequest()).thenReturn(mockAdsRequest);
+ when(mockImaFactory.createAdsLoader(any(), any(), any())).thenReturn(mockAdsLoader);
+ when(mockImaFactory.createFriendlyObstruction(any(), any(), any()))
+ .thenReturn(mockFriendlyObstruction);
+
+ when(mockAdPodInfo.getPodIndex()).thenReturn(0);
+ when(mockAdPodInfo.getTotalAds()).thenReturn(1);
+ when(mockAdPodInfo.getAdPosition()).thenReturn(1);
+
+ when(mockPrerollSingleAd.getAdPodInfo()).thenReturn(mockAdPodInfo);
+ }
+
private static AdEvent getAdEvent(AdEventType adEventType, @Nullable Ad ad) {
return new AdEvent() {
@Override
@@ -287,19 +860,21 @@ private static final class TestAdsLoaderListener implements AdsLoader.EventListe
private final FakePlayer fakeExoPlayer;
private final Timeline contentTimeline;
- private final long[][] adDurationsUs;
public AdPlaybackState adPlaybackState;
- public TestAdsLoaderListener(
- FakePlayer fakeExoPlayer, Timeline contentTimeline, long[][] adDurationsUs) {
+ public TestAdsLoaderListener(FakePlayer fakeExoPlayer, Timeline contentTimeline) {
this.fakeExoPlayer = fakeExoPlayer;
this.contentTimeline = contentTimeline;
- this.adDurationsUs = adDurationsUs;
}
@Override
public void onAdPlaybackState(AdPlaybackState adPlaybackState) {
+ long[][] adDurationsUs = new long[adPlaybackState.adGroupCount][];
+ for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) {
+ adDurationsUs[adGroupIndex] = new long[adPlaybackState.adGroups[adGroupIndex].uris.length];
+ Arrays.fill(adDurationsUs[adGroupIndex], TEST_AD_DURATION_US);
+ }
adPlaybackState = adPlaybackState.withAdDurationsUs(adDurationsUs);
this.adPlaybackState = adPlaybackState;
fakeExoPlayer.updateTimeline(
diff --git a/extensions/jobdispatcher/README.md b/extensions/jobdispatcher/README.md
index 613277bad2b..9e26c07c5da 100644
--- a/extensions/jobdispatcher/README.md
+++ b/extensions/jobdispatcher/README.md
@@ -1,12 +1,10 @@
# ExoPlayer Firebase JobDispatcher extension #
-**DEPRECATED - Please use [WorkManager extension][] or [PlatformScheduler][]
-instead.**
+**This extension is deprecated. Use the [WorkManager extension][] instead.**
This extension provides a Scheduler implementation which uses [Firebase JobDispatcher][].
[WorkManager extension]: https://github.com/google/ExoPlayer/blob/release-v2/extensions/workmanager/README.md
-[PlatformScheduler]: https://github.com/google/ExoPlayer/blob/release-v2/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java
[Firebase JobDispatcher]: https://github.com/firebase/firebase-jobdispatcher-android
## Getting the extension ##
diff --git a/extensions/jobdispatcher/build.gradle b/extensions/jobdispatcher/build.gradle
index 05ac82ba089..df50cde8f91 100644
--- a/extensions/jobdispatcher/build.gradle
+++ b/extensions/jobdispatcher/build.gradle
@@ -13,24 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-apply from: '../../constants.gradle'
-apply plugin: 'com.android.library'
-
-android {
- compileSdkVersion project.ext.compileSdkVersion
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- defaultConfig {
- minSdkVersion project.ext.minSdkVersion
- targetSdkVersion project.ext.targetSdkVersion
- }
-
- testOptions.unitTests.includeAndroidResources = true
-}
+apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle"
dependencies {
implementation project(modulePrefix + 'library-core')
diff --git a/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java
index 8841f8355fc..b65988a5e21 100644
--- a/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java
+++ b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java
@@ -60,11 +60,15 @@
@Deprecated
public final class JobDispatcherScheduler implements Scheduler {
- private static final boolean DEBUG = false;
private static final String TAG = "JobDispatcherScheduler";
private static final String KEY_SERVICE_ACTION = "service_action";
private static final String KEY_SERVICE_PACKAGE = "service_package";
private static final String KEY_REQUIREMENTS = "requirements";
+ private static final int SUPPORTED_REQUIREMENTS =
+ Requirements.NETWORK
+ | Requirements.NETWORK_UNMETERED
+ | Requirements.DEVICE_IDLE
+ | Requirements.DEVICE_CHARGING;
private final String jobTag;
private final FirebaseJobDispatcher jobDispatcher;
@@ -85,35 +89,44 @@ public JobDispatcherScheduler(Context context, String jobTag) {
public boolean schedule(Requirements requirements, String servicePackage, String serviceAction) {
Job job = buildJob(jobDispatcher, requirements, jobTag, servicePackage, serviceAction);
int result = jobDispatcher.schedule(job);
- logd("Scheduling job: " + jobTag + " result: " + result);
return result == FirebaseJobDispatcher.SCHEDULE_RESULT_SUCCESS;
}
@Override
public boolean cancel() {
int result = jobDispatcher.cancel(jobTag);
- logd("Canceling job: " + jobTag + " result: " + result);
return result == FirebaseJobDispatcher.CANCEL_RESULT_SUCCESS;
}
+ @Override
+ public Requirements getSupportedRequirements(Requirements requirements) {
+ return requirements.filterRequirements(SUPPORTED_REQUIREMENTS);
+ }
+
private static Job buildJob(
FirebaseJobDispatcher dispatcher,
Requirements requirements,
String tag,
String servicePackage,
String serviceAction) {
+ Requirements filteredRequirements = requirements.filterRequirements(SUPPORTED_REQUIREMENTS);
+ if (!filteredRequirements.equals(requirements)) {
+ Log.w(
+ TAG,
+ "Ignoring unsupported requirements: "
+ + (filteredRequirements.getRequirements() ^ requirements.getRequirements()));
+ }
+
Job.Builder builder =
dispatcher
.newJobBuilder()
.setService(JobDispatcherSchedulerService.class) // the JobService that will be called
.setTag(tag);
-
if (requirements.isUnmeteredNetworkRequired()) {
builder.addConstraint(Constraint.ON_UNMETERED_NETWORK);
} else if (requirements.isNetworkRequired()) {
builder.addConstraint(Constraint.ON_ANY_NETWORK);
}
-
if (requirements.isIdleRequired()) {
builder.addConstraint(Constraint.DEVICE_IDLE);
}
@@ -131,31 +144,20 @@ private static Job buildJob(
return builder.build();
}
- private static void logd(String message) {
- if (DEBUG) {
- Log.d(TAG, message);
- }
- }
-
/** A {@link JobService} that starts the target service if the requirements are met. */
public static final class JobDispatcherSchedulerService extends JobService {
@Override
public boolean onStartJob(JobParameters params) {
- logd("JobDispatcherSchedulerService is started");
- Bundle extras = params.getExtras();
- Assertions.checkNotNull(extras, "Service started without extras.");
+ Bundle extras = Assertions.checkNotNull(params.getExtras());
Requirements requirements = new Requirements(extras.getInt(KEY_REQUIREMENTS));
- if (requirements.checkRequirements(this)) {
- logd("Requirements are met");
- String serviceAction = extras.getString(KEY_SERVICE_ACTION);
- String servicePackage = extras.getString(KEY_SERVICE_PACKAGE);
- Assertions.checkNotNull(serviceAction, "Service action missing.");
- Assertions.checkNotNull(servicePackage, "Service package missing.");
+ int notMetRequirements = requirements.getNotMetRequirements(this);
+ if (notMetRequirements == 0) {
+ String serviceAction = Assertions.checkNotNull(extras.getString(KEY_SERVICE_ACTION));
+ String servicePackage = Assertions.checkNotNull(extras.getString(KEY_SERVICE_PACKAGE));
Intent intent = new Intent(serviceAction).setPackage(servicePackage);
- logd("Starting service action: " + serviceAction + " package: " + servicePackage);
Util.startForegroundService(this, intent);
} else {
- logd("Requirements are not met");
+ Log.w(TAG, "Requirements not met: " + notMetRequirements);
jobFinished(params, /* needsReschedule */ true);
}
return false;
diff --git a/extensions/leanback/build.gradle b/extensions/leanback/build.gradle
index 19b4cde3bf8..14ced09f121 100644
--- a/extensions/leanback/build.gradle
+++ b/extensions/leanback/build.gradle
@@ -11,24 +11,9 @@
// 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.
-apply from: '../../constants.gradle'
-apply plugin: 'com.android.library'
+apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle"
-android {
- compileSdkVersion project.ext.compileSdkVersion
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- defaultConfig {
- minSdkVersion 17
- targetSdkVersion project.ext.targetSdkVersion
- }
-
- testOptions.unitTests.includeAndroidResources = true
-}
+android.defaultConfig.minSdkVersion 17
dependencies {
implementation project(modulePrefix + 'library-core')
diff --git a/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java b/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java
index e385cd52e9a..6538160b8b9 100644
--- a/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java
+++ b/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java
@@ -72,7 +72,7 @@ public LeanbackPlayerAdapter(Context context, Player player, final int updatePer
this.context = context;
this.player = player;
this.updatePeriodMs = updatePeriodMs;
- handler = Util.createHandler();
+ handler = Util.createHandlerForCurrentOrMainLooper();
componentListener = new ComponentListener();
controlDispatcher = new DefaultControlDispatcher();
}
diff --git a/extensions/media2/README.md b/extensions/media2/README.md
new file mode 100644
index 00000000000..32ea8649406
--- /dev/null
+++ b/extensions/media2/README.md
@@ -0,0 +1,53 @@
+# ExoPlayer Media2 extension #
+
+The Media2 extension provides builders for [SessionPlayer][] and [MediaSession.SessionCallback][] in
+the [Media2 library][].
+
+Compared to [MediaSessionConnector][] that uses [MediaSessionCompat][], this provides finer grained
+control for incoming calls, so you can selectively allow/reject commands per controller.
+
+## Getting the extension ##
+
+The easiest way to use the extension is to add it as a gradle dependency:
+
+```gradle
+implementation 'com.google.android.exoplayer:extension-media2:2.X.X'
+```
+
+where `2.X.X` is the version, which must match the version of the ExoPlayer
+library being used.
+
+Alternatively, you can clone the ExoPlayer repository and depend on the module
+locally. Instructions for doing this can be found in ExoPlayer's
+[top level README][].
+
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
+
+## Using the extension ##
+
+### Using `SessionPlayerConnector` ###
+
+`SessionPlayerConnector` is a [SessionPlayer][] implementation wrapping a given `Player`.
+You can use a [SessionPlayer][] instance to build a [MediaSession][], or to set the player
+associated with a [VideoView][] or [MediaControlView][]
+
+### Using `SessionCallbackBuilder` ###
+
+`SessionCallbackBuilder` lets you build a [MediaSession.SessionCallback][] instance given its
+collaborators. You can use a [MediaSession.SessionCallback][] to build a [MediaSession][].
+
+## Links ##
+
+* [Javadoc][]: Classes matching
+ `com.google.android.exoplayer2.ext.media2.*` belong to this module.
+
+[Javadoc]: https://exoplayer.dev/doc/reference/index.html
+
+[SessionPlayer]: https://developer.android.com/reference/androidx/media2/common/SessionPlayer
+[MediaSession]: https://developer.android.com/reference/androidx/media2/session/MediaSession
+[MediaSession.SessionCallback]: https://developer.android.com/reference/androidx/media2/session/MediaSession.SessionCallback
+[Media2 library]: https://developer.android.com/jetpack/androidx/releases/media2
+[MediaSessionCompat]: https://developer.android.com/reference/android/support/v4/media/session/MediaSessionCompat
+[MediaSessionConnector]: https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.html
+[VideoView]: https://developer.android.com/reference/androidx/media2/widget/VideoView
+[MediaControlView]: https://developer.android.com/reference/androidx/media2/widget/MediaControlView
diff --git a/extensions/media2/build.gradle b/extensions/media2/build.gradle
new file mode 100644
index 00000000000..744d79980b2
--- /dev/null
+++ b/extensions/media2/build.gradle
@@ -0,0 +1,49 @@
+// Copyright 2019 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.
+apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle"
+
+android.defaultConfig.minSdkVersion 19
+
+dependencies {
+ implementation project(modulePrefix + 'library-core')
+ implementation 'androidx.collection:collection:' + androidxCollectionVersion
+ implementation 'androidx.concurrent:concurrent-futures:1.1.0'
+ implementation ('com.google.guava:guava:' + guavaVersion) {
+ exclude group: 'com.google.code.findbugs', module: 'jsr305'
+ exclude group: 'org.checkerframework', module: 'checker-compat-qual'
+ exclude group: 'com.google.errorprone', module: 'error_prone_annotations'
+ exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
+ exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations'
+ }
+ api 'androidx.media2:media2-session:1.0.3'
+ compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
+ compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion
+ androidTestImplementation 'androidx.test.ext:junit:' + androidxTestJUnitVersion
+ androidTestImplementation 'androidx.test:core:' + androidxTestCoreVersion
+ androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion
+ androidTestImplementation 'androidx.test:rules:' + androidxTestRulesVersion
+ androidTestImplementation 'com.google.truth:truth:' + truthVersion
+}
+
+ext {
+ javadocTitle = 'Media2 extension'
+}
+apply from: '../../javadoc_library.gradle'
+
+ext {
+ releaseArtifact = 'extension-media2'
+ releaseDescription = 'Media2 extension for ExoPlayer.'
+}
+apply from: '../../publish.gradle'
diff --git a/extensions/media2/src/androidTest/AndroidManifest.xml b/extensions/media2/src/androidTest/AndroidManifest.xml
new file mode 100644
index 00000000000..b699de67b16
--- /dev/null
+++ b/extensions/media2/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtilTest.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtilTest.java
new file mode 100644
index 00000000000..8cf586b8462
--- /dev/null
+++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtilTest.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2020 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.ext.media2;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import android.content.Context;
+import android.support.v4.media.session.MediaControllerCompat;
+import android.support.v4.media.session.MediaSessionCompat;
+import androidx.annotation.NonNull;
+import androidx.media2.common.SessionPlayer;
+import androidx.media2.common.SessionPlayer.PlayerResult;
+import androidx.media2.session.MediaSession;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.exoplayer2.ext.media2.test.R;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.concurrent.CountDownLatch;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit test for {@link MediaSessionUtil} */
+@RunWith(AndroidJUnit4.class)
+public class MediaSessionUtilTest {
+ private static final int PLAYER_STATE_CHANGE_WAIT_TIME_MS = 5_000;
+
+ @Rule public final PlayerTestRule playerTestRule = new PlayerTestRule();
+
+ @Test
+ public void getSessionCompatToken_withMediaControllerCompat_returnsValidToken() throws Exception {
+ Context context = ApplicationProvider.getApplicationContext();
+
+ SessionPlayerConnector sessionPlayerConnector = playerTestRule.getSessionPlayerConnector();
+ MediaSession.SessionCallback sessionCallback =
+ new SessionCallbackBuilder(context, sessionPlayerConnector).build();
+ TestUtils.loadResource(R.raw.audio, sessionPlayerConnector);
+ ListenableFuture prepareResult = sessionPlayerConnector.prepare();
+ CountDownLatch latch = new CountDownLatch(1);
+ sessionPlayerConnector.registerPlayerCallback(
+ playerTestRule.getExecutor(),
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlayerStateChanged(@NonNull SessionPlayer player, int playerState) {
+ if (playerState == SessionPlayer.PLAYER_STATE_PLAYING) {
+ latch.countDown();
+ }
+ }
+ });
+
+ MediaSession session2 =
+ new MediaSession.Builder(context, sessionPlayerConnector)
+ .setSessionCallback(playerTestRule.getExecutor(), sessionCallback)
+ .build();
+
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(
+ () -> {
+ try {
+ MediaSessionCompat.Token token =
+ Assertions.checkNotNull(MediaSessionUtil.getSessionCompatToken(session2));
+ MediaControllerCompat controllerCompat = new MediaControllerCompat(context, token);
+ controllerCompat.getTransportControls().play();
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ assertThat(prepareResult.get(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS).getResultCode())
+ .isEqualTo(PlayerResult.RESULT_SUCCESS);
+ assertThat(latch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue();
+ }
+}
diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaStubActivity.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaStubActivity.java
new file mode 100644
index 00000000000..23a44913891
--- /dev/null
+++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaStubActivity.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2019 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.ext.media2;
+
+import android.app.Activity;
+import android.app.KeyguardManager;
+import android.content.Context;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.WindowManager;
+import com.google.android.exoplayer2.ext.media2.test.R;
+import com.google.android.exoplayer2.util.Util;
+
+/** Stub activity to play media contents on. */
+public final class MediaStubActivity extends Activity {
+
+ private static final String TAG = "MediaStubActivity";
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.mediaplayer);
+
+ // disable enter animation.
+ overridePendingTransition(0, 0);
+
+ if (Util.SDK_INT >= 27) {
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ setTurnScreenOn(true);
+ setShowWhenLocked(true);
+ KeyguardManager keyguardManager =
+ (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
+ keyguardManager.requestDismissKeyguard(this, null);
+ } else {
+ getWindow()
+ .addFlags(
+ WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
+ | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
+ | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
+ | WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
+ }
+ }
+
+ @Override
+ public void finish() {
+ super.finish();
+
+ // disable exit animation.
+ overridePendingTransition(0, 0);
+ }
+
+ @Override
+ protected void onResume() {
+ Log.i(TAG, "onResume");
+ super.onResume();
+ }
+
+ @Override
+ protected void onPause() {
+ Log.i(TAG, "onPause");
+ super.onPause();
+ }
+
+ public SurfaceHolder getSurfaceHolder() {
+ SurfaceView surface = findViewById(R.id.surface);
+ return surface.getHolder();
+ }
+}
diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java
new file mode 100644
index 00000000000..df6963c2fc1
--- /dev/null
+++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java
@@ -0,0 +1,185 @@
+/*
+ * Copyright 2019 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.ext.media2;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.Looper;
+import androidx.annotation.Nullable;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.exoplayer2.SimpleExoPlayer;
+import com.google.android.exoplayer2.source.DefaultMediaSourceFactory;
+import com.google.android.exoplayer2.upstream.DataSource;
+import com.google.android.exoplayer2.upstream.DataSpec;
+import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
+import com.google.android.exoplayer2.upstream.TransferListener;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.rules.ExternalResource;
+
+/** Rule for tests that use {@link SessionPlayerConnector}. */
+/* package */ final class PlayerTestRule extends ExternalResource {
+
+ /** Instrumentation to attach to {@link DataSource} instances used by the player. */
+ public interface DataSourceInstrumentation {
+
+ /** Called at the start of {@link DataSource#open}. */
+ void onPreOpen(DataSpec dataSpec);
+ }
+
+ private Context context;
+ private ExecutorService executor;
+
+ private SessionPlayerConnector sessionPlayerConnector;
+ private SimpleExoPlayer exoPlayer;
+ @Nullable private DataSourceInstrumentation dataSourceInstrumentation;
+
+ @Override
+ protected void before() {
+ // Workaround limitation in androidx.media2.session:1.0.3 which session can only be instantiated
+ // on thread with prepared Looper.
+ // TODO: Remove when androidx.media2.session:1.1.0 is released without the limitation
+ // [Internal: b/146536708]
+ if (Looper.myLooper() == null) {
+ Looper.prepare();
+ }
+
+ context = ApplicationProvider.getApplicationContext();
+ executor = Executors.newFixedThreadPool(1);
+
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(
+ () -> {
+ // Initialize AudioManager on the main thread to workaround that
+ // audio focus listener is called on the thread where the AudioManager was
+ // originally initialized. [Internal: b/78617702]
+ // Without posting this, audio focus listeners wouldn't be called because the
+ // listeners would be posted to the test thread (here) where it waits until the
+ // tests are finished.
+ context.getSystemService(Context.AUDIO_SERVICE);
+
+ DataSource.Factory dataSourceFactory = new InstrumentingDataSourceFactory(context);
+ exoPlayer =
+ new SimpleExoPlayer.Builder(context)
+ .setLooper(Looper.myLooper())
+ .setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory))
+ .build();
+ sessionPlayerConnector = new SessionPlayerConnector(exoPlayer);
+ });
+ }
+
+ @Override
+ protected void after() {
+ if (sessionPlayerConnector != null) {
+ sessionPlayerConnector.close();
+ sessionPlayerConnector = null;
+ }
+ if (exoPlayer != null) {
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(
+ () -> {
+ exoPlayer.release();
+ exoPlayer = null;
+ });
+ }
+ if (executor != null) {
+ executor.shutdown();
+ executor = null;
+ }
+ }
+
+ public void setDataSourceInstrumentation(
+ @Nullable DataSourceInstrumentation dataSourceInstrumentation) {
+ this.dataSourceInstrumentation = dataSourceInstrumentation;
+ }
+
+ public ExecutorService getExecutor() {
+ return executor;
+ }
+
+ public SessionPlayerConnector getSessionPlayerConnector() {
+ return sessionPlayerConnector;
+ }
+
+ public SimpleExoPlayer getSimpleExoPlayer() {
+ return exoPlayer;
+ }
+
+ private final class InstrumentingDataSourceFactory implements DataSource.Factory {
+
+ private final DefaultDataSourceFactory defaultDataSourceFactory;
+
+ public InstrumentingDataSourceFactory(Context context) {
+ defaultDataSourceFactory = new DefaultDataSourceFactory(context);
+ }
+
+ @Override
+ public DataSource createDataSource() {
+ DataSource dataSource = defaultDataSourceFactory.createDataSource();
+ return dataSourceInstrumentation == null
+ ? dataSource
+ : new InstrumentedDataSource(dataSource, dataSourceInstrumentation);
+ }
+ }
+
+ private static final class InstrumentedDataSource implements DataSource {
+
+ private final DataSource wrappedDataSource;
+ private final DataSourceInstrumentation instrumentation;
+
+ public InstrumentedDataSource(
+ DataSource wrappedDataSource, DataSourceInstrumentation instrumentation) {
+ this.wrappedDataSource = wrappedDataSource;
+ this.instrumentation = instrumentation;
+ }
+
+ @Override
+ public void addTransferListener(TransferListener transferListener) {
+ wrappedDataSource.addTransferListener(transferListener);
+ }
+
+ @Override
+ public long open(DataSpec dataSpec) throws IOException {
+ instrumentation.onPreOpen(dataSpec);
+ return wrappedDataSource.open(dataSpec);
+ }
+
+ @Nullable
+ @Override
+ public Uri getUri() {
+ return wrappedDataSource.getUri();
+ }
+
+ @Override
+ public Map> getResponseHeaders() {
+ return wrappedDataSource.getResponseHeaders();
+ }
+
+ @Override
+ public int read(byte[] target, int offset, int length) throws IOException {
+ return wrappedDataSource.read(target, offset, length);
+ }
+
+ @Override
+ public void close() throws IOException {
+ wrappedDataSource.close();
+ }
+ }
+}
diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilderTest.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilderTest.java
new file mode 100644
index 00000000000..c578b0ba8c5
--- /dev/null
+++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilderTest.java
@@ -0,0 +1,680 @@
+/*
+ * Copyright 2019 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.ext.media2;
+
+import static com.google.android.exoplayer2.ext.media2.TestUtils.assertPlayerResultSuccess;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.TextUtils;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.media2.common.MediaItem;
+import androidx.media2.common.MediaMetadata;
+import androidx.media2.common.Rating;
+import androidx.media2.common.SessionPlayer;
+import androidx.media2.common.UriMediaItem;
+import androidx.media2.session.HeartRating;
+import androidx.media2.session.MediaController;
+import androidx.media2.session.MediaSession;
+import androidx.media2.session.SessionCommand;
+import androidx.media2.session.SessionCommandGroup;
+import androidx.media2.session.SessionResult;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.rule.ActivityTestRule;
+import com.google.android.exoplayer2.SimpleExoPlayer;
+import com.google.android.exoplayer2.ext.media2.test.R;
+import com.google.android.exoplayer2.upstream.RawResourceDataSource;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Future;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests {@link SessionCallbackBuilder}. */
+@RunWith(AndroidJUnit4.class)
+public class SessionCallbackBuilderTest {
+ @Rule
+ public final ActivityTestRule activityRule =
+ new ActivityTestRule<>(MediaStubActivity.class);
+
+ @Rule public final PlayerTestRule playerTestRule = new PlayerTestRule();
+
+ private static final String MEDIA_SESSION_ID = SessionCallbackBuilderTest.class.getSimpleName();
+ private static final long CONTROLLER_COMMAND_WAIT_TIME_MS = 3_000;
+ private static final long PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS = 10_000;
+ private static final long PLAYER_STATE_CHANGE_WAIT_TIME_MS = 5_000;
+
+ private Context context;
+ private Executor executor;
+ private SessionPlayerConnector sessionPlayerConnector;
+
+ @Before
+ public void setUp() {
+ context = ApplicationProvider.getApplicationContext();
+ executor = playerTestRule.getExecutor();
+ sessionPlayerConnector = playerTestRule.getSessionPlayerConnector();
+
+ // Sets the surface to the player for manual check.
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(
+ () -> {
+ SimpleExoPlayer exoPlayer = playerTestRule.getSimpleExoPlayer();
+ exoPlayer
+ .getVideoComponent()
+ .setVideoSurfaceHolder(activityRule.getActivity().getSurfaceHolder());
+ });
+ }
+
+ @Test
+ public void constructor() throws Exception {
+ try (MediaSession session =
+ createMediaSession(
+ sessionPlayerConnector,
+ new SessionCallbackBuilder(context, sessionPlayerConnector).build())) {
+ assertPlayerResultSuccess(sessionPlayerConnector.setMediaItem(TestUtils.createMediaItem()));
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+
+ OnConnectedListener listener =
+ (controller, allowedCommands) -> {
+ List disallowedCommandCodes =
+ Arrays.asList(
+ SessionCommand.COMMAND_CODE_SESSION_SET_RATING, // no rating callback
+ SessionCommand.COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM, // no media item provider
+ SessionCommand
+ .COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM, // no media item provider
+ SessionCommand.COMMAND_CODE_PLAYER_SET_MEDIA_ITEM, // no media item provider
+ SessionCommand.COMMAND_CODE_PLAYER_SET_PLAYLIST, // no media item provider
+ SessionCommand.COMMAND_CODE_SESSION_REWIND, // no current media item
+ SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD // no current media item
+ );
+ assertDisallowedCommands(disallowedCommandCodes, allowedCommands);
+ };
+ try (MediaController controller = createConnectedController(session, listener, null)) {
+ assertThat(controller.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED);
+ }
+ }
+ }
+
+ @Test
+ public void allowedCommand_withoutPlaylist_disallowsSkipTo() throws Exception {
+ int testRewindIncrementMs = 100;
+ int testFastForwardIncrementMs = 100;
+
+ try (MediaSession session =
+ createMediaSession(
+ sessionPlayerConnector,
+ new SessionCallbackBuilder(context, sessionPlayerConnector)
+ .setRatingCallback(
+ (mediaSession, controller, mediaId, rating) ->
+ SessionResult.RESULT_ERROR_BAD_VALUE)
+ .setRewindIncrementMs(testRewindIncrementMs)
+ .setFastForwardIncrementMs(testFastForwardIncrementMs)
+ .setMediaItemProvider(new SessionCallbackBuilder.MediaIdMediaItemProvider())
+ .build())) {
+ assertPlayerResultSuccess(sessionPlayerConnector.setMediaItem(TestUtils.createMediaItem()));
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+
+ CountDownLatch latch = new CountDownLatch(1);
+ OnConnectedListener listener =
+ (controller, allowedCommands) -> {
+ List disallowedCommandCodes =
+ Arrays.asList(
+ SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM,
+ SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM,
+ SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM);
+ assertDisallowedCommands(disallowedCommandCodes, allowedCommands);
+ latch.countDown();
+ };
+ try (MediaController controller = createConnectedController(session, listener, null)) {
+ assertThat(latch.await(CONTROLLER_COMMAND_WAIT_TIME_MS, MILLISECONDS)).isTrue();
+
+ assertSessionResultFailure(controller.skipToNextPlaylistItem());
+ assertSessionResultFailure(controller.skipToPreviousPlaylistItem());
+ assertSessionResultFailure(controller.skipToPlaylistItem(0));
+ }
+ }
+ }
+
+ @Test
+ public void allowedCommand_whenPlaylistSet_allowsSkipTo() throws Exception {
+ List testPlaylist = new ArrayList<>();
+ testPlaylist.add(TestUtils.createMediaItem(R.raw.video_desks));
+ testPlaylist.add(TestUtils.createMediaItem(R.raw.video_not_seekable));
+ int testRewindIncrementMs = 100;
+ int testFastForwardIncrementMs = 100;
+
+ try (MediaSession session =
+ createMediaSession(
+ sessionPlayerConnector,
+ new SessionCallbackBuilder(context, sessionPlayerConnector)
+ .setRatingCallback(
+ (mediaSession, controller, mediaId, rating) ->
+ SessionResult.RESULT_ERROR_BAD_VALUE)
+ .setRewindIncrementMs(testRewindIncrementMs)
+ .setFastForwardIncrementMs(testFastForwardIncrementMs)
+ .setMediaItemProvider(new SessionCallbackBuilder.MediaIdMediaItemProvider())
+ .build())) {
+
+ assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(testPlaylist, null));
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+
+ OnConnectedListener connectedListener =
+ (controller, allowedCommands) -> {
+ List allowedCommandCodes =
+ Arrays.asList(
+ SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM,
+ SessionCommand.COMMAND_CODE_PLAYER_SEEK_TO,
+ SessionCommand.COMMAND_CODE_SESSION_REWIND,
+ SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD);
+ assertAllowedCommands(allowedCommandCodes, allowedCommands);
+
+ List disallowedCommandCodes =
+ Arrays.asList(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM);
+ assertDisallowedCommands(disallowedCommandCodes, allowedCommands);
+ };
+
+ CountDownLatch allowedCommandChangedLatch = new CountDownLatch(1);
+ OnAllowedCommandsChangedListener allowedCommandChangedListener =
+ (controller, allowedCommands) -> {
+ List allowedCommandCodes =
+ Arrays.asList(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM);
+ assertAllowedCommands(allowedCommandCodes, allowedCommands);
+
+ List disallowedCommandCodes =
+ Arrays.asList(
+ SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM,
+ SessionCommand.COMMAND_CODE_PLAYER_SEEK_TO,
+ SessionCommand.COMMAND_CODE_SESSION_REWIND,
+ SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD);
+ assertDisallowedCommands(disallowedCommandCodes, allowedCommands);
+ allowedCommandChangedLatch.countDown();
+ };
+ try (MediaController controller =
+ createConnectedController(session, connectedListener, allowedCommandChangedListener)) {
+ assertPlayerResultSuccess(sessionPlayerConnector.skipToNextPlaylistItem());
+
+ assertThat(allowedCommandChangedLatch.await(CONTROLLER_COMMAND_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+
+ // Also test whether the rewind fails as expected.
+ assertSessionResultFailure(controller.rewind());
+ assertThat(sessionPlayerConnector.getCurrentPosition()).isEqualTo(0);
+ assertThat(controller.getCurrentPosition()).isEqualTo(0);
+ }
+ }
+ }
+
+ @Test
+ public void allowedCommand_afterCurrentMediaItemPrepared_notifiesSeekToAvailable()
+ throws Exception {
+ List testPlaylist = new ArrayList<>();
+ testPlaylist.add(TestUtils.createMediaItem(R.raw.video_desks));
+ UriMediaItem secondPlaylistItem = TestUtils.createMediaItem(R.raw.video_big_buck_bunny);
+ testPlaylist.add(secondPlaylistItem);
+
+ CountDownLatch readAllowedLatch = new CountDownLatch(1);
+ playerTestRule.setDataSourceInstrumentation(
+ dataSpec -> {
+ if (dataSpec.uri.equals(secondPlaylistItem.getUri())) {
+ try {
+ assertThat(readAllowedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+ } catch (Exception e) {
+ assertWithMessage("Unexpected exception %s", e).fail();
+ }
+ }
+ });
+
+ try (MediaSession session =
+ createMediaSession(
+ sessionPlayerConnector,
+ new SessionCallbackBuilder(context, sessionPlayerConnector).build())) {
+
+ assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(testPlaylist, null));
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+
+ CountDownLatch seekToAllowedForSecondMediaItem = new CountDownLatch(1);
+ OnAllowedCommandsChangedListener allowedCommandsChangedListener =
+ (controller, allowedCommands) -> {
+ if (allowedCommands.hasCommand(SessionCommand.COMMAND_CODE_PLAYER_SEEK_TO)
+ && controller.getCurrentMediaItemIndex() == 1) {
+ seekToAllowedForSecondMediaItem.countDown();
+ }
+ };
+ try (MediaController controller =
+ createConnectedController(
+ session, /* onConnectedListener= */ null, allowedCommandsChangedListener)) {
+ assertPlayerResultSuccess(sessionPlayerConnector.skipToNextPlaylistItem());
+
+ readAllowedLatch.countDown();
+ assertThat(
+ seekToAllowedForSecondMediaItem.await(
+ CONTROLLER_COMMAND_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+ }
+ }
+ }
+
+ @Test
+ public void setRatingCallback_withRatingCallback_receivesRatingCallback() throws Exception {
+ String testMediaId = "testRating";
+ Rating testRating = new HeartRating(true);
+ CountDownLatch latch = new CountDownLatch(1);
+
+ SessionCallbackBuilder.RatingCallback ratingCallback =
+ (session, controller, mediaId, rating) -> {
+ assertThat(mediaId).isEqualTo(testMediaId);
+ assertThat(rating).isEqualTo(testRating);
+ latch.countDown();
+ return SessionResult.RESULT_SUCCESS;
+ };
+
+ try (MediaSession session =
+ createMediaSession(
+ sessionPlayerConnector,
+ new SessionCallbackBuilder(context, sessionPlayerConnector)
+ .setRatingCallback(ratingCallback)
+ .build())) {
+ try (MediaController controller = createConnectedController(session)) {
+ assertSessionResultSuccess(
+ controller.setRating(testMediaId, testRating), CONTROLLER_COMMAND_WAIT_TIME_MS);
+ assertThat(latch.await(0, MILLISECONDS)).isTrue();
+ }
+ }
+ }
+
+ @Test
+ public void setCustomCommandProvider_withCustomCommandProvider_receivesCustomCommand()
+ throws Exception {
+ SessionCommand testCommand = new SessionCommand("exo.ext.media2.COMMAND", null);
+ CountDownLatch latch = new CountDownLatch(1);
+
+ SessionCallbackBuilder.CustomCommandProvider provider =
+ new SessionCallbackBuilder.CustomCommandProvider() {
+ @Override
+ public SessionResult onCustomCommand(
+ MediaSession session,
+ MediaSession.ControllerInfo controllerInfo,
+ SessionCommand customCommand,
+ @Nullable Bundle args) {
+ assertThat(customCommand.getCustomAction()).isEqualTo(testCommand.getCustomAction());
+ assertThat(args).isNull();
+ latch.countDown();
+ return new SessionResult(SessionResult.RESULT_SUCCESS, null);
+ }
+
+ @Override
+ public SessionCommandGroup getCustomCommands(
+ MediaSession session, MediaSession.ControllerInfo controllerInfo) {
+ return new SessionCommandGroup.Builder().addCommand(testCommand).build();
+ }
+ };
+
+ try (MediaSession session =
+ createMediaSession(
+ sessionPlayerConnector,
+ new SessionCallbackBuilder(context, sessionPlayerConnector)
+ .setCustomCommandProvider(provider)
+ .build())) {
+ OnAllowedCommandsChangedListener listener =
+ (controller, allowedCommands) -> {
+ boolean foundCustomCommand = false;
+ for (SessionCommand command : allowedCommands.getCommands()) {
+ if (TextUtils.equals(testCommand.getCustomAction(), command.getCustomAction())) {
+ foundCustomCommand = true;
+ break;
+ }
+ }
+ assertThat(foundCustomCommand).isTrue();
+ };
+ try (MediaController controller = createConnectedController(session, null, listener)) {
+ assertSessionResultSuccess(
+ controller.sendCustomCommand(testCommand, null), CONTROLLER_COMMAND_WAIT_TIME_MS);
+ assertThat(latch.await(0, MILLISECONDS)).isTrue();
+ }
+ }
+ }
+
+ @LargeTest
+ @Test
+ public void setRewindIncrementMs_withPositiveRewindIncrement_rewinds() throws Exception {
+ int testResId = R.raw.video_big_buck_bunny;
+ int testDuration = 10_000;
+ int tolerance = 100;
+ int testSeekPosition = 2_000;
+ int testRewindIncrementMs = 500;
+
+ TestUtils.loadResource(testResId, sessionPlayerConnector);
+
+ // seekTo() sometimes takes couple of seconds. Disable default timeout behavior.
+ try (MediaSession session =
+ createMediaSession(
+ sessionPlayerConnector,
+ new SessionCallbackBuilder(context, sessionPlayerConnector)
+ .setRewindIncrementMs(testRewindIncrementMs)
+ .setSeekTimeoutMs(0)
+ .build())) {
+ try (MediaController controller = createConnectedController(session)) {
+ // Prepare first to ensure that seek() works.
+ assertSessionResultSuccess(
+ controller.prepare(), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS);
+
+ assertThat((float) sessionPlayerConnector.getDuration())
+ .isWithin(tolerance)
+ .of(testDuration);
+ assertSessionResultSuccess(
+ controller.seekTo(testSeekPosition), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS);
+ assertThat((float) sessionPlayerConnector.getCurrentPosition())
+ .isWithin(tolerance)
+ .of(testSeekPosition);
+
+ // Test rewind
+ assertSessionResultSuccess(
+ controller.rewind(), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS);
+ assertThat((float) sessionPlayerConnector.getCurrentPosition())
+ .isWithin(tolerance)
+ .of(testSeekPosition - testRewindIncrementMs);
+ }
+ }
+ }
+
+ @LargeTest
+ @Test
+ public void setFastForwardIncrementMs_withPositiveFastForwardIncrement_fastsForward()
+ throws Exception {
+ int testResId = R.raw.video_big_buck_bunny;
+ int testDuration = 10_000;
+ int tolerance = 100;
+ int testSeekPosition = 2_000;
+ int testFastForwardIncrementMs = 300;
+
+ TestUtils.loadResource(testResId, sessionPlayerConnector);
+
+ // seekTo() sometimes takes couple of seconds. Disable default timeout behavior.
+ try (MediaSession session =
+ createMediaSession(
+ sessionPlayerConnector,
+ new SessionCallbackBuilder(context, sessionPlayerConnector)
+ .setFastForwardIncrementMs(testFastForwardIncrementMs)
+ .setSeekTimeoutMs(0)
+ .build())) {
+ try (MediaController controller = createConnectedController(session)) {
+ // Prepare first to ensure that seek() works.
+ assertSessionResultSuccess(
+ controller.prepare(), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS);
+
+ assertThat((float) sessionPlayerConnector.getDuration())
+ .isWithin(tolerance)
+ .of(testDuration);
+ assertSessionResultSuccess(
+ controller.seekTo(testSeekPosition), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS);
+ assertThat((float) sessionPlayerConnector.getCurrentPosition())
+ .isWithin(tolerance)
+ .of(testSeekPosition);
+
+ // Test fast-forward
+ assertSessionResultSuccess(
+ controller.fastForward(), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS);
+ assertThat((float) sessionPlayerConnector.getCurrentPosition())
+ .isWithin(tolerance)
+ .of(testSeekPosition + testFastForwardIncrementMs);
+ }
+ }
+ }
+
+ @Test
+ public void setMediaItemProvider_withMediaItemProvider_receivesOnCreateMediaItem()
+ throws Exception {
+ Uri testMediaUri = RawResourceDataSource.buildRawResourceUri(R.raw.audio);
+
+ CountDownLatch providerLatch = new CountDownLatch(1);
+ SessionCallbackBuilder.MediaIdMediaItemProvider mediaIdMediaItemProvider =
+ new SessionCallbackBuilder.MediaIdMediaItemProvider();
+ SessionCallbackBuilder.MediaItemProvider provider =
+ (session, controllerInfo, mediaId) -> {
+ assertThat(mediaId).isEqualTo(testMediaUri.toString());
+ providerLatch.countDown();
+ return mediaIdMediaItemProvider.onCreateMediaItem(session, controllerInfo, mediaId);
+ };
+
+ CountDownLatch currentMediaItemChangedLatch = new CountDownLatch(1);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onCurrentMediaItemChanged(
+ @NonNull SessionPlayer player, @NonNull MediaItem item) {
+ MediaMetadata metadata = item.getMetadata();
+ assertThat(metadata.getString(MediaMetadata.METADATA_KEY_MEDIA_ID))
+ .isEqualTo(testMediaUri.toString());
+ currentMediaItemChangedLatch.countDown();
+ }
+ });
+
+ try (MediaSession session =
+ createMediaSession(
+ sessionPlayerConnector,
+ new SessionCallbackBuilder(context, sessionPlayerConnector)
+ .setMediaItemProvider(provider)
+ .build())) {
+ try (MediaController controller = createConnectedController(session)) {
+ assertSessionResultSuccess(
+ controller.setMediaItem(testMediaUri.toString()),
+ PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS);
+ assertThat(providerLatch.await(0, MILLISECONDS)).isTrue();
+ assertThat(
+ currentMediaItemChangedLatch.await(CONTROLLER_COMMAND_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+ }
+ }
+ }
+
+ @Test
+ public void setSkipCallback_withSkipBackward_receivesOnSkipBackward() throws Exception {
+ CountDownLatch skipBackwardCalledLatch = new CountDownLatch(1);
+ SessionCallbackBuilder.SkipCallback skipCallback =
+ new SessionCallbackBuilder.SkipCallback() {
+ @Override
+ public int onSkipBackward(
+ MediaSession session, MediaSession.ControllerInfo controllerInfo) {
+ skipBackwardCalledLatch.countDown();
+ return SessionResult.RESULT_SUCCESS;
+ }
+
+ @Override
+ public int onSkipForward(
+ MediaSession session, MediaSession.ControllerInfo controllerInfo) {
+ return SessionResult.RESULT_ERROR_NOT_SUPPORTED;
+ }
+ };
+ try (MediaSession session =
+ createMediaSession(
+ sessionPlayerConnector,
+ new SessionCallbackBuilder(context, sessionPlayerConnector)
+ .setSkipCallback(skipCallback)
+ .build())) {
+ try (MediaController controller = createConnectedController(session)) {
+ assertSessionResultSuccess(controller.skipBackward(), CONTROLLER_COMMAND_WAIT_TIME_MS);
+ assertThat(skipBackwardCalledLatch.await(0, MILLISECONDS)).isTrue();
+ }
+ }
+ }
+
+ @Test
+ public void setSkipCallback_withSkipForward_receivesOnSkipForward() throws Exception {
+ CountDownLatch skipForwardCalledLatch = new CountDownLatch(1);
+ SessionCallbackBuilder.SkipCallback skipCallback =
+ new SessionCallbackBuilder.SkipCallback() {
+ @Override
+ public int onSkipBackward(
+ MediaSession session, MediaSession.ControllerInfo controllerInfo) {
+ return SessionResult.RESULT_ERROR_NOT_SUPPORTED;
+ }
+
+ @Override
+ public int onSkipForward(
+ MediaSession session, MediaSession.ControllerInfo controllerInfo) {
+ skipForwardCalledLatch.countDown();
+ return SessionResult.RESULT_SUCCESS;
+ }
+ };
+ try (MediaSession session =
+ createMediaSession(
+ sessionPlayerConnector,
+ new SessionCallbackBuilder(context, sessionPlayerConnector)
+ .setSkipCallback(skipCallback)
+ .build())) {
+ try (MediaController controller = createConnectedController(session)) {
+ assertSessionResultSuccess(controller.skipForward(), CONTROLLER_COMMAND_WAIT_TIME_MS);
+ assertThat(skipForwardCalledLatch.await(0, MILLISECONDS)).isTrue();
+ }
+ }
+ }
+
+ @Test
+ public void setPostConnectCallback_afterConnect_receivesOnPostConnect() throws Exception {
+ CountDownLatch postConnectLatch = new CountDownLatch(1);
+ SessionCallbackBuilder.PostConnectCallback postConnectCallback =
+ (session, controllerInfo) -> postConnectLatch.countDown();
+ try (MediaSession session =
+ createMediaSession(
+ sessionPlayerConnector,
+ new SessionCallbackBuilder(context, sessionPlayerConnector)
+ .setPostConnectCallback(postConnectCallback)
+ .build())) {
+ try (MediaController controller = createConnectedController(session)) {
+ assertThat(postConnectLatch.await(CONTROLLER_COMMAND_WAIT_TIME_MS, MILLISECONDS)).isTrue();
+ }
+ }
+ }
+
+ @Test
+ public void setDisconnectedCallback_afterDisconnect_receivesOnDisconnected() throws Exception {
+ CountDownLatch disconnectedLatch = new CountDownLatch(1);
+ SessionCallbackBuilder.DisconnectedCallback disconnectCallback =
+ (session, controllerInfo) -> disconnectedLatch.countDown();
+ try (MediaSession session =
+ createMediaSession(
+ sessionPlayerConnector,
+ new SessionCallbackBuilder(context, sessionPlayerConnector)
+ .setDisconnectedCallback(disconnectCallback)
+ .build())) {
+ try (MediaController controller = createConnectedController(session)) {}
+ assertThat(disconnectedLatch.await(CONTROLLER_COMMAND_WAIT_TIME_MS, MILLISECONDS)).isTrue();
+ }
+ }
+
+ private MediaSession createMediaSession(
+ SessionPlayer sessionPlayer, MediaSession.SessionCallback callback) {
+ return new MediaSession.Builder(context, sessionPlayer)
+ .setSessionCallback(executor, callback)
+ .setId(MEDIA_SESSION_ID)
+ .build();
+ }
+
+ private MediaController createConnectedController(MediaSession session) throws Exception {
+ return createConnectedController(session, null, null);
+ }
+
+ private MediaController createConnectedController(
+ MediaSession session,
+ OnConnectedListener onConnectedListener,
+ OnAllowedCommandsChangedListener onAllowedCommandsChangedListener)
+ throws Exception {
+ CountDownLatch latch = new CountDownLatch(1);
+ MediaController.ControllerCallback callback =
+ new MediaController.ControllerCallback() {
+ @Override
+ public void onAllowedCommandsChanged(
+ @NonNull MediaController controller, @NonNull SessionCommandGroup commands) {
+ if (onAllowedCommandsChangedListener != null) {
+ onAllowedCommandsChangedListener.onAllowedCommandsChanged(controller, commands);
+ }
+ }
+
+ @Override
+ public void onConnected(
+ @NonNull MediaController controller, @NonNull SessionCommandGroup allowedCommands) {
+ if (onConnectedListener != null) {
+ onConnectedListener.onConnected(controller, allowedCommands);
+ }
+ latch.countDown();
+ }
+ };
+ MediaController controller =
+ new MediaController.Builder(context)
+ .setSessionToken(session.getToken())
+ .setControllerCallback(ContextCompat.getMainExecutor(context), callback)
+ .build();
+ latch.await();
+ return controller;
+ }
+
+ private static void assertSessionResultSuccess(Future future) throws Exception {
+ assertSessionResultSuccess(future, CONTROLLER_COMMAND_WAIT_TIME_MS);
+ }
+
+ private static void assertSessionResultSuccess(Future future, long timeoutMs)
+ throws Exception {
+ SessionResult result = future.get(timeoutMs, MILLISECONDS);
+ assertThat(result.getResultCode()).isEqualTo(SessionResult.RESULT_SUCCESS);
+ }
+
+ private static void assertSessionResultFailure(Future future) throws Exception {
+ SessionResult result = future.get(PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS, MILLISECONDS);
+ assertThat(result.getResultCode()).isNotEqualTo(SessionResult.RESULT_SUCCESS);
+ }
+
+ private static void assertAllowedCommands(
+ List expectedAllowedCommandsCode, SessionCommandGroup allowedCommands) {
+ for (int commandCode : expectedAllowedCommandsCode) {
+ assertWithMessage("Command should be allowed, code=" + commandCode)
+ .that(allowedCommands.hasCommand(commandCode))
+ .isTrue();
+ }
+ }
+
+ private static void assertDisallowedCommands(
+ List expectedDisallowedCommandsCode, SessionCommandGroup allowedCommands) {
+ for (int commandCode : expectedDisallowedCommandsCode) {
+ assertWithMessage("Command shouldn't be allowed, code=" + commandCode)
+ .that(allowedCommands.hasCommand(commandCode))
+ .isFalse();
+ }
+ }
+
+ private interface OnAllowedCommandsChangedListener {
+ void onAllowedCommandsChanged(MediaController controller, SessionCommandGroup allowedCommands);
+ }
+
+ private interface OnConnectedListener {
+ void onConnected(MediaController controller, SessionCommandGroup allowedCommands);
+ }
+}
diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnectorTest.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnectorTest.java
new file mode 100644
index 00000000000..b80cbe5a5fa
--- /dev/null
+++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnectorTest.java
@@ -0,0 +1,1301 @@
+/*
+ * Copyright 2019 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.ext.media2;
+
+import static androidx.media2.common.SessionPlayer.PLAYER_STATE_PAUSED;
+import static androidx.media2.common.SessionPlayer.PLAYER_STATE_PLAYING;
+import static androidx.media2.common.SessionPlayer.PlayerResult.RESULT_INFO_SKIPPED;
+import static androidx.media2.common.SessionPlayer.PlayerResult.RESULT_SUCCESS;
+import static com.google.android.exoplayer2.ext.media2.TestUtils.assertPlayerResult;
+import static com.google.android.exoplayer2.ext.media2.TestUtils.assertPlayerResultSuccess;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import android.content.Context;
+import android.media.AudioManager;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import android.os.Looper;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.util.ObjectsCompat;
+import androidx.media.AudioAttributesCompat;
+import androidx.media2.common.MediaItem;
+import androidx.media2.common.MediaMetadata;
+import androidx.media2.common.SessionPlayer;
+import androidx.media2.common.SessionPlayer.PlayerResult;
+import androidx.media2.common.UriMediaItem;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import androidx.test.filters.MediumTest;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.rule.ActivityTestRule;
+import com.google.android.exoplayer2.ControlDispatcher;
+import com.google.android.exoplayer2.DefaultControlDispatcher;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.SimpleExoPlayer;
+import com.google.android.exoplayer2.ext.media2.test.R;
+import com.google.android.exoplayer2.upstream.RawResourceDataSource;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests {@link SessionPlayerConnector}. */
+@SuppressWarnings("FutureReturnValueIgnored")
+@RunWith(AndroidJUnit4.class)
+public class SessionPlayerConnectorTest {
+ @Rule
+ public final ActivityTestRule activityRule =
+ new ActivityTestRule<>(MediaStubActivity.class);
+
+ @Rule public final PlayerTestRule playerTestRule = new PlayerTestRule();
+
+ private static final long PLAYLIST_CHANGE_WAIT_TIME_MS = 1_000;
+ private static final long PLAYER_STATE_CHANGE_WAIT_TIME_MS = 5_000;
+ private static final long PLAYBACK_COMPLETED_WAIT_TIME_MS = 20_000;
+ private static final float FLOAT_TOLERANCE = .0001f;
+
+ private Context context;
+ private Executor executor;
+ private SessionPlayerConnector sessionPlayerConnector;
+
+ @Before
+ public void setUp() {
+ context = ApplicationProvider.getApplicationContext();
+ executor = playerTestRule.getExecutor();
+ sessionPlayerConnector = playerTestRule.getSessionPlayerConnector();
+
+ // Sets the surface to the player for manual check.
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(
+ () -> {
+ SimpleExoPlayer exoPlayer = playerTestRule.getSimpleExoPlayer();
+ exoPlayer
+ .getVideoComponent()
+ .setVideoSurfaceHolder(activityRule.getActivity().getSurfaceHolder());
+ });
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void play_onceWithAudioResource_changesPlayerStateToPlaying() throws Exception {
+ TestUtils.loadResource(R.raw.audio, sessionPlayerConnector);
+
+ AudioAttributesCompat attributes =
+ new AudioAttributesCompat.Builder().setLegacyStreamType(AudioManager.STREAM_MUSIC).build();
+ sessionPlayerConnector.setAudioAttributes(attributes);
+
+ CountDownLatch onPlayingLatch = new CountDownLatch(1);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlayerStateChanged(@NonNull SessionPlayer player, int playerState) {
+ if (playerState == PLAYER_STATE_PLAYING) {
+ onPlayingLatch.countDown();
+ }
+ }
+ });
+
+ sessionPlayerConnector.prepare();
+ sessionPlayerConnector.play();
+ assertThat(onPlayingLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue();
+ }
+
+ @Test
+ @MediumTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void play_onceWithAudioResourceOnMainThread_notifiesOnPlayerStateChanged()
+ throws Exception {
+ CountDownLatch onPlayerStatePlayingLatch = new CountDownLatch(1);
+
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(
+ () -> {
+ try {
+ TestUtils.loadResource(R.raw.audio, sessionPlayerConnector);
+ } catch (Exception e) {
+ assertWithMessage(e.getMessage()).fail();
+ }
+ AudioAttributesCompat attributes =
+ new AudioAttributesCompat.Builder()
+ .setLegacyStreamType(AudioManager.STREAM_MUSIC)
+ .build();
+ sessionPlayerConnector.setAudioAttributes(attributes);
+
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlayerStateChanged(
+ @NonNull SessionPlayer player, int playerState) {
+ if (playerState == PLAYER_STATE_PLAYING) {
+ onPlayerStatePlayingLatch.countDown();
+ }
+ }
+ });
+ sessionPlayerConnector.prepare();
+ sessionPlayerConnector.play();
+ });
+ assertThat(onPlayerStatePlayingLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void play_withCustomControlDispatcher_isSkipped() throws Exception {
+ if (Looper.myLooper() == null) {
+ Looper.prepare();
+ }
+
+ ControlDispatcher controlDispatcher =
+ new DefaultControlDispatcher() {
+ @Override
+ public boolean dispatchSetPlayWhenReady(Player player, boolean playWhenReady) {
+ return false;
+ }
+ };
+ SimpleExoPlayer simpleExoPlayer = null;
+ SessionPlayerConnector playerConnector = null;
+ try {
+ simpleExoPlayer =
+ new SimpleExoPlayer.Builder(context)
+ .setLooper(Looper.myLooper())
+ .build();
+ playerConnector =
+ new SessionPlayerConnector(simpleExoPlayer, new DefaultMediaItemConverter());
+ playerConnector.setControlDispatcher(controlDispatcher);
+ assertPlayerResult(playerConnector.play(), RESULT_INFO_SKIPPED);
+ } finally {
+ if (playerConnector != null) {
+ playerConnector.close();
+ }
+ if (simpleExoPlayer != null) {
+ simpleExoPlayer.release();
+ }
+ }
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void setMediaItem_withAudioResource_notifiesOnPlaybackCompleted() throws Exception {
+ TestUtils.loadResource(R.raw.audio, sessionPlayerConnector);
+
+ CountDownLatch onPlaybackCompletedLatch = new CountDownLatch(1);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlaybackCompleted(@NonNull SessionPlayer player) {
+ onPlaybackCompletedLatch.countDown();
+ }
+ });
+ sessionPlayerConnector.prepare();
+ sessionPlayerConnector.play();
+
+ // waiting to complete
+ assertThat(onPlaybackCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+ assertThat(sessionPlayerConnector.getPlayerState())
+ .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void setMediaItem_withVideoResource_notifiesOnPlaybackCompleted() throws Exception {
+ TestUtils.loadResource(R.raw.video_desks, sessionPlayerConnector);
+ CountDownLatch onPlaybackCompletedLatch = new CountDownLatch(1);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlaybackCompleted(@NonNull SessionPlayer player) {
+ onPlaybackCompletedLatch.countDown();
+ }
+ });
+ sessionPlayerConnector.prepare();
+ sessionPlayerConnector.play();
+
+ // waiting to complete
+ assertThat(onPlaybackCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+ assertThat(sessionPlayerConnector.getPlayerState())
+ .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED);
+ }
+
+ @Test
+ @SmallTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void getDuration_whenIdleState_returnsUnknownTime() {
+ assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE);
+ assertThat(sessionPlayerConnector.getDuration()).isEqualTo(SessionPlayer.UNKNOWN_TIME);
+ }
+
+ @Test
+ @MediumTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void getDuration_afterPrepared_returnsDuration() throws Exception {
+ TestUtils.loadResource(R.raw.video_desks, sessionPlayerConnector);
+
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+ assertThat(sessionPlayerConnector.getPlayerState())
+ .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED);
+ assertThat((float) sessionPlayerConnector.getDuration()).isWithin(50).of(5130);
+ }
+
+ @Test
+ @SmallTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void getCurrentPosition_whenIdleState_returnsDefaultPosition() {
+ assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE);
+ assertThat(sessionPlayerConnector.getCurrentPosition()).isEqualTo(0);
+ }
+
+ @Test
+ @SmallTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void getBufferedPosition_whenIdleState_returnsDefaultPosition() {
+ assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE);
+ assertThat(sessionPlayerConnector.getBufferedPosition()).isEqualTo(0);
+ }
+
+ @Test
+ @SmallTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void getPlaybackSpeed_whenIdleState_throwsNoException() {
+ assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE);
+ try {
+ sessionPlayerConnector.getPlaybackSpeed();
+ } catch (Exception e) {
+ assertWithMessage(e.getMessage()).fail();
+ }
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void play_withDataSourceCallback_changesPlayerState() throws Exception {
+ sessionPlayerConnector.setMediaItem(TestUtils.createMediaItem(R.raw.video_big_buck_bunny));
+ sessionPlayerConnector.prepare();
+ assertPlayerResultSuccess(sessionPlayerConnector.play());
+ assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(PLAYER_STATE_PLAYING);
+
+ // Test pause and restart.
+ assertPlayerResultSuccess(sessionPlayerConnector.pause());
+ assertThat(sessionPlayerConnector.getPlayerState()).isNotEqualTo(PLAYER_STATE_PLAYING);
+
+ assertPlayerResultSuccess(sessionPlayerConnector.play());
+ assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(PLAYER_STATE_PLAYING);
+ }
+
+ @Test
+ @SmallTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void setMediaItem_withNullMediaItem_throwsException() {
+ try {
+ sessionPlayerConnector.setMediaItem(null);
+ assertWithMessage("Null media item should be rejected").fail();
+ } catch (NullPointerException e) {
+ // Expected exception
+ }
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void setPlaybackSpeed_afterPlayback_remainsSame() throws Exception {
+ int resId1 = R.raw.video_big_buck_bunny;
+ MediaItem mediaItem1 =
+ new UriMediaItem.Builder(RawResourceDataSource.buildRawResourceUri(resId1))
+ .setStartPosition(6_000)
+ .setEndPosition(7_000)
+ .build();
+
+ MediaItem mediaItem2 =
+ new UriMediaItem.Builder(RawResourceDataSource.buildRawResourceUri(resId1))
+ .setStartPosition(3_000)
+ .setEndPosition(4_000)
+ .build();
+
+ List items = new ArrayList<>();
+ items.add(mediaItem1);
+ items.add(mediaItem2);
+ sessionPlayerConnector.setPlaylist(items, null);
+
+ CountDownLatch onPlaybackCompletedLatch = new CountDownLatch(1);
+ SessionPlayer.PlayerCallback callback =
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlaybackCompleted(@NonNull SessionPlayer player) {
+ onPlaybackCompletedLatch.countDown();
+ }
+ };
+ sessionPlayerConnector.registerPlayerCallback(executor, callback);
+
+ sessionPlayerConnector.prepare().get();
+
+ sessionPlayerConnector.setPlaybackSpeed(2.0f);
+ sessionPlayerConnector.play();
+
+ assertThat(onPlaybackCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+ assertThat(sessionPlayerConnector.getCurrentMediaItem()).isEqualTo(mediaItem2);
+ assertThat(sessionPlayerConnector.getPlaybackSpeed()).isWithin(0.001f).of(2.0f);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void seekTo_withSeriesOfSeek_succeeds() throws Exception {
+ TestUtils.loadResource(R.raw.video_big_buck_bunny, sessionPlayerConnector);
+
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+
+ List testSeekPositions = Arrays.asList(3000L, 2000L, 1000L);
+ for (long testSeekPosition : testSeekPositions) {
+ assertPlayerResultSuccess(sessionPlayerConnector.seekTo(testSeekPosition));
+ assertThat(sessionPlayerConnector.getCurrentPosition()).isEqualTo(testSeekPosition);
+ }
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void seekTo_skipsUnnecessarySeek() throws Exception {
+ CountDownLatch readAllowedLatch = new CountDownLatch(1);
+ playerTestRule.setDataSourceInstrumentation(
+ dataSpec -> {
+ try {
+ assertThat(readAllowedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+ } catch (Exception e) {
+ assertWithMessage("Unexpected exception %s", e).fail();
+ }
+ });
+
+ sessionPlayerConnector.setMediaItem(TestUtils.createMediaItem(R.raw.video_big_buck_bunny));
+
+ // prepare() will be pending until readAllowed is countDowned.
+ sessionPlayerConnector.prepare();
+
+ CopyOnWriteArrayList positionChanges = new CopyOnWriteArrayList<>();
+ long testIntermediateSeekToPosition1 = 3000;
+ long testIntermediateSeekToPosition2 = 2000;
+ long testFinalSeekToPosition = 1000;
+ CountDownLatch onSeekCompletedLatch = new CountDownLatch(1);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onSeekCompleted(@NonNull SessionPlayer player, long position) {
+ // Do not assert here, because onSeekCompleted() can be called after the player is
+ // closed.
+ positionChanges.add(position);
+ if (position == testFinalSeekToPosition) {
+ onSeekCompletedLatch.countDown();
+ }
+ }
+ });
+
+ ListenableFuture seekFuture1 =
+ sessionPlayerConnector.seekTo(testIntermediateSeekToPosition1);
+ ListenableFuture seekFuture2 =
+ sessionPlayerConnector.seekTo(testIntermediateSeekToPosition2);
+ ListenableFuture seekFuture3 =
+ sessionPlayerConnector.seekTo(testFinalSeekToPosition);
+
+ readAllowedLatch.countDown();
+
+ assertThat(seekFuture1.get().getResultCode()).isEqualTo(RESULT_INFO_SKIPPED);
+ assertThat(seekFuture2.get().getResultCode()).isEqualTo(RESULT_INFO_SKIPPED);
+ assertThat(seekFuture3.get().getResultCode()).isEqualTo(RESULT_SUCCESS);
+ assertThat(onSeekCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS)).isTrue();
+ assertThat(positionChanges)
+ .containsNoneOf(testIntermediateSeekToPosition1, testIntermediateSeekToPosition2);
+ assertThat(positionChanges).contains(testFinalSeekToPosition);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void seekTo_whenUnderlyingPlayerAlsoSeeks_throwsNoException() throws Exception {
+ TestUtils.loadResource(R.raw.video_big_buck_bunny, sessionPlayerConnector);
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+ SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer();
+
+ List> futures = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ futures.add(sessionPlayerConnector.seekTo(4123));
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(() -> simpleExoPlayer.seekTo(1243));
+ }
+
+ for (ListenableFuture future : futures) {
+ assertThat(future.get().getResultCode())
+ .isAnyOf(PlayerResult.RESULT_INFO_SKIPPED, PlayerResult.RESULT_SUCCESS);
+ }
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void seekTo_byUnderlyingPlayer_notifiesOnSeekCompleted() throws Exception {
+ TestUtils.loadResource(R.raw.video_big_buck_bunny, sessionPlayerConnector);
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+ SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer();
+ long testSeekPosition = 1023;
+ AtomicLong seekPosition = new AtomicLong();
+ CountDownLatch onSeekCompletedLatch = new CountDownLatch(1);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onSeekCompleted(@NonNull SessionPlayer player, long position) {
+ // Do not assert here, because onSeekCompleted() can be called after the player is
+ // closed.
+ seekPosition.set(position);
+ onSeekCompletedLatch.countDown();
+ }
+ });
+
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(() -> simpleExoPlayer.seekTo(testSeekPosition));
+ assertThat(onSeekCompletedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue();
+ assertThat(seekPosition.get()).isEqualTo(testSeekPosition);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void getPlayerState_withCallingPrepareAndPlayAndPause_reflectsPlayerState()
+ throws Throwable {
+ TestUtils.loadResource(R.raw.video_desks, sessionPlayerConnector);
+ assertThat(sessionPlayerConnector.getBufferingState())
+ .isEqualTo(SessionPlayer.BUFFERING_STATE_UNKNOWN);
+ assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE);
+
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+
+ assertThat(sessionPlayerConnector.getBufferingState())
+ .isAnyOf(
+ SessionPlayer.BUFFERING_STATE_BUFFERING_AND_PLAYABLE,
+ SessionPlayer.BUFFERING_STATE_COMPLETE);
+ assertThat(sessionPlayerConnector.getPlayerState())
+ .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED);
+
+ assertPlayerResultSuccess(sessionPlayerConnector.play());
+
+ assertThat(sessionPlayerConnector.getBufferingState())
+ .isAnyOf(
+ SessionPlayer.BUFFERING_STATE_BUFFERING_AND_PLAYABLE,
+ SessionPlayer.BUFFERING_STATE_COMPLETE);
+ assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(PLAYER_STATE_PLAYING);
+
+ assertPlayerResultSuccess(sessionPlayerConnector.pause());
+
+ assertThat(sessionPlayerConnector.getBufferingState())
+ .isAnyOf(
+ SessionPlayer.BUFFERING_STATE_BUFFERING_AND_PLAYABLE,
+ SessionPlayer.BUFFERING_STATE_COMPLETE);
+ assertThat(sessionPlayerConnector.getPlayerState())
+ .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = VERSION_CODES.KITKAT)
+ public void prepare_twice_finishes() throws Exception {
+ TestUtils.loadResource(R.raw.audio, sessionPlayerConnector);
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+ assertPlayerResult(sessionPlayerConnector.prepare(), RESULT_INFO_SKIPPED);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void prepare_notifiesOnPlayerStateChanged() throws Throwable {
+ TestUtils.loadResource(R.raw.video_big_buck_bunny, sessionPlayerConnector);
+
+ CountDownLatch onPlayerStatePaused = new CountDownLatch(1);
+ SessionPlayer.PlayerCallback callback =
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlayerStateChanged(@NonNull SessionPlayer player, int state) {
+ if (state == SessionPlayer.PLAYER_STATE_PAUSED) {
+ onPlayerStatePaused.countDown();
+ }
+ }
+ };
+ sessionPlayerConnector.registerPlayerCallback(executor, callback);
+
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+ assertThat(onPlayerStatePaused.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue();
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void prepare_notifiesBufferingCompletedOnce() throws Throwable {
+ TestUtils.loadResource(R.raw.video_big_buck_bunny, sessionPlayerConnector);
+
+ CountDownLatch onBufferingCompletedLatch = new CountDownLatch(2);
+ CopyOnWriteArrayList bufferingStateChanges = new CopyOnWriteArrayList<>();
+ SessionPlayer.PlayerCallback callback =
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onBufferingStateChanged(
+ @NonNull SessionPlayer player, MediaItem item, int buffState) {
+ bufferingStateChanges.add(buffState);
+ if (buffState == SessionPlayer.BUFFERING_STATE_COMPLETE) {
+ onBufferingCompletedLatch.countDown();
+ }
+ }
+ };
+ sessionPlayerConnector.registerPlayerCallback(executor, callback);
+
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+ assertWithMessage(
+ "Expected BUFFERING_STATE_COMPLETE only once. Full changes are %s",
+ bufferingStateChanges)
+ .that(onBufferingCompletedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS))
+ .isFalse();
+ assertThat(bufferingStateChanges).isNotEmpty();
+ int lastIndex = bufferingStateChanges.size() - 1;
+ assertWithMessage(
+ "Didn't end with BUFFERING_STATE_COMPLETE. Full changes are %s", bufferingStateChanges)
+ .that(bufferingStateChanges.get(lastIndex))
+ .isEqualTo(SessionPlayer.BUFFERING_STATE_COMPLETE);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void seekTo_whenPrepared_notifiesOnSeekCompleted() throws Throwable {
+ long mp4DurationMs = 8_484L;
+ TestUtils.loadResource(R.raw.video_big_buck_bunny, sessionPlayerConnector);
+
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+
+ CountDownLatch onSeekCompletedLatch = new CountDownLatch(1);
+ SessionPlayer.PlayerCallback callback =
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onSeekCompleted(@NonNull SessionPlayer player, long position) {
+ onSeekCompletedLatch.countDown();
+ }
+ };
+ sessionPlayerConnector.registerPlayerCallback(executor, callback);
+
+ sessionPlayerConnector.seekTo(mp4DurationMs >> 1);
+
+ assertThat(onSeekCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS)).isTrue();
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void setPlaybackSpeed_whenPrepared_notifiesOnPlaybackSpeedChanged() throws Throwable {
+ TestUtils.loadResource(R.raw.video_big_buck_bunny, sessionPlayerConnector);
+
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+
+ CountDownLatch onPlaybackSpeedChangedLatch = new CountDownLatch(1);
+ SessionPlayer.PlayerCallback callback =
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlaybackSpeedChanged(@NonNull SessionPlayer player, float speed) {
+ assertThat(speed).isWithin(FLOAT_TOLERANCE).of(0.5f);
+ onPlaybackSpeedChangedLatch.countDown();
+ }
+ };
+ sessionPlayerConnector.registerPlayerCallback(executor, callback);
+
+ sessionPlayerConnector.setPlaybackSpeed(0.5f);
+
+ assertThat(onPlaybackSpeedChangedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+ }
+
+ @Test
+ @SmallTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void setPlaybackSpeed_withZeroSpeed_throwsException() {
+ try {
+ sessionPlayerConnector.setPlaybackSpeed(0.0f);
+ assertWithMessage("zero playback speed shouldn't be allowed").fail();
+ } catch (IllegalArgumentException e) {
+ // expected. pass-through.
+ }
+ }
+
+ @Test
+ @SmallTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void setPlaybackSpeed_withNegativeSpeed_throwsException() {
+ try {
+ sessionPlayerConnector.setPlaybackSpeed(-1.0f);
+ assertWithMessage("negative playback speed isn't supported").fail();
+ } catch (IllegalArgumentException e) {
+ // expected. pass-through.
+ }
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void close_throwsNoExceptionAndDoesNotCrash() throws Exception {
+ TestUtils.loadResource(R.raw.audio, sessionPlayerConnector);
+ AudioAttributesCompat attributes =
+ new AudioAttributesCompat.Builder().setLegacyStreamType(AudioManager.STREAM_MUSIC).build();
+ sessionPlayerConnector.setAudioAttributes(attributes);
+ sessionPlayerConnector.prepare();
+ sessionPlayerConnector.play();
+ sessionPlayerConnector.close();
+
+ // Set the player to null so we don't try to close it again in tearDown().
+ sessionPlayerConnector = null;
+
+ // Tests whether the notification from the player after the close() doesn't crash.
+ Thread.sleep(PLAYER_STATE_CHANGE_WAIT_TIME_MS);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void cancelReturnedFuture_withSeekTo_cancelsPendingCommand() throws Exception {
+ CountDownLatch readRequestedLatch = new CountDownLatch(1);
+ CountDownLatch readAllowedLatch = new CountDownLatch(1);
+ // Need to wait from prepare() to counting down readAllowedLatch.
+ playerTestRule.setDataSourceInstrumentation(
+ dataSpec -> {
+ readRequestedLatch.countDown();
+ try {
+ assertThat(readAllowedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+ } catch (Exception e) {
+ assertWithMessage("Unexpected exception %s", e).fail();
+ }
+ });
+ assertPlayerResultSuccess(
+ sessionPlayerConnector.setMediaItem(TestUtils.createMediaItem(R.raw.audio)));
+
+ // prepare() will be pending until readAllowed is countDowned.
+ ListenableFuture prepareFuture = sessionPlayerConnector.prepare();
+ ListenableFuture seekFuture = sessionPlayerConnector.seekTo(1000);
+
+ assertThat(readRequestedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue();
+
+ // Cancel the pending commands while preparation is on hold.
+ seekFuture.cancel(false);
+
+ // Make the on-going prepare operation resumed and finished.
+ readAllowedLatch.countDown();
+ assertPlayerResultSuccess(prepareFuture);
+
+ // Check whether the canceled seek() didn't happened.
+ // Checking seekFuture.get() will be useless because it always throws CancellationException due
+ // to the CallbackToFuture implementation.
+ Thread.sleep(PLAYER_STATE_CHANGE_WAIT_TIME_MS);
+ assertThat(sessionPlayerConnector.getCurrentPosition()).isEqualTo(0);
+ }
+
+ @Test
+ @SmallTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void setPlaylist_withNullPlaylist_throwsException() throws Exception {
+ List playlist = TestUtils.createPlaylist(10);
+ try {
+ sessionPlayerConnector.setPlaylist(null, null);
+ assertWithMessage("null playlist shouldn't be allowed").fail();
+ } catch (Exception e) {
+ // pass-through
+ }
+ }
+
+ @Test
+ @SmallTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void setPlaylist_withPlaylistContainingNullItem_throwsException() {
+ try {
+ List list = new ArrayList<>();
+ list.add(null);
+ sessionPlayerConnector.setPlaylist(list, null);
+ assertWithMessage("playlist with null item shouldn't be allowed").fail();
+ } catch (Exception e) {
+ // pass-through
+ }
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void setPlaylist_setsPlaylistAndCurrentMediaItem() throws Exception {
+ List playlist = TestUtils.createPlaylist(10);
+ CountDownLatch onCurrentMediaItemChangedLatch = new CountDownLatch(1);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor, new PlayerCallbackForPlaylist(playlist, onCurrentMediaItemChangedLatch));
+
+ assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(playlist, null));
+ assertThat(onCurrentMediaItemChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+
+ assertThat(sessionPlayerConnector.getPlaylist()).isEqualTo(playlist);
+ assertThat(sessionPlayerConnector.getCurrentMediaItem()).isEqualTo(playlist.get(0));
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void setPlaylist_calledOnlyOnce_notifiesPlaylistChangeOnlyOnce() throws Exception {
+ List playlist = TestUtils.createPlaylist(10);
+ CountDownLatch onPlaylistChangedLatch = new CountDownLatch(2);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlaylistChanged(
+ @NonNull SessionPlayer player,
+ @Nullable List list,
+ @Nullable MediaMetadata metadata) {
+ assertThat(list).isEqualTo(playlist);
+ onPlaylistChangedLatch.countDown();
+ }
+ });
+
+ sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null);
+ sessionPlayerConnector.prepare();
+ assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isFalse();
+ assertThat(onPlaylistChangedLatch.getCount()).isEqualTo(1);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void setPlaylist_byUnderlyingPlayerBeforePrepare_notifiesOnPlaylistChanged()
+ throws Exception {
+ List playlistToSessionPlayer = TestUtils.createPlaylist(2);
+ List playlistToExoPlayer = TestUtils.createPlaylist(4);
+ DefaultMediaItemConverter converter = new DefaultMediaItemConverter();
+ List exoMediaItems = new ArrayList<>();
+ for (MediaItem mediaItem : playlistToExoPlayer) {
+ exoMediaItems.add(converter.convertToExoPlayerMediaItem(mediaItem));
+ }
+
+ CountDownLatch onPlaylistChangedLatch = new CountDownLatch(1);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlaylistChanged(
+ @NonNull SessionPlayer player,
+ @Nullable List list,
+ @Nullable MediaMetadata metadata) {
+ if (ObjectsCompat.equals(list, playlistToExoPlayer)) {
+ onPlaylistChangedLatch.countDown();
+ }
+ }
+ });
+ sessionPlayerConnector.setPlaylist(playlistToSessionPlayer, /* metadata= */ null);
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(() -> playerTestRule.getSimpleExoPlayer().setMediaItems(exoMediaItems));
+ assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue();
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void setPlaylist_byUnderlyingPlayerAfterPrepare_notifiesOnPlaylistChanged()
+ throws Exception {
+ List playlistToSessionPlayer = TestUtils.createPlaylist(2);
+ List playlistToExoPlayer = TestUtils.createPlaylist(4);
+ DefaultMediaItemConverter converter = new DefaultMediaItemConverter();
+ List exoMediaItems = new ArrayList<>();
+ for (MediaItem mediaItem : playlistToExoPlayer) {
+ exoMediaItems.add(converter.convertToExoPlayerMediaItem(mediaItem));
+ }
+
+ CountDownLatch onPlaylistChangedLatch = new CountDownLatch(1);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlaylistChanged(
+ @NonNull SessionPlayer player,
+ @Nullable List list,
+ @Nullable MediaMetadata metadata) {
+ if (ObjectsCompat.equals(list, playlistToExoPlayer)) {
+ onPlaylistChangedLatch.countDown();
+ }
+ }
+ });
+ sessionPlayerConnector.prepare();
+ sessionPlayerConnector.setPlaylist(playlistToSessionPlayer, /* metadata= */ null);
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(() -> playerTestRule.getSimpleExoPlayer().setMediaItems(exoMediaItems));
+ assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue();
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void addPlaylistItem_calledOnlyOnce_notifiesPlaylistChangeOnlyOnce() throws Exception {
+ List playlist = TestUtils.createPlaylist(10);
+ assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null));
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+
+ CountDownLatch onPlaylistChangedLatch = new CountDownLatch(2);
+ int addIndex = 2;
+ MediaItem newMediaItem = TestUtils.createMediaItem();
+ playlist.add(addIndex, newMediaItem);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlaylistChanged(
+ @NonNull SessionPlayer player,
+ @Nullable List list,
+ @Nullable MediaMetadata metadata) {
+ assertThat(list).isEqualTo(playlist);
+ onPlaylistChangedLatch.countDown();
+ }
+ });
+ sessionPlayerConnector.addPlaylistItem(addIndex, newMediaItem);
+ assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isFalse();
+ assertThat(onPlaylistChangedLatch.getCount()).isEqualTo(1);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void removePlaylistItem_calledOnlyOnce_notifiesPlaylistChangeOnlyOnce() throws Exception {
+ List playlist = TestUtils.createPlaylist(10);
+ assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null));
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+
+ CountDownLatch onPlaylistChangedLatch = new CountDownLatch(2);
+ int removeIndex = 3;
+ playlist.remove(removeIndex);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlaylistChanged(
+ @NonNull SessionPlayer player,
+ @Nullable List list,
+ @Nullable MediaMetadata metadata) {
+ assertThat(list).isEqualTo(playlist);
+ onPlaylistChangedLatch.countDown();
+ }
+ });
+ sessionPlayerConnector.removePlaylistItem(removeIndex);
+ assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isFalse();
+ assertThat(onPlaylistChangedLatch.getCount()).isEqualTo(1);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void replacePlaylistItem_calledOnlyOnce_notifiesPlaylistChangeOnlyOnce() throws Exception {
+ List playlist = TestUtils.createPlaylist(10);
+ assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null));
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+
+ CountDownLatch onPlaylistChangedLatch = new CountDownLatch(2);
+ int replaceIndex = 2;
+ MediaItem newMediaItem = TestUtils.createMediaItem(R.raw.video_big_buck_bunny);
+ playlist.set(replaceIndex, newMediaItem);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlaylistChanged(
+ @NonNull SessionPlayer player,
+ @Nullable List list,
+ @Nullable MediaMetadata metadata) {
+ assertThat(list).isEqualTo(playlist);
+ onPlaylistChangedLatch.countDown();
+ }
+ });
+ sessionPlayerConnector.replacePlaylistItem(replaceIndex, newMediaItem);
+ assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isFalse();
+ assertThat(onPlaylistChangedLatch.getCount()).isEqualTo(1);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void setPlaylist_withPlaylist_notifiesOnCurrentMediaItemChanged() throws Exception {
+ int listSize = 2;
+ List playlist = TestUtils.createPlaylist(listSize);
+
+ CountDownLatch onCurrentMediaItemChangedLatch = new CountDownLatch(1);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor, new PlayerCallbackForPlaylist(playlist, onCurrentMediaItemChangedLatch));
+
+ assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(playlist, null));
+ assertThat(sessionPlayerConnector.getCurrentMediaItemIndex()).isEqualTo(0);
+ assertThat(onCurrentMediaItemChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void play_twice_finishes() throws Exception {
+ TestUtils.loadResource(R.raw.audio, sessionPlayerConnector);
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+ assertPlayerResultSuccess(sessionPlayerConnector.play());
+ assertPlayerResult(sessionPlayerConnector.play(), RESULT_INFO_SKIPPED);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void play_withPlaylist_notifiesOnCurrentMediaItemChangedAndOnPlaybackCompleted()
+ throws Exception {
+ List playlist = new ArrayList<>();
+ playlist.add(TestUtils.createMediaItem(R.raw.video_1));
+ playlist.add(TestUtils.createMediaItem(R.raw.video_2));
+ playlist.add(TestUtils.createMediaItem(R.raw.video_3));
+
+ CountDownLatch onPlaybackCompletedLatch = new CountDownLatch(1);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ int currentMediaItemChangedCount = 0;
+
+ @Override
+ public void onCurrentMediaItemChanged(
+ @NonNull SessionPlayer player, @NonNull MediaItem item) {
+ assertThat(item).isEqualTo(player.getCurrentMediaItem());
+
+ int expectedCurrentIndex = currentMediaItemChangedCount++;
+ assertThat(player.getCurrentMediaItemIndex()).isEqualTo(expectedCurrentIndex);
+ assertThat(item).isEqualTo(playlist.get(expectedCurrentIndex));
+ }
+
+ @Override
+ public void onPlaybackCompleted(@NonNull SessionPlayer player) {
+ onPlaybackCompletedLatch.countDown();
+ }
+ });
+
+ assertThat(sessionPlayerConnector.setPlaylist(playlist, null)).isNotNull();
+ assertThat(sessionPlayerConnector.prepare()).isNotNull();
+ assertThat(sessionPlayerConnector.play()).isNotNull();
+
+ assertThat(onPlaybackCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void play_byUnderlyingPlayer_notifiesOnPlayerStateChanges() throws Exception {
+ TestUtils.loadResource(R.raw.audio, sessionPlayerConnector);
+ SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer();
+
+ CountDownLatch onPlayingLatch = new CountDownLatch(1);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlayerStateChanged(@NonNull SessionPlayer player, int playerState) {
+ if (playerState == PLAYER_STATE_PLAYING) {
+ onPlayingLatch.countDown();
+ }
+ }
+ });
+
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(() -> simpleExoPlayer.setPlayWhenReady(true));
+
+ assertThat(onPlayingLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue();
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void pause_twice_finishes() throws Exception {
+ TestUtils.loadResource(R.raw.audio, sessionPlayerConnector);
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+ assertPlayerResultSuccess(sessionPlayerConnector.play());
+ assertPlayerResultSuccess(sessionPlayerConnector.pause());
+ assertPlayerResult(sessionPlayerConnector.pause(), RESULT_INFO_SKIPPED);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void pause_byUnderlyingPlayer_notifiesOnPlayerStateChanges() throws Exception {
+ TestUtils.loadResource(R.raw.audio, sessionPlayerConnector);
+ SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer();
+
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+
+ CountDownLatch onPausedLatch = new CountDownLatch(1);
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlayerStateChanged(@NonNull SessionPlayer player, int playerState) {
+ if (playerState == PLAYER_STATE_PAUSED) {
+ onPausedLatch.countDown();
+ }
+ }
+ });
+ assertPlayerResultSuccess(sessionPlayerConnector.play());
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(() -> simpleExoPlayer.setPlayWhenReady(false));
+
+ assertThat(onPausedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue();
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void pause_byUnderlyingPlayerInListener_changesToPlayerStatePaused() throws Exception {
+ TestUtils.loadResource(R.raw.audio, sessionPlayerConnector);
+ SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer();
+
+ CountDownLatch playerStateChangesLatch = new CountDownLatch(3);
+ CopyOnWriteArrayList playerStateChanges = new CopyOnWriteArrayList<>();
+ sessionPlayerConnector.registerPlayerCallback(
+ executor,
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onPlayerStateChanged(@NonNull SessionPlayer player, int playerState) {
+ playerStateChanges.add(playerState);
+ playerStateChangesLatch.countDown();
+ }
+ });
+
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(
+ () ->
+ simpleExoPlayer.addListener(
+ new Player.EventListener() {
+ @Override
+ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
+ if (playWhenReady) {
+ simpleExoPlayer.setPlayWhenReady(false);
+ }
+ }
+ }));
+
+ assertPlayerResultSuccess(sessionPlayerConnector.play());
+ assertThat(playerStateChangesLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+ assertThat(playerStateChanges)
+ .containsExactly(
+ PLAYER_STATE_PAUSED, // After prepare()
+ PLAYER_STATE_PLAYING, // After play()
+ PLAYER_STATE_PAUSED) // After setPlayWhenREady(false)
+ .inOrder();
+ assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(PLAYER_STATE_PAUSED);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void skipToNextAndPrevious_calledInARow_notifiesOnCurrentMediaItemChanged()
+ throws Exception {
+ List playlist = new ArrayList<>();
+ playlist.add(TestUtils.createMediaItem(R.raw.video_1));
+ playlist.add(TestUtils.createMediaItem(R.raw.video_2));
+ playlist.add(TestUtils.createMediaItem(R.raw.video_3));
+ assertThat(sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null)).isNotNull();
+
+ // STEP 1: prepare()
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+
+ // STEP 2: skipToNextPlaylistItem()
+ CountDownLatch onNextMediaItemLatch = new CountDownLatch(1);
+ SessionPlayer.PlayerCallback skipToNextTestCallback =
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onCurrentMediaItemChanged(
+ @NonNull SessionPlayer player, @NonNull MediaItem item) {
+ super.onCurrentMediaItemChanged(player, item);
+ assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1);
+ assertThat(item).isEqualTo(player.getCurrentMediaItem());
+ assertThat(item).isEqualTo(playlist.get(1));
+ onNextMediaItemLatch.countDown();
+ }
+ };
+ sessionPlayerConnector.registerPlayerCallback(executor, skipToNextTestCallback);
+ assertPlayerResultSuccess(sessionPlayerConnector.skipToNextPlaylistItem());
+ assertThat(onNextMediaItemLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue();
+ sessionPlayerConnector.unregisterPlayerCallback(skipToNextTestCallback);
+
+ // STEP 3: skipToPreviousPlaylistItem()
+ CountDownLatch onPreviousMediaItemLatch = new CountDownLatch(1);
+ SessionPlayer.PlayerCallback skipToPreviousTestCallback =
+ new SessionPlayer.PlayerCallback() {
+ @Override
+ public void onCurrentMediaItemChanged(
+ @NonNull SessionPlayer player, @NonNull MediaItem item) {
+ super.onCurrentMediaItemChanged(player, item);
+ assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0);
+ assertThat(item).isEqualTo(player.getCurrentMediaItem());
+ assertThat(item).isEqualTo(playlist.get(0));
+ onPreviousMediaItemLatch.countDown();
+ }
+ };
+ sessionPlayerConnector.registerPlayerCallback(executor, skipToPreviousTestCallback);
+ assertPlayerResultSuccess(sessionPlayerConnector.skipToPreviousPlaylistItem());
+ assertThat(onPreviousMediaItemLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+ sessionPlayerConnector.unregisterPlayerCallback(skipToPreviousTestCallback);
+ }
+
+ @Test
+ @LargeTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT)
+ public void setRepeatMode_withRepeatAll_continuesToPlayPlaylistWithoutBeingCompleted()
+ throws Exception {
+ List playlist = new ArrayList<>();
+ playlist.add(TestUtils.createMediaItem(R.raw.video_1));
+ playlist.add(TestUtils.createMediaItem(R.raw.video_2));
+ playlist.add(TestUtils.createMediaItem(R.raw.video_3));
+ int listSize = playlist.size();
+
+ // Any value more than list size + 1, to see repeat mode with the recorded video.
+ CountDownLatch onCurrentMediaItemChangedLatch = new CountDownLatch(listSize + 2);
+ CopyOnWriteArrayList currentMediaItemChanges = new CopyOnWriteArrayList<>();
+ PlayerCallbackForPlaylist callback =
+ new PlayerCallbackForPlaylist(playlist, onCurrentMediaItemChangedLatch) {
+ @Override
+ public void onCurrentMediaItemChanged(
+ @NonNull SessionPlayer player, @NonNull MediaItem item) {
+ super.onCurrentMediaItemChanged(player, item);
+ currentMediaItemChanges.add(item);
+ onCurrentMediaItemChangedLatch.countDown();
+ }
+
+ @Override
+ public void onPlaybackCompleted(@NonNull SessionPlayer player) {
+ assertWithMessage(
+ "Playback shouldn't be completed, Actual changes were %s",
+ currentMediaItemChanges)
+ .fail();
+ }
+ };
+ sessionPlayerConnector.registerPlayerCallback(executor, callback);
+
+ assertThat(sessionPlayerConnector.setPlaylist(playlist, null)).isNotNull();
+ assertThat(sessionPlayerConnector.prepare()).isNotNull();
+ assertThat(sessionPlayerConnector.setRepeatMode(SessionPlayer.REPEAT_MODE_ALL)).isNotNull();
+ assertThat(sessionPlayerConnector.play()).isNotNull();
+
+ assertWithMessage(
+ "Current media item didn't change as expected. Actual changes were %s",
+ currentMediaItemChanges)
+ .that(onCurrentMediaItemChangedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS))
+ .isTrue();
+
+ int expectedMediaItemIndex = 0;
+ for (MediaItem mediaItemInPlaybackOrder : currentMediaItemChanges) {
+ assertWithMessage(
+ "Unexpected media item for %sth playback. Actual changes were %s",
+ expectedMediaItemIndex, currentMediaItemChanges)
+ .that(mediaItemInPlaybackOrder)
+ .isEqualTo(playlist.get(expectedMediaItemIndex));
+ expectedMediaItemIndex = (expectedMediaItemIndex + 1) % listSize;
+ }
+ }
+
+ @Test
+ @LargeTest
+ public void getPlayerState_withPrepareAndPlayAndPause_changesAsExpected() throws Exception {
+ TestUtils.loadResource(R.raw.audio, sessionPlayerConnector);
+
+ AudioAttributesCompat attributes =
+ new AudioAttributesCompat.Builder().setLegacyStreamType(AudioManager.STREAM_MUSIC).build();
+ sessionPlayerConnector.setAudioAttributes(attributes);
+ sessionPlayerConnector.setRepeatMode(SessionPlayer.REPEAT_MODE_ALL);
+
+ assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE);
+ assertPlayerResultSuccess(sessionPlayerConnector.prepare());
+ assertThat(sessionPlayerConnector.getPlayerState())
+ .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED);
+ assertPlayerResultSuccess(sessionPlayerConnector.play());
+ assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(PLAYER_STATE_PLAYING);
+ }
+
+ @Test
+ @LargeTest
+ public void getPlaylist_returnsPlaylistInUnderlyingPlayer() {
+ List playlistToExoPlayer = TestUtils.createPlaylist(4);
+ DefaultMediaItemConverter converter = new DefaultMediaItemConverter();
+ List exoMediaItems = new ArrayList<>();
+ for (MediaItem mediaItem : playlistToExoPlayer) {
+ exoMediaItems.add(converter.convertToExoPlayerMediaItem(mediaItem));
+ }
+
+ AtomicReference> playlistFromSessionPlayer = new AtomicReference<>();
+ InstrumentationRegistry.getInstrumentation()
+ .runOnMainSync(
+ () -> {
+ SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer();
+ simpleExoPlayer.setMediaItems(exoMediaItems);
+
+ try (SessionPlayerConnector sessionPlayer =
+ new SessionPlayerConnector(simpleExoPlayer)) {
+ List playlist = sessionPlayer.getPlaylist();
+ playlistFromSessionPlayer.set(playlist);
+ }
+ });
+ assertThat(playlistFromSessionPlayer.get()).isEqualTo(playlistToExoPlayer);
+ }
+
+ private class PlayerCallbackForPlaylist extends SessionPlayer.PlayerCallback {
+ private List playlist;
+ private CountDownLatch onCurrentMediaItemChangedLatch;
+
+ PlayerCallbackForPlaylist(List playlist, CountDownLatch latch) {
+ this.playlist = playlist;
+ onCurrentMediaItemChangedLatch = latch;
+ }
+
+ @Override
+ public void onCurrentMediaItemChanged(@NonNull SessionPlayer player, @NonNull MediaItem item) {
+ int currentIndex = playlist.indexOf(item);
+ assertThat(sessionPlayerConnector.getCurrentMediaItemIndex()).isEqualTo(currentIndex);
+ onCurrentMediaItemChangedLatch.countDown();
+ }
+ }
+}
diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/TestUtils.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/TestUtils.java
new file mode 100644
index 00000000000..a7eb058ee62
--- /dev/null
+++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/TestUtils.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2019 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.ext.media2;
+
+import static androidx.media2.common.SessionPlayer.PlayerResult.RESULT_SUCCESS;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import androidx.media2.common.MediaItem;
+import androidx.media2.common.MediaMetadata;
+import androidx.media2.common.SessionPlayer;
+import androidx.media2.common.SessionPlayer.PlayerResult;
+import androidx.media2.common.UriMediaItem;
+import com.google.android.exoplayer2.ext.media2.test.R;
+import com.google.android.exoplayer2.upstream.RawResourceDataSource;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Future;
+
+/** Utilities for tests. */
+/* package */ final class TestUtils {
+ private static final long PLAYER_STATE_CHANGE_WAIT_TIME_MS = 5_000;
+
+ public static UriMediaItem createMediaItem() {
+ return createMediaItem(R.raw.video_desks);
+ }
+
+ public static UriMediaItem createMediaItem(int resId) {
+ MediaMetadata metadata =
+ new MediaMetadata.Builder()
+ .putString(MediaMetadata.METADATA_KEY_MEDIA_ID, Integer.toString(resId))
+ .build();
+ return new UriMediaItem.Builder(RawResourceDataSource.buildRawResourceUri(resId))
+ .setMetadata(metadata)
+ .build();
+ }
+
+ public static List createPlaylist(int size) {
+ List items = new ArrayList<>();
+ for (int i = 0; i < size; ++i) {
+ items.add(createMediaItem());
+ }
+ return items;
+ }
+
+ public static void loadResource(int resId, SessionPlayer sessionPlayer) throws Exception {
+ MediaItem mediaItem = createMediaItem(resId);
+ assertPlayerResultSuccess(sessionPlayer.setMediaItem(mediaItem));
+ }
+
+ public static void assertPlayerResultSuccess(Future future) throws Exception {
+ assertPlayerResult(future, RESULT_SUCCESS);
+ }
+
+ public static void assertPlayerResult(
+ Future future, /* @PlayerResult.ResultCode */ int playerResult)
+ throws Exception {
+ assertThat(future).isNotNull();
+ PlayerResult result = future.get(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS);
+ assertThat(result).isNotNull();
+ assertThat(result.getResultCode()).isEqualTo(playerResult);
+ }
+
+ private TestUtils() {
+ // Prevent from instantiation.
+ }
+}
diff --git a/extensions/media2/src/androidTest/res/layout/mediaplayer.xml b/extensions/media2/src/androidTest/res/layout/mediaplayer.xml
new file mode 100644
index 00000000000..1861e5e44e8
--- /dev/null
+++ b/extensions/media2/src/androidTest/res/layout/mediaplayer.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/extensions/media2/src/androidTest/res/raw/audio.mp3 b/extensions/media2/src/androidTest/res/raw/audio.mp3
new file mode 100755
index 00000000000..657faf77186
Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/audio.mp3 differ
diff --git a/extensions/media2/src/androidTest/res/raw/video_1.mp4 b/extensions/media2/src/androidTest/res/raw/video_1.mp4
new file mode 100644
index 00000000000..b8d9236def5
Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/video_1.mp4 differ
diff --git a/extensions/media2/src/androidTest/res/raw/video_2.mp4 b/extensions/media2/src/androidTest/res/raw/video_2.mp4
new file mode 100644
index 00000000000..c29d88c21f8
Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/video_2.mp4 differ
diff --git a/extensions/media2/src/androidTest/res/raw/video_3.mp4 b/extensions/media2/src/androidTest/res/raw/video_3.mp4
new file mode 100644
index 00000000000..767bd5c6474
Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/video_3.mp4 differ
diff --git a/extensions/media2/src/androidTest/res/raw/video_big_buck_bunny.mp4 b/extensions/media2/src/androidTest/res/raw/video_big_buck_bunny.mp4
new file mode 100644
index 00000000000..571ff4459d0
Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/video_big_buck_bunny.mp4 differ
diff --git a/extensions/media2/src/androidTest/res/raw/video_desks.3gp b/extensions/media2/src/androidTest/res/raw/video_desks.3gp
new file mode 100644
index 00000000000..c51f109f97d
Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/video_desks.3gp differ
diff --git a/testdata/src/test/assets/ts/sample_h262_mpeg_audio.ts b/extensions/media2/src/androidTest/res/raw/video_not_seekable.ts
similarity index 100%
rename from testdata/src/test/assets/ts/sample_h262_mpeg_audio.ts
rename to extensions/media2/src/androidTest/res/raw/video_not_seekable.ts
diff --git a/extensions/media2/src/main/AndroidManifest.xml b/extensions/media2/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..3b87ee9dfa7
--- /dev/null
+++ b/extensions/media2/src/main/AndroidManifest.xml
@@ -0,0 +1,16 @@
+
+
+
diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/DefaultMediaItemConverter.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/DefaultMediaItemConverter.java
new file mode 100644
index 00000000000..c23bdd56692
--- /dev/null
+++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/DefaultMediaItemConverter.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2020 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.ext.media2;
+
+import static androidx.media2.common.MediaMetadata.METADATA_KEY_DISPLAY_TITLE;
+import static androidx.media2.common.MediaMetadata.METADATA_KEY_MEDIA_ID;
+import static androidx.media2.common.MediaMetadata.METADATA_KEY_MEDIA_URI;
+import static androidx.media2.common.MediaMetadata.METADATA_KEY_TITLE;
+
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import androidx.media2.common.CallbackMediaItem;
+import androidx.media2.common.FileMediaItem;
+import androidx.media2.common.UriMediaItem;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.util.Assertions;
+
+/**
+ * Default implementation of {@link MediaItemConverter}.
+ *
+ * Note that {@link #getMetadata} can be overridden to fill in additional metadata when
+ * converting {@link MediaItem ExoPlayer MediaItems} to their AndroidX equivalents.
+ */
+public class DefaultMediaItemConverter implements MediaItemConverter {
+
+ @Override
+ public MediaItem convertToExoPlayerMediaItem(androidx.media2.common.MediaItem media2MediaItem) {
+ if (media2MediaItem instanceof FileMediaItem) {
+ throw new IllegalStateException("FileMediaItem isn't supported");
+ }
+ if (media2MediaItem instanceof CallbackMediaItem) {
+ throw new IllegalStateException("CallbackMediaItem isn't supported");
+ }
+
+ @Nullable Uri uri = null;
+ @Nullable String mediaId = null;
+ @Nullable String title = null;
+ if (media2MediaItem instanceof UriMediaItem) {
+ UriMediaItem uriMediaItem = (UriMediaItem) media2MediaItem;
+ uri = uriMediaItem.getUri();
+ }
+ @Nullable androidx.media2.common.MediaMetadata metadata = media2MediaItem.getMetadata();
+ if (metadata != null) {
+ @Nullable String uriString = metadata.getString(METADATA_KEY_MEDIA_URI);
+ mediaId = metadata.getString(METADATA_KEY_MEDIA_ID);
+ if (uri == null) {
+ if (uriString != null) {
+ uri = Uri.parse(uriString);
+ } else if (mediaId != null) {
+ uri = Uri.parse("media2:///" + mediaId);
+ }
+ }
+ title = metadata.getString(METADATA_KEY_DISPLAY_TITLE);
+ if (title == null) {
+ title = metadata.getString(METADATA_KEY_TITLE);
+ }
+ }
+ if (uri == null) {
+ // Generate a URI to make it non-null. If not, then the tag passed to setTag will be ignored.
+ uri = Uri.parse("media2:///");
+ }
+ long startPositionMs = media2MediaItem.getStartPosition();
+ if (startPositionMs == androidx.media2.common.MediaItem.POSITION_UNKNOWN) {
+ startPositionMs = 0;
+ }
+ long endPositionMs = media2MediaItem.getEndPosition();
+ if (endPositionMs == androidx.media2.common.MediaItem.POSITION_UNKNOWN) {
+ endPositionMs = C.TIME_END_OF_SOURCE;
+ }
+
+ return new MediaItem.Builder()
+ .setUri(uri)
+ .setMediaId(mediaId)
+ .setMediaMetadata(
+ new com.google.android.exoplayer2.MediaMetadata.Builder().setTitle(title).build())
+ .setTag(media2MediaItem)
+ .setClipStartPositionMs(startPositionMs)
+ .setClipEndPositionMs(endPositionMs)
+ .build();
+ }
+
+ @Override
+ public androidx.media2.common.MediaItem convertToMedia2MediaItem(MediaItem exoPlayerMediaItem) {
+ Assertions.checkNotNull(exoPlayerMediaItem);
+ MediaItem.PlaybackProperties playbackProperties =
+ Assertions.checkNotNull(exoPlayerMediaItem.playbackProperties);
+
+ @Nullable Object tag = playbackProperties.tag;
+ if (tag instanceof androidx.media2.common.MediaItem) {
+ return (androidx.media2.common.MediaItem) tag;
+ }
+
+ androidx.media2.common.MediaMetadata metadata = getMetadata(exoPlayerMediaItem);
+ long startPositionMs = exoPlayerMediaItem.clippingProperties.startPositionMs;
+ long endPositionMs = exoPlayerMediaItem.clippingProperties.endPositionMs;
+ if (endPositionMs == C.TIME_END_OF_SOURCE) {
+ endPositionMs = androidx.media2.common.MediaItem.POSITION_UNKNOWN;
+ }
+
+ return new androidx.media2.common.MediaItem.Builder()
+ .setMetadata(metadata)
+ .setStartPosition(startPositionMs)
+ .setEndPosition(endPositionMs)
+ .build();
+ }
+
+ /**
+ * Returns a {@link androidx.media2.common.MediaMetadata} corresponding to the given {@link
+ * MediaItem ExoPlayer MediaItem}.
+ */
+ protected androidx.media2.common.MediaMetadata getMetadata(MediaItem exoPlayerMediaItem) {
+ @Nullable String title = exoPlayerMediaItem.mediaMetadata.title;
+
+ androidx.media2.common.MediaMetadata.Builder metadataBuilder =
+ new androidx.media2.common.MediaMetadata.Builder()
+ .putString(METADATA_KEY_MEDIA_ID, exoPlayerMediaItem.mediaId);
+ if (title != null) {
+ metadataBuilder.putString(METADATA_KEY_TITLE, title);
+ metadataBuilder.putString(METADATA_KEY_DISPLAY_TITLE, title);
+ }
+ return metadataBuilder.build();
+ }
+}
diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaItemConverter.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaItemConverter.java
new file mode 100644
index 00000000000..218c2a737e5
--- /dev/null
+++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaItemConverter.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2020 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.ext.media2;
+
+import com.google.android.exoplayer2.MediaItem;
+
+/**
+ * Converts between {@link androidx.media2.common.MediaItem Media2 MediaItem} and {@link MediaItem
+ * ExoPlayer MediaItem}.
+ */
+public interface MediaItemConverter {
+ /**
+ * Converts an {@link androidx.media2.common.MediaItem Media2 MediaItem} to an {@link MediaItem
+ * ExoPlayer MediaItem}.
+ */
+ MediaItem convertToExoPlayerMediaItem(androidx.media2.common.MediaItem media2MediaItem);
+
+ /**
+ * Converts an {@link MediaItem ExoPlayer MediaItem} to an {@link androidx.media2.common.MediaItem
+ * Media2 MediaItem}.
+ */
+ androidx.media2.common.MediaItem convertToMedia2MediaItem(MediaItem exoPlayerMediaItem);
+}
diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtil.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtil.java
new file mode 100644
index 00000000000..e7cc9545b15
--- /dev/null
+++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtil.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2020 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.ext.media2;
+
+import android.annotation.SuppressLint;
+import android.support.v4.media.session.MediaSessionCompat;
+import androidx.media2.session.MediaSession;
+
+/** Utility methods to use {@link MediaSession} with other ExoPlayer modules. */
+public final class MediaSessionUtil {
+
+ /** Gets the {@link MediaSessionCompat.Token} from the {@link MediaSession}. */
+ // TODO(b/152764014): Deprecate this API when MediaSession#getSessionCompatToken() is released.
+ public static MediaSessionCompat.Token getSessionCompatToken(MediaSession mediaSession) {
+ @SuppressLint("RestrictedApi")
+ @SuppressWarnings("RestrictTo")
+ MediaSessionCompat sessionCompat = mediaSession.getSessionCompat();
+ return sessionCompat.getSessionToken();
+ }
+
+ private MediaSessionUtil() {
+ // Prevent from instantiation.
+ }
+}
diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerCommandQueue.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerCommandQueue.java
new file mode 100644
index 00000000000..fc80c85856f
--- /dev/null
+++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerCommandQueue.java
@@ -0,0 +1,458 @@
+/*
+ * Copyright 2019 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.ext.media2;
+
+import static com.google.android.exoplayer2.util.Util.postOrRun;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import android.os.Handler;
+import androidx.annotation.GuardedBy;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import androidx.media2.common.MediaItem;
+import androidx.media2.common.MediaMetadata;
+import androidx.media2.common.SessionPlayer;
+import androidx.media2.common.SessionPlayer.PlayerResult;
+import com.google.android.exoplayer2.util.Log;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+import java.util.concurrent.Callable;
+
+/** Manages the queue of player actions and handles running them one by one. */
+/* package */ class PlayerCommandQueue implements AutoCloseable {
+
+ private static final String TAG = "PlayerCommandQueue";
+ private static final boolean DEBUG = false;
+
+ // Redefine command codes rather than using constants from SessionCommand here, because command
+ // code for setAudioAttribute() is missing in SessionCommand.
+ /** Command code for {@link SessionPlayer#setAudioAttributes}. */
+ public static final int COMMAND_CODE_PLAYER_SET_AUDIO_ATTRIBUTES = 0;
+
+ /** Command code for {@link SessionPlayer#play} */
+ public static final int COMMAND_CODE_PLAYER_PLAY = 1;
+
+ /** Command code for {@link SessionPlayer#replacePlaylistItem(int, MediaItem)} */
+ public static final int COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM = 2;
+
+ /** Command code for {@link SessionPlayer#skipToPreviousPlaylistItem()} */
+ public static final int COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM = 3;
+
+ /** Command code for {@link SessionPlayer#skipToNextPlaylistItem()} */
+ public static final int COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM = 4;
+
+ /** Command code for {@link SessionPlayer#skipToPlaylistItem(int)} */
+ public static final int COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM = 5;
+
+ /** Command code for {@link SessionPlayer#updatePlaylistMetadata(MediaMetadata)} */
+ public static final int COMMAND_CODE_PLAYER_UPDATE_LIST_METADATA = 6;
+
+ /** Command code for {@link SessionPlayer#setRepeatMode(int)} */
+ public static final int COMMAND_CODE_PLAYER_SET_REPEAT_MODE = 7;
+
+ /** Command code for {@link SessionPlayer#setShuffleMode(int)} */
+ public static final int COMMAND_CODE_PLAYER_SET_SHUFFLE_MODE = 8;
+
+ /** Command code for {@link SessionPlayer#setMediaItem(MediaItem)} */
+ public static final int COMMAND_CODE_PLAYER_SET_MEDIA_ITEM = 9;
+
+ /** Command code for {@link SessionPlayer#seekTo(long)} */
+ public static final int COMMAND_CODE_PLAYER_SEEK_TO = 10;
+
+ /** Command code for {@link SessionPlayer#prepare()} */
+ public static final int COMMAND_CODE_PLAYER_PREPARE = 11;
+
+ /** Command code for {@link SessionPlayer#setPlaybackSpeed(float)} */
+ public static final int COMMAND_CODE_PLAYER_SET_SPEED = 12;
+
+ /** Command code for {@link SessionPlayer#pause()} */
+ public static final int COMMAND_CODE_PLAYER_PAUSE = 13;
+
+ /** Command code for {@link SessionPlayer#setPlaylist(List, MediaMetadata)} */
+ public static final int COMMAND_CODE_PLAYER_SET_PLAYLIST = 14;
+
+ /** Command code for {@link SessionPlayer#addPlaylistItem(int, MediaItem)} */
+ public static final int COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM = 15;
+
+ /** Command code for {@link SessionPlayer#removePlaylistItem(int)} */
+ public static final int COMMAND_CODE_PLAYER_REMOVE_PLAYLIST_ITEM = 16;
+
+ /** List of session commands whose result would be set after the command is finished. */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ value = {
+ COMMAND_CODE_PLAYER_SET_AUDIO_ATTRIBUTES,
+ COMMAND_CODE_PLAYER_PLAY,
+ COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM,
+ COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM,
+ COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM,
+ COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM,
+ COMMAND_CODE_PLAYER_UPDATE_LIST_METADATA,
+ COMMAND_CODE_PLAYER_SET_REPEAT_MODE,
+ COMMAND_CODE_PLAYER_SET_SHUFFLE_MODE,
+ COMMAND_CODE_PLAYER_SET_MEDIA_ITEM,
+ COMMAND_CODE_PLAYER_SEEK_TO,
+ COMMAND_CODE_PLAYER_PREPARE,
+ COMMAND_CODE_PLAYER_SET_SPEED,
+ COMMAND_CODE_PLAYER_PAUSE,
+ COMMAND_CODE_PLAYER_SET_PLAYLIST,
+ COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM,
+ COMMAND_CODE_PLAYER_REMOVE_PLAYLIST_ITEM,
+ })
+ public @interface CommandCode {}
+
+ /** Command whose result would be set later via listener after the command is finished. */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ value = {COMMAND_CODE_PLAYER_PREPARE, COMMAND_CODE_PLAYER_PLAY, COMMAND_CODE_PLAYER_PAUSE})
+ public @interface AsyncCommandCode {}
+
+ // Should be only used on the handler.
+ private final PlayerWrapper player;
+ private final Handler handler;
+ private final Object lock;
+
+ @GuardedBy("lock")
+ private final Deque pendingPlayerCommandQueue;
+
+ @GuardedBy("lock")
+ private boolean closed;
+
+ // Should be only used on the handler.
+ @Nullable private AsyncPlayerCommandResult pendingAsyncPlayerCommandResult;
+
+ public PlayerCommandQueue(PlayerWrapper player, Handler handler) {
+ this.player = player;
+ this.handler = handler;
+ lock = new Object();
+ pendingPlayerCommandQueue = new ArrayDeque<>();
+ }
+
+ @Override
+ public void close() {
+ synchronized (lock) {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ }
+ reset();
+ }
+
+ public void reset() {
+ handler.removeCallbacksAndMessages(/* token= */ null);
+ List queue;
+ synchronized (lock) {
+ queue = new ArrayList<>(pendingPlayerCommandQueue);
+ pendingPlayerCommandQueue.clear();
+ }
+ for (PlayerCommand playerCommand : queue) {
+ playerCommand.result.set(
+ new PlayerResult(PlayerResult.RESULT_INFO_SKIPPED, /* item= */ null));
+ }
+ }
+
+ public ListenableFuture addCommand(
+ @CommandCode int commandCode, Callable command) {
+ return addCommand(commandCode, command, /* tag= */ null);
+ }
+
+ public ListenableFuture addCommand(
+ @CommandCode int commandCode, Callable command, @Nullable Object tag) {
+ SettableFuture result = SettableFuture.create();
+ synchronized (lock) {
+ if (closed) {
+ // OK to set result with lock hold because developers cannot add listener here.
+ result.set(new PlayerResult(PlayerResult.RESULT_ERROR_INVALID_STATE, /* item= */ null));
+ return result;
+ }
+ PlayerCommand playerCommand = new PlayerCommand(commandCode, command, result, tag);
+ result.addListener(
+ () -> {
+ if (result.isCancelled()) {
+ boolean isCommandPending;
+ synchronized (lock) {
+ isCommandPending = pendingPlayerCommandQueue.remove(playerCommand);
+ }
+ if (isCommandPending) {
+ result.set(
+ new PlayerResult(
+ PlayerResult.RESULT_INFO_SKIPPED, player.getCurrentMediaItem()));
+ if (DEBUG) {
+ Log.d(TAG, "canceled " + playerCommand);
+ }
+ }
+ if (pendingAsyncPlayerCommandResult != null
+ && pendingAsyncPlayerCommandResult.result == result) {
+ pendingAsyncPlayerCommandResult = null;
+ }
+ }
+ processPendingCommandOnHandler();
+ },
+ (runnable) -> postOrRun(handler, runnable));
+ if (DEBUG) {
+ Log.d(TAG, "adding " + playerCommand);
+ }
+ pendingPlayerCommandQueue.add(playerCommand);
+ }
+ processPendingCommand();
+ return result;
+ }
+
+ public void notifyCommandError() {
+ postOrRun(
+ handler,
+ () -> {
+ @Nullable AsyncPlayerCommandResult pendingResult = pendingAsyncPlayerCommandResult;
+ if (pendingResult == null) {
+ if (DEBUG) {
+ Log.d(TAG, "Ignoring notifyCommandError(). No pending async command.");
+ }
+ return;
+ }
+ pendingResult.result.set(
+ new PlayerResult(PlayerResult.RESULT_ERROR_UNKNOWN, player.getCurrentMediaItem()));
+ pendingAsyncPlayerCommandResult = null;
+ if (DEBUG) {
+ Log.d(TAG, "error on " + pendingResult);
+ }
+ processPendingCommandOnHandler();
+ });
+ }
+
+ public void notifyCommandCompleted(@AsyncCommandCode int completedCommandCode) {
+ if (DEBUG) {
+ Log.d(TAG, "notifyCommandCompleted, completedCommandCode=" + completedCommandCode);
+ }
+ postOrRun(
+ handler,
+ () -> {
+ @Nullable AsyncPlayerCommandResult pendingResult = pendingAsyncPlayerCommandResult;
+ if (pendingResult == null || pendingResult.commandCode != completedCommandCode) {
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "Unexpected Listener is notified from the Player. Player may be used"
+ + " directly rather than "
+ + toLogFriendlyString(completedCommandCode));
+ }
+ return;
+ }
+ pendingResult.result.set(
+ new PlayerResult(PlayerResult.RESULT_SUCCESS, player.getCurrentMediaItem()));
+ pendingAsyncPlayerCommandResult = null;
+ if (DEBUG) {
+ Log.d(TAG, "completed " + pendingResult);
+ }
+ processPendingCommandOnHandler();
+ });
+ }
+
+ private void processPendingCommand() {
+ postOrRun(handler, this::processPendingCommandOnHandler);
+ }
+
+ private void processPendingCommandOnHandler() {
+ while (pendingAsyncPlayerCommandResult == null) {
+ @Nullable PlayerCommand playerCommand;
+ synchronized (lock) {
+ playerCommand = pendingPlayerCommandQueue.poll();
+ }
+ if (playerCommand == null) {
+ return;
+ }
+
+ int commandCode = playerCommand.commandCode;
+ // Check if it's @AsyncCommandCode
+ boolean asyncCommand = isAsyncCommand(playerCommand.commandCode);
+
+ // Continuous COMMAND_CODE_PLAYER_SEEK_TO can be skipped.
+ if (commandCode == COMMAND_CODE_PLAYER_SEEK_TO) {
+ @Nullable List skippingCommands = null;
+ while (true) {
+ synchronized (lock) {
+ @Nullable PlayerCommand pendingCommand = pendingPlayerCommandQueue.peek();
+ if (pendingCommand == null || pendingCommand.commandCode != commandCode) {
+ break;
+ }
+ pendingPlayerCommandQueue.poll();
+ if (skippingCommands == null) {
+ skippingCommands = new ArrayList<>();
+ }
+ skippingCommands.add(playerCommand);
+ playerCommand = pendingCommand;
+ }
+ }
+ if (skippingCommands != null) {
+ for (PlayerCommand skippingCommand : skippingCommands) {
+ skippingCommand.result.set(
+ new PlayerResult(PlayerResult.RESULT_INFO_SKIPPED, player.getCurrentMediaItem()));
+ if (DEBUG) {
+ Log.d(TAG, "skipping pending command, " + skippingCommand);
+ }
+ }
+ }
+ }
+
+ if (asyncCommand) {
+ // Result would come later, via #notifyCommandCompleted().
+ // Set pending player result first because it may be notified while the command is running.
+ pendingAsyncPlayerCommandResult =
+ new AsyncPlayerCommandResult(commandCode, playerCommand.result);
+ }
+
+ if (DEBUG) {
+ Log.d(TAG, "start processing command, " + playerCommand);
+ }
+
+ int resultCode;
+ if (player.hasError()) {
+ resultCode = PlayerResult.RESULT_ERROR_INVALID_STATE;
+ } else {
+ try {
+ boolean handled = playerCommand.command.call();
+ resultCode = handled ? PlayerResult.RESULT_SUCCESS : PlayerResult.RESULT_INFO_SKIPPED;
+ } catch (IllegalStateException e) {
+ resultCode = PlayerResult.RESULT_ERROR_INVALID_STATE;
+ } catch (IllegalArgumentException | IndexOutOfBoundsException e) {
+ resultCode = PlayerResult.RESULT_ERROR_BAD_VALUE;
+ } catch (SecurityException e) {
+ resultCode = PlayerResult.RESULT_ERROR_PERMISSION_DENIED;
+ } catch (Exception e) {
+ resultCode = PlayerResult.RESULT_ERROR_UNKNOWN;
+ }
+ }
+ if (DEBUG) {
+ Log.d(TAG, "command processed, " + playerCommand);
+ }
+
+ if (asyncCommand) {
+ if (resultCode != PlayerResult.RESULT_SUCCESS
+ && pendingAsyncPlayerCommandResult != null
+ && playerCommand.result == pendingAsyncPlayerCommandResult.result) {
+ pendingAsyncPlayerCommandResult = null;
+ playerCommand.result.set(new PlayerResult(resultCode, player.getCurrentMediaItem()));
+ }
+ } else {
+ playerCommand.result.set(new PlayerResult(resultCode, player.getCurrentMediaItem()));
+ }
+ }
+ }
+
+ private static String toLogFriendlyString(@AsyncCommandCode int commandCode) {
+ switch (commandCode) {
+ case COMMAND_CODE_PLAYER_PLAY:
+ return "SessionPlayerConnector#play()";
+ case COMMAND_CODE_PLAYER_PAUSE:
+ return "SessionPlayerConnector#pause()";
+ case COMMAND_CODE_PLAYER_PREPARE:
+ return "SessionPlayerConnector#prepare()";
+ default:
+ // Never happens.
+ throw new IllegalStateException();
+ }
+ }
+
+ private static boolean isAsyncCommand(@CommandCode int commandCode) {
+ switch (commandCode) {
+ case COMMAND_CODE_PLAYER_PLAY:
+ case COMMAND_CODE_PLAYER_PAUSE:
+ case COMMAND_CODE_PLAYER_PREPARE:
+ return true;
+ }
+ return false;
+ }
+
+ private static final class AsyncPlayerCommandResult {
+ @AsyncCommandCode public final int commandCode;
+ public final SettableFuture result;
+
+ public AsyncPlayerCommandResult(
+ @AsyncCommandCode int commandCode, SettableFuture result) {
+ this.commandCode = commandCode;
+ this.result = result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder stringBuilder =
+ new StringBuilder("AsyncPlayerCommandResult {commandCode=")
+ .append(commandCode)
+ .append(", result=")
+ .append(result.hashCode());
+ if (result.isDone()) {
+ try {
+ int resultCode = result.get(/* timeout= */ 0, MILLISECONDS).getResultCode();
+ stringBuilder.append(", resultCode=").append(resultCode);
+ } catch (Exception e) {
+ // pass-through.
+ }
+ }
+ stringBuilder.append("}");
+ return stringBuilder.toString();
+ }
+ }
+
+ private static final class PlayerCommand {
+ public final int commandCode;
+ public final Callable command;
+ // Result shouldn't be set with lock held, because it may trigger listener set by developers.
+ public final SettableFuture result;
+ @Nullable private final Object tag;
+
+ public PlayerCommand(
+ int commandCode,
+ Callable command,
+ SettableFuture result,
+ @Nullable Object tag) {
+ this.commandCode = commandCode;
+ this.command = command;
+ this.result = result;
+ this.tag = tag;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder stringBuilder =
+ new StringBuilder("PlayerCommand {commandCode=")
+ .append(commandCode)
+ .append(", result=")
+ .append(result.hashCode());
+ if (result.isDone()) {
+ try {
+ int resultCode = result.get(/* timeout= */ 0, MILLISECONDS).getResultCode();
+ stringBuilder.append(", resultCode=").append(resultCode);
+ } catch (Exception e) {
+ // pass-through.
+ }
+ }
+ if (tag != null) {
+ stringBuilder.append(", tag=").append(tag);
+ }
+ stringBuilder.append("}");
+ return stringBuilder.toString();
+ }
+ }
+}
diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java
new file mode 100644
index 00000000000..09e0325e936
--- /dev/null
+++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java
@@ -0,0 +1,657 @@
+/*
+ * Copyright 2019 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.ext.media2;
+
+import static com.google.android.exoplayer2.util.Util.postOrRun;
+
+import android.os.Handler;
+import androidx.annotation.IntRange;
+import androidx.annotation.Nullable;
+import androidx.core.util.ObjectsCompat;
+import androidx.media.AudioAttributesCompat;
+import androidx.media2.common.CallbackMediaItem;
+import androidx.media2.common.MediaMetadata;
+import androidx.media2.common.SessionPlayer;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ControlDispatcher;
+import com.google.android.exoplayer2.DefaultControlDispatcher;
+import com.google.android.exoplayer2.ExoPlaybackException;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.PlaybackParameters;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.audio.AudioAttributes;
+import com.google.android.exoplayer2.audio.AudioListener;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Log;
+import com.google.android.exoplayer2.util.Util;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Wraps an ExoPlayer {@link Player} instance and provides methods and notifies events like those in
+ * the {@link SessionPlayer} API.
+ */
+/* package */ final class PlayerWrapper {
+ private static final String TAG = "PlayerWrapper";
+
+ /** Listener for player wrapper events. */
+ public interface Listener {
+ /**
+ * Called when the player state is changed.
+ *
+ * This method will be called at first if multiple events should be notified at once.
+ */
+ void onPlayerStateChanged(/* @SessionPlayer.PlayerState */ int playerState);
+
+ /** Called when the player is prepared. */
+ void onPrepared(androidx.media2.common.MediaItem media2MediaItem, int bufferingPercentage);
+
+ /** Called when a seek request has completed. */
+ void onSeekCompleted();
+
+ /** Called when the player rebuffers. */
+ void onBufferingStarted(androidx.media2.common.MediaItem media2MediaItem);
+
+ /** Called when the player becomes ready again after rebuffering. */
+ void onBufferingEnded(
+ androidx.media2.common.MediaItem media2MediaItem, int bufferingPercentage);
+
+ /** Called periodically with the player's buffered position as a percentage. */
+ void onBufferingUpdate(
+ androidx.media2.common.MediaItem media2MediaItem, int bufferingPercentage);
+
+ /** Called when current media item is changed. */
+ void onCurrentMediaItemChanged(androidx.media2.common.MediaItem media2MediaItem);
+
+ /** Called when playback of the item list has ended. */
+ void onPlaybackEnded();
+
+ /** Called when the player encounters an error. */
+ void onError(@Nullable androidx.media2.common.MediaItem media2MediaItem);
+
+ /** Called when the playlist is changed. */
+ void onPlaylistChanged();
+
+ /** Called when the shuffle mode is changed. */
+ void onShuffleModeChanged(int shuffleMode);
+
+ /** Called when the repeat mode is changed. */
+ void onRepeatModeChanged(int repeatMode);
+
+ /** Called when the audio attributes is changed. */
+ void onAudioAttributesChanged(AudioAttributesCompat audioAttributes);
+
+ /** Called when the playback speed is changed. */
+ void onPlaybackSpeedChanged(float playbackSpeed);
+ }
+
+ private static final int POLL_BUFFER_INTERVAL_MS = 1000;
+
+ private final Listener listener;
+ private final Handler handler;
+ private final Runnable pollBufferRunnable;
+
+ private final Player player;
+ private final MediaItemConverter mediaItemConverter;
+ private final ComponentListener componentListener;
+
+ @Nullable private MediaMetadata playlistMetadata;
+
+ // These should be only updated in TimelineChanges.
+ private final List