entry : info.getAllHeadersAsList()) {
if (entry.getKey().equalsIgnoreCase("Content-Encoding")) {
@@ -826,7 +860,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
// 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 + "]");
@@ -869,7 +903,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
// 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);
@@ -893,7 +927,11 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
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 4086011b4f..85c9d09a79 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 final class CronetDataSourceFactory extends BaseFactory {
}
/**
- * 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 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.
+ */
+ 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.
*
* @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 final class CronetDataSourceFactory extends BaseFactory {
}
/**
- * 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 final class CronetDataSourceFactory extends BaseFactory {
}
/**
- * 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 final class CronetDataSourceFactory extends BaseFactory {
}
/**
- * 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 final class CronetDataSourceFactory extends BaseFactory {
}
/**
- * 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 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 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.
*
* @param cronetEngineWrapper A {@link CronetEngineWrapper}.
* @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
@@ -244,7 +276,7 @@ public final class CronetDataSourceFactory extends BaseFactory {
}
/**
- * 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 final class CronetDataSourceFactory extends BaseFactory {
}
/**
- * 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 a05dda1983..9f709b14d0 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;
@@ -230,7 +232,7 @@ public final class CronetEngineWrapper {
}
String[] versionStringsLeft = Util.split(versionLeft, "\\.");
String[] versionStringsRight = Util.split(versionRight, "\\.");
- int minLength = Math.min(versionStringsLeft.length, versionStringsRight.length);
+ int minLength = min(versionStringsLeft.length, versionStringsRight.length);
for (int i = 0; i < minLength; i++) {
if (!versionStringsLeft[i].equals(versionStringsRight[i])) {
try {
diff --git a/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java
index 49c34ae53b..ac19c8548d 100644
--- a/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java
+++ b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java
@@ -16,6 +16,7 @@
package com.google.android.exoplayer2.ext.cronet;
import static com.google.common.truth.Truth.assertThat;
+import static java.lang.Math.min;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
@@ -64,13 +65,10 @@ import org.junit.runner.RunWith;
import org.mockito.ArgumentMatchers;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
-import org.robolectric.annotation.LooperMode;
-import org.robolectric.annotation.LooperMode.Mode;
import org.robolectric.shadows.ShadowLooper;
/** Tests for {@link CronetDataSource}. */
@RunWith(AndroidJUnit4.class)
-@LooperMode(Mode.PAUSED)
public final class CronetDataSourceTest {
private static final int TEST_CONNECT_TIMEOUT_MS = 100;
@@ -378,15 +376,18 @@ public final class CronetDataSourceTest {
}
@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).isInstanceOf(HttpDataSource.InvalidResponseCodeException.class);
+ 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())
@@ -1423,7 +1424,7 @@ public final class CronetDataSourceTest {
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 f6e3944572..639d1f6d6c 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,24 +60,23 @@ 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}" && \
+cd "${FFMPEG_EXT_PATH}/jni" && \
+ln -s "$FFMPEG_PATH" ffmpeg
+```
+
+* 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}/jni" && \
./build_ffmpeg.sh \
"${FFMPEG_EXT_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ENABLED_DECODERS[@]}"
```
-* Build the JNI native libraries, setting `APP_ABI` to include the architectures
- built in the previous step. For example:
-
-```
-cd "${FFMPEG_EXT_PATH}" && \
-${NDK_PATH}/ndk-build APP_ABI="armeabi-v7a arm64-v8a x86 x86_64" -j4
-```
-
## Build instructions (Windows) ##
We do not provide support for building this extension on Windows, however it
diff --git a/extensions/ffmpeg/build.gradle b/extensions/ffmpeg/build.gradle
index 26a72ae335..a9edeaff6b 100644
--- a/extensions/ffmpeg/build.gradle
+++ b/extensions/ffmpeg/build.gradle
@@ -11,29 +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'
- }
-
- sourceSets.main {
- 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 c5072a3398..d6980f2801 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 @@ import java.util.List;
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 @@ import java.util.List;
@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 efa1d3965f..0718dc2c5c 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.Format;
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 final class FfmpegAudioRenderer extends DecoderAudioRenderer {
this(
eventHandler,
eventListener,
- new DefaultAudioSink(/* audioCapabilities= */ null, audioProcessors),
- /* enableFloatOutput= */ false);
+ new DefaultAudioSink(/* audioCapabilities= */ null, audioProcessors));
}
/**
@@ -74,21 +74,15 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer {
* 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 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer {
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 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer {
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,31 +137,36 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer {
.build();
}
- private boolean isOutputSupported(Format inputFormat) {
- return shouldUseFloatOutput(inputFormat)
- || supportsOutput(inputFormat.channelCount, inputFormat.sampleRate, 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, inputFormat.sampleRate, 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 cc2a78ae86..71912aea2f 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() {}
@@ -58,7 +63,27 @@ public final class FfmpegLibrary {
/** Returns the version of the underlying library if available, or null otherwise. */
@Nullable
public static String getVersion() {
- return isAvailable() ? ffmpegGetVersion() : null;
+ 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;
}
/**
@@ -130,6 +155,8 @@ public final class FfmpegLibrary {
}
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 6f3b8b1fc7..d2f2fce639 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
@@ -38,7 +38,7 @@ import com.google.android.exoplayer2.video.VideoRendererEventListener;
*/
public final class FfmpegVideoRenderer extends DecoderVideoRenderer {
- private static final String TAG = "FfmpegAudioRenderer";
+ private static final String TAG = "FfmpegVideoRenderer";
/**
* Creates a new instance.
@@ -76,7 +76,7 @@ public final class FfmpegVideoRenderer extends DecoderVideoRenderer {
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(
diff --git a/extensions/ffmpeg/src/main/jni/Android.mk b/extensions/ffmpeg/src/main/jni/Android.mk
deleted file mode 100644
index bcaf12cd11..0000000000
--- 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
-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 7d6f732548..0000000000
--- 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
new file mode 100644
index 0000000000..b60af4fa18
--- /dev/null
+++ b/extensions/ffmpeg/src/main/jni/CMakeLists.txt
@@ -0,0 +1,36 @@
+cmake_minimum_required(VERSION 3.7.1 FATAL_ERROR)
+
+# Enable C++11 features.
+set(CMAKE_CXX_STANDARD 11)
+
+project(libffmpeg_jni C CXX)
+
+set(ffmpeg_location "${CMAKE_CURRENT_SOURCE_DIR}/ffmpeg")
+set(ffmpeg_binaries "${ffmpeg_location}/android-libs/${ANDROID_ABI}")
+
+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)
+
+add_library(ffmpeg_jni
+ SHARED
+ ffmpeg_jni.cc)
+
+target_link_libraries(ffmpeg_jni
+ PRIVATE android
+ PRIVATE avutil
+ PRIVATE swresample
+ PRIVATE avcodec
+ PRIVATE ${android_log_lib})
diff --git a/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh b/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh
index 833ea189b2..4660669a33 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 adbf515f9b..7738e5c2d5 100644
--- a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc
+++ b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc
@@ -113,6 +113,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 a52d1b1d7a..cc8ca5487e 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 com.google.android.exoplayer2.testutil.DefaultRenderersFactoryAsserts;
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 f220d21106..9aeeb83eb3 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 1c0c450a30..e6e66fbe29 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 @@ import org.junit.runner.RunWith;
@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 ed28a2286a..d260a58e5d 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
@@ -39,78 +39,80 @@ public class FlacExtractorTest {
@Test
public void sample() throws Exception {
ExtractorAsserts.assertAllBehaviors(
- FlacExtractor::new, /* file= */ "flac/bear.flac", /* dumpFilesPrefix= */ "flac/bear_raw");
+ FlacExtractor::new,
+ /* file= */ "media/flac/bear.flac",
+ /* dumpFilesPrefix= */ "extractordumps/flac/bear_raw");
}
@Test
public void sampleWithId3HeaderAndId3Enabled() throws Exception {
ExtractorAsserts.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_with_id3.flac",
- /* 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.assertAllBehaviors(
() -> new FlacExtractor(FlacExtractor.FLAG_DISABLE_ID3_METADATA),
- /* file= */ "flac/bear_with_id3.flac",
- /* 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.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_no_seek_table_no_num_samples.flac",
- /* 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.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_with_vorbis_comments.flac",
- /* 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.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_with_picture.flac",
- /* 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.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_one_metadata_block.flac",
- /* 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.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_no_min_max_frame_size.flac",
- /* 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.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_no_num_samples.flac",
- /* 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.assertAllBehaviors(
FlacExtractor::new,
- /* file= */ "flac/bear_uncommon_sample_rate.flac",
- /* 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 e9b1fd1019..bbcc26fb64 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.core.app.ApplicationProvider;
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.extractor.mkv.MatroskaExtractor;
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 @@ public class FlacPlaybackTest {
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 @@ public class FlacPlaybackTest {
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 class FlacPlaybackTest {
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 742ade214d..b736c4d743 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 @@ import java.nio.ByteBuffer;
/* 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 daf4584948..af4e571024 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 @@ import java.nio.ByteBuffer;
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 @@ import java.nio.ByteBuffer;
} 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 364cf80ef8..0ac4dbeffa 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 @@ public final class FlacExtractor implements Extractor {
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 9315c302cc..df511866a3 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.AudioSink;
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 final class LibflacAudioRenderer extends DecoderAudioRenderer {
}
/**
+ * 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 @@ public final class LibflacAudioRenderer extends DecoderAudioRenderer {
|| !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, format.sampleRate, 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 @@ public final class LibflacAudioRenderer extends DecoderAudioRenderer {
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 fb20ff1114..3fb8f2cece 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 @@ import org.junit.runner.RunWith;
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 4e6bd76cb4..891888a0d2 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 f28ba2977e..c67dfdbb5d 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 f5d29efb97..f7b2b3f77c 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.18.1'
+ 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 0e685e55ea..88bc4e14c5 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 static com.google.common.truth.Truth.assertThat;
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;
@@ -39,6 +38,7 @@ import com.google.android.exoplayer2.decoder.DecoderCounters;
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;
@@ -49,7 +49,7 @@ import com.google.android.exoplayer2.trackselection.MappingTrackSelector;
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;
@@ -234,29 +234,26 @@ public final class ImaPlaybackTest {
@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 0000000000..a97307a419
--- /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 77e0f0f7e8..88b0daac49 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,7 +15,11 @@
*/
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;
@@ -36,12 +40,15 @@ import com.google.ads.interactivemedia.v3.api.AdEvent;
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;
@@ -55,14 +62,16 @@ import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
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.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;
@@ -71,31 +80,28 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
-import java.util.HashMap;
-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");
@@ -104,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;
/**
@@ -121,11 +142,13 @@ public final class ImaAdsLoader
* @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();
}
@@ -139,7 +162,20 @@ public final class ImaAdsLoader
* @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;
}
@@ -151,7 +187,7 @@ public final class ImaAdsLoader
* @return This builder, for convenience.
*/
public Builder setAdEventListener(AdEventListener adEventListener) {
- this.adEventListener = Assertions.checkNotNull(adEventListener);
+ this.adEventListener = checkNotNull(adEventListener);
return this;
}
@@ -163,7 +199,38 @@ public final class ImaAdsLoader
* @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;
}
@@ -175,7 +242,7 @@ public final class ImaAdsLoader
* @see AdsRequest#setVastLoadTimeout(float)
*/
public Builder setVastLoadTimeoutMs(int vastLoadTimeoutMs) {
- Assertions.checkArgument(vastLoadTimeoutMs > 0);
+ checkArgument(vastLoadTimeoutMs > 0);
this.vastLoadTimeoutMs = vastLoadTimeoutMs;
return this;
}
@@ -188,7 +255,7 @@ public final class ImaAdsLoader
* @see AdsRenderingSettings#setLoadVideoTimeout(int)
*/
public Builder setMediaLoadTimeoutMs(int mediaLoadTimeoutMs) {
- Assertions.checkArgument(mediaLoadTimeoutMs > 0);
+ checkArgument(mediaLoadTimeoutMs > 0);
this.mediaLoadTimeoutMs = mediaLoadTimeoutMs;
return this;
}
@@ -201,7 +268,7 @@ public final class ImaAdsLoader
* @see AdsRenderingSettings#setBitrateKbps(int)
*/
public Builder setMaxMediaBitrate(int bitrate) {
- Assertions.checkArgument(bitrate > 0);
+ checkArgument(bitrate > 0);
this.mediaBitrate = bitrate;
return this;
}
@@ -220,9 +287,24 @@ public final class ImaAdsLoader
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;
}
@@ -239,12 +321,16 @@ public final class ImaAdsLoader
context,
adTagUri,
imaSdkSettings,
- null,
+ /* adsResponse= */ null,
+ adPreloadTimeoutMs,
vastLoadTimeoutMs,
mediaLoadTimeoutMs,
mediaBitrate,
focusSkipButtonWhenAvailable,
+ playAdBeforeStartPosition,
adUiElements,
+ companionAdSlots,
+ adErrorListener,
adEventListener,
imaFactory);
}
@@ -259,14 +345,18 @@ public final class ImaAdsLoader
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);
}
@@ -282,7 +372,7 @@ public final class ImaAdsLoader
* 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 com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer.VideoAdPlayerCallback
+ * @see VideoAdPlayer.VideoAdPlayerCallback
*/
private static final int AD_PROGRESS_UPDATE_INTERVAL_MS = 100;
@@ -293,7 +383,14 @@ public final class ImaAdsLoader
* 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_THRESHOLD_MS = 5000;
+ 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;
@@ -303,37 +400,43 @@ public final class ImaAdsLoader
@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(AdMediaInfo)} and not {@link
- * #pauseAd(AdMediaInfo)}.
+ * 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(AdMediaInfo)} 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 Handler handler;
- private final List adCallbacks;
- private final AdDisplayContainer adDisplayContainer;
- private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader;
+ private final ComponentListener componentListener;
+ private final List adCallbacks;
private final Runnable updateAdProgressRunnable;
- private final Map adInfoByAdMediaInfo;
+ private final BiMap adInfoByAdMediaInfo;
+ private @MonotonicNonNull AdDisplayContainer adDisplayContainer;
+ private @MonotonicNonNull AdsLoader adsLoader;
private boolean wasSetPlayerCalled;
@Nullable private Player nextPlayer;
@Nullable private Object pendingAdRequestContext;
@@ -342,10 +445,10 @@ public final class ImaAdsLoader
@Nullable private Player player;
private VideoProgressUpdate lastContentProgress;
private VideoProgressUpdate lastAdProgress;
- private int lastVolumePercentage;
+ private int lastVolumePercent;
@Nullable private AdsManager adsManager;
- private boolean initializedAdsManager;
+ private boolean isAdsManagerInitialized;
private boolean hasAdPlaybackState;
@Nullable private AdLoadException pendingAdLoadError;
private Timeline timeline;
@@ -362,10 +465,7 @@ public final class ImaAdsLoader
@Nullable private AdMediaInfo imaAdMediaInfo;
/** The current ad info, or {@code null} if in state {@link #IMA_AD_STATE_NONE}. */
@Nullable private AdInfo imaAdInfo;
- /**
- * Whether {@link com.google.ads.interactivemedia.v3.api.AdsLoader#contentComplete()} has been
- * called since starting ad playback.
- */
+ /** Whether IMA has been notified that playback of content has finished. */
private boolean sentContentComplete;
// Fields tracking the player/loader state.
@@ -385,10 +485,10 @@ public final class ImaAdsLoader
*/
@Nullable private AdInfo pendingAdPrepareErrorAdInfo;
/**
- * If a content period has finished but IMA has not yet called {@link #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.
+ * 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;
/**
@@ -398,8 +498,16 @@ public final class ImaAdsLoader
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.
@@ -417,38 +525,15 @@ public final class ImaAdsLoader
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,
- /* 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,
+ /* companionAdSlots= */ null,
+ /* adErrorListener= */ null,
/* adEventListener= */ null,
/* imaFactory= */ new DefaultImaFactory());
}
@@ -459,21 +544,30 @@ public final class ImaAdsLoader
@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) {
@@ -484,64 +578,50 @@ public final class 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.getApplicationContext(), imaSdkSettings, adDisplayContainer);
- adsLoader.addAdErrorListener(/* adErrorListener= */ this);
- adsLoader.addAdsLoadedListener(/* adsLoadedListener= */ this);
updateAdProgressRunnable = this::updateAdProgress;
- adInfoByAdMediaInfo = new HashMap<>();
+ 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;
+ 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.
*
@@ -549,14 +629,30 @@ public final class ImaAdsLoader
* 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) {
+ public void requestAds(@Nullable ViewGroup adViewGroup) {
if (hasAdPlaybackState || adsManager != null || pendingAdRequestContext != null) {
// Ads have already been requested.
return;
}
- adDisplayContainer.setAdContainer(adViewGroup);
+ 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());
@@ -566,18 +662,31 @@ public final class ImaAdsLoader
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.myLooper() == getImaLooper());
- Assertions.checkState(player == null || player.getApplicationLooper() == getImaLooper());
+ checkState(Looper.myLooper() == getImaLooper());
+ checkState(player == null || player.getApplicationLooper() == getImaLooper());
nextPlayer = player;
wasSetPlayerCalled = true;
}
@@ -606,7 +715,7 @@ public final class ImaAdsLoader
@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) {
@@ -615,15 +724,9 @@ public final class ImaAdsLoader
player.addListener(this);
boolean playWhenReady = player.getPlayWhenReady();
this.eventListener = eventListener;
- lastVolumePercentage = 0;
+ lastVolumePercent = 0;
lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
- ViewGroup adViewGroup = adViewProvider.getAdViewGroup();
- adDisplayContainer.setAdContainer(adViewGroup);
- View[] adOverlayViews = adViewProvider.getAdOverlayViews();
- for (View view : adOverlayViews) {
- adDisplayContainer.registerVideoControlsOverlay(view);
- }
maybeNotifyPendingAdLoadError();
if (hasAdPlaybackState) {
// Pass the ad playback state to the player, and resume ads if necessary.
@@ -632,11 +735,20 @@ public final class ImaAdsLoader
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));
+ }
}
}
@@ -652,10 +764,12 @@ public final class ImaAdsLoader
adPlaybackState.withAdResumePositionUs(
playingAd ? C.msToUs(player.getCurrentPosition()) : 0);
}
- lastVolumePercentage = getVolume();
+ lastVolumePercent = getPlayerVolumePercent();
lastAdProgress = getAdVideoProgressUpdate();
- lastContentProgress = getContentProgress();
- adDisplayContainer.unregisterAllVideoControlsOverlays();
+ lastContentProgress = getContentVideoProgressUpdate();
+ if (adDisplayContainer != null) {
+ adDisplayContainer.unregisterAllFriendlyObstructions();
+ }
player.removeListener(this);
this.player = null;
eventListener = null;
@@ -664,27 +778,41 @@ public final class ImaAdsLoader
@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 = false;
+ 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) {
@@ -692,266 +820,11 @@ public final class ImaAdsLoader
}
try {
handleAdPrepareError(adGroupIndex, adIndexInAdGroup, exception);
- } catch (Exception e) {
+ } catch (RuntimeException e) {
maybeNotifyInternalError("handlePrepareError", e);
}
}
- // com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener implementation.
-
- @Override
- public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) {
- AdsManager adsManager = adsManagerLoadedEvent.getAdsManager();
- if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) {
- adsManager.destroy();
- 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()));
- hasAdPlaybackState = true;
- updateAdPlaybackState();
- } catch (Exception e) {
- maybeNotifyInternalError("onAdsManagerLoaded", e);
- }
- }
- }
-
- // AdEvent.AdEventListener implementation.
-
- @Override
- public void onAdEvent(AdEvent adEvent) {
- AdEventType adEventType = adEvent.getType();
- if (DEBUG) {
- Log.d(TAG, "onAdEvent: " + adEventType);
- }
- if (adsManager == null) {
- // Drop events after release.
- return;
- }
- try {
- handleAdEvent(adEvent);
- } catch (Exception 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 (Exception e) {
- maybeNotifyInternalError("onAdError", e);
- }
- }
- if (pendingAdLoadError == null) {
- pendingAdLoadError = AdLoadException.createForAllAds(error);
- }
- maybeNotifyPendingAdLoadError();
- }
-
- // ContentProgressProvider implementation.
-
- @Override
- public VideoProgressUpdate getContentProgress() {
- if (player == null) {
- return lastContentProgress;
- }
- boolean hasContentDuration = contentDurationMs != C.TIME_UNSET;
- long contentPositionMs;
- if (pendingContentPositionMs != C.TIME_UNSET) {
- sentPendingContentPositionMs = true;
- contentPositionMs = pendingContentPositionMs;
- } else if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) {
- long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs;
- contentPositionMs = fakeContentProgressOffsetMs + elapsedSinceEndMs;
- } else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) {
- contentPositionMs = getContentPeriodPositionMs(player, timeline, period);
- } else {
- return VideoProgressUpdate.VIDEO_TIME_NOT_READY;
- }
- long contentDurationMs = hasContentDuration ? this.contentDurationMs : IMA_DURATION_UNSET;
- return new VideoProgressUpdate(contentPositionMs, contentDurationMs);
- }
-
- // VideoAdPlayer implementation.
-
- @Override
- public VideoProgressUpdate getAdProgress() {
- throw new IllegalStateException("Unexpected call to getAdProgress when using preloading");
- }
-
- @Override
- public int getVolume() {
- @Nullable Player player = this.player;
- if (player == null) {
- return lastVolumePercentage;
- }
-
- @Nullable Player.AudioComponent audioComponent = player.getAudioComponent();
- if (audioComponent != null) {
- return (int) (audioComponent.getVolume() * 100);
- }
-
- // Check for a selected track using an audio renderer.
- TrackSelectionArray trackSelections = player.getCurrentTrackSelections();
- for (int i = 0; i < player.getRendererCount() && i < trackSelections.length; i++) {
- if (player.getRendererType(i) == C.TRACK_TYPE_AUDIO && trackSelections.get(i) != null) {
- return 100;
- }
- }
- return 0;
- }
-
- @Override
- public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) {
- try {
- if (DEBUG) {
- Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo);
- }
- if (adsManager == null) {
- // Drop events after release.
- return;
- }
- int adGroupIndex = getAdGroupIndex(adPodInfo);
- int adIndexInAdGroup = adPodInfo.getAdPosition() - 1;
- AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup);
- adInfoByAdMediaInfo.put(adMediaInfo, adInfo);
- AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex];
- if (adGroup.count == C.LENGTH_UNSET) {
- adPlaybackState =
- adPlaybackState.withAdCount(
- adInfo.adGroupIndex, Math.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= */ adGroupIndex, /* adIndexInAdGroup= */ i);
- }
- }
- Uri adUri = Uri.parse(adMediaInfo.getUrl());
- adPlaybackState =
- adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri);
- 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(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 = Assertions.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;
- Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo));
- for (int i = 0; i < adCallbacks.size(); i++) {
- adCallbacks.get(i).onResume(adMediaInfo);
- }
- }
- if (!Assertions.checkNotNull(player).getPlayWhenReady()) {
- Assertions.checkNotNull(adsManager).pause();
- }
- }
-
- @Override
- public void stopAd(AdMediaInfo adMediaInfo) {
- if (DEBUG) {
- Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo));
- }
- if (adsManager == null) {
- // Drop event after release.
- return;
- }
-
- Assertions.checkNotNull(player);
- Assertions.checkState(imaAdState != IMA_AD_STATE_NONE);
- try {
- stopAdInternal();
- } catch (Exception e) {
- maybeNotifyInternalError("stopAd", e);
- }
- }
-
- @Override
- public void pauseAd(AdMediaInfo adMediaInfo) {
- if (DEBUG) {
- Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo));
- }
- if (imaAdState == IMA_AD_STATE_NONE) {
- // This method is called after content is resumed.
- return;
- }
- Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo));
- imaAdState = IMA_AD_STATE_PAUSED;
- for (int i = 0; i < adCallbacks.size(); i++) {
- adCallbacks.get(i).onPause(adMediaInfo);
- }
- }
-
// Player.EventListener implementation.
@Override
@@ -960,16 +833,28 @@ public final class ImaAdsLoader
// The player is being reset or contains no media.
return;
}
- Assertions.checkArgument(timeline.getPeriodCount() == 1);
+ 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(adsManager);
+ @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();
}
@@ -981,9 +866,34 @@ public final class ImaAdsLoader
@Override
public void onPlaybackStateChanged(@Player.State int playbackState) {
+ @Nullable Player player = this.player;
if (adsManager == null || player == null) {
return;
}
+
+ 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;
+ }
+
handlePlayerStateChanged(player.getPlayWhenReady(), playbackState);
}
@@ -1009,7 +919,7 @@ public final class ImaAdsLoader
@Override
public void onPlayerError(ExoPlaybackException error) {
if (imaAdState != IMA_AD_STATE_NONE) {
- AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo);
+ AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onError(adMediaInfo);
}
@@ -1018,7 +928,12 @@ public final class ImaAdsLoader
// Internal methods.
- private void initializeAdsManager(AdsManager adsManager) {
+ /**
+ * 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);
@@ -1034,65 +949,135 @@ public final class ImaAdsLoader
}
// Skip ads based on the start position as required.
- long[] adGroupTimesUs = getAdGroupTimesUs(adsManager.getAdCuePoints());
- long contentPositionMs =
- getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period);
- int adGroupIndexForPosition =
+ long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs;
+ long contentPositionMs = getContentPeriodPositionMs(checkNotNull(player), timeline, period);
+ int adGroupForPositionIndex =
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);
+ 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);
+ }
}
- // 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);
}
+ return adsRenderingSettings;
+ }
- if (adGroupIndexForPosition != C.INDEX_UNSET && hasMidrollAdGroups(adGroupTimesUs)) {
- // Provide the player's initial position to trigger loading and playing the ad.
- pendingContentPositionMs = contentPositionMs;
+ private VideoProgressUpdate getContentVideoProgressUpdate() {
+ if (player == null) {
+ return lastContentProgress;
}
+ boolean hasContentDuration = contentDurationMs != C.TIME_UNSET;
+ long contentPositionMs;
+ if (pendingContentPositionMs != C.TIME_UNSET) {
+ sentPendingContentPositionMs = true;
+ contentPositionMs = pendingContentPositionMs;
+ } else if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) {
+ long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs;
+ contentPositionMs = fakeContentProgressOffsetMs + elapsedSinceEndMs;
+ } else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) {
+ contentPositionMs = getContentPeriodPositionMs(player, timeline, period);
+ } else {
+ return VideoProgressUpdate.VIDEO_TIME_NOT_READY;
+ }
+ long contentDurationMs = hasContentDuration ? this.contentDurationMs : IMA_DURATION_UNSET;
+ return new VideoProgressUpdate(contentPositionMs, contentDurationMs);
+ }
- adsManager.init(adsRenderingSettings);
- adsManager.start();
- updateAdPlaybackState();
- if (DEBUG) {
- Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings);
+ 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
+ : new VideoProgressUpdate(player.getCurrentPosition(), adDuration);
+ } else {
+ return VideoProgressUpdate.VIDEO_TIME_NOT_READY;
}
}
+ 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 lastVolumePercent;
+ }
+
+ @Nullable Player.AudioComponent audioComponent = player.getAudioComponent();
+ if (audioComponent != null) {
+ return (int) (audioComponent.getVolume() * 100);
+ }
+
+ // Check for a selected track using an audio renderer.
+ TrackSelectionArray trackSelections = player.getCurrentTrackSelections();
+ for (int i = 0; i < player.getRendererCount() && i < trackSelections.length; i++) {
+ if (player.getRendererType(i) == C.TRACK_TYPE_AUDIO && trackSelections.get(i) != null) {
+ return 100;
+ }
+ }
+ return 0;
+ }
+
private void handleAdEvent(AdEvent adEvent) {
+ if (adsManager == null) {
+ // Drop events after release.
+ return;
+ }
switch (adEvent.getType()) {
case AD_BREAK_FETCH_ERROR:
- String adGroupTimeSecondsString =
- Assertions.checkNotNull(adEvent.getAdData().get("adBreakTime"));
+ String adGroupTimeSecondsString = checkNotNull(adEvent.getAdData().get("adBreakTime"));
if (DEBUG) {
Log.d(TAG, "Fetch error for ad at " + adGroupTimeSecondsString + " seconds");
}
- int adGroupTimeSeconds = Integer.parseInt(adGroupTimeSecondsString);
+ double adGroupTimeSeconds = Double.parseDouble(adGroupTimeSecondsString);
int adGroupIndex =
- Arrays.binarySearch(
- adPlaybackState.adGroupTimesUs, C.MICROS_PER_SECOND * adGroupTimeSeconds);
- AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex];
- if (adGroup.count == C.LENGTH_UNSET) {
- adPlaybackState =
- adPlaybackState.withAdCount(adGroupIndex, Math.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();
+ 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
@@ -1124,37 +1109,30 @@ public final class ImaAdsLoader
}
}
- 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
- : new VideoProgressUpdate(player.getCurrentPosition(), adDuration);
- } else {
- return VideoProgressUpdate.VIDEO_TIME_NOT_READY;
+ private void pauseContentInternal() {
+ imaAdState = IMA_AD_STATE_NONE;
+ if (sentPendingContentPositionMs) {
+ pendingContentPositionMs = C.TIME_UNSET;
+ sentPendingContentPositionMs = false;
}
}
- private void updateAdProgress() {
- VideoProgressUpdate videoProgressUpdate = getAdVideoProgressUpdate();
- AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo);
- for (int i = 0; i < adCallbacks.size(); i++) {
- adCallbacks.get(i).onAdProgress(adMediaInfo, videoProgressUpdate);
+ 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();
}
- handler.removeCallbacks(updateAdProgressRunnable);
- handler.postDelayed(updateAdProgressRunnable, AD_PROGRESS_UPDATE_INTERVAL_MS);
- }
-
- private void stopUpdatingAdProgress() {
- handler.removeCallbacks(updateAdProgressRunnable);
}
private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) {
if (playingAd && imaAdState == IMA_AD_STATE_PLAYING) {
if (!bufferingAd && playbackState == Player.STATE_BUFFERING) {
- AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo);
+ AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onBuffering(adMediaInfo);
}
@@ -1168,9 +1146,9 @@ public final class ImaAdsLoader
if (imaAdState == IMA_AD_STATE_NONE
&& playbackState == Player.STATE_BUFFERING
&& playWhenReady) {
- checkForContentComplete();
+ ensureSentContentCompleteIfAtEndOfStream();
} else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) {
- AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo);
+ AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
if (adMediaInfo == null) {
Log.w(TAG, "onEnded without ad media info");
} else {
@@ -1190,15 +1168,8 @@ public final class ImaAdsLoader
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()) {
+ ensureSentContentCompleteIfAtEndOfStream();
+ if (!sentContentComplete && !timeline.isEmpty()) {
long positionMs = getContentPeriodPositionMs(player, timeline, period);
timeline.getPeriod(/* periodIndex= */ 0, period);
int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs));
@@ -1231,37 +1202,159 @@ public final class ImaAdsLoader
}
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 (imaAdInfo != null) {
- adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex);
- updateAdPlaybackState();
+ private void loadAdInternal(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) {
+ if (adsManager == null) {
+ // Drop events after release.
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "loadAd after release " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo);
+ }
+ return;
+ }
+
+ 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 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 pauseContentInternal() {
- imaAdState = IMA_AD_STATE_NONE;
- if (sentPendingContentPositionMs) {
- pendingContentPositionMs = C.TIME_UNSET;
- sentPendingContentPositionMs = false;
+ 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() {
+ 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;
stopUpdatingAdProgress();
// TODO: Handle the skipped event so the ad can be marked as skipped rather than played.
- Assertions.checkNotNull(imaAdInfo);
+ 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();
@@ -1271,30 +1364,38 @@ public final class ImaAdsLoader
}
}
+ 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) {
if (player == null) {
return;
}
- // TODO: Once IMA signals which ad group failed to load, clean up this code.
- long playerPositionMs = player.getContentPosition();
- int adGroupIndex =
- adPlaybackState.getAdGroupIndexForPositionUs(
- C.msToUs(playerPositionMs), C.msToUs(contentDurationMs));
+ // TODO: Once IMA signals which ad group failed to load, remove this call.
+ int adGroupIndex = getLoadingAdGroupIndex();
if (adGroupIndex == C.INDEX_UNSET) {
- adGroupIndex =
- adPlaybackState.getAdGroupIndexAfterPositionUs(
- C.msToUs(playerPositionMs), C.msToUs(contentDurationMs));
- if (adGroupIndex == C.INDEX_UNSET) {
- // The error doesn't seem to relate to any ad group so give up handling it.
- return;
- }
+ 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++) {
@@ -1332,7 +1433,7 @@ public final class ImaAdsLoader
}
pendingAdPrepareErrorAdInfo = new AdInfo(adGroupIndex, adIndexInAdGroup);
} else {
- AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo);
+ 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,
@@ -1343,27 +1444,40 @@ public final class ImaAdsLoader
}
playingAdIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay();
for (int i = 0; i < adCallbacks.size(); i++) {
- adCallbacks.get(i).onError(Assertions.checkNotNull(adMediaInfo));
+ adCallbacks.get(i).onError(checkNotNull(adMediaInfo));
}
}
adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, adIndexInAdGroup);
updateAdPlaybackState();
}
- private void checkForContentComplete() {
- long positionMs = getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period);
+ private void ensureSentContentCompleteIfAtEndOfStream() {
if (!sentContentComplete
&& contentDurationMs != C.TIME_UNSET
&& pendingContentPositionMs == C.TIME_UNSET
- && positionMs + END_OF_CONTENT_THRESHOLD_MS >= contentDurationMs) {
- adsLoader.contentComplete();
- if (DEBUG) {
- Log.d(TAG, "adsLoader.contentComplete");
- }
- sentContentComplete = true;
+ && 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);
+ }
+ }
+ updateAdPlaybackState();
+ }
+
private void updateAdPlaybackState() {
// Ignore updates while detached. When a player is attached it will receive the latest state.
if (eventListener != null) {
@@ -1393,6 +1507,68 @@ public final class ImaAdsLoader
}
}
+ 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());
+ }
+
+ /**
+ * 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;
+ }
+
+ 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;
+ }
+ }
+ 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);
}
@@ -1401,45 +1577,9 @@ public final class ImaAdsLoader
Player player, Timeline timeline, Timeline.Period period) {
long contentWindowPositionMs = player.getContentPosition();
return contentWindowPositionMs
- - timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs();
- }
-
- private int getAdGroupIndex(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.
- long adGroupTimeUs = (long) (((float) adPodInfo.getTimeOffset()) * C.MICROS_PER_SECOND);
- for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) {
- if (adPlaybackState.adGroupTimesUs[adGroupIndex] == adGroupTimeUs) {
- return adGroupIndex;
- }
- }
- throw new IllegalStateException("Failed to find cue point");
- }
-
- private static long[] getAdGroupTimesUs(List cuePoints) {
- if (cuePoints.isEmpty()) {
- // If no cue points are specified, there is a preroll ad.
- return new long[] {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++] = (long) (C.MICROS_PER_SECOND * cuePoint);
- }
- }
- // Cue points may be out of order, so sort them.
- Arrays.sort(adGroupTimesUs, 0, adGroupIndex);
- return adGroupTimesUs;
+ - (timeline.isEmpty()
+ ? 0
+ : timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs());
}
private static boolean isAdGroupLoadError(AdError adError) {
@@ -1467,25 +1607,227 @@ public final class ImaAdsLoader
}
}
+ 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 String getAdMediaInfoString(AdMediaInfo adMediaInfo) {
- @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo);
- return "AdMediaInfo[" + adMediaInfo.getUrl() + (adInfo != null ? ", " + adInfo : "") + "]";
+ 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.
@@ -1539,8 +1881,25 @@ public final class ImaAdsLoader
}
@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
@@ -1549,7 +1908,7 @@ public final class ImaAdsLoader
}
@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/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java
index 6405583bf1..e32a199200 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,12 +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;
@@ -29,7 +33,6 @@ import android.view.View;
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;
@@ -40,15 +43,19 @@ 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;
@@ -56,7 +63,10 @@ import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline;
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;
@@ -73,6 +83,7 @@ import org.mockito.Mock;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.mockito.stubbing.Answer;
+import org.robolectric.shadows.ShadowSystemClock;
/** Tests for {@link ImaAdsLoader}. */
@RunWith(AndroidJUnit4.class)
@@ -88,8 +99,7 @@ public final class ImaAdsLoaderTest {
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 ImmutableList PREROLL_CUE_POINTS_SECONDS = ImmutableList.of(0f);
@Rule public final MockitoRule mockito = MockitoJUnit.rule();
@@ -100,13 +110,17 @@ public final class ImaAdsLoaderTest {
@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;
@@ -114,8 +128,8 @@ public final class ImaAdsLoaderTest {
@Before
public void setUp() {
setupMocks();
- adViewGroup = new FrameLayout(ApplicationProvider.getApplicationContext());
- adOverlayView = new View(ApplicationProvider.getApplicationContext());
+ adViewGroup = new FrameLayout(getApplicationContext());
+ View adOverlayView = new View(getApplicationContext());
adViewProvider =
new AdsLoader.AdViewProvider() {
@Override
@@ -124,8 +138,21 @@ public final class ImaAdsLoaderTest {
}
@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();
}
};
}
@@ -140,24 +167,36 @@ public final class ImaAdsLoaderTest {
@Test
public void builder_overridesPlayerType() {
when(mockImaSdkSettings.getPlayerType()).thenReturn("test player type");
- setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+ setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS);
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(mockAdDisplayContainer, atLeastOnce()).setAdContainer(adViewGroup);
- verify(mockAdDisplayContainer, 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.
@@ -166,26 +205,27 @@ public final class ImaAdsLoaderTest {
@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);
@@ -196,47 +236,47 @@ public final class ImaAdsLoaderTest {
// 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, mockPrerollSingleAd));
- imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo);
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd));
- imaAdsLoader.playAd(TEST_AD_MEDIA_INFO);
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd));
- imaAdsLoader.pauseAd(TEST_AD_MEDIA_INFO);
- imaAdsLoader.stopAd(TEST_AD_MEDIA_INFO);
+ 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, mockPrerollSingleAd));
- imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo);
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd));
+ 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(TEST_AD_MEDIA_INFO);
+ 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, mockPrerollSingleAd));
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, mockPrerollSingleAd));
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.MIDPOINT, mockPrerollSingleAd));
- imaAdsLoader.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, mockPrerollSingleAd));
+ 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(TEST_AD_MEDIA_INFO);
- 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)
@@ -245,32 +285,479 @@ public final class ImaAdsLoaderTest {
.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(mockAdDisplayContainer);
- inOrder.verify(mockAdDisplayContainer).registerVideoControlsOverlay(adOverlayView);
- inOrder.verify(mockAdDisplayContainer).unregisterAllVideoControlsOverlays();
+ 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(mockAdsManager.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(mockImaSdkSettings)
- .buildForAdTag(TEST_URI);
+ .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);
}
@@ -278,9 +765,11 @@ public final class ImaAdsLoaderTest {
ArgumentCaptor userRequestContextCaptor = ArgumentCaptor.forClass(Object.class);
doNothing().when(mockAdsRequest).setUserRequestContext(userRequestContextCaptor.capture());
when(mockAdsRequest.getUserRequestContext())
- .thenAnswer((Answer) invocation -> userRequestContextCaptor.getValue());
+ .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));
@@ -288,13 +777,6 @@ public final class ImaAdsLoaderTest {
})
.when(mockAdsLoader)
.addAdsLoadedListener(any());
- doAnswer(
- invocation -> {
- adsLoadedListeners.remove(invocation.getArgument(0));
- return null;
- })
- .when(mockAdsLoader)
- .removeAdsLoadedListener(any());
when(mockAdsManagerLoadedEvent.getAdsManager()).thenReturn(mockAdsManager);
when(mockAdsManagerLoadedEvent.getUserRequestContext())
.thenAnswer(invocation -> mockAdsRequest.getUserRequestContext());
@@ -310,10 +792,41 @@ public final class ImaAdsLoaderTest {
.when(mockAdsLoader)
.requestAds(mockAdsRequest);
- when(mockImaFactory.createAdDisplayContainer()).thenReturn(mockAdDisplayContainer);
+ 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);
@@ -347,19 +860,21 @@ public final class ImaAdsLoaderTest {
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 613277bad2..9e26c07c5d 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 05ac82ba08..df50cde8f9 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 8841f8355f..b65988a5e2 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 @@ import com.google.android.exoplayer2.util.Util;
@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 final class JobDispatcherScheduler implements Scheduler {
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 @@ public final class JobDispatcherScheduler implements Scheduler {
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 19b4cde3bf..14ced09f12 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 e385cd52e9..6538160b8b 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 final class LeanbackPlayerAdapter extends PlayerAdapter implements Runnab
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 0000000000..32ea864940
--- /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 0000000000..744d79980b
--- /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 0000000000..b699de67b1
--- /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 0000000000..8cf586b846
--- /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 0000000000..23a4491389
--- /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 0000000000..df6963c2fc
--- /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 0000000000..c578b0ba8c
--- /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 0000000000..b80cbe5a5f
--- /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 0000000000..a7eb058ee6
--- /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 0000000000..1861e5e44e
--- /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 0000000000..657faf7718
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 0000000000..b8d9236def
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 0000000000..c29d88c21f
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 0000000000..767bd5c647
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 0000000000..571ff4459d
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 0000000000..c51f109f97
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 0000000000..3b87ee9dfa
--- /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 0000000000..c23bdd5669
--- /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 0000000000..218c2a737e
--- /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 0000000000..e7cc9545b1
--- /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 0000000000..fc80c85856
--- /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 0000000000..09e0325e93
--- /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 media2Playlist;
+ private final List exoPlayerPlaylist;
+
+ private ControlDispatcher controlDispatcher;
+ private boolean prepared;
+ private boolean rebuffering;
+ private int currentWindowIndex;
+ private boolean ignoreTimelineUpdates;
+
+ /**
+ * Creates a new ExoPlayer wrapper.
+ *
+ * @param listener A {@link Listener}.
+ * @param player The {@link Player}.
+ * @param mediaItemConverter The {@link MediaItemConverter}.
+ */
+ public PlayerWrapper(Listener listener, Player player, MediaItemConverter mediaItemConverter) {
+ this.listener = listener;
+ this.player = player;
+ this.mediaItemConverter = mediaItemConverter;
+
+ controlDispatcher = new DefaultControlDispatcher();
+ componentListener = new ComponentListener();
+ player.addListener(componentListener);
+ @Nullable Player.AudioComponent audioComponent = player.getAudioComponent();
+ if (audioComponent != null) {
+ audioComponent.addAudioListener(componentListener);
+ }
+
+ handler = new Handler(player.getApplicationLooper());
+ pollBufferRunnable = new PollBufferRunnable();
+
+ media2Playlist = new ArrayList<>();
+ exoPlayerPlaylist = new ArrayList<>();
+ currentWindowIndex = C.INDEX_UNSET;
+
+ prepared = player.getPlaybackState() != Player.STATE_IDLE;
+ rebuffering = player.getPlaybackState() == Player.STATE_BUFFERING;
+
+ updatePlaylist(player.getCurrentTimeline());
+ }
+
+ public void setControlDispatcher(ControlDispatcher controlDispatcher) {
+ this.controlDispatcher = controlDispatcher;
+ }
+
+ public boolean setMediaItem(androidx.media2.common.MediaItem media2MediaItem) {
+ return setPlaylist(Collections.singletonList(media2MediaItem), /* metadata= */ null);
+ }
+
+ public boolean setPlaylist(
+ List playlist, @Nullable MediaMetadata metadata) {
+ // Check for duplication.
+ for (int i = 0; i < playlist.size(); i++) {
+ androidx.media2.common.MediaItem media2MediaItem = playlist.get(i);
+ Assertions.checkArgument(playlist.indexOf(media2MediaItem) == i);
+ }
+
+ this.playlistMetadata = metadata;
+ List exoPlayerMediaItems = new ArrayList<>();
+ for (int i = 0; i < playlist.size(); i++) {
+ androidx.media2.common.MediaItem media2MediaItem = playlist.get(i);
+ MediaItem exoPlayerMediaItem =
+ Assertions.checkNotNull(mediaItemConverter.convertToExoPlayerMediaItem(media2MediaItem));
+ exoPlayerMediaItems.add(exoPlayerMediaItem);
+ }
+
+ player.setMediaItems(exoPlayerMediaItems, /* resetPosition= */ true);
+
+ currentWindowIndex = getCurrentMediaItemIndex();
+ return true;
+ }
+
+ public boolean addPlaylistItem(int index, androidx.media2.common.MediaItem media2MediaItem) {
+ Assertions.checkArgument(!media2Playlist.contains(media2MediaItem));
+ index = Util.constrainValue(index, 0, media2Playlist.size());
+
+ MediaItem exoPlayerMediaItem =
+ Assertions.checkNotNull(mediaItemConverter.convertToExoPlayerMediaItem(media2MediaItem));
+ player.addMediaItem(index, exoPlayerMediaItem);
+ return true;
+ }
+
+ public boolean removePlaylistItem(@IntRange(from = 0) int index) {
+ player.removeMediaItem(index);
+ return true;
+ }
+
+ public boolean replacePlaylistItem(int index, androidx.media2.common.MediaItem media2MediaItem) {
+ Assertions.checkArgument(!media2Playlist.contains(media2MediaItem));
+ index = Util.constrainValue(index, 0, media2Playlist.size());
+
+ MediaItem exoPlayerMediaItemToAdd =
+ Assertions.checkNotNull(mediaItemConverter.convertToExoPlayerMediaItem(media2MediaItem));
+
+ ignoreTimelineUpdates = true;
+ player.removeMediaItem(index);
+ ignoreTimelineUpdates = false;
+ player.addMediaItem(index, exoPlayerMediaItemToAdd);
+ return true;
+ }
+
+ public boolean skipToPreviousPlaylistItem() {
+ Timeline timeline = player.getCurrentTimeline();
+ Assertions.checkState(!timeline.isEmpty());
+ int previousWindowIndex = player.getPreviousWindowIndex();
+ if (previousWindowIndex != C.INDEX_UNSET) {
+ return controlDispatcher.dispatchSeekTo(player, previousWindowIndex, C.TIME_UNSET);
+ }
+ return false;
+ }
+
+ public boolean skipToNextPlaylistItem() {
+ Timeline timeline = player.getCurrentTimeline();
+ Assertions.checkState(!timeline.isEmpty());
+ int nextWindowIndex = player.getNextWindowIndex();
+ if (nextWindowIndex != C.INDEX_UNSET) {
+ return controlDispatcher.dispatchSeekTo(player, nextWindowIndex, C.TIME_UNSET);
+ }
+ return false;
+ }
+
+ public boolean skipToPlaylistItem(@IntRange(from = 0) int index) {
+ Timeline timeline = player.getCurrentTimeline();
+ Assertions.checkState(!timeline.isEmpty());
+ // Use checkState() instead of checkIndex() for throwing IllegalStateException.
+ // checkIndex() throws IndexOutOfBoundsException which maps the RESULT_ERROR_BAD_VALUE
+ // but RESULT_ERROR_INVALID_STATE with IllegalStateException is expected here.
+ Assertions.checkState(0 <= index && index < timeline.getWindowCount());
+ int windowIndex = player.getCurrentWindowIndex();
+ if (windowIndex != index) {
+ return controlDispatcher.dispatchSeekTo(player, index, C.TIME_UNSET);
+ }
+ return false;
+ }
+
+ public boolean updatePlaylistMetadata(@Nullable MediaMetadata metadata) {
+ this.playlistMetadata = metadata;
+ return true;
+ }
+
+ public boolean setRepeatMode(int repeatMode) {
+ return controlDispatcher.dispatchSetRepeatMode(
+ player, Utils.getExoPlayerRepeatMode(repeatMode));
+ }
+
+ public boolean setShuffleMode(int shuffleMode) {
+ return controlDispatcher.dispatchSetShuffleModeEnabled(
+ player, Utils.getExoPlayerShuffleMode(shuffleMode));
+ }
+
+ @Nullable
+ public List getPlaylist() {
+ return new ArrayList<>(media2Playlist);
+ }
+
+ @Nullable
+ public MediaMetadata getPlaylistMetadata() {
+ return playlistMetadata;
+ }
+
+ public int getRepeatMode() {
+ return Utils.getRepeatMode(player.getRepeatMode());
+ }
+
+ public int getShuffleMode() {
+ return Utils.getShuffleMode(player.getShuffleModeEnabled());
+ }
+
+ public int getCurrentMediaItemIndex() {
+ return media2Playlist.isEmpty() ? C.INDEX_UNSET : player.getCurrentWindowIndex();
+ }
+
+ public int getPreviousMediaItemIndex() {
+ return player.getPreviousWindowIndex();
+ }
+
+ public int getNextMediaItemIndex() {
+ return player.getNextWindowIndex();
+ }
+
+ @Nullable
+ public androidx.media2.common.MediaItem getCurrentMediaItem() {
+ int index = getCurrentMediaItemIndex();
+ return index == C.INDEX_UNSET ? null : media2Playlist.get(index);
+ }
+
+ public boolean prepare() {
+ if (prepared) {
+ return false;
+ }
+ player.prepare();
+ return true;
+ }
+
+ public boolean play() {
+ if (player.getPlaybackState() == Player.STATE_ENDED) {
+ boolean seekHandled =
+ controlDispatcher.dispatchSeekTo(
+ player, player.getCurrentWindowIndex(), /* positionMs= */ 0);
+ if (!seekHandled) {
+ return false;
+ }
+ }
+ boolean playWhenReady = player.getPlayWhenReady();
+ int suppressReason = player.getPlaybackSuppressionReason();
+ if (playWhenReady && suppressReason == Player.PLAYBACK_SUPPRESSION_REASON_NONE) {
+ return false;
+ }
+ return controlDispatcher.dispatchSetPlayWhenReady(player, /* playWhenReady= */ true);
+ }
+
+ public boolean pause() {
+ boolean playWhenReady = player.getPlayWhenReady();
+ int suppressReason = player.getPlaybackSuppressionReason();
+ if (!playWhenReady && suppressReason == Player.PLAYBACK_SUPPRESSION_REASON_NONE) {
+ return false;
+ }
+ return controlDispatcher.dispatchSetPlayWhenReady(player, /* playWhenReady= */ false);
+ }
+
+ public boolean seekTo(long position) {
+ return controlDispatcher.dispatchSeekTo(player, player.getCurrentWindowIndex(), position);
+ }
+
+ public long getCurrentPosition() {
+ return player.getCurrentPosition();
+ }
+
+ public long getDuration() {
+ long duration = player.getDuration();
+ return duration == C.TIME_UNSET ? SessionPlayer.UNKNOWN_TIME : duration;
+ }
+
+ public long getBufferedPosition() {
+ return player.getBufferedPosition();
+ }
+
+ /* @SessionPlayer.PlayerState */
+ private int getState() {
+ if (hasError()) {
+ return SessionPlayer.PLAYER_STATE_ERROR;
+ }
+ int state = player.getPlaybackState();
+ boolean playWhenReady = player.getPlayWhenReady();
+ switch (state) {
+ case Player.STATE_IDLE:
+ return SessionPlayer.PLAYER_STATE_IDLE;
+ case Player.STATE_ENDED:
+ return SessionPlayer.PLAYER_STATE_PAUSED;
+ case Player.STATE_BUFFERING:
+ case Player.STATE_READY:
+ return playWhenReady
+ ? SessionPlayer.PLAYER_STATE_PLAYING
+ : SessionPlayer.PLAYER_STATE_PAUSED;
+ default:
+ throw new IllegalStateException();
+ }
+ }
+
+ public void setAudioAttributes(AudioAttributesCompat audioAttributes) {
+ Player.AudioComponent audioComponent = Assertions.checkStateNotNull(player.getAudioComponent());
+ audioComponent.setAudioAttributes(
+ Utils.getAudioAttributes(audioAttributes), /* handleAudioFocus= */ true);
+ }
+
+ public AudioAttributesCompat getAudioAttributes() {
+ @Nullable Player.AudioComponent audioComponent = player.getAudioComponent();
+ return Utils.getAudioAttributesCompat(
+ audioComponent != null ? audioComponent.getAudioAttributes() : AudioAttributes.DEFAULT);
+ }
+
+ public void setPlaybackSpeed(float playbackSpeed) {
+ player.setPlaybackParameters(new PlaybackParameters(playbackSpeed));
+ }
+
+ public float getPlaybackSpeed() {
+ return player.getPlaybackParameters().speed;
+ }
+
+ public void reset() {
+ controlDispatcher.dispatchStop(player, /* reset= */ true);
+ prepared = false;
+ rebuffering = false;
+ }
+
+ public void close() {
+ handler.removeCallbacks(pollBufferRunnable);
+ player.removeListener(componentListener);
+
+ @Nullable Player.AudioComponent audioComponent = player.getAudioComponent();
+ if (audioComponent != null) {
+ audioComponent.removeAudioListener(componentListener);
+ }
+ }
+
+ public boolean isCurrentMediaItemSeekable() {
+ return getCurrentMediaItem() != null
+ && !player.isPlayingAd()
+ && player.isCurrentWindowSeekable();
+ }
+
+ public boolean canSkipToPlaylistItem() {
+ @Nullable List playlist = getPlaylist();
+ return playlist != null && playlist.size() > 1;
+ }
+
+ public boolean canSkipToPreviousPlaylistItem() {
+ return player.hasPrevious();
+ }
+
+ public boolean canSkipToNextPlaylistItem() {
+ return player.hasNext();
+ }
+
+ public boolean hasError() {
+ return player.getPlayerError() != null;
+ }
+
+ private void handlePlayWhenReadyChanged() {
+ listener.onPlayerStateChanged(getState());
+ }
+
+ private void handlePlayerStateChanged(@Player.State int state) {
+ if (state == Player.STATE_READY || state == Player.STATE_BUFFERING) {
+ postOrRun(handler, pollBufferRunnable);
+ } else {
+ handler.removeCallbacks(pollBufferRunnable);
+ }
+
+ switch (state) {
+ case Player.STATE_BUFFERING:
+ maybeNotifyBufferingEvents();
+ break;
+ case Player.STATE_READY:
+ maybeNotifyReadyEvents();
+ break;
+ case Player.STATE_ENDED:
+ maybeNotifyEndedEvents();
+ break;
+ case Player.STATE_IDLE:
+ // Do nothing.
+ break;
+ default:
+ throw new IllegalStateException();
+ }
+ }
+
+ private void handlePositionDiscontinuity(@Player.DiscontinuityReason int reason) {
+ int currentWindowIndex = getCurrentMediaItemIndex();
+ if (this.currentWindowIndex != currentWindowIndex) {
+ this.currentWindowIndex = currentWindowIndex;
+ androidx.media2.common.MediaItem currentMediaItem =
+ Assertions.checkNotNull(getCurrentMediaItem());
+ listener.onCurrentMediaItemChanged(currentMediaItem);
+ } else {
+ listener.onSeekCompleted();
+ }
+ }
+
+ private void handlePlayerError() {
+ listener.onPlayerStateChanged(SessionPlayer.PLAYER_STATE_ERROR);
+ listener.onError(getCurrentMediaItem());
+ }
+
+ private void handleRepeatModeChanged(@Player.RepeatMode int repeatMode) {
+ listener.onRepeatModeChanged(Utils.getRepeatMode(repeatMode));
+ }
+
+ private void handleShuffleMode(boolean shuffleModeEnabled) {
+ listener.onShuffleModeChanged(Utils.getShuffleMode(shuffleModeEnabled));
+ }
+
+ private void handlePlaybackParametersChanged(PlaybackParameters playbackParameters) {
+ listener.onPlaybackSpeedChanged(playbackParameters.speed);
+ }
+
+ private void handleTimelineChanged(Timeline timeline) {
+ if (ignoreTimelineUpdates) {
+ return;
+ }
+ if (!isExoPlayerMediaItemsChanged(timeline)) {
+ return;
+ }
+ updatePlaylist(timeline);
+ listener.onPlaylistChanged();
+ }
+
+ // Check whether Timeline is changed by media item changes or not
+ private boolean isExoPlayerMediaItemsChanged(Timeline timeline) {
+ if (exoPlayerPlaylist.size() != timeline.getWindowCount()) {
+ return true;
+ }
+ Timeline.Window window = new Timeline.Window();
+ int windowCount = timeline.getWindowCount();
+ for (int i = 0; i < windowCount; i++) {
+ timeline.getWindow(i, window);
+ if (!ObjectsCompat.equals(exoPlayerPlaylist.get(i), window.mediaItem)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void updatePlaylist(Timeline timeline) {
+ List media2MediaItemToBeRemoved =
+ new ArrayList<>(media2Playlist);
+ media2Playlist.clear();
+ exoPlayerPlaylist.clear();
+
+ Timeline.Window window = new Timeline.Window();
+ int windowCount = timeline.getWindowCount();
+ for (int i = 0; i < windowCount; i++) {
+ timeline.getWindow(i, window);
+ MediaItem exoPlayerMediaItem = window.mediaItem;
+ androidx.media2.common.MediaItem media2MediaItem =
+ Assertions.checkNotNull(mediaItemConverter.convertToMedia2MediaItem(exoPlayerMediaItem));
+ exoPlayerPlaylist.add(exoPlayerMediaItem);
+ media2Playlist.add(media2MediaItem);
+ media2MediaItemToBeRemoved.remove(media2MediaItem);
+ }
+
+ for (androidx.media2.common.MediaItem item : media2MediaItemToBeRemoved) {
+ releaseMediaItem(item);
+ }
+ }
+
+ private void handleAudioAttributesChanged(AudioAttributes audioAttributes) {
+ listener.onAudioAttributesChanged(Utils.getAudioAttributesCompat(audioAttributes));
+ }
+
+ private void updateBufferingAndScheduleNextPollBuffer() {
+ androidx.media2.common.MediaItem media2MediaItem =
+ Assertions.checkNotNull(getCurrentMediaItem());
+ listener.onBufferingUpdate(media2MediaItem, player.getBufferedPercentage());
+ handler.removeCallbacks(pollBufferRunnable);
+ handler.postDelayed(pollBufferRunnable, POLL_BUFFER_INTERVAL_MS);
+ }
+
+ private void maybeNotifyBufferingEvents() {
+ androidx.media2.common.MediaItem media2MediaItem =
+ Assertions.checkNotNull(getCurrentMediaItem());
+ if (prepared && !rebuffering) {
+ rebuffering = true;
+ listener.onBufferingStarted(media2MediaItem);
+ }
+ }
+
+ private void maybeNotifyReadyEvents() {
+ androidx.media2.common.MediaItem media2MediaItem =
+ Assertions.checkNotNull(getCurrentMediaItem());
+ boolean prepareComplete = !prepared;
+ if (prepareComplete) {
+ prepared = true;
+ handlePositionDiscontinuity(Player.DISCONTINUITY_REASON_PERIOD_TRANSITION);
+ listener.onPlayerStateChanged(SessionPlayer.PLAYER_STATE_PAUSED);
+ listener.onPrepared(media2MediaItem, player.getBufferedPercentage());
+ }
+ if (rebuffering) {
+ rebuffering = false;
+ listener.onBufferingEnded(media2MediaItem, player.getBufferedPercentage());
+ }
+ }
+
+ private void maybeNotifyEndedEvents() {
+ if (player.getPlayWhenReady()) {
+ listener.onPlayerStateChanged(SessionPlayer.PLAYER_STATE_PAUSED);
+ listener.onPlaybackEnded();
+ player.setPlayWhenReady(false);
+ }
+ }
+
+ private void releaseMediaItem(androidx.media2.common.MediaItem media2MediaItem) {
+ try {
+ if (media2MediaItem instanceof CallbackMediaItem) {
+ ((CallbackMediaItem) media2MediaItem).getDataSourceCallback().close();
+ }
+ } catch (IOException e) {
+ Log.w(TAG, "Error releasing media item " + media2MediaItem, e);
+ }
+ }
+
+ private final class ComponentListener implements Player.EventListener, AudioListener {
+
+ // Player.EventListener implementation.
+
+ @Override
+ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
+ handlePlayWhenReadyChanged();
+ }
+
+ @Override
+ public void onPlaybackStateChanged(@Player.State int state) {
+ handlePlayerStateChanged(state);
+ }
+
+ @Override
+ public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) {
+ handlePositionDiscontinuity(reason);
+ }
+
+ @Override
+ public void onPlayerError(ExoPlaybackException error) {
+ handlePlayerError();
+ }
+
+ @Override
+ public void onRepeatModeChanged(@Player.RepeatMode int repeatMode) {
+ handleRepeatModeChanged(repeatMode);
+ }
+
+ @Override
+ public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) {
+ handleShuffleMode(shuffleModeEnabled);
+ }
+
+ @Override
+ public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) {
+ handlePlaybackParametersChanged(playbackParameters);
+ }
+
+ @Override
+ public void onTimelineChanged(Timeline timeline, int reason) {
+ handleTimelineChanged(timeline);
+ }
+
+ // AudioListener implementation.
+
+ @Override
+ public void onAudioAttributesChanged(AudioAttributes audioAttributes) {
+ handleAudioAttributesChanged(audioAttributes);
+ }
+ }
+
+ private final class PollBufferRunnable implements Runnable {
+ @Override
+ public void run() {
+ updateBufferingAndScheduleNextPollBuffer();
+ }
+ }
+}
diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallback.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallback.java
new file mode 100644
index 0000000000..1f60db947e
--- /dev/null
+++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallback.java
@@ -0,0 +1,384 @@
+/*
+ * 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 java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.media2.common.MediaItem;
+import androidx.media2.common.MediaMetadata;
+import androidx.media2.common.Rating;
+import androidx.media2.common.SessionPlayer;
+import androidx.media2.session.MediaSession;
+import androidx.media2.session.SessionCommand;
+import androidx.media2.session.SessionCommandGroup;
+import androidx.media2.session.SessionResult;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.AllowedCommandProvider;
+import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.CustomCommandProvider;
+import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.DisconnectedCallback;
+import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.MediaItemProvider;
+import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.PostConnectCallback;
+import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.RatingCallback;
+import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.SkipCallback;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Log;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+/* package */ class SessionCallback extends MediaSession.SessionCallback {
+ private static final String TAG = "SessionCallback";
+
+ private final SessionPlayer sessionPlayer;
+ private final int fastForwardMs;
+ private final int rewindMs;
+ private final int seekTimeoutMs;
+ private final Set sessions;
+ private final AllowedCommandProvider allowedCommandProvider;
+ @Nullable private final RatingCallback ratingCallback;
+ @Nullable private final CustomCommandProvider customCommandProvider;
+ @Nullable private final MediaItemProvider mediaItemProvider;
+ @Nullable private final SkipCallback skipCallback;
+ @Nullable private final PostConnectCallback postConnectCallback;
+ @Nullable private final DisconnectedCallback disconnectedCallback;
+ private boolean loggedUnexpectedSessionPlayerWarning;
+
+ public SessionCallback(
+ SessionPlayerConnector sessionPlayerConnector,
+ int fastForwardMs,
+ int rewindMs,
+ int seekTimeoutMs,
+ AllowedCommandProvider allowedCommandProvider,
+ @Nullable RatingCallback ratingCallback,
+ @Nullable CustomCommandProvider customCommandProvider,
+ @Nullable MediaItemProvider mediaItemProvider,
+ @Nullable SkipCallback skipCallback,
+ @Nullable PostConnectCallback postConnectCallback,
+ @Nullable DisconnectedCallback disconnectedCallback) {
+ this.sessionPlayer = sessionPlayerConnector;
+ this.allowedCommandProvider = allowedCommandProvider;
+ this.ratingCallback = ratingCallback;
+ this.customCommandProvider = customCommandProvider;
+ this.mediaItemProvider = mediaItemProvider;
+ this.skipCallback = skipCallback;
+ this.postConnectCallback = postConnectCallback;
+ this.disconnectedCallback = disconnectedCallback;
+ this.fastForwardMs = fastForwardMs;
+ this.rewindMs = rewindMs;
+ this.seekTimeoutMs = seekTimeoutMs;
+ this.sessions = Collections.newSetFromMap(new ConcurrentHashMap<>());
+
+ // Register PlayerCallback and make it to be called before the ListenableFuture set the result.
+ // It help the PlayerCallback to update allowed commands before pended Player APIs are executed.
+ sessionPlayerConnector.registerPlayerCallback(Runnable::run, new PlayerCallback());
+ }
+
+ @Override
+ @Nullable
+ public SessionCommandGroup onConnect(
+ MediaSession session, MediaSession.ControllerInfo controllerInfo) {
+ sessions.add(session);
+ if (!allowedCommandProvider.acceptConnection(session, controllerInfo)) {
+ return null;
+ }
+ SessionCommandGroup baseAllowedCommands = buildAllowedCommands(session, controllerInfo);
+ return allowedCommandProvider.getAllowedCommands(session, controllerInfo, baseAllowedCommands);
+ }
+
+ @Override
+ public void onPostConnect(
+ @NonNull MediaSession session, @NonNull MediaSession.ControllerInfo controller) {
+ if (postConnectCallback != null) {
+ postConnectCallback.onPostConnect(session, controller);
+ }
+ }
+
+ @Override
+ public void onDisconnected(MediaSession session, MediaSession.ControllerInfo controller) {
+ if (session.getConnectedControllers().isEmpty()) {
+ sessions.remove(session);
+ }
+ if (disconnectedCallback != null) {
+ disconnectedCallback.onDisconnected(session, controller);
+ }
+ }
+
+ @Override
+ public int onCommandRequest(
+ MediaSession session, MediaSession.ControllerInfo controller, SessionCommand command) {
+ return allowedCommandProvider.onCommandRequest(session, controller, command);
+ }
+
+ @Override
+ @Nullable
+ public MediaItem onCreateMediaItem(
+ MediaSession session, MediaSession.ControllerInfo controller, String mediaId) {
+ Assertions.checkNotNull(mediaItemProvider);
+ return mediaItemProvider.onCreateMediaItem(session, controller, mediaId);
+ }
+
+ @Override
+ public int onSetRating(
+ MediaSession session, MediaSession.ControllerInfo controller, String mediaId, Rating rating) {
+ if (ratingCallback != null) {
+ return ratingCallback.onSetRating(session, controller, mediaId, rating);
+ }
+ return SessionResult.RESULT_ERROR_NOT_SUPPORTED;
+ }
+
+ @Override
+ public SessionResult onCustomCommand(
+ MediaSession session,
+ MediaSession.ControllerInfo controller,
+ SessionCommand customCommand,
+ @Nullable Bundle args) {
+ if (customCommandProvider != null) {
+ return customCommandProvider.onCustomCommand(session, controller, customCommand, args);
+ }
+ return new SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED, null);
+ }
+
+ @Override
+ public int onFastForward(MediaSession session, MediaSession.ControllerInfo controller) {
+ if (fastForwardMs > 0) {
+ return seekToOffset(fastForwardMs);
+ }
+ return SessionResult.RESULT_ERROR_NOT_SUPPORTED;
+ }
+
+ @Override
+ public int onRewind(MediaSession session, MediaSession.ControllerInfo controller) {
+ if (rewindMs > 0) {
+ return seekToOffset(-rewindMs);
+ }
+ return SessionResult.RESULT_ERROR_NOT_SUPPORTED;
+ }
+
+ @Override
+ public int onSkipBackward(
+ @NonNull MediaSession session, @NonNull MediaSession.ControllerInfo controller) {
+ if (skipCallback != null) {
+ return skipCallback.onSkipBackward(session, controller);
+ }
+ return SessionResult.RESULT_ERROR_NOT_SUPPORTED;
+ }
+
+ @Override
+ public int onSkipForward(
+ @NonNull MediaSession session, @NonNull MediaSession.ControllerInfo controller) {
+ if (skipCallback != null) {
+ return skipCallback.onSkipForward(session, controller);
+ }
+ return SessionResult.RESULT_ERROR_NOT_SUPPORTED;
+ }
+
+ private int seekToOffset(long offsetMs) {
+ long positionMs = sessionPlayer.getCurrentPosition() + offsetMs;
+ long durationMs = sessionPlayer.getDuration();
+ if (durationMs != C.TIME_UNSET) {
+ positionMs = Math.min(positionMs, durationMs);
+ }
+ positionMs = Math.max(positionMs, 0);
+
+ ListenableFuture result = sessionPlayer.seekTo(positionMs);
+ try {
+ if (seekTimeoutMs <= 0) {
+ return result.get().getResultCode();
+ }
+ return result.get(seekTimeoutMs, MILLISECONDS).getResultCode();
+ } catch (ExecutionException | InterruptedException | TimeoutException e) {
+ Log.w(TAG, "Failed to get the seeking result", e);
+ return SessionResult.RESULT_ERROR_UNKNOWN;
+ }
+ }
+
+ private SessionCommandGroup buildAllowedCommands(
+ MediaSession session, MediaSession.ControllerInfo controllerInfo) {
+ SessionCommandGroup.Builder build;
+ @Nullable
+ SessionCommandGroup commands =
+ (customCommandProvider != null)
+ ? customCommandProvider.getCustomCommands(session, controllerInfo)
+ : null;
+ if (commands != null) {
+ build = new SessionCommandGroup.Builder(commands);
+ } else {
+ build = new SessionCommandGroup.Builder();
+ }
+
+ build.addAllPredefinedCommands(SessionCommand.COMMAND_VERSION_1);
+ // TODO: Use removeCommand(int) when it's added [Internal: b/142848015].
+ if (mediaItemProvider == null) {
+ build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SET_MEDIA_ITEM));
+ build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SET_PLAYLIST));
+ build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM));
+ build.removeCommand(
+ new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM));
+ }
+ if (ratingCallback == null) {
+ build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING));
+ }
+ if (skipCallback == null) {
+ build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SKIP_BACKWARD));
+ build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SKIP_FORWARD));
+ }
+
+ // Apply player's capability.
+ // Check whether the session has unexpectedly changed the player.
+ if (session.getPlayer() instanceof SessionPlayerConnector) {
+ SessionPlayerConnector sessionPlayerConnector = (SessionPlayerConnector) session.getPlayer();
+
+ // Check whether skipTo* works.
+ if (!sessionPlayerConnector.canSkipToPlaylistItem()) {
+ build.removeCommand(
+ new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM));
+ }
+ if (!sessionPlayerConnector.canSkipToPreviousPlaylistItem()) {
+ build.removeCommand(
+ new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM));
+ }
+ if (!sessionPlayerConnector.canSkipToNextPlaylistItem()) {
+ build.removeCommand(
+ new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM));
+ }
+
+ // Check whether seekTo/rewind/fastForward works.
+ if (!sessionPlayerConnector.isCurrentMediaItemSeekable()) {
+ build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SEEK_TO));
+ build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD));
+ build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_REWIND));
+ } else {
+ if (fastForwardMs <= 0) {
+ build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD));
+ }
+ if (rewindMs <= 0) {
+ build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_REWIND));
+ }
+ }
+ } else {
+ if (!loggedUnexpectedSessionPlayerWarning) {
+ // This can happen if MediaSession#updatePlayer() is called.
+ Log.e(TAG, "SessionPlayer isn't a SessionPlayerConnector. Guess the allowed command.");
+ loggedUnexpectedSessionPlayerWarning = true;
+ }
+
+ if (fastForwardMs <= 0) {
+ build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD));
+ }
+ if (rewindMs <= 0) {
+ build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_REWIND));
+ }
+ @Nullable List playlist = sessionPlayer.getPlaylist();
+ if (playlist == null) {
+ build.removeCommand(
+ new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM));
+ build.removeCommand(
+ new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM));
+ build.removeCommand(
+ new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM));
+ } else {
+ if (playlist.isEmpty()
+ && (sessionPlayer.getRepeatMode() == SessionPlayer.REPEAT_MODE_NONE
+ || sessionPlayer.getRepeatMode() == SessionPlayer.REPEAT_MODE_ONE)) {
+ build.removeCommand(
+ new SessionCommand(
+ SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM));
+ }
+ if (playlist.size() == sessionPlayer.getCurrentMediaItemIndex() + 1
+ && (sessionPlayer.getRepeatMode() == SessionPlayer.REPEAT_MODE_NONE
+ || sessionPlayer.getRepeatMode() == SessionPlayer.REPEAT_MODE_ONE)) {
+ build.removeCommand(
+ new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM));
+ }
+ if (playlist.size() <= 1) {
+ build.removeCommand(
+ new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM));
+ }
+ }
+ }
+ return build.build();
+ }
+
+ private static boolean isBufferedState(/* @SessionPlayer.BuffState */ int buffState) {
+ return buffState == SessionPlayer.BUFFERING_STATE_BUFFERING_AND_PLAYABLE
+ || buffState == SessionPlayer.BUFFERING_STATE_COMPLETE;
+ }
+
+ private final class PlayerCallback extends SessionPlayer.PlayerCallback {
+ private boolean currentMediaItemBuffered;
+
+ @Override
+ public void onPlaylistChanged(
+ SessionPlayer player, @Nullable List list, @Nullable MediaMetadata metadata) {
+ updateAllowedCommands();
+ }
+
+ @Override
+ public void onPlayerStateChanged(SessionPlayer player, int playerState) {
+ updateAllowedCommands();
+ }
+
+ @Override
+ public void onRepeatModeChanged(SessionPlayer player, int repeatMode) {
+ updateAllowedCommands();
+ }
+
+ @Override
+ public void onShuffleModeChanged(SessionPlayer player, int shuffleMode) {
+ updateAllowedCommands();
+ }
+
+ @Override
+ public void onCurrentMediaItemChanged(SessionPlayer player, MediaItem item) {
+ currentMediaItemBuffered = isBufferedState(player.getBufferingState());
+ updateAllowedCommands();
+ }
+
+ @Override
+ public void onBufferingStateChanged(
+ SessionPlayer player, @Nullable MediaItem item, int buffState) {
+ if (currentMediaItemBuffered || player.getCurrentMediaItem() != item) {
+ return;
+ }
+ if (isBufferedState(buffState)) {
+ currentMediaItemBuffered = true;
+ updateAllowedCommands();
+ }
+ }
+
+ private void updateAllowedCommands() {
+ for (MediaSession session : sessions) {
+ List connectedControllers = session.getConnectedControllers();
+ for (MediaSession.ControllerInfo controller : connectedControllers) {
+ SessionCommandGroup baseAllowedCommands = buildAllowedCommands(session, controller);
+ SessionCommandGroup allowedCommands =
+ allowedCommandProvider.getAllowedCommands(session, controller, baseAllowedCommands);
+ if (allowedCommands == null) {
+ allowedCommands = new SessionCommandGroup.Builder().build();
+ }
+ session.setAllowedCommands(controller, allowedCommands);
+ }
+ }
+ }
+ }
+}
diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilder.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilder.java
new file mode 100644
index 0000000000..516ec20b3b
--- /dev/null
+++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilder.java
@@ -0,0 +1,550 @@
+/*
+ * 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.Manifest;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.provider.Settings;
+import android.text.TextUtils;
+import androidx.annotation.Nullable;
+import androidx.media.MediaSessionManager;
+import androidx.media.MediaSessionManager.RemoteUserInfo;
+import androidx.media2.common.MediaItem;
+import androidx.media2.common.MediaMetadata;
+import androidx.media2.common.Rating;
+import androidx.media2.common.SessionPlayer;
+import androidx.media2.session.MediaController;
+import androidx.media2.session.MediaSession;
+import androidx.media2.session.MediaSession.ControllerInfo;
+import androidx.media2.session.SessionCommand;
+import androidx.media2.session.SessionCommandGroup;
+import androidx.media2.session.SessionResult;
+import com.google.android.exoplayer2.util.Assertions;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Builds a {@link MediaSession.SessionCallback} with various collaborators.
+ *
+ * @see MediaSession.SessionCallback
+ */
+public final class SessionCallbackBuilder {
+ /** Default timeout value for {@link #setSeekTimeoutMs}. */
+ public static final int DEFAULT_SEEK_TIMEOUT_MS = 1_000;
+
+ private final Context context;
+ private final SessionPlayerConnector sessionPlayerConnector;
+ private int fastForwardMs;
+ private int rewindMs;
+ private int seekTimeoutMs;
+ @Nullable private RatingCallback ratingCallback;
+ @Nullable private CustomCommandProvider customCommandProvider;
+ @Nullable private MediaItemProvider mediaItemProvider;
+ @Nullable private AllowedCommandProvider allowedCommandProvider;
+ @Nullable private SkipCallback skipCallback;
+ @Nullable private PostConnectCallback postConnectCallback;
+ @Nullable private DisconnectedCallback disconnectedCallback;
+
+ /** Provides allowed commands for {@link MediaController}. */
+ public interface AllowedCommandProvider {
+ /**
+ * Called to query whether to allow connection from the controller.
+ *
+ * If it returns {@code true} to accept connection, then {@link #getAllowedCommands} will be
+ * immediately followed to return initial allowed command.
+ *
+ *
Prefer use {@link PostConnectCallback} for any extra initialization about controller,
+ * where controller is connected and session can send commands to the controller.
+ *
+ * @param session The media session.
+ * @param controllerInfo The {@link ControllerInfo} for the controller that is requesting
+ * connect.
+ * @return {@code true} to accept connection. {@code false} otherwise.
+ */
+ boolean acceptConnection(MediaSession session, ControllerInfo controllerInfo);
+
+ /**
+ * Called to query allowed commands in following cases:
+ *
+ *
+ * A {@link MediaController} requests to connect, and allowed commands is required to tell
+ * initial allowed commands.
+ * Underlying {@link SessionPlayer} state changes, and allowed commands may be updated via
+ * {@link MediaSession#setAllowedCommands}.
+ *
+ *
+ * The provided {@code baseAllowedSessionCommand} is built automatically based on the state
+ * of the {@link SessionPlayer}, {@link RatingCallback}, {@link MediaItemProvider}, {@link
+ * CustomCommandProvider}, and {@link SkipCallback} so may be a useful starting point for any
+ * required customizations.
+ *
+ * @param session The media session.
+ * @param controllerInfo The {@link ControllerInfo} for the controller for which allowed
+ * commands are being queried.
+ * @param baseAllowedSessionCommand Base allowed session commands for customization.
+ * @return The allowed commands for the controller.
+ * @see MediaSession.SessionCallback#onConnect(MediaSession, ControllerInfo)
+ */
+ SessionCommandGroup getAllowedCommands(
+ MediaSession session,
+ ControllerInfo controllerInfo,
+ SessionCommandGroup baseAllowedSessionCommand);
+
+ /**
+ * Called when a {@link MediaController} has called an API that controls {@link SessionPlayer}
+ * set to the {@link MediaSession}.
+ *
+ * @param session The media session.
+ * @param controllerInfo A {@link ControllerInfo} that needs allowed command update.
+ * @param command A {@link SessionCommand} from the controller.
+ * @return A session result code defined in {@link SessionResult}.
+ * @see MediaSession.SessionCallback#onCommandRequest
+ */
+ int onCommandRequest(
+ MediaSession session, ControllerInfo controllerInfo, SessionCommand command);
+ }
+
+ /** Callback receiving a user rating for a specified media id. */
+ public interface RatingCallback {
+ /**
+ * Called when the specified controller has set a rating for the specified media id.
+ *
+ * @see MediaSession.SessionCallback#onSetRating(MediaSession, MediaSession.ControllerInfo,
+ * String, Rating)
+ * @see androidx.media2.session.MediaController#setRating(String, Rating)
+ * @return One of the {@link SessionResult} {@code RESULT_*} constants describing the success or
+ * failure of the operation, for example, {@link SessionResult#RESULT_SUCCESS} if the
+ * operation succeeded.
+ */
+ int onSetRating(MediaSession session, ControllerInfo controller, String mediaId, Rating rating);
+ }
+
+ /**
+ * Callbacks for querying what custom commands are supported, and for handling a custom command
+ * when a controller sends it.
+ */
+ public interface CustomCommandProvider {
+ /**
+ * Called when a controller has sent a custom command.
+ *
+ * @param session The media session.
+ * @param controllerInfo The {@link ControllerInfo} for the controller that sent the custom
+ * command.
+ * @param customCommand A {@link SessionCommand} from the controller.
+ * @param args A {@link Bundle} with the extra argument.
+ * @see MediaSession.SessionCallback#onCustomCommand(MediaSession, MediaSession.ControllerInfo,
+ * SessionCommand, Bundle)
+ * @see androidx.media2.session.MediaController#sendCustomCommand(SessionCommand, Bundle)
+ */
+ SessionResult onCustomCommand(
+ MediaSession session,
+ ControllerInfo controllerInfo,
+ SessionCommand customCommand,
+ @Nullable Bundle args);
+
+ /**
+ * Returns a {@link SessionCommandGroup} with custom commands to publish to the controller, or
+ * {@code null} if no custom commands should be published.
+ *
+ * @param session The media session.
+ * @param controllerInfo The {@link ControllerInfo} for the controller that is requesting custom
+ * commands.
+ * @return The custom commands to publish, or {@code null} if no custom commands should be
+ * published.
+ */
+ @Nullable
+ SessionCommandGroup getCustomCommands(MediaSession session, ControllerInfo controllerInfo);
+ }
+
+ /** Provides the {@link MediaItem}. */
+ public interface MediaItemProvider {
+ /**
+ * Called when {@link MediaSession.SessionCallback#onCreateMediaItem(MediaSession,
+ * ControllerInfo, String)} is called.
+ *
+ * @param session The media session.
+ * @param controllerInfo The {@link ControllerInfo} for the controller that has requested to
+ * create the item.
+ * @return A new {@link MediaItem} that {@link SessionPlayerConnector} can play.
+ * @see MediaSession.SessionCallback#onCreateMediaItem(MediaSession, ControllerInfo, String)
+ * @see androidx.media2.session.MediaController#addPlaylistItem(int, String)
+ * @see androidx.media2.session.MediaController#replacePlaylistItem(int, String)
+ * @see androidx.media2.session.MediaController#setMediaItem(String)
+ * @see androidx.media2.session.MediaController#setPlaylist(List, MediaMetadata)
+ */
+ @Nullable
+ MediaItem onCreateMediaItem(
+ MediaSession session, ControllerInfo controllerInfo, String mediaId);
+ }
+
+ /** Callback receiving skip backward and skip forward. */
+ public interface SkipCallback {
+ /**
+ * Called when the specified controller has sent skip backward.
+ *
+ * @param session The media session.
+ * @param controllerInfo The {@link ControllerInfo} for the controller that has requested to
+ * skip backward.
+ * @see MediaSession.SessionCallback#onSkipBackward(MediaSession, MediaSession.ControllerInfo)
+ * @see MediaController#skipBackward()
+ * @return One of the {@link SessionResult} {@code RESULT_*} constants describing the success or
+ * failure of the operation, for example, {@link SessionResult#RESULT_SUCCESS} if the
+ * operation succeeded.
+ */
+ int onSkipBackward(MediaSession session, ControllerInfo controllerInfo);
+
+ /**
+ * Called when the specified controller has sent skip forward.
+ *
+ * @param session The media session.
+ * @param controllerInfo The {@link ControllerInfo} for the controller that has requested to
+ * skip forward.
+ * @see MediaSession.SessionCallback#onSkipForward(MediaSession, MediaSession.ControllerInfo)
+ * @see MediaController#skipForward()
+ * @return One of the {@link SessionResult} {@code RESULT_*} constants describing the success or
+ * failure of the operation, for example, {@link SessionResult#RESULT_SUCCESS} if the
+ * operation succeeded.
+ */
+ int onSkipForward(MediaSession session, ControllerInfo controllerInfo);
+ }
+
+ /** Callback for handling extra initialization after the connection. */
+ public interface PostConnectCallback {
+ /**
+ * Called after the specified controller is connected, and you need extra initialization.
+ *
+ * @param session The media session.
+ * @param controllerInfo The {@link ControllerInfo} for the controller that just connected.
+ * @see MediaSession.SessionCallback#onPostConnect(MediaSession, ControllerInfo)
+ */
+ void onPostConnect(MediaSession session, MediaSession.ControllerInfo controllerInfo);
+ }
+
+ /** Callback for handling controller disconnection. */
+ public interface DisconnectedCallback {
+ /**
+ * Called when the specified controller is disconnected.
+ *
+ * @param session The media session.
+ * @param controllerInfo The {@link ControllerInfo} for the disconnected controller.
+ * @see MediaSession.SessionCallback#onDisconnected(MediaSession, ControllerInfo)
+ */
+ void onDisconnected(MediaSession session, MediaSession.ControllerInfo controllerInfo);
+ }
+
+ /**
+ * Default implementation of {@link AllowedCommandProvider} that behaves as follows:
+ *
+ *
+ * Accepts connection requests from controller if any of the following conditions are met:
+ *
+ * Controller is in the same package as the session.
+ * Controller is allowed via {@link #setTrustedPackageNames(List)}.
+ * Controller has package name {@link RemoteUserInfo#LEGACY_CONTROLLER}. See {@link
+ * ControllerInfo#getPackageName() package name limitation} for details.
+ * Controller is trusted (i.e. has MEDIA_CONTENT_CONTROL permission or has enabled
+ * notification manager).
+ *
+ * Allows all commands that the current player can handle.
+ * Accepts all command requests for allowed commands.
+ *
+ *
+ * Note: this implementation matches the behavior of the ExoPlayer MediaSession extension and
+ * {@link android.support.v4.media.session.MediaSessionCompat}.
+ */
+ public static final class DefaultAllowedCommandProvider implements AllowedCommandProvider {
+ private final Context context;
+ private final List trustedPackageNames;
+
+ public DefaultAllowedCommandProvider(Context context) {
+ this.context = context;
+ trustedPackageNames = new ArrayList<>();
+ }
+
+ @Override
+ public boolean acceptConnection(MediaSession session, ControllerInfo controllerInfo) {
+ return TextUtils.equals(controllerInfo.getPackageName(), context.getPackageName())
+ || TextUtils.equals(controllerInfo.getPackageName(), RemoteUserInfo.LEGACY_CONTROLLER)
+ || trustedPackageNames.contains(controllerInfo.getPackageName())
+ || isTrusted(controllerInfo);
+ }
+
+ @Override
+ public SessionCommandGroup getAllowedCommands(
+ MediaSession session,
+ ControllerInfo controllerInfo,
+ SessionCommandGroup baseAllowedSessionCommands) {
+ return baseAllowedSessionCommands;
+ }
+
+ @Override
+ public int onCommandRequest(
+ MediaSession session, ControllerInfo controllerInfo, SessionCommand command) {
+ return SessionResult.RESULT_SUCCESS;
+ }
+
+ /**
+ * Sets the package names from which the session will accept incoming connections.
+ *
+ * Apps that have {@code android.Manifest.permission.MEDIA_CONTENT_CONTROL}, packages listed
+ * in enabled_notification_listeners and the current package are always trusted, even if they
+ * are not specified here.
+ *
+ * @param packageNames Package names from which the session will accept incoming connections.
+ * @see MediaSession.SessionCallback#onConnect(MediaSession, MediaSession.ControllerInfo)
+ * @see MediaSessionManager#isTrustedForMediaControl(RemoteUserInfo)
+ */
+ public void setTrustedPackageNames(@Nullable List packageNames) {
+ trustedPackageNames.clear();
+ if (packageNames != null && !packageNames.isEmpty()) {
+ trustedPackageNames.addAll(packageNames);
+ }
+ }
+
+ // TODO: Replace with ControllerInfo#isTrusted() when it's unhidden [Internal: b/142835448].
+ private boolean isTrusted(MediaSession.ControllerInfo controllerInfo) {
+ // Check whether the controller has granted MEDIA_CONTENT_CONTROL.
+ if (context
+ .getPackageManager()
+ .checkPermission(
+ Manifest.permission.MEDIA_CONTENT_CONTROL, controllerInfo.getPackageName())
+ == PackageManager.PERMISSION_GRANTED) {
+ return true;
+ }
+
+ // Check whether the app has an enabled notification listener.
+ String enabledNotificationListeners =
+ Settings.Secure.getString(context.getContentResolver(), "enabled_notification_listeners");
+ if (!TextUtils.isEmpty(enabledNotificationListeners)) {
+ String[] components = enabledNotificationListeners.split(":");
+ for (String componentString : components) {
+ @Nullable ComponentName component = ComponentName.unflattenFromString(componentString);
+ if (component != null) {
+ if (component.getPackageName().equals(controllerInfo.getPackageName())) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+ }
+
+ /** A {@link MediaItemProvider} that creates media items containing only a media ID. */
+ public static final class MediaIdMediaItemProvider implements MediaItemProvider {
+ @Override
+ @Nullable
+ public MediaItem onCreateMediaItem(
+ MediaSession session, ControllerInfo controllerInfo, String mediaId) {
+ if (TextUtils.isEmpty(mediaId)) {
+ return null;
+ }
+ MediaMetadata metadata =
+ new MediaMetadata.Builder()
+ .putString(MediaMetadata.METADATA_KEY_MEDIA_ID, mediaId)
+ .build();
+ return new MediaItem.Builder().setMetadata(metadata).build();
+ }
+ }
+
+ /**
+ * Creates a new builder.
+ *
+ * The builder uses the following default values:
+ *
+ *
+ * {@link AllowedCommandProvider}: {@link DefaultAllowedCommandProvider}
+ * Seek timeout: {@link #DEFAULT_SEEK_TIMEOUT_MS}
+ *
+ *
+ *
+ * Unless stated above, {@code null} or {@code 0} would be used to disallow relevant features.
+ *
+ * @param context A context.
+ * @param sessionPlayerConnector A session player connector to handle incoming calls from the
+ * controller.
+ */
+ public SessionCallbackBuilder(Context context, SessionPlayerConnector sessionPlayerConnector) {
+ this.context = Assertions.checkNotNull(context);
+ this.sessionPlayerConnector = Assertions.checkNotNull(sessionPlayerConnector);
+ this.seekTimeoutMs = DEFAULT_SEEK_TIMEOUT_MS;
+ }
+
+ /**
+ * Sets the {@link RatingCallback} to handle user ratings.
+ *
+ * @param ratingCallback A rating callback.
+ * @return This builder.
+ * @see MediaSession.SessionCallback#onSetRating(MediaSession, ControllerInfo, String, Rating)
+ * @see androidx.media2.session.MediaController#setRating(String, Rating)
+ */
+ public SessionCallbackBuilder setRatingCallback(@Nullable RatingCallback ratingCallback) {
+ this.ratingCallback = ratingCallback;
+ return this;
+ }
+
+ /**
+ * Sets the {@link CustomCommandProvider} to handle incoming custom commands.
+ *
+ * @param customCommandProvider A custom command provider.
+ * @return This builder.
+ * @see MediaSession.SessionCallback#onCustomCommand(MediaSession, ControllerInfo, SessionCommand,
+ * Bundle)
+ * @see androidx.media2.session.MediaController#sendCustomCommand(SessionCommand, Bundle)
+ */
+ public SessionCallbackBuilder setCustomCommandProvider(
+ @Nullable CustomCommandProvider customCommandProvider) {
+ this.customCommandProvider = customCommandProvider;
+ return this;
+ }
+
+ /**
+ * Sets the {@link MediaItemProvider} that will convert media ids to {@link MediaItem MediaItems}.
+ *
+ * @param mediaItemProvider The media item provider.
+ * @return This builder.
+ * @see MediaSession.SessionCallback#onCreateMediaItem(MediaSession, ControllerInfo, String)
+ * @see androidx.media2.session.MediaController#addPlaylistItem(int, String)
+ * @see androidx.media2.session.MediaController#replacePlaylistItem(int, String)
+ * @see androidx.media2.session.MediaController#setMediaItem(String)
+ * @see androidx.media2.session.MediaController#setPlaylist(List, MediaMetadata)
+ */
+ public SessionCallbackBuilder setMediaItemProvider(
+ @Nullable MediaItemProvider mediaItemProvider) {
+ this.mediaItemProvider = mediaItemProvider;
+ return this;
+ }
+
+ /**
+ * Sets the {@link AllowedCommandProvider} to provide allowed commands for controllers.
+ *
+ * @param allowedCommandProvider A allowed command provider.
+ * @return This builder.
+ */
+ public SessionCallbackBuilder setAllowedCommandProvider(
+ @Nullable AllowedCommandProvider allowedCommandProvider) {
+ this.allowedCommandProvider = allowedCommandProvider;
+ return this;
+ }
+
+ /**
+ * Sets the {@link SkipCallback} to handle skip backward and skip forward.
+ *
+ * @param skipCallback The skip callback.
+ * @return This builder.
+ * @see MediaSession.SessionCallback#onSkipBackward(MediaSession, ControllerInfo)
+ * @see MediaSession.SessionCallback#onSkipForward(MediaSession, ControllerInfo)
+ * @see MediaController#skipBackward()
+ * @see MediaController#skipForward()
+ */
+ public SessionCallbackBuilder setSkipCallback(@Nullable SkipCallback skipCallback) {
+ this.skipCallback = skipCallback;
+ return this;
+ }
+
+ /**
+ * Sets the {@link PostConnectCallback} to handle extra initialization after the connection.
+ *
+ * @param postConnectCallback The post connect callback.
+ * @return This builder.
+ * @see MediaSession.SessionCallback#onPostConnect(MediaSession, ControllerInfo)
+ */
+ public SessionCallbackBuilder setPostConnectCallback(
+ @Nullable PostConnectCallback postConnectCallback) {
+ this.postConnectCallback = postConnectCallback;
+ return this;
+ }
+
+ /**
+ * Sets the {@link DisconnectedCallback} to handle cleaning up controller.
+ *
+ * @param disconnectedCallback The disconnected callback.
+ * @return This builder.
+ * @see MediaSession.SessionCallback#onDisconnected(MediaSession, ControllerInfo)
+ */
+ public SessionCallbackBuilder setDisconnectedCallback(
+ @Nullable DisconnectedCallback disconnectedCallback) {
+ this.disconnectedCallback = disconnectedCallback;
+ return this;
+ }
+
+ /**
+ * Sets the rewind increment in milliseconds.
+ *
+ * @param rewindMs The rewind increment in milliseconds. A non-positive value will cause the
+ * rewind to be disabled.
+ * @return This builder.
+ * @see MediaSession.SessionCallback#onRewind(MediaSession, MediaSession.ControllerInfo)
+ * @see #setSeekTimeoutMs(int)
+ */
+ public SessionCallbackBuilder setRewindIncrementMs(int rewindMs) {
+ this.rewindMs = rewindMs;
+ return this;
+ }
+
+ /**
+ * Sets the fast forward increment in milliseconds.
+ *
+ * @param fastForwardMs The fast forward increment in milliseconds. A non-positive value will
+ * cause the fast forward to be disabled.
+ * @return This builder.
+ * @see MediaSession.SessionCallback#onFastForward(MediaSession, MediaSession.ControllerInfo)
+ * @see #setSeekTimeoutMs(int)
+ */
+ public SessionCallbackBuilder setFastForwardIncrementMs(int fastForwardMs) {
+ this.fastForwardMs = fastForwardMs;
+ return this;
+ }
+
+ /**
+ * Sets the timeout in milliseconds for fast forward and rewind operations, or {@code 0} for no
+ * timeout. If a timeout is set, controllers will receive an error if the session's call to {@link
+ * SessionPlayer#seekTo} takes longer than this amount of time.
+ *
+ * @param seekTimeoutMs A timeout for {@link SessionPlayer#seekTo}. A non-positive value will wait
+ * forever.
+ * @return This builder.
+ */
+ public SessionCallbackBuilder setSeekTimeoutMs(int seekTimeoutMs) {
+ this.seekTimeoutMs = seekTimeoutMs;
+ return this;
+ }
+
+ /**
+ * Builds {@link MediaSession.SessionCallback}.
+ *
+ * @return A new callback for a media session.
+ */
+ public MediaSession.SessionCallback build() {
+ return new SessionCallback(
+ sessionPlayerConnector,
+ fastForwardMs,
+ rewindMs,
+ seekTimeoutMs,
+ allowedCommandProvider == null
+ ? new DefaultAllowedCommandProvider(context)
+ : allowedCommandProvider,
+ ratingCallback,
+ customCommandProvider,
+ mediaItemProvider,
+ skipCallback,
+ postConnectCallback,
+ disconnectedCallback);
+ }
+}
diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java
new file mode 100644
index 0000000000..1c6cc151c9
--- /dev/null
+++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java
@@ -0,0 +1,776 @@
+/*
+ * 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.FloatRange;
+import androidx.annotation.GuardedBy;
+import androidx.annotation.IntRange;
+import androidx.annotation.Nullable;
+import androidx.core.util.ObjectsCompat;
+import androidx.core.util.Pair;
+import androidx.media.AudioAttributesCompat;
+import androidx.media2.common.CallbackMediaItem;
+import androidx.media2.common.FileMediaItem;
+import androidx.media2.common.MediaItem;
+import androidx.media2.common.MediaMetadata;
+import androidx.media2.common.SessionPlayer;
+import com.google.android.exoplayer2.ControlDispatcher;
+import com.google.android.exoplayer2.DefaultControlDispatcher;
+import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Log;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import org.checkerframework.checker.nullness.compatqual.NullableType;
+
+/**
+ * An implementation of {@link SessionPlayer} that wraps a given ExoPlayer {@link Player} instance.
+ *
+ * Internally this implementation posts operations to and receives callbacks on the thread
+ * associated with {@link Player#getApplicationLooper()}, so it is important not to block this
+ * thread. In particular, when awaiting the result of an asynchronous session player operation, apps
+ * should generally use {@link ListenableFuture#addListener(Runnable, Executor)} to be notified of
+ * completion, rather than calling the blocking {@link ListenableFuture#get()} method.
+ */
+public final class SessionPlayerConnector extends SessionPlayer {
+
+ static {
+ ExoPlayerLibraryInfo.registerModule("goog.exo.media2");
+ }
+
+ private static final String TAG = "SessionPlayerConnector";
+ private static final boolean DEBUG = false;
+
+ private static final int END_OF_PLAYLIST = -1;
+ private final Object stateLock = new Object();
+
+ private final Handler taskHandler;
+ private final Executor taskHandlerExecutor;
+ private final PlayerWrapper player;
+ private final PlayerCommandQueue playerCommandQueue;
+
+ @GuardedBy("stateLock")
+ private final Map mediaItemToBuffState = new HashMap<>();
+
+ @GuardedBy("stateLock")
+ /* @PlayerState */
+ private int state;
+
+ @GuardedBy("stateLock")
+ private boolean closed;
+
+ // Should be only accessed on the executor, which is currently single-threaded.
+ @Nullable private MediaItem currentMediaItem;
+
+ /**
+ * Creates an instance using {@link DefaultMediaItemConverter} to convert between ExoPlayer and
+ * media2 MediaItems and {@link DefaultControlDispatcher} to dispatch player commands.
+ *
+ * @param player The player to wrap.
+ */
+ public SessionPlayerConnector(Player player) {
+ this(player, new DefaultMediaItemConverter());
+ }
+
+ /**
+ * Creates an instance using the provided {@link ControlDispatcher} to dispatch player commands.
+ *
+ * @param player The player to wrap.
+ * @param mediaItemConverter The {@link MediaItemConverter}.
+ */
+ public SessionPlayerConnector(Player player, MediaItemConverter mediaItemConverter) {
+ Assertions.checkNotNull(player);
+ Assertions.checkNotNull(mediaItemConverter);
+
+ state = PLAYER_STATE_IDLE;
+ taskHandler = new Handler(player.getApplicationLooper());
+ taskHandlerExecutor = (runnable) -> postOrRun(taskHandler, runnable);
+
+ this.player = new PlayerWrapper(new ExoPlayerWrapperListener(), player, mediaItemConverter);
+ playerCommandQueue = new PlayerCommandQueue(this.player, taskHandler);
+ }
+
+ /**
+ * Sets the {@link ControlDispatcher}.
+ *
+ * @param controlDispatcher The {@link ControlDispatcher}.
+ */
+ public void setControlDispatcher(ControlDispatcher controlDispatcher) {
+ player.setControlDispatcher(controlDispatcher);
+ }
+
+ @Override
+ public ListenableFuture play() {
+ return playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_PLAY, /* command= */ player::play);
+ }
+
+ @Override
+ public ListenableFuture pause() {
+ return playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_PAUSE, /* command= */ player::pause);
+ }
+
+ @Override
+ public ListenableFuture prepare() {
+ return playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_PREPARE, /* command= */ player::prepare);
+ }
+
+ @Override
+ public ListenableFuture seekTo(long position) {
+ return playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_SEEK_TO,
+ /* command= */ () -> player.seekTo(position),
+ /* tag= */ position);
+ }
+
+ @Override
+ public ListenableFuture setPlaybackSpeed(
+ @FloatRange(from = 0.0f, to = Float.MAX_VALUE, fromInclusive = false) float playbackSpeed) {
+ Assertions.checkArgument(playbackSpeed > 0f);
+ return playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_SPEED,
+ /* command= */ () -> {
+ player.setPlaybackSpeed(playbackSpeed);
+ return true;
+ });
+ }
+
+ @Override
+ public ListenableFuture setAudioAttributes(AudioAttributesCompat attr) {
+ return playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_AUDIO_ATTRIBUTES,
+ /* command= */ () -> {
+ player.setAudioAttributes(Assertions.checkNotNull(attr));
+ return true;
+ });
+ }
+
+ @Override
+ /* @PlayerState */
+ public int getPlayerState() {
+ synchronized (stateLock) {
+ return state;
+ }
+ }
+
+ @Override
+ public long getCurrentPosition() {
+ long position =
+ runPlayerCallableBlocking(
+ /* callable= */ player::getCurrentPosition,
+ /* defaultValueWhenException= */ UNKNOWN_TIME);
+ return position >= 0 ? position : UNKNOWN_TIME;
+ }
+
+ @Override
+ public long getDuration() {
+ long position =
+ runPlayerCallableBlocking(
+ /* callable= */ player::getDuration, /* defaultValueWhenException= */ UNKNOWN_TIME);
+ return position >= 0 ? position : UNKNOWN_TIME;
+ }
+
+ @Override
+ public long getBufferedPosition() {
+ long position =
+ runPlayerCallableBlocking(
+ /* callable= */ player::getBufferedPosition,
+ /* defaultValueWhenException= */ UNKNOWN_TIME);
+ return position >= 0 ? position : UNKNOWN_TIME;
+ }
+
+ @Override
+ /* @BuffState */
+ public int getBufferingState() {
+ @Nullable
+ MediaItem mediaItem =
+ this.<@NullableType MediaItem>runPlayerCallableBlocking(
+ /* callable= */ player::getCurrentMediaItem, /* defaultValueWhenException= */ null);
+ if (mediaItem == null) {
+ return BUFFERING_STATE_UNKNOWN;
+ }
+ @Nullable Integer buffState;
+ synchronized (stateLock) {
+ buffState = mediaItemToBuffState.get(mediaItem);
+ }
+ return buffState == null ? BUFFERING_STATE_UNKNOWN : buffState;
+ }
+
+ @Override
+ @FloatRange(from = 0.0f, to = Float.MAX_VALUE, fromInclusive = false)
+ public float getPlaybackSpeed() {
+ return runPlayerCallableBlocking(
+ /* callable= */ player::getPlaybackSpeed, /* defaultValueWhenException= */ 1.0f);
+ }
+
+ @Override
+ @Nullable
+ public AudioAttributesCompat getAudioAttributes() {
+ return runPlayerCallableBlockingWithNullOnException(/* callable= */ player::getAudioAttributes);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * {@link FileMediaItem} and {@link CallbackMediaItem} are not supported.
+ */
+ @Override
+ public ListenableFuture setMediaItem(MediaItem item) {
+ Assertions.checkNotNull(item);
+ Assertions.checkArgument(!(item instanceof FileMediaItem));
+ Assertions.checkArgument(!(item instanceof CallbackMediaItem));
+ ListenableFuture result =
+ playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_MEDIA_ITEM, () -> player.setMediaItem(item));
+ return result;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * {@link FileMediaItem} and {@link CallbackMediaItem} are not supported.
+ */
+ @Override
+ public ListenableFuture setPlaylist(
+ final List playlist, @Nullable MediaMetadata metadata) {
+ Assertions.checkNotNull(playlist);
+ Assertions.checkArgument(!playlist.isEmpty());
+ for (int i = 0; i < playlist.size(); i++) {
+ MediaItem item = playlist.get(i);
+ Assertions.checkNotNull(item);
+ Assertions.checkArgument(!(item instanceof FileMediaItem));
+ Assertions.checkArgument(!(item instanceof CallbackMediaItem));
+ for (int j = 0; j < i; j++) {
+ Assertions.checkArgument(
+ item != playlist.get(j),
+ "playlist shouldn't contain duplicated item, index=" + i + " vs index=" + j);
+ }
+ }
+ ListenableFuture result =
+ playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_PLAYLIST,
+ /* command= */ () -> player.setPlaylist(playlist, metadata));
+ return result;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * {@link FileMediaItem} and {@link CallbackMediaItem} are not supported.
+ */
+ @Override
+ public ListenableFuture addPlaylistItem(int index, MediaItem item) {
+ Assertions.checkArgument(index >= 0);
+ Assertions.checkNotNull(item);
+ Assertions.checkArgument(!(item instanceof FileMediaItem));
+ Assertions.checkArgument(!(item instanceof CallbackMediaItem));
+ ListenableFuture result =
+ playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM,
+ /* command= */ () -> player.addPlaylistItem(index, item));
+ return result;
+ }
+
+ @Override
+ public ListenableFuture removePlaylistItem(@IntRange(from = 0) int index) {
+ Assertions.checkArgument(index >= 0);
+ ListenableFuture result =
+ playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_REMOVE_PLAYLIST_ITEM,
+ /* command= */ () -> player.removePlaylistItem(index));
+ return result;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * {@link FileMediaItem} and {@link CallbackMediaItem} are not supported.
+ */
+ @Override
+ public ListenableFuture replacePlaylistItem(int index, MediaItem item) {
+ Assertions.checkArgument(index >= 0);
+ Assertions.checkNotNull(item);
+ Assertions.checkArgument(!(item instanceof FileMediaItem));
+ Assertions.checkArgument(!(item instanceof CallbackMediaItem));
+ ListenableFuture result =
+ playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM,
+ /* command= */ () -> player.replacePlaylistItem(index, item));
+ return result;
+ }
+
+ @Override
+ public ListenableFuture skipToPreviousPlaylistItem() {
+ ListenableFuture result =
+ playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM,
+ /* command= */ player::skipToPreviousPlaylistItem);
+ result.addListener(this::notifySkipToCompletedOnHandler, taskHandlerExecutor);
+ return result;
+ }
+
+ @Override
+ public ListenableFuture skipToNextPlaylistItem() {
+ ListenableFuture result =
+ playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM,
+ /* command= */ player::skipToNextPlaylistItem);
+ result.addListener(this::notifySkipToCompletedOnHandler, taskHandlerExecutor);
+ return result;
+ }
+
+ @Override
+ public ListenableFuture skipToPlaylistItem(@IntRange(from = 0) int index) {
+ Assertions.checkArgument(index >= 0);
+ ListenableFuture result =
+ playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM,
+ /* command= */ () -> player.skipToPlaylistItem(index));
+ result.addListener(this::notifySkipToCompletedOnHandler, taskHandlerExecutor);
+ return result;
+ }
+
+ @Override
+ public ListenableFuture updatePlaylistMetadata(@Nullable MediaMetadata metadata) {
+ return playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_UPDATE_LIST_METADATA,
+ /* command= */ () -> {
+ boolean handled = player.updatePlaylistMetadata(metadata);
+ if (handled) {
+ notifySessionPlayerCallback(
+ callback ->
+ callback.onPlaylistMetadataChanged(SessionPlayerConnector.this, metadata));
+ }
+ return handled;
+ });
+ }
+
+ @Override
+ public ListenableFuture setRepeatMode(int repeatMode) {
+ return playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_REPEAT_MODE,
+ /* command= */ () -> player.setRepeatMode(repeatMode));
+ }
+
+ @Override
+ public ListenableFuture setShuffleMode(int shuffleMode) {
+ return playerCommandQueue.addCommand(
+ PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_SHUFFLE_MODE,
+ /* command= */ () -> player.setShuffleMode(shuffleMode));
+ }
+
+ @Override
+ @Nullable
+ public List getPlaylist() {
+ return runPlayerCallableBlockingWithNullOnException(/* callable= */ player::getPlaylist);
+ }
+
+ @Override
+ @Nullable
+ public MediaMetadata getPlaylistMetadata() {
+ return runPlayerCallableBlockingWithNullOnException(
+ /* callable= */ player::getPlaylistMetadata);
+ }
+
+ @Override
+ public int getRepeatMode() {
+ return runPlayerCallableBlocking(
+ /* callable= */ player::getRepeatMode, /* defaultValueWhenException= */ REPEAT_MODE_NONE);
+ }
+
+ @Override
+ public int getShuffleMode() {
+ return runPlayerCallableBlocking(
+ /* callable= */ player::getShuffleMode, /* defaultValueWhenException= */ SHUFFLE_MODE_NONE);
+ }
+
+ @Override
+ @Nullable
+ public MediaItem getCurrentMediaItem() {
+ return runPlayerCallableBlockingWithNullOnException(
+ /* callable= */ player::getCurrentMediaItem);
+ }
+
+ @Override
+ public int getCurrentMediaItemIndex() {
+ return runPlayerCallableBlocking(
+ /* callable= */ player::getCurrentMediaItemIndex,
+ /* defaultValueWhenException= */ END_OF_PLAYLIST);
+ }
+
+ @Override
+ public int getPreviousMediaItemIndex() {
+ return runPlayerCallableBlocking(
+ /* callable= */ player::getPreviousMediaItemIndex,
+ /* defaultValueWhenException= */ END_OF_PLAYLIST);
+ }
+
+ @Override
+ public int getNextMediaItemIndex() {
+ return runPlayerCallableBlocking(
+ /* callable= */ player::getNextMediaItemIndex,
+ /* defaultValueWhenException= */ END_OF_PLAYLIST);
+ }
+
+ // TODO(b/147706139): Call super.close() after updating media2-common to 1.1.0
+ @SuppressWarnings("MissingSuperCall")
+ @Override
+ public void close() {
+ synchronized (stateLock) {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ }
+ reset();
+
+ this.runPlayerCallableBlocking(
+ /* callable= */ () -> {
+ player.close();
+ return null;
+ });
+ }
+
+ // SessionPlayerConnector-specific functions.
+
+ /**
+ * Returns whether the current media item is seekable.
+ *
+ * @return {@code true} if supported. {@code false} otherwise.
+ */
+ /* package */ boolean isCurrentMediaItemSeekable() {
+ return runPlayerCallableBlocking(
+ /* callable= */ player::isCurrentMediaItemSeekable, /* defaultValueWhenException= */ false);
+ }
+
+ /**
+ * Returns whether {@link #skipToPlaylistItem(int)} is supported.
+ *
+ * @return {@code true} if supported. {@code false} otherwise.
+ */
+ /* package */ boolean canSkipToPlaylistItem() {
+ return runPlayerCallableBlocking(
+ /* callable= */ player::canSkipToPlaylistItem, /* defaultValueWhenException= */ false);
+ }
+
+ /**
+ * Returns whether {@link #skipToPreviousPlaylistItem()} is supported.
+ *
+ * @return {@code true} if supported. {@code false} otherwise.
+ */
+ /* package */ boolean canSkipToPreviousPlaylistItem() {
+ return runPlayerCallableBlocking(
+ /* callable= */ player::canSkipToPreviousPlaylistItem,
+ /* defaultValueWhenException= */ false);
+ }
+
+ /**
+ * Returns whether {@link #skipToNextPlaylistItem()} is supported.
+ *
+ * @return {@code true} if supported. {@code false} otherwise.
+ */
+ /* package */ boolean canSkipToNextPlaylistItem() {
+ return runPlayerCallableBlocking(
+ /* callable= */ player::canSkipToNextPlaylistItem, /* defaultValueWhenException= */ false);
+ }
+
+ /**
+ * Resets {@link SessionPlayerConnector} to its uninitialized state if not closed. After calling
+ * this method, you will have to initialize it again by setting the media item and calling {@link
+ * #prepare()}.
+ *
+ * Note that if the player is closed, there is no way to reuse the instance.
+ */
+ private void reset() {
+ // Cancel the pending commands.
+ playerCommandQueue.reset();
+ synchronized (stateLock) {
+ state = PLAYER_STATE_IDLE;
+ mediaItemToBuffState.clear();
+ }
+ this.runPlayerCallableBlocking(
+ /* callable= */ () -> {
+ player.reset();
+ return null;
+ });
+ }
+
+ private void setState(/* @PlayerState */ int state) {
+ boolean needToNotify = false;
+ synchronized (stateLock) {
+ if (this.state != state) {
+ this.state = state;
+ needToNotify = true;
+ }
+ }
+ if (needToNotify) {
+ notifySessionPlayerCallback(
+ callback -> callback.onPlayerStateChanged(SessionPlayerConnector.this, state));
+ }
+ }
+
+ private void setBufferingState(MediaItem item, /* @BuffState */ int state) {
+ @Nullable Integer previousState;
+ synchronized (stateLock) {
+ previousState = mediaItemToBuffState.put(item, state);
+ }
+ if (previousState == null || previousState != state) {
+ notifySessionPlayerCallback(
+ callback -> callback.onBufferingStateChanged(SessionPlayerConnector.this, item, state));
+ }
+ }
+
+ private void notifySessionPlayerCallback(SessionPlayerCallbackNotifier notifier) {
+ synchronized (stateLock) {
+ if (closed) {
+ return;
+ }
+ }
+ List> callbacks = getCallbacks();
+ for (Pair pair : callbacks) {
+ SessionPlayer.PlayerCallback callback = Assertions.checkNotNull(pair.first);
+ Executor executor = Assertions.checkNotNull(pair.second);
+ executor.execute(() -> notifier.callCallback(callback));
+ }
+ }
+
+ private void handlePlaylistChangedOnHandler() {
+ List currentPlaylist = player.getPlaylist();
+ MediaMetadata playlistMetadata = player.getPlaylistMetadata();
+
+ MediaItem currentMediaItem = player.getCurrentMediaItem();
+ boolean notifyCurrentMediaItem = !ObjectsCompat.equals(this.currentMediaItem, currentMediaItem);
+ this.currentMediaItem = currentMediaItem;
+
+ long currentPosition = getCurrentPosition();
+ notifySessionPlayerCallback(
+ callback -> {
+ callback.onPlaylistChanged(
+ SessionPlayerConnector.this, currentPlaylist, playlistMetadata);
+ if (notifyCurrentMediaItem) {
+ Assertions.checkNotNull(
+ currentMediaItem, "PlaylistManager#currentMediaItem() cannot be changed to null");
+
+ callback.onCurrentMediaItemChanged(SessionPlayerConnector.this, currentMediaItem);
+
+ // Workaround for MediaSession's issue that current media item change isn't propagated
+ // to the legacy controllers.
+ // TODO(b/160846312): Remove this workaround with media2 1.1.0-stable.
+ callback.onSeekCompleted(SessionPlayerConnector.this, currentPosition);
+ }
+ });
+ }
+
+ private void notifySkipToCompletedOnHandler() {
+ MediaItem currentMediaItem = Assertions.checkNotNull(player.getCurrentMediaItem());
+ if (ObjectsCompat.equals(this.currentMediaItem, currentMediaItem)) {
+ return;
+ }
+ this.currentMediaItem = currentMediaItem;
+ long currentPosition = getCurrentPosition();
+ notifySessionPlayerCallback(
+ callback -> {
+ callback.onCurrentMediaItemChanged(SessionPlayerConnector.this, currentMediaItem);
+
+ // Workaround for MediaSession's issue that current media item change isn't propagated
+ // to the legacy controllers.
+ // TODO(b/160846312): Remove this workaround with media2 1.1.0-stable.
+ callback.onSeekCompleted(SessionPlayerConnector.this, currentPosition);
+ });
+ }
+
+ private T runPlayerCallableBlocking(Callable callable) {
+ SettableFuture future = SettableFuture.create();
+ boolean success =
+ postOrRun(
+ taskHandler,
+ () -> {
+ try {
+ future.set(callable.call());
+ } catch (Throwable e) {
+ future.setException(e);
+ }
+ });
+ Assertions.checkState(success);
+ boolean wasInterrupted = false;
+ try {
+ while (true) {
+ try {
+ return future.get();
+ } catch (InterruptedException e) {
+ // We always wait for player calls to return.
+ wasInterrupted = true;
+ } catch (ExecutionException e) {
+ if (DEBUG) {
+ Log.d(TAG, "Internal player error", e);
+ }
+ throw new IllegalStateException(e.getCause());
+ }
+ }
+ } finally {
+ if (wasInterrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ @Nullable
+ private T runPlayerCallableBlockingWithNullOnException(Callable<@NullableType T> callable) {
+ try {
+ return runPlayerCallableBlocking(callable);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private T runPlayerCallableBlocking(Callable callable, T defaultValueWhenException) {
+ try {
+ return runPlayerCallableBlocking(callable);
+ } catch (Exception e) {
+ return defaultValueWhenException;
+ }
+ }
+
+ private interface SessionPlayerCallbackNotifier {
+ void callCallback(SessionPlayer.PlayerCallback callback);
+ }
+
+ private final class ExoPlayerWrapperListener implements PlayerWrapper.Listener {
+ @Override
+ public void onPlayerStateChanged(int playerState) {
+ setState(playerState);
+ if (playerState == PLAYER_STATE_PLAYING) {
+ playerCommandQueue.notifyCommandCompleted(PlayerCommandQueue.COMMAND_CODE_PLAYER_PLAY);
+ } else if (playerState == PLAYER_STATE_PAUSED) {
+ playerCommandQueue.notifyCommandCompleted(PlayerCommandQueue.COMMAND_CODE_PLAYER_PAUSE);
+ }
+ }
+
+ @Override
+ public void onPrepared(MediaItem mediaItem, int bufferingPercentage) {
+ Assertions.checkNotNull(mediaItem);
+
+ if (bufferingPercentage >= 100) {
+ setBufferingState(mediaItem, BUFFERING_STATE_COMPLETE);
+ } else {
+ setBufferingState(mediaItem, BUFFERING_STATE_BUFFERING_AND_PLAYABLE);
+ }
+ playerCommandQueue.notifyCommandCompleted(PlayerCommandQueue.COMMAND_CODE_PLAYER_PREPARE);
+ }
+
+ @Override
+ public void onSeekCompleted() {
+ long currentPosition = getCurrentPosition();
+ notifySessionPlayerCallback(
+ callback -> callback.onSeekCompleted(SessionPlayerConnector.this, currentPosition));
+ }
+
+ @Override
+ public void onBufferingStarted(MediaItem mediaItem) {
+ setBufferingState(mediaItem, BUFFERING_STATE_BUFFERING_AND_STARVED);
+ }
+
+ @Override
+ public void onBufferingUpdate(MediaItem mediaItem, int bufferingPercentage) {
+ if (bufferingPercentage >= 100) {
+ setBufferingState(mediaItem, BUFFERING_STATE_COMPLETE);
+ }
+ }
+
+ @Override
+ public void onBufferingEnded(MediaItem mediaItem, int bufferingPercentage) {
+ if (bufferingPercentage >= 100) {
+ setBufferingState(mediaItem, BUFFERING_STATE_COMPLETE);
+ } else {
+ setBufferingState(mediaItem, BUFFERING_STATE_BUFFERING_AND_PLAYABLE);
+ }
+ }
+
+ @Override
+ public void onCurrentMediaItemChanged(MediaItem mediaItem) {
+ if (ObjectsCompat.equals(currentMediaItem, mediaItem)) {
+ return;
+ }
+ currentMediaItem = mediaItem;
+ long currentPosition = getCurrentPosition();
+ notifySessionPlayerCallback(
+ callback -> {
+ callback.onCurrentMediaItemChanged(SessionPlayerConnector.this, mediaItem);
+
+ // Workaround for MediaSession's issue that current media item change isn't propagated
+ // to the legacy controllers.
+ // TODO(b/160846312): Remove this workaround with media2 1.1.0-stable.
+ callback.onSeekCompleted(SessionPlayerConnector.this, currentPosition);
+ });
+ }
+
+ @Override
+ public void onPlaybackEnded() {
+ notifySessionPlayerCallback(
+ callback -> callback.onPlaybackCompleted(SessionPlayerConnector.this));
+ }
+
+ @Override
+ public void onError(@Nullable MediaItem mediaItem) {
+ playerCommandQueue.notifyCommandError();
+ if (mediaItem != null) {
+ setBufferingState(mediaItem, BUFFERING_STATE_UNKNOWN);
+ }
+ }
+
+ @Override
+ public void onPlaylistChanged() {
+ handlePlaylistChangedOnHandler();
+ }
+
+ @Override
+ public void onShuffleModeChanged(int shuffleMode) {
+ notifySessionPlayerCallback(
+ callback -> callback.onShuffleModeChanged(SessionPlayerConnector.this, shuffleMode));
+ }
+
+ @Override
+ public void onRepeatModeChanged(int repeatMode) {
+ notifySessionPlayerCallback(
+ callback -> callback.onRepeatModeChanged(SessionPlayerConnector.this, repeatMode));
+ }
+
+ @Override
+ public void onPlaybackSpeedChanged(float playbackSpeed) {
+ notifySessionPlayerCallback(
+ callback -> callback.onPlaybackSpeedChanged(SessionPlayerConnector.this, playbackSpeed));
+ }
+
+ @Override
+ public void onAudioAttributesChanged(AudioAttributesCompat audioAttributes) {
+ notifySessionPlayerCallback(
+ callback ->
+ callback.onAudioAttributesChanged(SessionPlayerConnector.this, audioAttributes));
+ }
+ }
+}
diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/Utils.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/Utils.java
new file mode 100644
index 0000000000..873e35cc25
--- /dev/null
+++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/Utils.java
@@ -0,0 +1,96 @@
+/*
+ * 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 androidx.media.AudioAttributesCompat;
+import androidx.media2.common.SessionPlayer;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.audio.AudioAttributes;
+
+/** Utility methods for translating between the media2 and ExoPlayer APIs. */
+/* package */ final class Utils {
+
+ /** Returns ExoPlayer audio attributes for the given audio attributes. */
+ public static AudioAttributes getAudioAttributes(AudioAttributesCompat audioAttributesCompat) {
+ return new AudioAttributes.Builder()
+ .setContentType(audioAttributesCompat.getContentType())
+ .setFlags(audioAttributesCompat.getFlags())
+ .setUsage(audioAttributesCompat.getUsage())
+ .build();
+ }
+
+ /** Returns audio attributes for the given ExoPlayer audio attributes. */
+ public static AudioAttributesCompat getAudioAttributesCompat(AudioAttributes audioAttributes) {
+ return new AudioAttributesCompat.Builder()
+ .setContentType(audioAttributes.contentType)
+ .setFlags(audioAttributes.flags)
+ .setUsage(audioAttributes.usage)
+ .build();
+ }
+
+ /** Returns the SimpleExoPlayer's shuffle mode for the given shuffle mode. */
+ public static boolean getExoPlayerShuffleMode(int shuffleMode) {
+ switch (shuffleMode) {
+ case SessionPlayer.SHUFFLE_MODE_ALL:
+ case SessionPlayer.SHUFFLE_MODE_GROUP:
+ return true;
+ case SessionPlayer.SHUFFLE_MODE_NONE:
+ return false;
+ default:
+ throw new IllegalArgumentException();
+ }
+ }
+
+ /** Returns the shuffle mode for the given ExoPlayer's shuffle mode */
+ public static int getShuffleMode(boolean exoPlayerShuffleMode) {
+ return exoPlayerShuffleMode ? SessionPlayer.SHUFFLE_MODE_ALL : SessionPlayer.SHUFFLE_MODE_NONE;
+ }
+
+ /** Returns the ExoPlayer's repeat mode for the given repeat mode. */
+ @Player.RepeatMode
+ public static int getExoPlayerRepeatMode(int repeatMode) {
+ switch (repeatMode) {
+ case SessionPlayer.REPEAT_MODE_ALL:
+ case SessionPlayer.REPEAT_MODE_GROUP:
+ return Player.REPEAT_MODE_ALL;
+ case SessionPlayer.REPEAT_MODE_ONE:
+ return Player.REPEAT_MODE_ONE;
+ case SessionPlayer.REPEAT_MODE_NONE:
+ return Player.REPEAT_MODE_OFF;
+ default:
+ throw new IllegalArgumentException();
+ }
+ }
+
+ /** Returns the repeat mode for the given SimpleExoPlayer's repeat mode. */
+ public static int getRepeatMode(@Player.RepeatMode int exoPlayerRepeatMode) {
+ switch (exoPlayerRepeatMode) {
+ case Player.REPEAT_MODE_ALL:
+ return SessionPlayer.REPEAT_MODE_ALL;
+ case Player.REPEAT_MODE_ONE:
+ return SessionPlayer.REPEAT_MODE_ONE;
+ case Player.REPEAT_MODE_OFF:
+ return SessionPlayer.REPEAT_MODE_NONE;
+ default:
+ throw new IllegalArgumentException();
+ }
+ }
+
+
+ private Utils() {
+ // Prevent instantiation.
+ }
+}
diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/package-info.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/package-info.java
new file mode 100644
index 0000000000..4003847b3f
--- /dev/null
+++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * 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.
+ */
+@NonNullApi
+package com.google.android.exoplayer2.ext.media2;
+
+import com.google.android.exoplayer2.util.NonNullApi;
diff --git a/extensions/mediasession/build.gradle b/extensions/mediasession/build.gradle
index f32ef263e0..5c827084da 100644
--- a/extensions/mediasession/build.gradle
+++ b/extensions/mediasession/build.gradle
@@ -11,24 +11,7 @@
// 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'
-
-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/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java
index fc75d4f549..85d0155bd7 100644
--- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java
+++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java
@@ -38,6 +38,7 @@ import com.google.android.exoplayer2.ControlDispatcher;
import com.google.android.exoplayer2.DefaultControlDispatcher;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
+import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.util.Assertions;
@@ -127,8 +128,8 @@ public final class MediaSessionConnector {
@PlaybackActions public static final long DEFAULT_PLAYBACK_ACTIONS = ALL_PLAYBACK_ACTIONS;
/**
- * The name of the {@link PlaybackStateCompat} float extra with the value of {@link
- * Player#getPlaybackSpeed()}.
+ * The name of the {@link PlaybackStateCompat} float extra with the value of {@code
+ * Player.getPlaybackParameters().speed}.
*/
public static final String EXTRAS_SPEED = "EXO_SPEED";
@@ -437,7 +438,7 @@ public final class MediaSessionConnector {
*/
public MediaSessionConnector(MediaSessionCompat mediaSession) {
this.mediaSession = mediaSession;
- looper = Util.getLooper();
+ looper = Util.getCurrentOrMainLooper();
componentListener = new ComponentListener();
commandReceivers = new ArrayList<>();
customCommandReceivers = new ArrayList<>();
@@ -765,7 +766,7 @@ public final class MediaSessionConnector {
queueNavigator != null
? queueNavigator.getActiveQueueItemId(player)
: MediaSessionCompat.QueueItem.UNKNOWN_ID;
- float playbackSpeed = player.getPlaybackSpeed();
+ float playbackSpeed = player.getPlaybackParameters().speed;
extras.putFloat(EXTRAS_SPEED, playbackSpeed);
float sessionPlaybackSpeed = player.isPlaying() ? playbackSpeed : 0f;
builder
@@ -946,7 +947,9 @@ public final class MediaSessionConnector {
@Player.State int exoPlayerPlaybackState, boolean playWhenReady) {
switch (exoPlayerPlaybackState) {
case Player.STATE_BUFFERING:
- return PlaybackStateCompat.STATE_BUFFERING;
+ return playWhenReady
+ ? PlaybackStateCompat.STATE_BUFFERING
+ : PlaybackStateCompat.STATE_PAUSED;
case Player.STATE_READY:
return playWhenReady ? PlaybackStateCompat.STATE_PLAYING : PlaybackStateCompat.STATE_PAUSED;
case Player.STATE_ENDED:
@@ -1132,7 +1135,7 @@ public final class MediaSessionConnector {
}
@Override
- public void onPlaybackSpeedChanged(float playbackSpeed) {
+ public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) {
invalidateMediaSessionPlaybackState();
}
diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java
index 024faea209..203479a7ed 100644
--- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java
+++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java
@@ -15,6 +15,8 @@
*/
package com.google.android.exoplayer2.ext.mediasession;
+import static java.lang.Math.min;
+
import android.os.Bundle;
import android.os.ResultReceiver;
import android.support.v4.media.MediaDescriptionCompat;
@@ -177,7 +179,7 @@ public abstract class TimelineQueueNavigator implements MediaSessionConnector.Qu
return;
}
ArrayDeque queue = new ArrayDeque<>();
- int queueSize = Math.min(maxQueueSize, timeline.getWindowCount());
+ int queueSize = min(maxQueueSize, timeline.getWindowCount());
// Add the active queue item.
int currentWindowIndex = player.getCurrentWindowIndex();
diff --git a/extensions/okhttp/build.gradle b/extensions/okhttp/build.gradle
index 220522b9d9..f16e382aa1 100644
--- a/extensions/okhttp/build.gradle
+++ b/extensions/okhttp/build.gradle
@@ -11,32 +11,22 @@
// 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'
-
-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'
- }
-
- testOptions.unitTests.includeAndroidResources = true
-}
+apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle"
dependencies {
implementation project(modulePrefix + 'library-core')
implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ 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
testImplementation project(modulePrefix + 'testutils')
+ testImplementation 'com.squareup.okhttp3:mockwebserver:' + mockWebServerVersion
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
// Do not update to 3.13.X or later until minSdkVersion is increased to 21:
// https://cashapp.github.io/2019-02-05/okhttp-3-13-requires-android-5
diff --git a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java
index fe2bdd672b..57fee20d04 100644
--- a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java
+++ b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java
@@ -16,6 +16,7 @@
package com.google.android.exoplayer2.ext.okhttp;
import static com.google.android.exoplayer2.util.Util.castNonNull;
+import static java.lang.Math.min;
import android.net.Uri;
import androidx.annotation.Nullable;
@@ -26,8 +27,8 @@ import com.google.android.exoplayer2.upstream.DataSourceException;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.upstream.HttpDataSource;
import com.google.android.exoplayer2.util.Assertions;
-import com.google.android.exoplayer2.util.Predicate;
import com.google.android.exoplayer2.util.Util;
+import com.google.common.base.Predicate;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
@@ -80,6 +81,18 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
private long bytesRead;
/**
+ * Creates an instance.
+ *
+ * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use
+ * by the source.
+ */
+ public OkHttpDataSource(Call.Factory callFactory) {
+ this(callFactory, ExoPlayerLibraryInfo.DEFAULT_USER_AGENT);
+ }
+
+ /**
+ * Creates an instance.
+ *
* @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use
* by the source.
* @param userAgent An optional User-Agent string.
@@ -89,6 +102,8 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
}
/**
+ * Creates an instance.
+ *
* @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use
* by the source.
* @param userAgent An optional User-Agent string.
@@ -110,6 +125,8 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
}
/**
+ * Creates an instance.
+ *
* @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use
* by the source.
* @param userAgent An optional User-Agent string.
@@ -119,6 +136,7 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
* @deprecated Use {@link #OkHttpDataSource(Call.Factory, String)} and {@link
* #setContentTypePredicate(Predicate)}.
*/
+ @SuppressWarnings("deprecation")
@Deprecated
public OkHttpDataSource(
Call.Factory callFactory,
@@ -133,6 +151,8 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
}
/**
+ * Creates an instance.
+ *
* @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use
* by the source.
* @param userAgent An optional User-Agent string.
@@ -230,10 +250,18 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
// Check for a valid response code.
if (!response.isSuccessful()) {
+ byte[] errorResponseBody;
+ try {
+ errorResponseBody = Util.toByteArray(Assertions.checkNotNull(responseByteStream));
+ } catch (IOException e) {
+ throw new HttpDataSourceException(
+ "Error reading non-2xx response body", e, dataSpec, HttpDataSourceException.TYPE_OPEN);
+ }
Map> headers = response.headers().toMultimap();
closeConnectionQuietly();
InvalidResponseCodeException exception =
- new InvalidResponseCodeException(responseCode, response.message(), headers, dataSpec);
+ new InvalidResponseCodeException(
+ responseCode, response.message(), headers, dataSpec, errorResponseBody);
if (responseCode == 416) {
exception.initCause(new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE));
}
@@ -243,7 +271,7 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
// Check for a valid content type.
MediaType mediaType = responseBody.contentType();
String contentType = mediaType != null ? mediaType.toString() : "";
- if (contentTypePredicate != null && !contentTypePredicate.evaluate(contentType)) {
+ if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) {
closeConnectionQuietly();
throw new InvalidContentTypeException(contentType, dataSpec);
}
@@ -386,7 +414,7 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
}
while (bytesSkipped != bytesToSkip) {
- int readLength = (int) Math.min(bytesToSkip - bytesSkipped, SKIP_BUFFER.length);
+ int readLength = (int) min(bytesToSkip - bytesSkipped, SKIP_BUFFER.length);
int read = castNonNull(responseByteStream).read(SKIP_BUFFER, 0, readLength);
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedIOException();
@@ -422,7 +450,7 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
if (bytesRemaining == 0) {
return C.RESULT_END_OF_INPUT;
}
- readLength = (int) Math.min(readLength, bytesRemaining);
+ readLength = (int) min(readLength, bytesRemaining);
}
int read = castNonNull(responseByteStream).read(buffer, offset, readLength);
diff --git a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java
index f3d74f9233..728428c811 100644
--- a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java
+++ b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java
@@ -15,6 +15,8 @@
*/
package com.google.android.exoplayer2.ext.okhttp;
+import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT;
+
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.upstream.HttpDataSource;
import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory;
@@ -34,6 +36,18 @@ public final class OkHttpDataSourceFactory extends BaseFactory {
@Nullable private final CacheControl cacheControl;
/**
+ * Creates an instance.
+ *
+ * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use
+ * by the sources created by the factory.
+ */
+ public OkHttpDataSourceFactory(Call.Factory callFactory) {
+ this(callFactory, DEFAULT_USER_AGENT, /* listener= */ null, /* cacheControl= */ null);
+ }
+
+ /**
+ * Creates an instance.
+ *
* @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use
* by the sources created by the factory.
* @param userAgent An optional User-Agent string.
@@ -43,6 +57,8 @@ public final class OkHttpDataSourceFactory extends BaseFactory {
}
/**
+ * Creates an instance.
+ *
* @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use
* by the sources created by the factory.
* @param userAgent An optional User-Agent string.
@@ -54,6 +70,8 @@ public final class OkHttpDataSourceFactory extends BaseFactory {
}
/**
+ * Creates an instance.
+ *
* @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use
* by the sources created by the factory.
* @param userAgent An optional User-Agent string.
@@ -65,6 +83,8 @@ public final class OkHttpDataSourceFactory extends BaseFactory {
}
/**
+ * Creates an instance.
+ *
* @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use
* by the sources created by the factory.
* @param userAgent An optional User-Agent string.
diff --git a/extensions/okhttp/src/test/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceTest.java b/extensions/okhttp/src/test/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceTest.java
index 393c048eec..73e9909a8d 100644
--- a/extensions/okhttp/src/test/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceTest.java
+++ b/extensions/okhttp/src/test/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceTest.java
@@ -17,107 +17,109 @@
package com.google.android.exoplayer2.ext.okhttp;
import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.Assert.assertThrows;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.upstream.HttpDataSource;
+import com.google.common.base.Charsets;
import java.util.HashMap;
import java.util.Map;
-import okhttp3.Call;
-import okhttp3.MediaType;
-import okhttp3.Protocol;
-import okhttp3.Request;
-import okhttp3.Response;
-import okhttp3.ResponseBody;
+import okhttp3.Headers;
+import okhttp3.OkHttpClient;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
import org.junit.Test;
import org.junit.runner.RunWith;
-import org.mockito.ArgumentMatchers;
-import org.mockito.Mockito;
/** Unit tests for {@link OkHttpDataSource}. */
@RunWith(AndroidJUnit4.class)
public class OkHttpDataSourceTest {
+ /**
+ * This test will set HTTP default request parameters (1) in the OkHttpDataSource, (2) via
+ * OkHttpDataSource.setRequestProperty() and (3) in the DataSpec instance according to the table
+ * below. Values wrapped in '*' are the ones that should be set in the connection request.
+ *
+ * {@code
+ * +---------------+-----+-----+-----+-----+-----+-----+-----+
+ * | | Header Key |
+ * +---------------+-----+-----+-----+-----+-----+-----+-----+
+ * | Location | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
+ * +---------------+-----+-----+-----+-----+-----+-----+-----+
+ * | Constructor | *Y* | Y | Y | | Y | | |
+ * | Setter | | *Y* | Y | Y | | *Y* | |
+ * | DataSpec | | | *Y* | *Y* | *Y* | | *Y* |
+ * +---------------+-----+-----+-----+-----+-----+-----+-----+
+ * }
+ */
@Test
- public void open_setsCorrectHeaders() throws HttpDataSource.HttpDataSourceException {
- /*
- * This test will set HTTP default request parameters (1) in the OkHttpDataSource, (2) via
- * OkHttpDataSource.setRequestProperty() and (3) in the DataSpec instance according to the table
- * below. Values wrapped in '*' are the ones that should be set in the connection request.
- *
- * +-----------------------+---+-----+-----+-----+-----+-----+
- * | | Header Key |
- * +-----------------------+---+-----+-----+-----+-----+-----+
- * | Location | 0 | 1 | 2 | 3 | 4 | 5 |
- * +-----------------------+---+-----+-----+-----+-----+-----+
- * | Default |*Y*| Y | Y | | | |
- * | OkHttpDataSource | | *Y* | Y | Y | *Y* | |
- * | DataSpec | | | *Y* | *Y* | | *Y* |
- * +-----------------------+---+-----+-----+-----+-----+-----+
- */
+ public void open_setsCorrectHeaders() throws Exception {
+ MockWebServer mockWebServer = new MockWebServer();
+ mockWebServer.enqueue(new MockResponse());
- String defaultValue = "Default";
- String okHttpDataSourceValue = "OkHttpDataSource";
- String dataSpecValue = "DataSpec";
-
- // 1. Default properties on OkHttpDataSource
- HttpDataSource.RequestProperties defaultRequestProperties =
- new HttpDataSource.RequestProperties();
- defaultRequestProperties.set("0", defaultValue);
- defaultRequestProperties.set("1", defaultValue);
- defaultRequestProperties.set("2", defaultValue);
-
- Call.Factory mockCallFactory = Mockito.mock(Call.Factory.class);
- OkHttpDataSource okHttpDataSource =
+ String propertyFromConstructor = "fromConstructor";
+ HttpDataSource.RequestProperties constructorProperties = new HttpDataSource.RequestProperties();
+ constructorProperties.set("0", propertyFromConstructor);
+ constructorProperties.set("1", propertyFromConstructor);
+ constructorProperties.set("2", propertyFromConstructor);
+ constructorProperties.set("4", propertyFromConstructor);
+ OkHttpDataSource dataSource =
new OkHttpDataSource(
- mockCallFactory, "testAgent", /* cacheControl= */ null, defaultRequestProperties);
+ new OkHttpClient(), "testAgent", /* cacheControl= */ null, constructorProperties);
- // 2. Additional properties set with setRequestProperty().
- okHttpDataSource.setRequestProperty("1", okHttpDataSourceValue);
- okHttpDataSource.setRequestProperty("2", okHttpDataSourceValue);
- okHttpDataSource.setRequestProperty("3", okHttpDataSourceValue);
- okHttpDataSource.setRequestProperty("4", okHttpDataSourceValue);
+ String propertyFromSetter = "fromSetter";
+ dataSource.setRequestProperty("1", propertyFromSetter);
+ dataSource.setRequestProperty("2", propertyFromSetter);
+ dataSource.setRequestProperty("3", propertyFromSetter);
+ dataSource.setRequestProperty("5", propertyFromSetter);
- // 3. DataSpec properties
+ String propertyFromDataSpec = "fromDataSpec";
Map dataSpecRequestProperties = new HashMap<>();
- dataSpecRequestProperties.put("2", dataSpecValue);
- dataSpecRequestProperties.put("3", dataSpecValue);
- dataSpecRequestProperties.put("5", dataSpecValue);
+ dataSpecRequestProperties.put("2", propertyFromDataSpec);
+ dataSpecRequestProperties.put("3", propertyFromDataSpec);
+ dataSpecRequestProperties.put("4", propertyFromDataSpec);
+ dataSpecRequestProperties.put("6", propertyFromDataSpec);
DataSpec dataSpec =
new DataSpec.Builder()
- .setUri("http://www.google.com")
- .setPosition(1000)
- .setLength(5000)
+ .setUri(mockWebServer.url("/test-path").toString())
.setHttpRequestHeaders(dataSpecRequestProperties)
.build();
- Mockito.doAnswer(
- invocation -> {
- Request request = invocation.getArgument(0);
- assertThat(request.header("0")).isEqualTo(defaultValue);
- assertThat(request.header("1")).isEqualTo(okHttpDataSourceValue);
- assertThat(request.header("2")).isEqualTo(dataSpecValue);
- assertThat(request.header("3")).isEqualTo(dataSpecValue);
- assertThat(request.header("4")).isEqualTo(okHttpDataSourceValue);
- assertThat(request.header("5")).isEqualTo(dataSpecValue);
+ dataSource.open(dataSpec);
- // return a Call whose .execute() will return a mock Response
- Call returnValue = Mockito.mock(Call.class);
- Mockito.doReturn(
- new Response.Builder()
- .request(request)
- .protocol(Protocol.HTTP_1_1)
- .code(200)
- .message("OK")
- .body(ResponseBody.create(MediaType.parse("text/plain"), ""))
- .build())
- .when(returnValue)
- .execute();
- return returnValue;
- })
- .when(mockCallFactory)
- .newCall(ArgumentMatchers.any());
- okHttpDataSource.open(dataSpec);
+ Headers headers = mockWebServer.takeRequest(10, SECONDS).getHeaders();
+ assertThat(headers.get("0")).isEqualTo(propertyFromConstructor);
+ assertThat(headers.get("1")).isEqualTo(propertyFromSetter);
+ assertThat(headers.get("2")).isEqualTo(propertyFromDataSpec);
+ assertThat(headers.get("3")).isEqualTo(propertyFromDataSpec);
+ assertThat(headers.get("4")).isEqualTo(propertyFromDataSpec);
+ assertThat(headers.get("5")).isEqualTo(propertyFromSetter);
+ assertThat(headers.get("6")).isEqualTo(propertyFromDataSpec);
+ }
+
+ @Test
+ public void open_invalidResponseCode() throws Exception {
+ MockWebServer mockWebServer = new MockWebServer();
+ mockWebServer.enqueue(new MockResponse().setResponseCode(404).setBody("failure msg"));
+
+ OkHttpDataSource okHttpDataSource =
+ new OkHttpDataSource(
+ new OkHttpClient(),
+ "testAgent",
+ /* cacheControl= */ null,
+ /* defaultRequestProperties= */ null);
+ DataSpec dataSpec =
+ new DataSpec.Builder().setUri(mockWebServer.url("/test-path").toString()).build();
+
+ HttpDataSource.InvalidResponseCodeException exception =
+ assertThrows(
+ HttpDataSource.InvalidResponseCodeException.class,
+ () -> okHttpDataSource.open(dataSpec));
+
+ assertThat(exception.responseCode).isEqualTo(404);
+ assertThat(exception.responseBody).isEqualTo("failure msg".getBytes(Charsets.UTF_8));
}
}
diff --git a/extensions/opus/build.gradle b/extensions/opus/build.gradle
index 545b5a7af8..ba670037f6 100644
--- a/extensions/opus/build.gradle
+++ b/extensions/opus/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/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java b/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java
index e4e392f2d3..c964b0cc1c 100644
--- a/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java
+++ b/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java
@@ -25,6 +25,7 @@ import androidx.test.core.app.ApplicationProvider;
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.extractor.mkv.MatroskaExtractor;
import com.google.android.exoplayer2.source.MediaSource;
@@ -38,9 +39,9 @@ import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public class OpusPlaybackTest {
- private static final String BEAR_OPUS_URI = "asset:///mka/bear-opus.mka";
+ private static final String BEAR_OPUS_URI = "asset:///media/mka/bear-opus.mka";
private static final String BEAR_OPUS_NEGATIVE_GAIN_URI =
- "asset:///mka/bear-opus-negative-gain.mka";
+ "asset:///media/mka/bear-opus-negative-gain.mka";
@Before
public void setUp() {
@@ -91,10 +92,10 @@ public class OpusPlaybackTest {
player.addListener(this);
MediaSource mediaSource =
new ProgressiveMediaSource.Factory(
- new DefaultDataSourceFactory(context, "ExoPlayerExtOpusTest"),
- MatroskaExtractor.FACTORY)
- .createMediaSource(uri);
- player.prepare(mediaSource);
+ new DefaultDataSourceFactory(context), MatroskaExtractor.FACTORY)
+ .createMediaSource(MediaItem.fromUri(uri));
+ player.setMediaSource(mediaSource);
+ player.prepare();
player.play();
Looper.loop();
}
diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java
index 6fe1fa8895..603241486c 100644
--- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java
+++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java
@@ -21,13 +21,17 @@ import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.Format;
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.OpusUtil;
import com.google.android.exoplayer2.drm.ExoMediaCrypto;
import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.util.TraceUtil;
+import com.google.android.exoplayer2.util.Util;
/** Decodes and renders audio using the native Opus decoder. */
-public class LibopusAudioRenderer extends DecoderAudioRenderer {
+public class LibopusAudioRenderer extends DecoderAudioRenderer {
private static final String TAG = "LibopusAudioRenderer";
/** The number of input and output buffers. */
@@ -35,14 +39,13 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer {
/** The default input buffer size. */
private static final int DEFAULT_INPUT_BUFFER_SIZE = 960 * 6;
- private int channelCount;
- private int sampleRate;
-
public LibopusAudioRenderer() {
this(/* eventHandler= */ null, /* eventListener= */ null);
}
/**
+ * Creates a new 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.
@@ -55,6 +58,21 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer {
super(eventHandler, eventListener, audioProcessors);
}
+ /**
+ * Creates a new 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.
+ * @param audioSink The sink to which audio will be output.
+ */
+ public LibopusAudioRenderer(
+ @Nullable Handler eventHandler,
+ @Nullable AudioRendererEventListener eventListener,
+ AudioSink audioSink) {
+ super(eventHandler, eventListener, audioSink);
+ }
+
@Override
public String getName() {
return TAG;
@@ -64,12 +82,13 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer {
@FormatSupport
protected int supportsFormatInternal(Format format) {
boolean drmIsSupported =
- format.drmInitData == null
+ format.exoMediaCryptoType == null
|| OpusLibrary.matchesExpectedExoMediaCryptoType(format.exoMediaCryptoType);
if (!OpusLibrary.isAvailable()
|| !MimeTypes.AUDIO_OPUS.equalsIgnoreCase(format.sampleMimeType)) {
return FORMAT_UNSUPPORTED_TYPE;
- } else if (!supportsOutput(format.channelCount, format.sampleRate, C.ENCODING_PCM_16BIT)) {
+ } else if (!sinkSupportsFormat(
+ Util.getPcmFormat(C.ENCODING_PCM_16BIT, format.channelCount, format.sampleRate))) {
return FORMAT_UNSUPPORTED_SUBTYPE;
} else if (!drmIsSupported) {
return FORMAT_UNSUPPORTED_DRM;
@@ -82,6 +101,12 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer {
protected OpusDecoder createDecoder(Format format, @Nullable ExoMediaCrypto mediaCrypto)
throws OpusDecoderException {
TraceUtil.beginSection("createOpusDecoder");
+ @SinkFormatSupport
+ int formatSupport =
+ getSinkFormatSupport(
+ Util.getPcmFormat(C.ENCODING_PCM_FLOAT, format.channelCount, format.sampleRate));
+ boolean outputFloat = formatSupport == AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY;
+
int initialInputBufferSize =
format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE;
OpusDecoder decoder =
@@ -90,20 +115,17 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer {
NUM_BUFFERS,
initialInputBufferSize,
format.initializationData,
- mediaCrypto);
- channelCount = decoder.getChannelCount();
- sampleRate = decoder.getSampleRate();
+ mediaCrypto,
+ outputFloat);
+
TraceUtil.endSection();
return decoder;
}
@Override
- protected Format getOutputFormat() {
- return new Format.Builder()
- .setSampleMimeType(MimeTypes.AUDIO_RAW)
- .setChannelCount(channelCount)
- .setSampleRate(sampleRate)
- .setPcmEncoding(C.ENCODING_PCM_16BIT)
- .build();
+ protected Format getOutputFormat(OpusDecoder decoder) {
+ @C.PcmEncoding
+ int pcmEncoding = decoder.outputFloat ? C.ENCODING_PCM_FLOAT : C.ENCODING_PCM_16BIT;
+ return Util.getPcmFormat(pcmEncoding, decoder.channelCount, OpusUtil.SAMPLE_RATE);
}
}
diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java
index 8795950671..6b96cc5e49 100644
--- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java
+++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java
@@ -17,39 +17,32 @@ package com.google.android.exoplayer2.ext.opus;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.audio.OpusUtil;
import com.google.android.exoplayer2.decoder.CryptoInfo;
import com.google.android.exoplayer2.decoder.DecoderInputBuffer;
import com.google.android.exoplayer2.decoder.SimpleDecoder;
import com.google.android.exoplayer2.decoder.SimpleOutputBuffer;
import com.google.android.exoplayer2.drm.DecryptionException;
import com.google.android.exoplayer2.drm.ExoMediaCrypto;
+import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Util;
import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
import java.util.List;
-/**
- * Opus decoder.
- */
-/* package */ final class OpusDecoder extends
- SimpleDecoder {
-
- private static final int DEFAULT_SEEK_PRE_ROLL_SAMPLES = 3840;
-
- /**
- * Opus streams are always decoded at 48000 Hz.
- */
- private static final int SAMPLE_RATE = 48000;
+/** Opus decoder. */
+/* package */ final class OpusDecoder
+ extends SimpleDecoder {
private static final int NO_ERROR = 0;
private static final int DECODE_ERROR = -1;
private static final int DRM_ERROR = -2;
- @Nullable private final ExoMediaCrypto exoMediaCrypto;
+ public final boolean outputFloat;
+ public final int channelCount;
- private final int channelCount;
- private final int headerSkipSamples;
- private final int headerSeekPreRollSamples;
+ @Nullable private final ExoMediaCrypto exoMediaCrypto;
+ private final int preSkipSamples;
+ private final int seekPreRollSamples;
private final long nativeDecoderContext;
private int skipSamples;
@@ -65,6 +58,7 @@ import java.util.List;
* the encoder delay and seek pre roll values in nanoseconds, encoded as longs.
* @param exoMediaCrypto The {@link ExoMediaCrypto} object required for decoding encrypted
* content. Maybe null and can be ignored if decoder does not handle encrypted content.
+ * @param outputFloat Forces the decoder to output float PCM samples when set
* @throws OpusDecoderException Thrown if an exception occurs when initializing the decoder.
*/
public OpusDecoder(
@@ -72,25 +66,36 @@ import java.util.List;
int numOutputBuffers,
int initialInputBufferSize,
List initializationData,
- @Nullable ExoMediaCrypto exoMediaCrypto)
+ @Nullable ExoMediaCrypto exoMediaCrypto,
+ boolean outputFloat)
throws OpusDecoderException {
super(new DecoderInputBuffer[numInputBuffers], new SimpleOutputBuffer[numOutputBuffers]);
if (!OpusLibrary.isAvailable()) {
- throw new OpusDecoderException("Failed to load decoder native libraries.");
+ throw new OpusDecoderException("Failed to load decoder native libraries");
}
this.exoMediaCrypto = exoMediaCrypto;
if (exoMediaCrypto != null && !OpusLibrary.opusIsSecureDecodeSupported()) {
- throw new OpusDecoderException("Opus decoder does not support secure decode.");
+ throw new OpusDecoderException("Opus decoder does not support secure decode");
}
+ int initializationDataSize = initializationData.size();
+ if (initializationDataSize != 1 && initializationDataSize != 3) {
+ throw new OpusDecoderException("Invalid initialization data size");
+ }
+ if (initializationDataSize == 3
+ && (initializationData.get(1).length != 8 || initializationData.get(2).length != 8)) {
+ throw new OpusDecoderException("Invalid pre-skip or seek pre-roll");
+ }
+ preSkipSamples = OpusUtil.getPreSkipSamples(initializationData);
+ seekPreRollSamples = OpusUtil.getSeekPreRollSamples(initializationData);
+
byte[] headerBytes = initializationData.get(0);
if (headerBytes.length < 19) {
- throw new OpusDecoderException("Header size is too small.");
+ throw new OpusDecoderException("Invalid header length");
}
- channelCount = headerBytes[9] & 0xFF;
+ channelCount = OpusUtil.getChannelCount(headerBytes);
if (channelCount > 8) {
throw new OpusDecoderException("Invalid channel count: " + channelCount);
}
- int preskip = readUnsignedLittleEndian16(headerBytes, 10);
int gain = readSignedLittleEndian16(headerBytes, 16);
byte[] streamMap = new byte[8];
@@ -99,7 +104,7 @@ import java.util.List;
if (headerBytes[18] == 0) { // Channel mapping
// If there is no channel mapping, use the defaults.
if (channelCount > 2) { // Maximum channel count with default layout.
- throw new OpusDecoderException("Invalid Header, missing stream map.");
+ throw new OpusDecoderException("Invalid header, missing stream map");
}
numStreams = 1;
numCoupled = (channelCount == 2) ? 1 : 0;
@@ -107,33 +112,24 @@ import java.util.List;
streamMap[1] = 1;
} else {
if (headerBytes.length < 21 + channelCount) {
- throw new OpusDecoderException("Header size is too small.");
+ throw new OpusDecoderException("Invalid header length");
}
// Read the channel mapping.
numStreams = headerBytes[19] & 0xFF;
numCoupled = headerBytes[20] & 0xFF;
System.arraycopy(headerBytes, 21, streamMap, 0, channelCount);
}
- if (initializationData.size() == 3) {
- if (initializationData.get(1).length != 8 || initializationData.get(2).length != 8) {
- throw new OpusDecoderException("Invalid Codec Delay or Seek Preroll");
- }
- long codecDelayNs =
- ByteBuffer.wrap(initializationData.get(1)).order(ByteOrder.nativeOrder()).getLong();
- long seekPreRollNs =
- ByteBuffer.wrap(initializationData.get(2)).order(ByteOrder.nativeOrder()).getLong();
- headerSkipSamples = nsToSamples(codecDelayNs);
- headerSeekPreRollSamples = nsToSamples(seekPreRollNs);
- } else {
- headerSkipSamples = preskip;
- headerSeekPreRollSamples = DEFAULT_SEEK_PRE_ROLL_SAMPLES;
- }
- nativeDecoderContext = opusInit(SAMPLE_RATE, channelCount, numStreams, numCoupled, gain,
- streamMap);
+ nativeDecoderContext =
+ opusInit(OpusUtil.SAMPLE_RATE, channelCount, numStreams, numCoupled, gain, streamMap);
if (nativeDecoderContext == 0) {
throw new OpusDecoderException("Failed to initialize decoder");
}
setInitialInputBufferSize(initialInputBufferSize);
+
+ this.outputFloat = outputFloat;
+ if (outputFloat) {
+ opusSetFloatOutput();
+ }
}
@Override
@@ -164,22 +160,37 @@ import java.util.List;
opusReset(nativeDecoderContext);
// When seeking to 0, skip number of samples as specified in opus header. When seeking to
// any other time, skip number of samples as specified by seek preroll.
- skipSamples = (inputBuffer.timeUs == 0) ? headerSkipSamples : headerSeekPreRollSamples;
+ skipSamples = (inputBuffer.timeUs == 0) ? preSkipSamples : seekPreRollSamples;
}
ByteBuffer inputData = Util.castNonNull(inputBuffer.data);
CryptoInfo cryptoInfo = inputBuffer.cryptoInfo;
- int result = inputBuffer.isEncrypted()
- ? opusSecureDecode(nativeDecoderContext, inputBuffer.timeUs, inputData, inputData.limit(),
- outputBuffer, SAMPLE_RATE, exoMediaCrypto, cryptoInfo.mode,
- cryptoInfo.key, cryptoInfo.iv, cryptoInfo.numSubSamples,
- cryptoInfo.numBytesOfClearData, cryptoInfo.numBytesOfEncryptedData)
- : opusDecode(nativeDecoderContext, inputBuffer.timeUs, inputData, inputData.limit(),
- outputBuffer);
+ int result =
+ inputBuffer.isEncrypted()
+ ? opusSecureDecode(
+ nativeDecoderContext,
+ inputBuffer.timeUs,
+ inputData,
+ inputData.limit(),
+ outputBuffer,
+ OpusUtil.SAMPLE_RATE,
+ exoMediaCrypto,
+ cryptoInfo.mode,
+ Assertions.checkNotNull(cryptoInfo.key),
+ Assertions.checkNotNull(cryptoInfo.iv),
+ cryptoInfo.numSubSamples,
+ cryptoInfo.numBytesOfClearData,
+ cryptoInfo.numBytesOfEncryptedData)
+ : opusDecode(
+ nativeDecoderContext,
+ inputBuffer.timeUs,
+ inputData,
+ inputData.limit(),
+ outputBuffer);
if (result < 0) {
if (result == DRM_ERROR) {
String message = "Drm error: " + opusGetErrorMessage(nativeDecoderContext);
- DecryptionException cause = new DecryptionException(
- opusGetErrorCode(nativeDecoderContext), message);
+ DecryptionException cause =
+ new DecryptionException(opusGetErrorCode(nativeDecoderContext), message);
return new OpusDecoderException(message, cause);
} else {
return new OpusDecoderException("Decode error: " + opusGetErrorMessage(result));
@@ -210,37 +221,20 @@ import java.util.List;
opusClose(nativeDecoderContext);
}
- /**
- * Returns the channel count of output audio.
- */
- public int getChannelCount() {
- return channelCount;
- }
-
- /**
- * Returns the sample rate of output audio.
- */
- public int getSampleRate() {
- return SAMPLE_RATE;
- }
-
- private static int nsToSamples(long ns) {
- return (int) (ns * SAMPLE_RATE / 1000000000);
- }
-
- private static int readUnsignedLittleEndian16(byte[] input, int offset) {
+ private static int readSignedLittleEndian16(byte[] input, int offset) {
int value = input[offset] & 0xFF;
value |= (input[offset + 1] & 0xFF) << 8;
- return value;
+ return (short) value;
}
- private static int readSignedLittleEndian16(byte[] input, int offset) {
- return (short) readUnsignedLittleEndian16(input, offset);
- }
+ private native long opusInit(
+ int sampleRate, int channelCount, int numStreams, int numCoupled, int gain, byte[] streamMap);
- private native long opusInit(int sampleRate, int channelCount, int numStreams, int numCoupled,
- int gain, byte[] streamMap);
- private native int opusDecode(long decoder, long timeUs, ByteBuffer inputBuffer, int inputSize,
+ private native int opusDecode(
+ long decoder,
+ long timeUs,
+ ByteBuffer inputBuffer,
+ int inputSize,
SimpleOutputBuffer outputBuffer);
private native int opusSecureDecode(
@@ -255,12 +249,16 @@ import java.util.List;
byte[] key,
byte[] iv,
int numSubSamples,
- int[] numBytesOfClearData,
- int[] numBytesOfEncryptedData);
+ @Nullable int[] numBytesOfClearData,
+ @Nullable int[] numBytesOfEncryptedData);
private native void opusClose(long decoder);
+
private native void opusReset(long decoder);
+
private native int opusGetErrorCode(long decoder);
+
private native String opusGetErrorMessage(long decoder);
+ private native void opusSetFloatOutput();
}
diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusLibrary.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusLibrary.java
index d09d69bf03..5529701c06 100644
--- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusLibrary.java
+++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusLibrary.java
@@ -68,7 +68,7 @@ public final class OpusLibrary {
* protected content.
*/
public static boolean matchesExpectedExoMediaCryptoType(
- @Nullable Class extends ExoMediaCrypto> exoMediaCryptoType) {
+ Class extends ExoMediaCrypto> exoMediaCryptoType) {
return Util.areEqual(OpusLibrary.exoMediaCryptoType, exoMediaCryptoType);
}
diff --git a/extensions/opus/src/main/jni/opus_jni.cc b/extensions/opus/src/main/jni/opus_jni.cc
index 9042e4cb89..a2515be7f6 100644
--- a/extensions/opus/src/main/jni/opus_jni.cc
+++ b/extensions/opus/src/main/jni/opus_jni.cc
@@ -58,10 +58,12 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
return JNI_VERSION_1_6;
}
-static const int kBytesPerSample = 2; // opus fixed point uses 16 bit samples.
+static const int kBytesPerIntPcmSample = 2;
+static const int kBytesPerFloatSample = 4;
static const int kMaxOpusOutputPacketSizeSamples = 960 * 6;
static int channelCount;
static int errorCode;
+static bool outputFloat = false;
DECODER_FUNC(jlong, opusInit, jint sampleRate, jint channelCount,
jint numStreams, jint numCoupled, jint gain, jbyteArray jStreamMap) {
@@ -99,8 +101,10 @@ DECODER_FUNC(jint, opusDecode, jlong jDecoder, jlong jTimeUs,
reinterpret_cast(
env->GetDirectBufferAddress(jInputBuffer));
+ const int byteSizePerSample = outputFloat ?
+ kBytesPerFloatSample : kBytesPerIntPcmSample;
const jint outputSize =
- kMaxOpusOutputPacketSizeSamples * kBytesPerSample * channelCount;
+ kMaxOpusOutputPacketSizeSamples * byteSizePerSample * channelCount;
env->CallObjectMethod(jOutputBuffer, outputBufferInit, jTimeUs, outputSize);
if (env->ExceptionCheck()) {
@@ -114,14 +118,23 @@ DECODER_FUNC(jint, opusDecode, jlong jDecoder, jlong jTimeUs,
return -1;
}
- int16_t* outputBufferData = reinterpret_cast(
- env->GetDirectBufferAddress(jOutputBufferData));
- int sampleCount = opus_multistream_decode(decoder, inputBuffer, inputSize,
+ int sampleCount;
+ if (outputFloat) {
+ float* outputBufferData = reinterpret_cast(
+ env->GetDirectBufferAddress(jOutputBufferData));
+ sampleCount = opus_multistream_decode_float(decoder, inputBuffer, inputSize,
outputBufferData, kMaxOpusOutputPacketSizeSamples, 0);
+ } else {
+ int16_t* outputBufferData = reinterpret_cast(
+ env->GetDirectBufferAddress(jOutputBufferData));
+ sampleCount = opus_multistream_decode(decoder, inputBuffer, inputSize,
+ outputBufferData, kMaxOpusOutputPacketSizeSamples, 0);
+ }
+
// record error code
errorCode = (sampleCount < 0) ? sampleCount : 0;
return (sampleCount < 0) ? sampleCount
- : sampleCount * kBytesPerSample * channelCount;
+ : sampleCount * byteSizePerSample * channelCount;
}
DECODER_FUNC(jint, opusSecureDecode, jlong jDecoder, jlong jTimeUs,
@@ -154,6 +167,10 @@ DECODER_FUNC(jint, opusGetErrorCode, jlong jContext) {
return errorCode;
}
+DECODER_FUNC(void, opusSetFloatOutput) {
+ outputFloat = true;
+}
+
LIBRARY_FUNC(jstring, opusIsSecureDecodeSupported) {
// Doesn't support
return 0;
diff --git a/extensions/opus/src/test/java/com/google/android/exoplayer2/ext/opus/DefaultRenderersFactoryTest.java b/extensions/opus/src/test/java/com/google/android/exoplayer2/ext/opus/DefaultRenderersFactoryTest.java
index e57ad84a41..9931f2d05f 100644
--- a/extensions/opus/src/test/java/com/google/android/exoplayer2/ext/opus/DefaultRenderersFactoryTest.java
+++ b/extensions/opus/src/test/java/com/google/android/exoplayer2/ext/opus/DefaultRenderersFactoryTest.java
@@ -26,7 +26,7 @@ import org.junit.runner.RunWith;
public final class DefaultRenderersFactoryTest {
@Test
- public void createRenderers_instantiatesVpxRenderer() {
+ public void createRenderers_instantiatesOpusRenderer() {
DefaultRenderersFactoryAsserts.assertExtensionRendererCreated(
LibopusAudioRenderer.class, C.TRACK_TYPE_AUDIO);
}
diff --git a/extensions/rtmp/build.gradle b/extensions/rtmp/build.gradle
index 621f8b2998..3d912bebf6 100644
--- a/extensions/rtmp/build.gradle
+++ b/extensions/rtmp/build.gradle
@@ -11,24 +11,7 @@
// 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'
-
-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/vp9/README.md b/extensions/vp9/README.md
index fd0836648a..765cdbca3b 100644
--- a/extensions/vp9/README.md
+++ b/extensions/vp9/README.md
@@ -42,9 +42,8 @@ cd "${VP9_EXT_PATH}/jni" && \
git clone https://chromium.googlesource.com/webm/libvpx libvpx
```
-* Checkout the appropriate branch of libvpx (the scripts and makefiles bundled
- in this repo are known to work only at specific versions of the library - we
- will update this periodically as newer versions of libvpx are released):
+* Checkout an appropriate branch of libvpx. We cannot guarantee compatibility
+ with all versions of libvpx. We currently recommend version 1.8.0:
```
cd "${VP9_EXT_PATH}/jni/libvpx" && \
@@ -127,19 +126,22 @@ To try out playback using the extension in the [demo application][], see
There are two possibilities for rendering the output `LibvpxVideoRenderer`
gets from the libvpx decoder:
-* GL rendering using GL shader for color space conversion
- * If you are using `SimpleExoPlayer` with `PlayerView`, enable this option by
- setting `surface_type` of `PlayerView` to be
- `video_decoder_gl_surface_view`.
- * Otherwise, enable this option by sending `LibvpxVideoRenderer` a message of
- type `C.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER` with an instance of
- `VideoDecoderOutputBufferRenderer` as its object.
+* GL rendering using GL shader for color space conversion
-* Native rendering using `ANativeWindow`
- * If you are using `SimpleExoPlayer` with `PlayerView`, this option is enabled
- by default.
- * Otherwise, enable this option by sending `LibvpxVideoRenderer` a message of
- type `C.MSG_SET_SURFACE` with an instance of `SurfaceView` as its object.
+ * If you are using `SimpleExoPlayer` with `PlayerView`, enable this option
+ by setting `surface_type` of `PlayerView` to be
+ `video_decoder_gl_surface_view`.
+ * Otherwise, enable this option by sending `LibvpxVideoRenderer` a message
+ of type `Renderer.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER` with an
+ instance of `VideoDecoderOutputBufferRenderer` as its object.
+
+* Native rendering using `ANativeWindow`
+
+ * If you are using `SimpleExoPlayer` with `PlayerView`, this option is
+ enabled by default.
+ * Otherwise, enable this option by sending `LibvpxVideoRenderer` a message
+ of type `Renderer.MSG_SET_SURFACE` with an instance of `SurfaceView` as
+ its object.
Note: Although the default option uses `ANativeWindow`, based on our testing the
GL rendering mode has better performance, so should be preferred.
diff --git a/extensions/vp9/build.gradle b/extensions/vp9/build.gradle
index ffd76d6e2f..79d85a6ac5 100644
--- a/extensions/vp9/build.gradle
+++ b/extensions/vp9/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/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java b/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java
index 7b81c0b9b8..823ce02cfe 100644
--- a/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java
+++ b/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java
@@ -24,10 +24,11 @@ import android.os.Looper;
import androidx.annotation.Nullable;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
-import com.google.android.exoplayer2.C;
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.Renderer;
import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
@@ -42,10 +43,11 @@ import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public class VpxPlaybackTest {
- private static final String BEAR_URI = "asset:///vp9/bear-vp9.webm";
- private static final String BEAR_ODD_DIMENSIONS_URI = "asset:///vp9/bear-vp9-odd-dimensions.webm";
- private static final String ROADTRIP_10BIT_URI = "asset:///vp9/roadtrip-vp92-10bit.webm";
- private static final String INVALID_BITSTREAM_URI = "asset:///vp9/invalid-bitstream.webm";
+ private static final String BEAR_URI = "asset:///media/vp9/bear-vp9.webm";
+ private static final String BEAR_ODD_DIMENSIONS_URI =
+ "asset:///media/vp9/bear-vp9-odd-dimensions.webm";
+ private static final String ROADTRIP_10BIT_URI = "asset:///media/vp9/roadtrip-vp92-10bit.webm";
+ private static final String INVALID_BITSTREAM_URI = "asset:///media/vp9/invalid-bitstream.webm";
private static final String TAG = "VpxPlaybackTest";
@@ -119,15 +121,15 @@ public class VpxPlaybackTest {
player.addListener(this);
MediaSource mediaSource =
new ProgressiveMediaSource.Factory(
- new DefaultDataSourceFactory(context, "ExoPlayerExtVp9Test"),
- MatroskaExtractor.FACTORY)
- .createMediaSource(uri);
+ new DefaultDataSourceFactory(context), MatroskaExtractor.FACTORY)
+ .createMediaSource(MediaItem.fromUri(uri));
player
.createMessage(videoRenderer)
- .setType(C.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER)
+ .setType(Renderer.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER)
.setPayload(new VideoDecoderGLSurfaceView(context).getVideoDecoderOutputBufferRenderer())
.send();
- player.prepare(mediaSource);
+ player.setMediaSource(mediaSource);
+ player.prepare();
player.play();
Looper.loop();
}
diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java
index 8f95024423..61ebc8b0d9 100644
--- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java
+++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java
@@ -102,7 +102,6 @@ public class LibvpxVideoRenderer extends DecoderVideoRenderer {
* @param numInputBuffers Number of input buffers.
* @param numOutputBuffers Number of output buffers.
*/
- @SuppressWarnings("deprecation")
public LibvpxVideoRenderer(
long allowedJoiningTimeMs,
@Nullable Handler eventHandler,
@@ -129,7 +128,7 @@ public class LibvpxVideoRenderer extends DecoderVideoRenderer {
return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE);
}
boolean drmIsSupported =
- format.drmInitData == null
+ format.exoMediaCryptoType == null
|| VpxLibrary.matchesExpectedExoMediaCryptoType(format.exoMediaCryptoType);
if (!drmIsSupported) {
return RendererCapabilities.create(FORMAT_UNSUPPORTED_DRM);
diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java
index 98a26727ee..ce0873ad40 100644
--- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java
+++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java
@@ -19,6 +19,7 @@ import android.view.Surface;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.decoder.CryptoInfo;
+import com.google.android.exoplayer2.decoder.DecoderInputBuffer;
import com.google.android.exoplayer2.decoder.SimpleDecoder;
import com.google.android.exoplayer2.drm.DecryptionException;
import com.google.android.exoplayer2.drm.ExoMediaCrypto;
@@ -86,18 +87,9 @@ import java.nio.ByteBuffer;
return "libvpx" + VpxLibrary.getVersion();
}
- /**
- * Sets the output mode for frames rendered by the decoder.
- *
- * @param outputMode The output mode.
- */
- public void setOutputMode(@C.VideoOutputMode int outputMode) {
- this.outputMode = outputMode;
- }
-
@Override
protected VideoDecoderInputBuffer createInputBuffer() {
- return new VideoDecoderInputBuffer();
+ return new VideoDecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT);
}
@Override
@@ -132,11 +124,20 @@ import java.nio.ByteBuffer;
ByteBuffer inputData = Util.castNonNull(inputBuffer.data);
int inputSize = inputData.limit();
CryptoInfo cryptoInfo = inputBuffer.cryptoInfo;
- final long result = inputBuffer.isEncrypted()
- ? vpxSecureDecode(vpxDecContext, inputData, inputSize, exoMediaCrypto,
- cryptoInfo.mode, cryptoInfo.key, cryptoInfo.iv, cryptoInfo.numSubSamples,
- cryptoInfo.numBytesOfClearData, cryptoInfo.numBytesOfEncryptedData)
- : vpxDecode(vpxDecContext, inputData, inputSize);
+ final long result =
+ inputBuffer.isEncrypted()
+ ? vpxSecureDecode(
+ vpxDecContext,
+ inputData,
+ inputSize,
+ exoMediaCrypto,
+ cryptoInfo.mode,
+ Assertions.checkNotNull(cryptoInfo.key),
+ Assertions.checkNotNull(cryptoInfo.iv),
+ cryptoInfo.numSubSamples,
+ cryptoInfo.numBytesOfClearData,
+ cryptoInfo.numBytesOfEncryptedData)
+ : vpxDecode(vpxDecContext, inputData, inputSize);
if (result != NO_ERROR) {
if (result == DRM_ERROR) {
String message = "Drm error: " + vpxGetErrorMessage(vpxDecContext);
@@ -170,7 +171,7 @@ import java.nio.ByteBuffer;
} else if (getFrameResult == -1) {
return new VpxDecoderException("Buffer initialization failed.");
}
- outputBuffer.colorInfo = inputBuffer.colorInfo;
+ outputBuffer.format = inputBuffer.format;
}
return null;
}
@@ -182,6 +183,15 @@ import java.nio.ByteBuffer;
vpxClose(vpxDecContext);
}
+ /**
+ * Sets the output mode for frames rendered by the decoder.
+ *
+ * @param outputMode The output mode.
+ */
+ public void setOutputMode(@C.VideoOutputMode int outputMode) {
+ this.outputMode = outputMode;
+ }
+
/** Renders the outputBuffer to the surface. Used with OUTPUT_MODE_SURFACE_YUV only. */
public void renderToSurface(VideoDecoderOutputBuffer outputBuffer, Surface surface)
throws VpxDecoderException {
@@ -206,8 +216,8 @@ import java.nio.ByteBuffer;
byte[] key,
byte[] iv,
int numSubSamples,
- int[] numBytesOfClearData,
- int[] numBytesOfEncryptedData);
+ @Nullable int[] numBytesOfClearData,
+ @Nullable int[] numBytesOfEncryptedData);
private native int vpxGetFrame(long context, VideoDecoderOutputBuffer outputBuffer);
diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxLibrary.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxLibrary.java
index e620332fc8..5106ab67ad 100644
--- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxLibrary.java
+++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxLibrary.java
@@ -87,7 +87,7 @@ public final class VpxLibrary {
* protected content.
*/
public static boolean matchesExpectedExoMediaCryptoType(
- @Nullable Class extends ExoMediaCrypto> exoMediaCryptoType) {
+ Class extends ExoMediaCrypto> exoMediaCryptoType) {
return Util.areEqual(VpxLibrary.exoMediaCryptoType, exoMediaCryptoType);
}
diff --git a/extensions/vp9/src/main/jni/vpx_jni.cc b/extensions/vp9/src/main/jni/vpx_jni.cc
index 9996848047..1fc0f9d56e 100644
--- a/extensions/vp9/src/main/jni/vpx_jni.cc
+++ b/extensions/vp9/src/main/jni/vpx_jni.cc
@@ -65,9 +65,11 @@ static jfieldID dataField;
static jfieldID outputModeField;
static jfieldID decoderPrivateField;
-// android.graphics.ImageFormat.YV12.
-static const int kHalPixelFormatYV12 = 0x32315659;
+// Android YUV format. See:
+// https://developer.android.com/reference/android/graphics/ImageFormat.html#YV12.
+static const int kImageFormatYV12 = 0x32315659;
static const int kDecoderPrivateBase = 0x100;
+
static int errorCode;
jint JNI_OnLoad(JavaVM* vm, void* reserved) {
@@ -635,7 +637,7 @@ DECODER_FUNC(jint, vpxRenderFrame, jlong jContext, jobject jSurface,
}
if (context->width != srcBuffer->d_w || context->height != srcBuffer->d_h) {
ANativeWindow_setBuffersGeometry(context->native_window, srcBuffer->d_w,
- srcBuffer->d_h, kHalPixelFormatYV12);
+ srcBuffer->d_h, kImageFormatYV12);
context->width = srcBuffer->d_w;
context->height = srcBuffer->d_h;
}
diff --git a/extensions/workmanager/build.gradle b/extensions/workmanager/build.gradle
index 6025ecfcd0..1882ebac81 100644
--- a/extensions/workmanager/build.gradle
+++ b/extensions/workmanager/build.gradle
@@ -13,28 +13,21 @@
* 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')
- implementation 'androidx.work:work-runtime:2.3.4'
+ implementation 'androidx.work:work-runtime:2.4.0'
+ // Guava & Gradle interact badly, and this prevents
+ // "cannot access ListenableFuture" errors [internal b/157225611].
+ // More info: https://blog.gradle.org/guava
+ 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.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion
}
diff --git a/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java b/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java
index 97b132980d..ff9335ad84 100644
--- a/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java
+++ b/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java
@@ -35,22 +35,38 @@ import com.google.android.exoplayer2.util.Util;
/** A {@link Scheduler} that uses {@link WorkManager}. */
public final class WorkManagerScheduler implements Scheduler {
- private static final boolean DEBUG = false;
private static final String TAG = "WorkManagerScheduler";
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
+ | (Util.SDK_INT >= 23 ? Requirements.DEVICE_IDLE : 0)
+ | Requirements.DEVICE_CHARGING
+ | Requirements.DEVICE_STORAGE_NOT_LOW;
+ private final WorkManager workManager;
private final String workName;
+ /** @deprecated Call {@link #WorkManagerScheduler(Context, String)} instead. */
+ @Deprecated
+ @SuppressWarnings("deprecation")
+ public WorkManagerScheduler(String workName) {
+ this.workName = workName;
+ workManager = WorkManager.getInstance();
+ }
+
/**
+ * @param context A context.
* @param workName A name for work scheduled by this instance. If the same name was used by a
* previous instance, anything scheduled by the previous instance will be canceled by this
* instance if {@link #schedule(Requirements, String, String)} or {@link #cancel()} are
* called.
*/
- public WorkManagerScheduler(String workName) {
+ public WorkManagerScheduler(Context context, String workName) {
this.workName = workName;
+ workManager = WorkManager.getInstance(context.getApplicationContext());
}
@Override
@@ -58,21 +74,31 @@ public final class WorkManagerScheduler implements Scheduler {
Constraints constraints = buildConstraints(requirements);
Data inputData = buildInputData(requirements, servicePackage, serviceAction);
OneTimeWorkRequest workRequest = buildWorkRequest(constraints, inputData);
- logd("Scheduling work: " + workName);
- WorkManager.getInstance().enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest);
+ workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest);
return true;
}
@Override
public boolean cancel() {
- logd("Canceling work: " + workName);
- WorkManager.getInstance().cancelUniqueWork(workName);
+ workManager.cancelUniqueWork(workName);
return true;
}
- private static Constraints buildConstraints(Requirements requirements) {
- Constraints.Builder builder = new Constraints.Builder();
+ @Override
+ public Requirements getSupportedRequirements(Requirements requirements) {
+ return requirements.filterRequirements(SUPPORTED_REQUIREMENTS);
+ }
+ private static Constraints buildConstraints(Requirements requirements) {
+ Requirements filteredRequirements = requirements.filterRequirements(SUPPORTED_REQUIREMENTS);
+ if (!filteredRequirements.equals(requirements)) {
+ Log.w(
+ TAG,
+ "Ignoring unsupported requirements: "
+ + (filteredRequirements.getRequirements() ^ requirements.getRequirements()));
+ }
+
+ Constraints.Builder builder = new Constraints.Builder();
if (requirements.isUnmeteredNetworkRequired()) {
builder.setRequiredNetworkType(NetworkType.UNMETERED);
} else if (requirements.isNetworkRequired()) {
@@ -80,13 +106,14 @@ public final class WorkManagerScheduler implements Scheduler {
} else {
builder.setRequiredNetworkType(NetworkType.NOT_REQUIRED);
}
-
+ if (Util.SDK_INT >= 23 && requirements.isIdleRequired()) {
+ setRequiresDeviceIdle(builder);
+ }
if (requirements.isChargingRequired()) {
builder.setRequiresCharging(true);
}
-
- if (requirements.isIdleRequired() && Util.SDK_INT >= 23) {
- setRequiresDeviceIdle(builder);
+ if (requirements.isStorageNotLowRequired()) {
+ builder.setRequiresStorageNotLow(true);
}
return builder.build();
@@ -117,12 +144,6 @@ public final class WorkManagerScheduler implements Scheduler {
return builder.build();
}
- private static void logd(String message) {
- if (DEBUG) {
- Log.d(TAG, message);
- }
- }
-
/** A {@link Worker} that starts the target service if the requirements are met. */
// This class needs to be public so that WorkManager can instantiate it.
public static final class SchedulerWorker extends Worker {
@@ -138,22 +159,17 @@ public final class WorkManagerScheduler implements Scheduler {
@Override
public Result doWork() {
- logd("SchedulerWorker is started");
- Data inputData = workerParams.getInputData();
- Assertions.checkNotNull(inputData, "Work started without input data.");
+ Data inputData = Assertions.checkNotNull(workerParams.getInputData());
Requirements requirements = new Requirements(inputData.getInt(KEY_REQUIREMENTS, 0));
- if (requirements.checkRequirements(context)) {
- logd("Requirements are met");
- String serviceAction = inputData.getString(KEY_SERVICE_ACTION);
- String servicePackage = inputData.getString(KEY_SERVICE_PACKAGE);
- Assertions.checkNotNull(serviceAction, "Service action missing.");
- Assertions.checkNotNull(servicePackage, "Service package missing.");
+ int notMetRequirements = requirements.getNotMetRequirements(context);
+ if (notMetRequirements == 0) {
+ String serviceAction = Assertions.checkNotNull(inputData.getString(KEY_SERVICE_ACTION));
+ String servicePackage = Assertions.checkNotNull(inputData.getString(KEY_SERVICE_PACKAGE));
Intent intent = new Intent(serviceAction).setPackage(servicePackage);
- logd("Starting service action: " + serviceAction + " package: " + servicePackage);
Util.startForegroundService(context, intent);
return Result.success();
} else {
- logd("Requirements are not met");
+ Log.w(TAG, "Requirements not met: " + notMetRequirements);
return Result.retry();
}
}
diff --git a/javadoc_combined.gradle b/javadoc_combined.gradle
index 3b482910ae..1030d3e16a 100644
--- a/javadoc_combined.gradle
+++ b/javadoc_combined.gradle
@@ -11,6 +11,7 @@
// 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: "${buildscript.sourceFile.parentFile}/constants.gradle"
apply from: "${buildscript.sourceFile.parentFile}/javadoc_util.gradle"
class CombinedJavadocPlugin implements Plugin {
@@ -29,7 +30,8 @@ class CombinedJavadocPlugin implements Plugin