From cdac347f8f175fb5ac5a9f4250d5462c6a083aed Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 17 May 2017 08:54:09 -0700 Subject: [PATCH] Open source IMA extension ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=156312761 --- demo/build.gradle | 1 + demo/src/main/assets/media.exolist.json | 80 +++ .../exoplayer2/demo/PlayerActivity.java | 25 + .../demo/SampleChooserActivity.java | 11 +- demo/src/main/res/values/strings.xml | 2 + extensions/ima/README.md | 31 + extensions/ima/build.gradle | 35 + extensions/ima/src/main/AndroidManifest.xml | 5 + .../exoplayer2/ext/ima/AdTimeline.java | 275 ++++++++ .../exoplayer2/ext/ima/ImaAdsLoader.java | 633 ++++++++++++++++++ .../exoplayer2/ext/ima/ImaAdsMediaSource.java | 530 +++++++++++++++ settings.gradle | 2 + 12 files changed, 1628 insertions(+), 2 deletions(-) create mode 100644 extensions/ima/README.md create mode 100644 extensions/ima/build.gradle create mode 100644 extensions/ima/src/main/AndroidManifest.xml create mode 100644 extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTimeline.java create mode 100644 extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java create mode 100644 extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsMediaSource.java diff --git a/demo/build.gradle b/demo/build.gradle index be5e52a25c..939c5ac93d 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -52,6 +52,7 @@ dependencies { compile project(':library-ui') withExtensionsCompile project(path: ':extension-ffmpeg') withExtensionsCompile project(path: ':extension-flac') + withExtensionsCompile project(path: ':extension-ima') withExtensionsCompile project(path: ':extension-opus') withExtensionsCompile project(path: ':extension-vp9') } diff --git a/demo/src/main/assets/media.exolist.json b/demo/src/main/assets/media.exolist.json index 814c89a45b..4a51919657 100644 --- a/demo/src/main/assets/media.exolist.json +++ b/demo/src/main/assets/media.exolist.json @@ -452,5 +452,85 @@ ] } ] + }, + { + "name": "IMA sample ad tags", + "samples": [ + { + "name": "Single inline linear", + "uri": "http://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", + "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/single_ad_samples&ciu_szs=300x250&impl=s&gdfp_req=1&env=vp&output=vast&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ct%3Dlinear&correlator=" + }, + { + "name": "Single skippable inline", + "uri": "http://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", + "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/single_ad_samples&ciu_szs=300x250&impl=s&gdfp_req=1&env=vp&output=vast&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ct%3Dskippablelinear&correlator=" + }, + { + "name": "Single redirect linear", + "uri": "http://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", + "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/single_ad_samples&ciu_szs=300x250&impl=s&gdfp_req=1&env=vp&output=vast&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ct%3Dredirectlinear&correlator=" + }, + { + "name": "Single redirect error", + "uri": "http://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", + "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/single_ad_samples&ciu_szs=300x250&impl=s&gdfp_req=1&env=vp&output=vast&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ct%3Dredirecterror&nofb=1&correlator=" + }, + { + "name": "Single redirect broken (fallback)", + "uri": "http://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", + "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/single_ad_samples&ciu_szs=300x250&impl=s&gdfp_req=1&env=vp&output=vast&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ct%3Dredirecterror&correlator=" + }, + { + "name": "VMAP pre-roll", + "uri": "http://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", + "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpreonly&cmsid=496&vid=short_onecue&correlator=" + }, + { + "name": "VMAP pre-roll + bumper", + "uri": "http://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", + "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpreonlybumper&cmsid=496&vid=short_onecue&correlator=" + }, + { + "name": "VMAP post-roll", + "uri": "http://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", + "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpostonly&cmsid=496&vid=short_onecue&correlator=" + }, + { + "name": "VMAP post-roll + bumper", + "uri": "http://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", + "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpostonlybumper&cmsid=496&vid=short_onecue&correlator=" + }, + { + "name": "VMAP pre-, mid- and post-rolls, single ads", + "uri": "http://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", + "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/single_ad_samples&ciu_szs=300x250&impl=s&gdfp_req=1&env=vp&output=vast&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ct%3Dlinear&correlator=" + }, + { + "name": "VMAP pre-roll single ad, mid-roll standard pod with 3 ads, post-roll single ad", + "uri": "http://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", + "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpremidpostpod&cmsid=496&vid=short_onecue&correlator=" + }, + { + "name": "VMAP pre-roll single ad, mid-roll optimized pod with 3 ads, post-roll single ad", + "uri": "http://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", + "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpremidpostoptimizedpod&cmsid=496&vid=short_onecue&correlator=" + }, + { + "name": "VMAP pre-roll single ad, mid-roll standard pod with 3 ads, post-roll single ad (bumpers around all ad breaks)", + "uri": "http://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", + "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpremidpostpodbumper&cmsid=496&vid=short_onecue&correlator=" + }, + { + "name": "VMAP pre-roll single ad, mid-roll optimized pod with 3 ads, post-roll single ad (bumpers around all ad breaks)", + "uri": "http://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", + "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpremidpostoptimizedpodbumper&cmsid=496&vid=short_onecue&correlator=" + }, + { + "name": "VMAP pre-roll single ad, mid-roll standard pods with 5 ads every 10 seconds for 1:40, post-roll single ad", + "uri": "http://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", + "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpremidpostlongpod&cmsid=496&vid=short_tencue&correlator=" + } + ] } ] diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demo/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index 6cc9cabfc0..2cb35bf75e 100644 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demo/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.demo; import android.app.Activity; +import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; @@ -26,7 +27,9 @@ import android.text.TextUtils; import android.view.KeyEvent; import android.view.View; import android.view.View.OnClickListener; +import android.view.ViewGroup; import android.widget.Button; +import android.widget.FrameLayout; import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; @@ -69,6 +72,7 @@ import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.util.Util; +import java.lang.reflect.Constructor; import java.net.CookieHandler; import java.net.CookieManager; import java.net.CookiePolicy; @@ -92,6 +96,7 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay "com.google.android.exoplayer.demo.action.VIEW_LIST"; public static final String URI_LIST_EXTRA = "uri_list"; public static final String EXTENSION_LIST_EXTRA = "extension_list"; + public static final String AD_TAG_URI_EXTRA = "ad_tag_uri"; private static final DefaultBandwidthMeter BANDWIDTH_METER = new DefaultBandwidthMeter(); private static final CookieManager DEFAULT_COOKIE_MANAGER; @@ -312,6 +317,26 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay } MediaSource mediaSource = mediaSources.length == 1 ? mediaSources[0] : new ConcatenatingMediaSource(mediaSources); + String adTagUriString = intent.getStringExtra(AD_TAG_URI_EXTRA); + if (adTagUriString != null) { + Uri adTagUri = Uri.parse(adTagUriString); + ViewGroup adOverlayViewGroup = new FrameLayout(this); + // Load the extension source using reflection so that demo app doesn't have to depend on it. + try { + Class clazz = Class.forName("com.google.android.exoplayer2.ext.ima.ImaAdsMediaSource"); + Constructor constructor = clazz.getConstructor(MediaSource.class, + DataSource.Factory.class, Context.class, Uri.class, ViewGroup.class); + mediaSource = (MediaSource) constructor.newInstance(mediaSource, + mediaDataSourceFactory, this, adTagUri, adOverlayViewGroup); + // The demo app has a non-null overlay frame layout. + simpleExoPlayerView.getOverlayFrameLayout().addView(adOverlayViewGroup); + // Show a multi-window time bar, which will include ad break position markers. + simpleExoPlayerView.setShowMultiWindowTimeBar(true); + } catch (Exception e) { + // Throw if the media source class was not found, or there was an error instantiating it. + showToast(R.string.ima_not_loaded); + } + } boolean haveResumePosition = resumeWindow != C.INDEX_UNSET; if (haveResumePosition) { player.seekTo(resumeWindow, resumePosition); diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/demo/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java index 081ad190b5..87b8e92e83 100644 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java +++ b/demo/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java @@ -184,6 +184,7 @@ public class SampleChooserActivity extends Activity { String[] drmKeyRequestProperties = null; boolean preferExtensionDecoders = false; ArrayList playlistSamples = null; + String adTagUri = null; reader.beginObject(); while (reader.hasNext()) { @@ -233,6 +234,9 @@ public class SampleChooserActivity extends Activity { } reader.endArray(); break; + case "ad_tag_uri": + adTagUri = reader.nextString(); + break; default: throw new ParserException("Unsupported attribute name: " + name); } @@ -246,7 +250,7 @@ public class SampleChooserActivity extends Activity { preferExtensionDecoders, playlistSamplesArray); } else { return new UriSample(sampleName, drmUuid, drmLicenseUrl, drmKeyRequestProperties, - preferExtensionDecoders, uri, extension); + preferExtensionDecoders, uri, extension, adTagUri); } } @@ -402,13 +406,15 @@ public class SampleChooserActivity extends Activity { public final String uri; public final String extension; + public final String adTagUri; public UriSample(String name, UUID drmSchemeUuid, String drmLicenseUrl, String[] drmKeyRequestProperties, boolean preferExtensionDecoders, String uri, - String extension) { + String extension, String adTagUri) { super(name, drmSchemeUuid, drmLicenseUrl, drmKeyRequestProperties, preferExtensionDecoders); this.uri = uri; this.extension = extension; + this.adTagUri = adTagUri; } @Override @@ -416,6 +422,7 @@ public class SampleChooserActivity extends Activity { return super.buildIntent(context) .setData(Uri.parse(uri)) .putExtra(PlayerActivity.EXTENSION_EXTRA, extension) + .putExtra(PlayerActivity.AD_TAG_URI_EXTRA, adTagUri) .setAction(PlayerActivity.ACTION_VIEW); } diff --git a/demo/src/main/res/values/strings.xml b/demo/src/main/res/values/strings.xml index ac17ad4443..57a05d24cd 100644 --- a/demo/src/main/res/values/strings.xml +++ b/demo/src/main/res/values/strings.xml @@ -58,4 +58,6 @@ One or more sample lists failed to load + Playing sample without ads, as the IMA extension was not loaded + diff --git a/extensions/ima/README.md b/extensions/ima/README.md new file mode 100644 index 0000000000..1dbdfd7f9b --- /dev/null +++ b/extensions/ima/README.md @@ -0,0 +1,31 @@ +# ExoPlayer IMA extension # + +## Description ## + +The IMA extension is a [MediaSource][] implementation wrapping the +[Interactive Media Ads SDK for Android][IMA]. You can use it to insert ads +alongside content. + +[IMA]: https://developers.google.com/interactive-media-ads/docs/sdks/android/ +[MediaSource]: https://github.com/google/ExoPlayer/blob/release-v2/library/src/main/java/com/google/android/exoplayer2/source/MediaSource.java + +## Using the extension ## + +Pass a single-window content `MediaSource` to `ImaAdsMediaSource`'s constructor, +along with a `ViewGroup` that is on top of the player and the ad tag URI to +show. The IMA documentation includes some [sample ad tags][] for testing. Then +pass the `ImaAdsMediaSource` to `ExoPlayer.prepare`. + +[sample ad tags]: https://developers.google.com/interactive-media-ads/docs/sdks/android/tags + +## Known issues ## + +This is a preview version with some known issues: + +* Midroll ads are not yet fully supported. In particular, seeking with midroll +ads is not yet supported. Played ad periods are not removed. Also, `playAd` and +`AD_STARTED` events are sometimes delayed, meaning that midroll ads take a long +time to start and the ad overlay does not show immediately. +* Tapping the 'More info' button on an ad in the demo app will pause the +activity, which destroys the ImaAdsMediaSource. Played ad breaks will be +shown to the user again if the demo app returns to the foreground. diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle new file mode 100644 index 0000000000..4ba26cc244 --- /dev/null +++ b/extensions/ima/build.gradle @@ -0,0 +1,35 @@ +apply plugin: 'com.android.library' + +android { + compileSdkVersion project.ext.compileSdkVersion + buildToolsVersion project.ext.buildToolsVersion + + defaultConfig { + minSdkVersion 14 + targetSdkVersion project.ext.targetSdkVersion + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } +} + +dependencies { + compile project(':library-core') + compile 'com.android.support:support-annotations:' + supportLibraryVersion + compile 'com.google.ads.interactivemedia.v3:interactivemedia:3.6.0' + compile 'com.google.android.gms:play-services-ads:10.2.4' + // There exists a dependency chain: + // com.google.android.gms:play-services-ads:10.2.4 + // |-> com.google.android.gms:play-services-ads-lite:10.2.4 + // |-> com.google.android.gms:play-services-basement:10.2.4 + // |-> com.android.support:support-v4:24.0.0 + // The support-v4:24.0.0 module directly includes older versions of the same + // classes as com.android.support:support-annotations. We need to manually + // force it to the version we're using to avoid a compilation failure. This + // will become unnecessary when the support-v4 dependency in the chain above + // has been updated to 24.2.0 or later. + compile 'com.android.support:support-v4:' + supportLibraryVersion + androidTestCompile project(':library') + androidTestCompile 'com.google.dexmaker:dexmaker:' + dexmakerVersion + androidTestCompile 'com.google.dexmaker:dexmaker-mockito:' + dexmakerVersion + androidTestCompile 'org.mockito:mockito-core:' + mockitoVersion + androidTestCompile 'com.android.support.test:runner:' + testSupportLibraryVersion +} diff --git a/extensions/ima/src/main/AndroidManifest.xml b/extensions/ima/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..22fb518c58 --- /dev/null +++ b/extensions/ima/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTimeline.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTimeline.java new file mode 100644 index 0000000000..1f8008ed10 --- /dev/null +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTimeline.java @@ -0,0 +1,275 @@ +/* + * Copyright (C) 2017 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 android.util.Pair; +import com.google.ads.interactivemedia.v3.api.Ad; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; + +/** + * A {@link Timeline} for {@link ImaAdsMediaSource}. + */ +/* package */ final class AdTimeline extends Timeline { + + private static final Object AD_ID = new Object(); + + /** + * Builder for ad timelines. + */ + public static final class Builder { + + private final Timeline contentTimeline; + private final long contentDurationUs; + private final ArrayList isAd; + private final ArrayList ads; + private final ArrayList startTimesUs; + private final ArrayList endTimesUs; + private final ArrayList uids; + + /** + * Creates a new ad timeline builder using the specified {@code contentTimeline} as the timeline + * of the content within which to insert ad breaks. + * + * @param contentTimeline The timeline of the content within which to insert ad breaks. + */ + public Builder(Timeline contentTimeline) { + this.contentTimeline = contentTimeline; + contentDurationUs = contentTimeline.getPeriod(0, new Period()).durationUs; + isAd = new ArrayList<>(); + ads = new ArrayList<>(); + startTimesUs = new ArrayList<>(); + endTimesUs = new ArrayList<>(); + uids = new ArrayList<>(); + } + + /** + * Adds an ad period. Each individual ad in an ad pod is represented by a separate ad period. + * + * @param ad The {@link Ad} instance representing the ad break, or {@code null} if not known. + * @param adBreakIndex The index of the ad break that contains the ad in the timeline. + * @param adIndexInAdBreak The index of the ad in its ad break. + * @param durationUs The duration of the ad, in microseconds. May be {@link C#TIME_UNSET}. + * @return The builder. + */ + public Builder addAdPeriod(Ad ad, int adBreakIndex, int adIndexInAdBreak, long durationUs) { + isAd.add(true); + ads.add(ad); + startTimesUs.add(0L); + endTimesUs.add(durationUs); + uids.add(Pair.create(adBreakIndex, adIndexInAdBreak)); + return this; + } + + /** + * Adds a content period. + * + * @param startTimeUs The start time of the period relative to the start of the content + * timeline, in microseconds. + * @param endTimeUs The end time of the period relative to the start of the content timeline, in + * microseconds. May be {@link C#TIME_UNSET} to include the rest of the content. + * @return The builder. + */ + public Builder addContent(long startTimeUs, long endTimeUs) { + ads.add(null); + isAd.add(false); + startTimesUs.add(startTimeUs); + endTimesUs.add(endTimeUs == C.TIME_UNSET ? contentDurationUs : endTimeUs); + uids.add(Pair.create(startTimeUs, endTimeUs)); + return this; + } + + /** + * Builds and returns the ad timeline. + */ + public AdTimeline build() { + int periodCount = uids.size(); + Assertions.checkState(periodCount > 0); + Ad[] ads = new Ad[periodCount]; + boolean[] isAd = new boolean[periodCount]; + long[] startTimesUs = new long[periodCount]; + long[] endTimesUs = new long[periodCount]; + for (int i = 0; i < periodCount; i++) { + ads[i] = this.ads.get(i); + isAd[i] = this.isAd.get(i); + startTimesUs[i] = this.startTimesUs.get(i); + endTimesUs[i] = this.endTimesUs.get(i); + } + Object[] uids = this.uids.toArray(new Object[periodCount]); + return new AdTimeline(contentTimeline, isAd, ads, startTimesUs, endTimesUs, uids); + } + + } + + private final Period contentPeriod; + private final Window contentWindow; + private final boolean[] isAd; + private final Ad[] ads; + private final long[] startTimesUs; + private final long[] endTimesUs; + private final Object[] uids; + + private AdTimeline(Timeline contentTimeline, boolean[] isAd, Ad[] ads, long[] startTimesUs, + long[] endTimesUs, Object[] uids) { + contentWindow = contentTimeline.getWindow(0, new Window(), true); + contentPeriod = contentTimeline.getPeriod(0, new Period(), true); + this.isAd = isAd; + this.ads = ads; + this.startTimesUs = startTimesUs; + this.endTimesUs = endTimesUs; + this.uids = uids; + } + + /** + * Returns whether the period at {@code index} contains ad media. + */ + public boolean isPeriodAd(int index) { + return isAd[index]; + } + + /** + * Returns the duration of the content within which ads have been inserted, in microseconds. + */ + public long getContentDurationUs() { + return contentPeriod.durationUs; + } + + /** + * Returns the start time of the period at {@code periodIndex} relative to the start of the + * content, in microseconds. + * + * @throws IllegalArgumentException Thrown if the period at {@code periodIndex} is not a content + * period. + */ + public long getContentStartTimeUs(int periodIndex) { + Assertions.checkArgument(!isAd[periodIndex]); + return startTimesUs[periodIndex]; + } + + /** + * Returns the end time of the period at {@code periodIndex} relative to the start of the content, + * in microseconds. + * + * @throws IllegalArgumentException Thrown if the period at {@code periodIndex} is not a content + * period. + */ + public long getContentEndTimeUs(int periodIndex) { + Assertions.checkArgument(!isAd[periodIndex]); + return endTimesUs[periodIndex]; + } + + /** + * Returns the index of the ad break to which the period at {@code periodIndex} belongs. + * + * @param periodIndex The period index. + * @return The index of the ad break to which the period belongs. + * @throws IllegalArgumentException Thrown if the period at {@code periodIndex} is not an ad. + */ + public int getAdBreakIndex(int periodIndex) { + Assertions.checkArgument(isAd[periodIndex]); + int adBreakIndex = 0; + for (int i = 1; i < periodIndex; i++) { + if (!isAd[i] && isAd[i - 1]) { + adBreakIndex++; + } + } + return adBreakIndex; + } + + /** + * Returns the index of the ad at {@code periodIndex} in its ad break. + * + * @param periodIndex The period index. + * @return The index of the ad at {@code periodIndex} in its ad break. + * @throws IllegalArgumentException Thrown if the period at {@code periodIndex} is not an ad. + */ + public int getAdIndexInAdBreak(int periodIndex) { + Assertions.checkArgument(isAd[periodIndex]); + int adIndex = 0; + for (int i = 0; i < periodIndex; i++) { + if (isAd[i]) { + adIndex++; + } else { + adIndex = 0; + } + } + return adIndex; + } + + @Override + public int getWindowCount() { + return uids.length; + } + + @Override + public int getNextWindowIndex(int windowIndex, @ExoPlayer.RepeatMode int repeatMode) { + if (repeatMode == ExoPlayer.REPEAT_MODE_ONE) { + repeatMode = ExoPlayer.REPEAT_MODE_ALL; + } + return super.getNextWindowIndex(windowIndex, repeatMode); + } + + @Override + public int getPreviousWindowIndex(int windowIndex, @ExoPlayer.RepeatMode int repeatMode) { + if (repeatMode == ExoPlayer.REPEAT_MODE_ONE) { + repeatMode = ExoPlayer.REPEAT_MODE_ALL; + } + return super.getPreviousWindowIndex(windowIndex, repeatMode); + } + + @Override + public Window getWindow(int index, Window window, boolean setIds, + long defaultPositionProjectionUs) { + long startTimeUs = startTimesUs[index]; + long durationUs = endTimesUs[index] - startTimeUs; + if (isAd[index]) { + window.set(ads[index], C.TIME_UNSET, C.TIME_UNSET, false, false, 0L, durationUs, index, index, + 0L); + } else { + window.set(contentWindow.id, contentWindow.presentationStartTimeMs + C.usToMs(startTimeUs), + contentWindow.windowStartTimeMs + C.usToMs(startTimeUs), contentWindow.isSeekable, false, + 0L, durationUs, index, index, 0L); + } + return window; + } + + @Override + public int getPeriodCount() { + return uids.length; + } + + @Override + public Period getPeriod(int index, Period period, boolean setIds) { + Object id = setIds ? (isAd[index] ? AD_ID : contentPeriod.id) : null; + return period.set(id, uids[index], index, endTimesUs[index] - startTimesUs[index], 0, + isAd[index]); + } + + @Override + public int getIndexOfPeriod(Object uid) { + for (int i = 0; i < uids.length; i++) { + if (Util.areEqual(uid, uids[i])) { + return i; + } + } + return C.INDEX_UNSET; + } + +} 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 new file mode 100644 index 0000000000..0c89ff604c --- /dev/null +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -0,0 +1,633 @@ +/* + * Copyright (C) 2017 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 android.content.Context; +import android.net.Uri; +import android.os.SystemClock; +import android.util.Log; +import android.view.ViewGroup; +import com.google.ads.interactivemedia.v3.api.Ad; +import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; +import com.google.ads.interactivemedia.v3.api.AdErrorEvent; +import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener; +import com.google.ads.interactivemedia.v3.api.AdEvent; +import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventListener; +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.ImaSdkFactory; +import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; +import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider; +import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer; +import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Loads ads using the IMA SDK. All methods are called on the main thread. + */ +/* package */ final class ImaAdsLoader implements ExoPlayer.EventListener, VideoAdPlayer, + ContentProgressProvider, AdErrorListener, AdsLoadedListener, AdEventListener { + + private static final boolean DEBUG = false; + private static final String TAG = "ImaAdsLoader"; + + /** + * Listener for ad loader events. All methods are called on the main thread. + */ + public interface EventListener { + + /** + * Called when the timestamps of ad breaks are known. + * + * @param adBreakTimesUs The times of ad breaks, in microseconds. + */ + void onAdBreakTimesUsLoaded(long[] adBreakTimesUs); + + /** + * Called when the URI for the media of an ad has been loaded. + * + * @param adBreakIndex The index of the ad break containing the ad with the media URI. + * @param adIndexInAdBreak The index of the ad in its ad break. + * @param uri The URI for the ad's media. + */ + void onUriLoaded(int adBreakIndex, int adIndexInAdBreak, Uri uri); + + /** + * Called when the {@link Ad} instance for a specified ad has been loaded. + * + * @param adBreakIndex The index of the ad break containing the ad. + * @param adIndexInAdBreak The index of the ad in its ad break. + * @param ad The {@link Ad} instance for the ad. + */ + void onAdLoaded(int adBreakIndex, int adIndexInAdBreak, Ad ad); + + /** + * Called when the specified ad break has been played to the end. + * + * @param adBreakIndex The index of the ad break. + */ + void onAdBreakPlayedToEnd(int adBreakIndex); + + /** + * Called when there was an error loading ads. + * + * @param error The error. + */ + void onLoadError(IOException error); + + } + + /** + * Whether to enable preloading of ads in {@link AdsRenderingSettings}. + */ + private static final boolean ENABLE_PRELOADING = true; + + private static final String IMA_SDK_SETTINGS_PLAYER_TYPE = + "google/com.google.android.exoplayer2.ext.ima"; + private static final String IMA_SDK_SETTINGS_PLAYER_VERSION = ExoPlayerLibraryInfo.VERSION; + + /** + * Threshold before the end of content at which IMA is notified that content is complete if the + * player buffers, in milliseconds. + */ + private static final long END_OF_CONTENT_POSITION_THRESHOLD_MS = 5000; + + private final EventListener eventListener; + private final ExoPlayer player; + private final Timeline.Period period; + private final List adCallbacks; + private final AdsLoader adsLoader; + + private AdsManager adsManager; + private AdTimeline adTimeline; + private long contentDurationMs; + private int lastContentPeriodIndex; + + private int playerPeriodIndex; + + private boolean released; + + // Fields tracking IMA's state. + + /** + * The index of the current ad break that IMA is loading. + */ + private int adBreakIndex; + /** + * The index of the ad within its ad break, in {@link #loadAd(String)}. + */ + private int adIndexInAdBreak; + /** + * The total number of ads in the current ad break, or {@link C#INDEX_UNSET} if unknown. + */ + private int adCountInAdBreak; + + /** + * Tracks the period currently being played in IMA's model of playback. + */ + private int imaPeriodIndex; + /** + * Whether the period at {@link #imaPeriodIndex} is an ad. + */ + private boolean isAdDisplayed; + /** + * Whether {@link AdsLoader#contentComplete()} has been called since starting ad playback. + */ + private boolean sentContentComplete; + /** + * If {@link #isAdDisplayed} is set, stores whether IMA has called {@link #playAd()} and not + * {@link #stopAd()}. + */ + private boolean playingAd; + /** + * If {@link #isAdDisplayed} is set, stores whether IMA has called {@link #pauseAd()} since a + * preceding call to {@link #playAd()} for the current ad. + */ + private boolean pausedInAd; + /** + * If a content period has finished but IMA has not yet sent an ad event with + * {@link AdEvent.AdEventType#CONTENT_PAUSE_REQUESTED}, 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; + + /** + * 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 adUiViewGroup A {@link ViewGroup} on top of the player that will show any ad UI. + * @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. + * @param player The player instance that will play the loaded ad schedule. The player's timeline + * must be an {@link AdTimeline} matching the loaded ad schedule. + * @param eventListener Listener for ad loader events. + */ + public ImaAdsLoader(Context context, Uri adTagUri, ViewGroup adUiViewGroup, + ImaSdkSettings imaSdkSettings, ExoPlayer player, EventListener eventListener) { + this.eventListener = eventListener; + this.player = player; + period = new Timeline.Period(); + adCallbacks = new ArrayList<>(1); + + lastContentPeriodIndex = C.INDEX_UNSET; + adCountInAdBreak = C.INDEX_UNSET; + fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; + + player.addListener(this); + + ImaSdkFactory imaSdkFactory = ImaSdkFactory.getInstance(); + AdDisplayContainer adDisplayContainer = imaSdkFactory.createAdDisplayContainer(); + adDisplayContainer.setPlayer(this); + adDisplayContainer.setAdContainer(adUiViewGroup); + + if (imaSdkSettings == null) { + imaSdkSettings = imaSdkFactory.createImaSdkSettings(); + } + imaSdkSettings.setPlayerType(IMA_SDK_SETTINGS_PLAYER_TYPE); + imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION); + + AdsRequest request = imaSdkFactory.createAdsRequest(); + request.setAdTagUrl(adTagUri.toString()); + request.setAdDisplayContainer(adDisplayContainer); + request.setContentProgressProvider(this); + + adsLoader = imaSdkFactory.createAdsLoader(context, imaSdkSettings); + adsLoader.addAdErrorListener(this); + adsLoader.addAdsLoadedListener(this); + adsLoader.requestAds(request); + } + + /** + * Releases the loader. Must be called when the instance is no longer needed. + */ + public void release() { + if (adsManager != null) { + adsManager.destroy(); + adsManager = null; + } + player.removeListener(this); + released = true; + } + + // AdsLoader.AdsLoadedListener implementation. + + @Override + public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { + adsManager = adsManagerLoadedEvent.getAdsManager(); + adsManager.addAdErrorListener(this); + adsManager.addAdEventListener(this); + if (ENABLE_PRELOADING) { + ImaSdkFactory imaSdkFactory = ImaSdkFactory.getInstance(); + AdsRenderingSettings adsRenderingSettings = imaSdkFactory.createAdsRenderingSettings(); + adsRenderingSettings.setEnablePreloading(true); + adsManager.init(adsRenderingSettings); + if (DEBUG) { + Log.d(TAG, "Initialized with preloading"); + } + } else { + adsManager.init(); + if (DEBUG) { + Log.d(TAG, "Initialized without preloading"); + } + } + eventListener.onAdBreakTimesUsLoaded(getAdBreakTimesUs(adsManager.getAdCuePoints())); + } + + // AdEvent.AdEventListener implementation. + + @Override + public void onAdEvent(AdEvent adEvent) { + if (DEBUG) { + Log.d(TAG, "onAdEvent " + adEvent.getType()); + } + if (released) { + // The ads manager may pass CONTENT_RESUME_REQUESTED after it is destroyed. + return; + } + switch (adEvent.getType()) { + case LOADED: + adsManager.start(); + break; + case STARTED: + // Note: This event is sometimes delivered several seconds after playAd is called. + // See [Internal: b/37775441]. + Ad ad = adEvent.getAd(); + AdPodInfo adPodInfo = ad.getAdPodInfo(); + adCountInAdBreak = adPodInfo.getTotalAds(); + int adPosition = adPodInfo.getAdPosition(); + eventListener.onAdLoaded(adBreakIndex, adPosition - 1, ad); + if (DEBUG) { + Log.d(TAG, "Started ad " + adPosition + " of " + adCountInAdBreak + " in ad break " + + adBreakIndex); + } + break; + case CONTENT_PAUSE_REQUESTED: + // After CONTENT_PAUSE_REQUESTED, IMA will playAd/pauseAd/stopAd to show one or more ads + // before sending CONTENT_RESUME_REQUESTED. + pauseContentInternal(); + break; + case SKIPPED: // Fall through. + case CONTENT_RESUME_REQUESTED: + resumeContentInternal(); + break; + case ALL_ADS_COMPLETED: + // Do nothing. The ads manager will be released when the source is released. + default: + break; + } + } + + // AdErrorEvent.AdErrorListener implementation. + + @Override + public void onAdError(AdErrorEvent adErrorEvent) { + if (DEBUG) { + Log.d(TAG, "onAdError " + adErrorEvent); + } + IOException exception = new IOException("Ad error: " + adErrorEvent, adErrorEvent.getError()); + eventListener.onLoadError(exception); + // TODO: Provide a timeline to the player if it doesn't have one yet, so the content can play. + } + + // ContentProgressProvider implementation. + + @Override + public VideoProgressUpdate getContentProgress() { + if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) { + long contentEndTimeMs = C.usToMs(adTimeline.getContentEndTimeUs(imaPeriodIndex)); + long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs; + return new VideoProgressUpdate(contentEndTimeMs + elapsedSinceEndMs, contentDurationMs); + } + + if (adTimeline == null || isAdDisplayed || imaPeriodIndex != playerPeriodIndex + || contentDurationMs == C.TIME_UNSET) { + return VideoProgressUpdate.VIDEO_TIME_NOT_READY; + } + checkForContentComplete(); + long positionMs = C.usToMs(adTimeline.getContentStartTimeUs(imaPeriodIndex)) + + player.getCurrentPosition(); + return new VideoProgressUpdate(positionMs, contentDurationMs); + } + + // VideoAdPlayer implementation. + + @Override + public VideoProgressUpdate getAdProgress() { + if (adTimeline == null || !isAdDisplayed || imaPeriodIndex != playerPeriodIndex + || adTimeline.getPeriod(imaPeriodIndex, period).getDurationUs() == C.TIME_UNSET) { + return VideoProgressUpdate.VIDEO_TIME_NOT_READY; + } + return new VideoProgressUpdate(player.getCurrentPosition(), period.getDurationMs()); + } + + @Override + public void loadAd(String adUriString) { + if (DEBUG) { + Log.d(TAG, "loadAd at index " + adIndexInAdBreak + " in ad break " + adBreakIndex); + } + eventListener.onUriLoaded(adBreakIndex, adIndexInAdBreak, Uri.parse(adUriString)); + adIndexInAdBreak++; + } + + @Override + public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) { + adCallbacks.add(videoAdPlayerCallback); + } + + @Override + public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) { + adCallbacks.remove(videoAdPlayerCallback); + } + + @Override + public void playAd() { + if (DEBUG) { + Log.d(TAG, "playAd"); + } + Assertions.checkState(isAdDisplayed); + if (playingAd && !pausedInAd) { + // Work around an issue where IMA does not always call stopAd before resuming content. + // See [Internal: b/38354028]. + if (DEBUG) { + Log.d(TAG, "Unexpected playAd without stopAd"); + } + stopAdInternal(); + } + player.setPlayWhenReady(true); + if (!playingAd) { + playingAd = true; + for (VideoAdPlayerCallback callback : adCallbacks) { + callback.onPlay(); + } + } else if (pausedInAd) { + pausedInAd = false; + for (VideoAdPlayerCallback callback : adCallbacks) { + callback.onResume(); + } + } + } + + @Override + public void stopAd() { + if (!playingAd) { + if (DEBUG) { + Log.d(TAG, "Ignoring unexpected stopAd"); + } + return; + } + if (DEBUG) { + Log.d(TAG, "stopAd"); + } + stopAdInternal(); + } + + @Override + public void pauseAd() { + if (DEBUG) { + Log.d(TAG, "pauseAd"); + } + if (released || !playingAd) { + // This method is called after content is resumed, and may also be called after release. + return; + } + pausedInAd = true; + player.setPlayWhenReady(false); + for (VideoAdPlayerCallback callback : adCallbacks) { + callback.onPause(); + } + } + + @Override + public void resumeAd() { + // This method is never called. See [Internal: b/18931719]. + throw new IllegalStateException(); + } + + // ExoPlayer.EventListener implementation. + + @Override + public void onTimelineChanged(Timeline timeline, Object manifest) { + if (timeline.isEmpty()) { + // The player is being re-prepared and this source will be released. + return; + } + if (adTimeline == null) { + // TODO: Handle initial seeks after the first period. + isAdDisplayed = timeline.getPeriod(0, period).isAd; + imaPeriodIndex = 0; + player.seekTo(0, 0); + } + adTimeline = (AdTimeline) timeline; + contentDurationMs = C.usToMs(adTimeline.getContentDurationUs()); + lastContentPeriodIndex = adTimeline.getPeriodCount() - 1; + while (adTimeline.isPeriodAd(lastContentPeriodIndex)) { + // All timelines have at least one content period. + lastContentPeriodIndex--; + } + } + + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + // Do nothing. + } + + @Override + public void onLoadingChanged(boolean isLoading) { + // Do nothing. + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + if (playbackState == ExoPlayer.STATE_BUFFERING && playWhenReady) { + checkForContentComplete(); + } else if (playbackState == ExoPlayer.STATE_ENDED && isAdDisplayed) { + // IMA is waiting for the ad playback to finish so invoke the callback now. + // Either CONTENT_RESUME_REQUESTED will be passed next, or playAd will be called again. + for (VideoAdPlayerCallback callback : adCallbacks) { + callback.onEnded(); + } + } + } + + @Override + public void onRepeatModeChanged(int repeatMode) { + // Do nothing. + } + + @Override + public void onPlayerError(ExoPlaybackException error) { + if (isAdDisplayed && adTimeline.isPeriodAd(playerPeriodIndex)) { + for (VideoAdPlayerCallback callback : adCallbacks) { + callback.onError(); + } + } + } + + @Override + public void onPositionDiscontinuity() { + if (player.getCurrentPeriodIndex() == playerPeriodIndex + 1) { + if (isAdDisplayed) { + // IMA is waiting for the ad playback to finish so invoke the callback now. + // Either CONTENT_RESUME_REQUESTED will be passed next, or playAd will be called again. + for (VideoAdPlayerCallback callback : adCallbacks) { + callback.onEnded(); + } + } else { + player.setPlayWhenReady(false); + if (imaPeriodIndex == playerPeriodIndex) { + // IMA hasn't sent CONTENT_PAUSE_REQUESTED yet, so fake the content position. + Assertions.checkState(fakeContentProgressElapsedRealtimeMs == C.TIME_UNSET); + fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime(); + } + } + } + playerPeriodIndex = player.getCurrentPeriodIndex(); + } + + @Override + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + // Do nothing. + } + + // Internal methods. + + /** + * Resumes the player, ensuring the current period is a content period by seeking if necessary. + */ + private void resumeContentInternal() { + if (adTimeline != null) { + if (imaPeriodIndex < lastContentPeriodIndex) { + if (playingAd) { + // Work around an issue where IMA does not always call stopAd before resuming content. + // See [Internal: b/38354028]. + if (DEBUG) { + Log.d(TAG, "Unexpected CONTENT_RESUME_REQUESTED without stopAd"); + } + stopAdInternal(); + } + while (adTimeline.isPeriodAd(imaPeriodIndex)) { + imaPeriodIndex++; + } + synchronizePlayerToIma(); + } + } + player.setPlayWhenReady(true); + } + + /** + * Pauses the player, and ensures that the current period is an ad period by seeking if necessary. + */ + private void pauseContentInternal() { + // IMA is requesting to pause content, so stop faking the content position. + fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; + if (adTimeline != null && !isAdDisplayed) { + // Seek to the next ad. + while (!adTimeline.isPeriodAd(imaPeriodIndex)) { + imaPeriodIndex++; + } + synchronizePlayerToIma(); + } else { + // IMA is sending an initial CONTENT_PAUSE_REQUESTED before a pre-roll ad. + Assertions.checkState(playerPeriodIndex == 0 && imaPeriodIndex == 0); + } + player.setPlayWhenReady(false); + } + + /** + * Stops the currently playing ad, seeking to the next content period if there is one. May only be + * called when {@link #playingAd} is {@code true}. + */ + private void stopAdInternal() { + Assertions.checkState(playingAd); + if (imaPeriodIndex != adTimeline.getPeriodCount() - 1) { + player.setPlayWhenReady(false); + imaPeriodIndex++; + if (!adTimeline.isPeriodAd(imaPeriodIndex)) { + eventListener.onAdBreakPlayedToEnd(adBreakIndex); + adBreakIndex++; + adIndexInAdBreak = 0; + } + synchronizePlayerToIma(); + } else { + eventListener.onAdBreakPlayedToEnd(adTimeline.getAdBreakIndex(imaPeriodIndex)); + } + } + + private void synchronizePlayerToIma() { + if (playerPeriodIndex != imaPeriodIndex) { + player.seekTo(imaPeriodIndex, 0); + } + + isAdDisplayed = adTimeline.isPeriodAd(imaPeriodIndex); + // If an ad is displayed, these flags will be updated in response to playAd/pauseAd/stopAd until + // the content is resumed. + playingAd = false; + pausedInAd = false; + } + + private void checkForContentComplete() { + if (adTimeline == null || isAdDisplayed || sentContentComplete) { + return; + } + long positionMs = C.usToMs(adTimeline.getContentStartTimeUs(imaPeriodIndex)) + + player.getCurrentPosition(); + if (playerPeriodIndex == lastContentPeriodIndex + && positionMs + END_OF_CONTENT_POSITION_THRESHOLD_MS + >= C.usToMs(adTimeline.getContentEndTimeUs(playerPeriodIndex))) { + adsLoader.contentComplete(); + if (DEBUG) { + Log.d(TAG, "adsLoader.contentComplete"); + } + sentContentComplete = true; + } + } + + private static long[] getAdBreakTimesUs(List cuePoints) { + if (cuePoints.isEmpty()) { + // If no cue points are specified, there is a preroll ad break. + return new long[] {0}; + } + + int count = cuePoints.size(); + long[] adBreakTimesUs = new long[count]; + for (int i = 0; i < count; i++) { + double cuePoint = cuePoints.get(i); + adBreakTimesUs[i] = cuePoint == -1.0 ? C.TIME_UNSET : (long) (C.MICROS_PER_SECOND * cuePoint); + } + return adBreakTimesUs; + } + +} diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsMediaSource.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsMediaSource.java new file mode 100644 index 0000000000..0055fbca32 --- /dev/null +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsMediaSource.java @@ -0,0 +1,530 @@ +/* + * Copyright (C) 2017 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 android.content.Context; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.view.ViewGroup; +import com.google.ads.interactivemedia.v3.api.Ad; +import com.google.ads.interactivemedia.v3.api.AdPodInfo; +import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.source.ClippingMediaPeriod; +import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.SampleStream; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +/** + * A {@link MediaSource} that inserts ads linearly with a provided content media source using the + * Interactive Media Ads SDK for ad loading and tracking. + */ +public final class ImaAdsMediaSource implements MediaSource { + + private final MediaSource contentMediaSource; + private final DataSource.Factory dataSourceFactory; + private final Context context; + private final Uri adTagUri; + private final ViewGroup adUiViewGroup; + private final ImaSdkSettings imaSdkSettings; + private final Handler mainHandler; + private final AdListener adLoaderListener; + private final Map mediaSourceByMediaPeriod; + + private Handler playerHandler; + private ExoPlayer player; + private volatile boolean released; + + // Accessed on the player thread. + private Timeline contentTimeline; + private Object contentManifest; + private long[] adBreakTimesUs; + private boolean[] playedAdBreak; + private Ad[][] adBreakAds; + private Timeline[][] adBreakTimelines; + private MediaSource[][] adBreakMediaSources; + private DeferredMediaPeriod[][] adBreakDeferredMediaPeriods; + private AdTimeline timeline; + private MediaSource.Listener listener; + private IOException adLoadError; + + // Accessed on the main thread. + private ImaAdsLoader imaAdsLoader; + + /** + * Constructs a new source that inserts ads linearly with the content specified by + * {@code contentMediaSource}. + * + * @param contentMediaSource The {@link MediaSource} providing the content to play. + * @param dataSourceFactory Factory for data sources used to load ad media. + * @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 adUiViewGroup A {@link ViewGroup} on top of the player that will show any ad user + * interface. + */ + public ImaAdsMediaSource(MediaSource contentMediaSource, DataSource.Factory dataSourceFactory, + Context context, Uri adTagUri, ViewGroup adUiViewGroup) { + this(contentMediaSource, dataSourceFactory, context, adTagUri, adUiViewGroup, null); + } + + /** + * Constructs a new source that inserts ads linearly with the content specified by + * {@code contentMediaSource}. + * + * @param contentMediaSource The {@link MediaSource} providing the content to play. + * @param dataSourceFactory Factory for data sources used to load ad media. + * @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 adUiViewGroup A {@link ViewGroup} on top of the player that will show any ad UI. + * @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. + */ + public ImaAdsMediaSource(MediaSource contentMediaSource, DataSource.Factory dataSourceFactory, + Context context, Uri adTagUri, ViewGroup adUiViewGroup, ImaSdkSettings imaSdkSettings) { + this.contentMediaSource = contentMediaSource; + this.dataSourceFactory = dataSourceFactory; + this.context = context; + this.adTagUri = adTagUri; + this.adUiViewGroup = adUiViewGroup; + this.imaSdkSettings = imaSdkSettings; + mainHandler = new Handler(Looper.getMainLooper()); + adLoaderListener = new AdListener(); + mediaSourceByMediaPeriod = new HashMap<>(); + adBreakMediaSources = new MediaSource[0][]; + } + + @Override + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { + Assertions.checkArgument(isTopLevelSource); + this.listener = listener; + this.player = player; + playerHandler = new Handler(); + mainHandler.post(new Runnable() { + @Override + public void run() { + imaAdsLoader = new ImaAdsLoader(context, adTagUri, adUiViewGroup, imaSdkSettings, + ImaAdsMediaSource.this.player, adLoaderListener); + } + }); + contentMediaSource.prepareSource(player, false, new Listener() { + @Override + public void onSourceInfoRefreshed(Timeline timeline, Object manifest) { + ImaAdsMediaSource.this.onContentSourceInfoRefreshed(timeline, manifest); + } + }); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + if (adLoadError != null) { + throw adLoadError; + } + contentMediaSource.maybeThrowSourceInfoRefreshError(); + for (MediaSource[] mediaSources : adBreakMediaSources) { + for (MediaSource mediaSource : mediaSources) { + mediaSource.maybeThrowSourceInfoRefreshError(); + } + } + } + + @Override + public MediaPeriod createPeriod(int index, Allocator allocator, long positionUs) { + if (timeline.isPeriodAd(index)) { + int adBreakIndex = timeline.getAdBreakIndex(index); + int adIndexInAdBreak = timeline.getAdIndexInAdBreak(index); + if (adIndexInAdBreak >= adBreakMediaSources[adBreakIndex].length) { + DeferredMediaPeriod deferredPeriod = new DeferredMediaPeriod(0, allocator, positionUs); + if (adIndexInAdBreak >= adBreakDeferredMediaPeriods[adBreakIndex].length) { + adBreakDeferredMediaPeriods[adBreakIndex] = Arrays.copyOf( + adBreakDeferredMediaPeriods[adBreakIndex], adIndexInAdBreak + 1); + } + adBreakDeferredMediaPeriods[adBreakIndex][adIndexInAdBreak] = deferredPeriod; + return deferredPeriod; + } + + MediaSource adBreakMediaSource = adBreakMediaSources[adBreakIndex][adIndexInAdBreak]; + MediaPeriod adBreakMediaPeriod = adBreakMediaSource.createPeriod(0, allocator, positionUs); + mediaSourceByMediaPeriod.put(adBreakMediaPeriod, adBreakMediaSource); + return adBreakMediaPeriod; + } else { + long startUs = timeline.getContentStartTimeUs(index); + long endUs = timeline.getContentEndTimeUs(index); + long contentStartUs = startUs + positionUs; + MediaPeriod contentMediaPeriod = contentMediaSource.createPeriod(0, allocator, + contentStartUs); + ClippingMediaPeriod clippingPeriod = new ClippingMediaPeriod(contentMediaPeriod); + clippingPeriod.setClipping(startUs, endUs == C.TIME_UNSET ? C.TIME_END_OF_SOURCE : endUs); + mediaSourceByMediaPeriod.put(contentMediaPeriod, contentMediaSource); + return clippingPeriod; + } + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + if (mediaPeriod instanceof DeferredMediaPeriod) { + mediaPeriod = ((DeferredMediaPeriod) mediaPeriod).mediaPeriod; + if (mediaPeriod == null) { + // Nothing to do. + return; + } + } else if (mediaPeriod instanceof ClippingMediaPeriod) { + mediaPeriod = ((ClippingMediaPeriod) mediaPeriod).mediaPeriod; + } + mediaSourceByMediaPeriod.remove(mediaPeriod).releasePeriod(mediaPeriod); + } + + @Override + public void releaseSource() { + released = true; + adLoadError = null; + contentMediaSource.releaseSource(); + for (MediaSource[] mediaSources : adBreakMediaSources) { + for (MediaSource mediaSource : mediaSources) { + mediaSource.releaseSource(); + } + } + mainHandler.post(new Runnable() { + @Override + public void run() { + // TODO: The source will be released when the application is paused/stopped, which can occur + // if the user taps on the ad. In this case, we should keep the ads manager alive but pause + // it, instead of destroying it. + imaAdsLoader.release(); + imaAdsLoader = null; + } + }); + } + + // Internal methods. + + private void onAdBreakTimesUsLoaded(long[] adBreakTimesUs) { + Assertions.checkState(this.adBreakTimesUs == null); + this.adBreakTimesUs = adBreakTimesUs; + int adBreakCount = adBreakTimesUs.length; + adBreakAds = new Ad[adBreakCount][]; + Arrays.fill(adBreakAds, new Ad[0]); + adBreakTimelines = new Timeline[adBreakCount][]; + Arrays.fill(adBreakTimelines, new Timeline[0]); + adBreakMediaSources = new MediaSource[adBreakCount][]; + Arrays.fill(adBreakMediaSources, new MediaSource[0]); + adBreakDeferredMediaPeriods = new DeferredMediaPeriod[adBreakCount][]; + Arrays.fill(adBreakDeferredMediaPeriods, new DeferredMediaPeriod[0]); + playedAdBreak = new boolean[adBreakCount]; + maybeUpdateSourceInfo(); + } + + private void onContentSourceInfoRefreshed(Timeline timeline, Object manifest) { + contentTimeline = timeline; + contentManifest = manifest; + maybeUpdateSourceInfo(); + } + + private void onAdUriLoaded(final int adBreakIndex, final int adIndexInAdBreak, Uri uri) { + MediaSource adMediaSource = new ExtractorMediaSource(uri, dataSourceFactory, + new DefaultExtractorsFactory(), mainHandler, adLoaderListener); + if (adBreakMediaSources[adBreakIndex].length <= adIndexInAdBreak) { + int adCount = adIndexInAdBreak + 1; + adBreakMediaSources[adBreakIndex] = Arrays.copyOf(adBreakMediaSources[adBreakIndex], adCount); + adBreakTimelines[adBreakIndex] = Arrays.copyOf(adBreakTimelines[adBreakIndex], adCount); + } + adBreakMediaSources[adBreakIndex][adIndexInAdBreak] = adMediaSource; + if (adIndexInAdBreak < adBreakDeferredMediaPeriods[adBreakIndex].length + && adBreakDeferredMediaPeriods[adBreakIndex][adIndexInAdBreak] != null) { + adBreakDeferredMediaPeriods[adBreakIndex][adIndexInAdBreak].setMediaSource( + adBreakMediaSources[adBreakIndex][adIndexInAdBreak]); + mediaSourceByMediaPeriod.put( + adBreakDeferredMediaPeriods[adBreakIndex][adIndexInAdBreak].mediaPeriod, adMediaSource); + } + adMediaSource.prepareSource(player, false, new Listener() { + @Override + public void onSourceInfoRefreshed(Timeline timeline, Object manifest) { + onAdSourceInfoRefreshed(adBreakIndex, adIndexInAdBreak, timeline); + } + }); + } + + private void onAdSourceInfoRefreshed(int adBreakIndex, int adIndexInAdBreak, Timeline timeline) { + adBreakTimelines[adBreakIndex][adIndexInAdBreak] = timeline; + maybeUpdateSourceInfo(); + } + + private void onAdLoaded(int adBreakIndex, int adIndexInAdBreak, Ad ad) { + if (adBreakAds[adBreakIndex].length <= adIndexInAdBreak) { + int adCount = adIndexInAdBreak + 1; + adBreakAds[adBreakIndex] = Arrays.copyOf(adBreakAds[adBreakIndex], adCount); + } + adBreakAds[adBreakIndex][adIndexInAdBreak] = ad; + maybeUpdateSourceInfo(); + } + + private void maybeUpdateSourceInfo() { + if (adBreakTimesUs == null || contentTimeline == null) { + // We don't have enough information to start building the timeline yet. + return; + } + + AdTimeline.Builder builder = new AdTimeline.Builder(contentTimeline); + int count = adBreakTimesUs.length; + boolean preroll = adBreakTimesUs[0] == 0; + boolean postroll = adBreakTimesUs[count - 1] == C.TIME_UNSET; + int midrollCount = count - (preroll ? 1 : 0) - (postroll ? 1 : 0); + + int adBreakIndex = 0; + long contentTimeUs = 0; + if (preroll) { + addAdBreak(builder, adBreakIndex++); + } + for (int i = 0; i < midrollCount; i++) { + long startTimeUs = contentTimeUs; + contentTimeUs = adBreakTimesUs[adBreakIndex]; + builder.addContent(startTimeUs, contentTimeUs); + addAdBreak(builder, adBreakIndex++); + } + builder.addContent(contentTimeUs, C.TIME_UNSET); + if (postroll) { + addAdBreak(builder, adBreakIndex); + } + + timeline = builder.build(); + listener.onSourceInfoRefreshed(timeline, contentManifest); + } + + private void addAdBreak(AdTimeline.Builder builder, int adBreakIndex) { + int adCount = adBreakMediaSources[adBreakIndex].length; + AdPodInfo adPodInfo = null; + for (int adIndex = 0; adIndex < adCount; adIndex++) { + Timeline adTimeline = adBreakTimelines[adBreakIndex][adIndex]; + long adDurationUs = adTimeline != null + ? adTimeline.getPeriod(0, new Timeline.Period()).getDurationUs() : C.TIME_UNSET; + Ad ad = adIndex < adBreakAds[adBreakIndex].length + ? adBreakAds[adBreakIndex][adIndex] : null; + builder.addAdPeriod(ad, adBreakIndex, adIndex, adDurationUs); + if (ad != null) { + adPodInfo = ad.getAdPodInfo(); + } + } + if (adPodInfo == null || adPodInfo.getTotalAds() > adCount) { + // We don't know how many ads are in the ad break, or they have not loaded yet. + builder.addAdPeriod(null, adBreakIndex, adCount, C.TIME_UNSET); + } + } + + private void onAdBreakPlayedToEnd(int adBreakIndex) { + playedAdBreak[adBreakIndex] = true; + } + + /** + * Listener for ad loading events. All methods are called on the main thread. + */ + private final class AdListener implements ImaAdsLoader.EventListener, + ExtractorMediaSource.EventListener { + + @Override + public void onAdBreakTimesUsLoaded(final long[] adBreakTimesUs) { + if (released) { + return; + } + playerHandler.post(new Runnable() { + @Override + public void run() { + if (released) { + return; + } + ImaAdsMediaSource.this.onAdBreakTimesUsLoaded(adBreakTimesUs); + } + }); + } + + @Override + public void onUriLoaded(final int adBreakIndex, final int adIndexInAdBreak, final Uri uri) { + if (released) { + return; + } + playerHandler.post(new Runnable() { + @Override + public void run() { + if (released) { + return; + } + ImaAdsMediaSource.this.onAdUriLoaded(adBreakIndex, adIndexInAdBreak, uri); + } + }); + } + + @Override + public void onAdLoaded(final int adBreakIndex, final int adIndexInAdBreak, final Ad ad) { + if (released) { + return; + } + playerHandler.post(new Runnable() { + @Override + public void run() { + if (released) { + return; + } + ImaAdsMediaSource.this.onAdLoaded(adBreakIndex, adIndexInAdBreak, ad); + } + }); + } + + @Override + public void onAdBreakPlayedToEnd(final int adBreakIndex) { + if (released) { + return; + } + playerHandler.post(new Runnable() { + @Override + public void run() { + if (released) { + return; + } + ImaAdsMediaSource.this.onAdBreakPlayedToEnd(adBreakIndex); + } + }); + } + + @Override + public void onLoadError(final IOException error) { + if (released) { + return; + } + playerHandler.post(new Runnable() { + @Override + public void run() { + if (released) { + return; + } + adLoadError = error; + } + }); + } + + } + + private static final class DeferredMediaPeriod implements MediaPeriod, MediaPeriod.Callback { + + private final int index; + private final Allocator allocator; + private final long positionUs; + + public MediaPeriod mediaPeriod; + private MediaPeriod.Callback callback; + + public DeferredMediaPeriod(int index, Allocator allocator, long positionUs) { + this.index = index; + this.allocator = allocator; + this.positionUs = positionUs; + } + + public void setMediaSource(MediaSource mediaSource) { + mediaPeriod = mediaSource.createPeriod(index, allocator, positionUs); + if (callback != null) { + mediaPeriod.prepare(this); + } + } + + @Override + public void prepare(Callback callback) { + this.callback = callback; + if (mediaPeriod != null) { + mediaPeriod.prepare(this); + } + } + + @Override + public void maybeThrowPrepareError() throws IOException { + if (mediaPeriod != null) { + mediaPeriod.maybeThrowPrepareError(); + } + } + + @Override + public TrackGroupArray getTrackGroups() { + return mediaPeriod.getTrackGroups(); + } + + @Override + public long selectTracks(TrackSelection[] selections, boolean[] mayRetainStreamFlags, + SampleStream[] streams, boolean[] streamResetFlags, long positionUs) { + return mediaPeriod.selectTracks(selections, mayRetainStreamFlags, streams, streamResetFlags, + positionUs); + } + + @Override + public void discardBuffer(long positionUs) { + // Do nothing. + } + + @Override + public long readDiscontinuity() { + return mediaPeriod.readDiscontinuity(); + } + + @Override + public long getBufferedPositionUs() { + return mediaPeriod.getBufferedPositionUs(); + } + + @Override + public long seekToUs(long positionUs) { + return mediaPeriod.seekToUs(positionUs); + } + + @Override + public long getNextLoadPositionUs() { + return mediaPeriod.getNextLoadPositionUs(); + } + + @Override + public boolean continueLoading(long positionUs) { + return mediaPeriod.continueLoading(positionUs); + } + + // MediaPeriod.Callback implementation. + + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + Assertions.checkArgument(this.mediaPeriod == mediaPeriod); + callback.onPrepared(this); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod mediaPeriod) { + Assertions.checkArgument(this.mediaPeriod == mediaPeriod); + callback.onContinueLoadingRequested(this); + } + + } + +} diff --git a/settings.gradle b/settings.gradle index 544d2d4a21..d50cb9d3dd 100644 --- a/settings.gradle +++ b/settings.gradle @@ -23,6 +23,7 @@ include ':playbacktests' include ':extension-ffmpeg' include ':extension-flac' include ':extension-gvr' +include ':extension-ima' include ':extension-okhttp' include ':extension-opus' include ':extension-vp9' @@ -38,6 +39,7 @@ project(':library-ui').projectDir = new File(settingsDir, 'library/ui') project(':extension-ffmpeg').projectDir = new File(settingsDir, 'extensions/ffmpeg') project(':extension-flac').projectDir = new File(settingsDir, 'extensions/flac') project(':extension-gvr').projectDir = new File(settingsDir, 'extensions/gvr') +project(':extension-ima').projectDir = new File(settingsDir, 'extensions/ima') project(':extension-okhttp').projectDir = new File(settingsDir, 'extensions/okhttp') project(':extension-opus').projectDir = new File(settingsDir, 'extensions/opus') project(':extension-vp9').projectDir = new File(settingsDir, 'extensions/vp9')