diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index f94123426e..217d328171 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -45,10 +45,8 @@ import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryExcep import com.google.android.exoplayer2.offline.DownloadRequest; import com.google.android.exoplayer2.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; -import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.ads.AdsLoader; -import com.google.android.exoplayer2.source.ads.AdsMediaSource; import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; @@ -60,7 +58,6 @@ import com.google.android.exoplayer2.ui.PlayerControlView; import com.google.android.exoplayer2.ui.PlayerView; import com.google.android.exoplayer2.ui.spherical.SphericalGLSurfaceView; import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ErrorMessageProvider; import com.google.android.exoplayer2.util.EventLogger; @@ -138,12 +135,11 @@ public class PlayerActivity extends AppCompatActivity private DataSource.Factory dataSourceFactory; private SimpleExoPlayer player; - private List mediaSources; + private List mediaItems; private DefaultTrackSelector trackSelector; private DefaultTrackSelector.Parameters trackSelectorParameters; private DebugTextViewHelper debugViewHelper; private TrackGroupArray lastSeenTrackGroupArray; - private DefaultMediaSourceFactory mediaSourceFactory; private boolean startAutoPlay; private int startWindow; private long startPosition; @@ -164,8 +160,6 @@ public class PlayerActivity extends AppCompatActivity } super.onCreate(savedInstanceState); dataSourceFactory = buildDataSourceFactory(); - mediaSourceFactory = - DefaultMediaSourceFactory.newInstance(/* context= */ this, dataSourceFactory); if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) { CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER); } @@ -328,7 +322,7 @@ public class PlayerActivity extends AppCompatActivity @Override public void preparePlayback() { - player.retry(); + player.prepare(); } // PlaybackControlView.VisibilityListener implementation @@ -343,8 +337,8 @@ public class PlayerActivity extends AppCompatActivity private void initializePlayer() { if (player == null) { Intent intent = getIntent(); - mediaSources = createTopLevelMediaSources(intent); - if (mediaSources.isEmpty()) { + mediaItems = createMediaItems(intent); + if (mediaItems.isEmpty()) { return; } TrackSelection.Factory trackSelectionFactory; @@ -370,6 +364,9 @@ public class PlayerActivity extends AppCompatActivity player = new SimpleExoPlayer.Builder(/* context= */ this, renderersFactory) + .setMediaSourceFactory( + new DefaultMediaSourceFactory( + /* context= */ this, dataSourceFactory, new AdSupportProvider())) .setTrackSelector(trackSelector) .build(); player.addListener(new PlayerEventListener()); @@ -380,20 +377,17 @@ public class PlayerActivity extends AppCompatActivity playerView.setPlaybackPreparer(this); debugViewHelper = new DebugTextViewHelper(player, debugTextView); debugViewHelper.start(); - if (adsLoader != null) { - adsLoader.setPlayer(player); - } } boolean haveStartPosition = startWindow != C.INDEX_UNSET; if (haveStartPosition) { player.seekTo(startWindow, startPosition); } - player.setMediaSources(mediaSources, /* resetPosition= */ !haveStartPosition); + player.setMediaItems(mediaItems, /* resetPosition= */ !haveStartPosition); player.prepare(); updateButtonVisibility(); } - private List createTopLevelMediaSources(Intent intent) { + private List createMediaItems(Intent intent) { String action = intent.getAction(); boolean actionIsListView = ACTION_VIEW_LIST.equals(action); if (!actionIsListView && !ACTION_VIEW.equals(action)) { @@ -408,11 +402,23 @@ public class PlayerActivity extends AppCompatActivity ? ((Sample.PlaylistSample) intentAsSample).children : new UriSample[] {(UriSample) intentAsSample}; - List mediaSources = new ArrayList<>(); - Uri adTagUri = null; + List mediaItems = new ArrayList<>(); + boolean hasAds = false; for (UriSample sample : samples) { MediaItem mediaItem = sample.toMediaItem(); - Assertions.checkNotNull(mediaItem.playbackProperties); + DownloadRequest downloadRequest = + ((DemoApplication) getApplication()) + .getDownloadTracker() + .getDownloadRequest(Assertions.checkNotNull(mediaItem.playbackProperties).sourceUri); + if (downloadRequest != null) { + mediaItem = + mediaItem + .buildUpon() + .setStreamKeys(downloadRequest.streamKeys) + .setCustomCacheKey(downloadRequest.customCacheKey) + .build(); + } + if (!Util.checkCleartextTrafficPermitted(mediaItem)) { showToast(R.string.error_cleartext_not_permitted); return Collections.emptyList(); @@ -421,67 +427,27 @@ public class PlayerActivity extends AppCompatActivity // The player will be reinitialized if the permission is granted. return Collections.emptyList(); } - MediaSource mediaSource = createLeafMediaSource(mediaItem); - if (mediaSource != null) { - adTagUri = sample.adTagUri; - mediaSources.add(mediaSource); - } - } - if (adTagUri == null) { - releaseAdsLoader(); - } else if (mediaSources.size() == 1) { - if (!adTagUri.equals(loadedAdTagUri)) { - releaseAdsLoader(); - loadedAdTagUri = adTagUri; + MediaItem.DrmConfiguration drmConfiguration = + Assertions.checkNotNull(mediaItem.playbackProperties).drmConfiguration; + if (drmConfiguration != null) { + if (Util.SDK_INT < 18) { + showToast(R.string.error_drm_unsupported_before_api_18); + finish(); + return Collections.emptyList(); + } else if (!MediaDrm.isCryptoSchemeSupported(drmConfiguration.uuid)) { + showToast(R.string.error_drm_unsupported_scheme); + finish(); + return Collections.emptyList(); + } } - MediaSource adsMediaSource = createAdsMediaSource(mediaSources.get(0), adTagUri); - if (adsMediaSource != null) { - mediaSources.set(0, adsMediaSource); - } else { - showToast(R.string.ima_not_loaded); - } - } else if (mediaSources.size() > 1) { - showToast(R.string.unsupported_ads_in_concatenation); + hasAds |= mediaItem.playbackProperties.adTagUri != null; + mediaItems.add(mediaItem); + } + if (!hasAds) { releaseAdsLoader(); } - - return mediaSources; - } - - @Nullable - private MediaSource createLeafMediaSource(MediaItem mediaItem) { - Assertions.checkNotNull(mediaItem.playbackProperties); - HttpDataSource.Factory drmDataSourceFactory = null; - if (mediaItem.playbackProperties.drmConfiguration != null) { - if (Util.SDK_INT < 18) { - showToast(R.string.error_drm_unsupported_before_api_18); - finish(); - return null; - } else if (!MediaDrm.isCryptoSchemeSupported( - mediaItem.playbackProperties.drmConfiguration.uuid)) { - showToast(R.string.error_drm_unsupported_scheme); - finish(); - return null; - } - drmDataSourceFactory = ((DemoApplication) getApplication()).buildHttpDataSourceFactory(); - } - - DownloadRequest downloadRequest = - ((DemoApplication) getApplication()) - .getDownloadTracker() - .getDownloadRequest(mediaItem.playbackProperties.sourceUri); - if (downloadRequest != null) { - mediaItem = - mediaItem - .buildUpon() - .setStreamKeys(downloadRequest.streamKeys) - .setCustomCacheKey(downloadRequest.customCacheKey) - .build(); - } - return mediaSourceFactory - .setDrmHttpDataSourceFactory(drmDataSourceFactory) - .createMediaSource(mediaItem); + return mediaItems; } private void releasePlayer() { @@ -492,7 +458,7 @@ public class PlayerActivity extends AppCompatActivity debugViewHelper = null; player.release(); player = null; - mediaSources = Collections.emptyList(); + mediaItems = Collections.emptyList(); trackSelector = null; } if (adsLoader != null) { @@ -534,24 +500,23 @@ public class PlayerActivity extends AppCompatActivity return ((DemoApplication) getApplication()).buildDataSourceFactory(); } - /** Returns an ads media source, reusing the ads loader if one exists. */ + /** + * Returns an ads loader for the Interactive Media Ads SDK if found in the classpath, or null + * otherwise. + */ @Nullable - private MediaSource createAdsMediaSource(MediaSource mediaSource, Uri adTagUri) { + private AdsLoader createAdsLoader(Uri adTagUri) { // Load the extension source using reflection so the demo app doesn't have to depend on it. - // The ads loader is reused for multiple playbacks, so that ad playback can resume. try { Class loaderClass = Class.forName("com.google.android.exoplayer2.ext.ima.ImaAdsLoader"); - if (adsLoader == null) { - // Full class names used so the lint rule triggers should any of the classes move. - // LINT.IfChange - Constructor loaderConstructor = - loaderClass - .asSubclass(AdsLoader.class) - .getConstructor(android.content.Context.class, android.net.Uri.class); - // LINT.ThenChange(../../../../../../../../proguard-rules.txt) - adsLoader = loaderConstructor.newInstance(this, adTagUri); - } - return new AdsMediaSource(mediaSource, mediaSourceFactory, adsLoader, playerView); + // Full class names used so the lint rule triggers should any of the classes move. + // LINT.IfChange + Constructor loaderConstructor = + loaderClass + .asSubclass(AdsLoader.class) + .getConstructor(android.content.Context.class, android.net.Uri.class); + // LINT.ThenChange(../../../../../../../../proguard-rules.txt) + return loaderConstructor.newInstance(this, adTagUri); } catch (ClassNotFoundException e) { // IMA extension not loaded. return null; @@ -670,4 +635,36 @@ public class PlayerActivity extends AppCompatActivity return Pair.create(0, errorString); } } + + private class AdSupportProvider implements DefaultMediaSourceFactory.AdSupportProvider { + + @Nullable + @Override + public AdsLoader getAdsLoader(Uri adTagUri) { + if (mediaItems.size() > 1) { + showToast(R.string.unsupported_ads_in_concatenation); + releaseAdsLoader(); + return null; + } + if (!adTagUri.equals(loadedAdTagUri)) { + releaseAdsLoader(); + loadedAdTagUri = adTagUri; + } + if (adsLoader == null) { + // The ads loader is reused for multiple playbacks, so that ad playback can resume. + adsLoader = createAdsLoader(adTagUri); + if (adsLoader != null) { + adsLoader.setPlayer(player); + } else { + showToast(R.string.ima_not_loaded); + } + } + return adsLoader; + } + + @Override + public AdsLoader.AdViewProvider getAdViewProvider() { + return Assertions.checkNotNull(playerView); + } + } } diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/Sample.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/Sample.java index 1225c8b6c4..d149f6d205 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/Sample.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/Sample.java @@ -146,8 +146,11 @@ import java.util.UUID; } public MediaItem toMediaItem() { - MediaItem.Builder builder = new MediaItem.Builder().setSourceUri(uri); - builder.setMimeType(inferAdaptiveStreamMimeType(uri, extension)); + MediaItem.Builder builder = + new MediaItem.Builder() + .setSourceUri(uri) + .setMimeType(inferAdaptiveStreamMimeType(uri, extension)) + .setAdTagUri(adTagUri); if (drmInfo != null) { Map headers = new HashMap<>(); if (drmInfo.drmKeyRequestProperties != null) { diff --git a/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java b/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java index f484d3f80e..37c47a2c7f 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java @@ -71,6 +71,7 @@ public final class MediaItem { private List streamKeys; @Nullable private String customCacheKey; private List subtitles; + @Nullable private Uri adTagUri; @Nullable private Object tag; @Nullable private MediaMetadata mediaMetadata; @@ -88,12 +89,13 @@ public final class MediaItem { clipEndPositionMs = mediaItem.clippingProperties.endPositionMs; clipRelativeToLiveWindow = mediaItem.clippingProperties.relativeToLiveWindow; clipRelativeToDefaultPosition = mediaItem.clippingProperties.relativeToDefaultPosition; - clipStartsAtKeyFrame = mediaItem.clippingProperties.startsAtKeyFrame; clipStartPositionMs = mediaItem.clippingProperties.startPositionMs; + clipStartsAtKeyFrame = mediaItem.clippingProperties.startsAtKeyFrame; mediaId = mediaItem.mediaId; mediaMetadata = mediaItem.mediaMetadata; @Nullable PlaybackProperties playbackProperties = mediaItem.playbackProperties; if (playbackProperties != null) { + adTagUri = playbackProperties.adTagUri; customCacheKey = playbackProperties.customCacheKey; mimeType = playbackProperties.mimeType; sourceUri = playbackProperties.sourceUri; @@ -353,6 +355,28 @@ public final class MediaItem { return this; } + /** + * Sets the optional ad tag URI. + * + *

If a {@link PlaybackProperties#sourceUri} is set, the ad tag URI is used to create a + * {@link PlaybackProperties} object. Otherwise it will be ignored. + */ + public Builder setAdTagUri(@Nullable String adTagUri) { + this.adTagUri = adTagUri != null ? Uri.parse(adTagUri) : null; + return this; + } + + /** + * Sets the optional ad tag {@link Uri}. + * + *

If a {@link PlaybackProperties#sourceUri} is set, the ad tag URI is used to create a + * {@link PlaybackProperties} object. Otherwise it will be ignored. + */ + public Builder setAdTagUri(@Nullable Uri adTagUri) { + this.adTagUri = adTagUri; + return this; + } + /** * Sets the optional tag for custom attributes. The tag for the media source which will be * published in the {@code com.google.android.exoplayer2.Timeline} of the source as {@code @@ -395,6 +419,7 @@ public final class MediaItem { streamKeys, customCacheKey, subtitles, + adTagUri, tag); mediaId = mediaId != null ? mediaId : sourceUri.toString(); } @@ -509,6 +534,9 @@ public final class MediaItem { /** Optional subtitles to be sideloaded. */ public final List subtitles; + /** Optional ad tag {@link Uri}. */ + @Nullable public final Uri adTagUri; + /** * Optional tag for custom attributes. The tag for the media source which will be published in * the {@code com.google.android.exoplayer2.Timeline} of the source as {@code @@ -523,6 +551,7 @@ public final class MediaItem { List streamKeys, @Nullable String customCacheKey, List subtitles, + @Nullable Uri adTagUri, @Nullable Object tag) { this.sourceUri = sourceUri; this.mimeType = mimeType; @@ -530,6 +559,7 @@ public final class MediaItem { this.streamKeys = streamKeys; this.customCacheKey = customCacheKey; this.subtitles = subtitles; + this.adTagUri = adTagUri; this.tag = tag; } @@ -549,6 +579,7 @@ public final class MediaItem { && streamKeys.equals(other.streamKeys) && Util.areEqual(customCacheKey, other.customCacheKey) && subtitles.equals(other.subtitles) + && Util.areEqual(adTagUri, other.adTagUri) && Util.areEqual(tag, other.tag); } @@ -560,6 +591,7 @@ public final class MediaItem { result = 31 * result + streamKeys.hashCode(); result = 31 * result + (customCacheKey == null ? 0 : customCacheKey.hashCode()); result = 31 * result + subtitles.hashCode(); + result = 31 * result + (adTagUri == null ? 0 : adTagUri.hashCode()); result = 31 * result + (tag == null ? 0 : tag.hashCode()); return result; } diff --git a/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java b/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java index adfbc60085..15a35f3f9d 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java @@ -266,6 +266,16 @@ public class MediaItemTest { assertThat(mediaItem.clippingProperties.startsAtKeyFrame).isTrue(); } + @Test + public void builderSetAdTagUri_setsAdTagUri() { + Uri adTagUri = Uri.parse(URI_STRING + "/ad"); + + MediaItem mediaItem = + new MediaItem.Builder().setSourceUri(URI_STRING).setAdTagUri(adTagUri).build(); + + assertThat(mediaItem.playbackProperties.adTagUri).isEqualTo(adTagUri); + } + @Test public void builderSetMediaMetadata_setsMetadata() { MediaMetadata mediaMetadata = new MediaMetadata.Builder().setTitle("title").build(); @@ -280,6 +290,7 @@ public class MediaItemTest { public void buildUpon_equalsToOriginal() { MediaItem mediaItem = new MediaItem.Builder() + .setAdTagUri(URI_STRING) .setClipEndPositionMs(1000) .setClipRelativeToDefaultPosition(true) .setClipRelativeToLiveWindow(true) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 0344b09707..2d8ea2e09c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.annotation.SuppressLint; import android.os.Handler; import android.os.Looper; @@ -28,6 +30,7 @@ import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSourceFactory; import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.source.ads.AdsMediaSource; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.trackselection.TrackSelector; @@ -84,6 +87,7 @@ import java.util.concurrent.TimeoutException; private SeekParameters seekParameters; private ShuffleOrder shuffleOrder; private boolean pauseAtEndOfMediaItems; + private boolean hasAdsMediaSource; // Playback information when there is no pending seek/set source operation. private PlaybackInfo playbackInfo; @@ -123,8 +127,8 @@ import java.util.concurrent.TimeoutException; Log.i(TAG, "Init " + Integer.toHexString(System.identityHashCode(this)) + " [" + ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "]"); Assertions.checkState(renderers.length > 0); - this.renderers = Assertions.checkNotNull(renderers); - this.trackSelector = Assertions.checkNotNull(trackSelector); + this.renderers = checkNotNull(renderers); + this.trackSelector = checkNotNull(trackSelector); this.mediaSourceFactory = mediaSourceFactory; this.useLazyPreparation = useLazyPreparation; repeatMode = Player.REPEAT_MODE_OFF; @@ -397,9 +401,7 @@ import java.util.concurrent.TimeoutException; @Override public void addMediaSources(int index, List mediaSources) { Assertions.checkArgument(index >= 0); - for (int i = 0; i < mediaSources.size(); i++) { - Assertions.checkArgument(mediaSources.get(i) != null); - } + validateMediaSources(mediaSources, /* mediaSourceReplacement= */ false); int currentWindowIndex = getCurrentWindowIndex(); long currentPositionMs = getCurrentPosition(); Timeline oldTimeline = getCurrentTimeline(); @@ -973,9 +975,7 @@ import java.util.concurrent.TimeoutException; int startWindowIndex, long startPositionMs, boolean resetToDefaultPosition) { - for (int i = 0; i < mediaSources.size(); i++) { - Assertions.checkArgument(mediaSources.get(i) != null); - } + validateMediaSources(mediaSources, /* mediaSourceReplacement= */ true); int currentWindowIndex = getCurrentWindowIndexInternal(); long currentPositionMs = getCurrentPosition(); pendingOperationAcks++; @@ -1076,9 +1076,42 @@ import java.util.concurrent.TimeoutException; removed.add(mediaSourceHolders.remove(i)); } shuffleOrder = shuffleOrder.cloneAndRemove(fromIndex, toIndexExclusive); + if (mediaSourceHolders.isEmpty()) { + hasAdsMediaSource = false; + } return removed; } + /** + * Validates media sources before any modification of the existing list of media sources is made. + * This way we can throw an exception before changing the state of the player in case of a + * validation failure. + * + * @param mediaSources The media sources to set or add. + * @param mediaSourceReplacement Whether the given media sources will replace existing ones. + */ + private void validateMediaSources( + List mediaSources, boolean mediaSourceReplacement) { + if (hasAdsMediaSource && !mediaSourceReplacement && !mediaSources.isEmpty()) { + // Adding media sources to an ads media source is not allowed + // (see https://github.com/google/ExoPlayer/issues/3750). + throw new IllegalStateException(); + } + int sizeAfterModification = + mediaSources.size() + (mediaSourceReplacement ? 0 : mediaSourceHolders.size()); + for (int i = 0; i < mediaSources.size(); i++) { + MediaSource mediaSource = checkNotNull(mediaSources.get(i)); + if (mediaSource instanceof AdsMediaSource) { + if (sizeAfterModification > 1) { + // Ads media sources only allowed with a single source + // (see https://github.com/google/ExoPlayer/issues/3750). + throw new IllegalArgumentException(); + } + hasAdsMediaSource = true; + } + } + } + private PlaybackInfo maskTimeline() { return playbackInfo.copyWithTimeline( mediaSourceHolders.isEmpty() diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java index c3beb0d00e..fc9ffab139 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java @@ -29,6 +29,8 @@ import com.google.android.exoplayer2.drm.FrameworkMediaDrm; import com.google.android.exoplayer2.drm.HttpMediaDrmCallback; import com.google.android.exoplayer2.drm.MediaDrmCallback; import com.google.android.exoplayer2.offline.StreamKey; +import com.google.android.exoplayer2.source.ads.AdsLoader; +import com.google.android.exoplayer2.source.ads.AdsMediaSource; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; @@ -36,6 +38,7 @@ import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; 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 java.util.Arrays; @@ -89,9 +92,36 @@ import java.util.Map; * alternative dummy, apps can pass a drm session manager to {@link * #setDrmSessionManager(DrmSessionManager)} which will be used for all items without a drm * configuration. + * + *

Ad support for media items with ad tag uri

+ * + *

For a media item with an ad tag uri an {@link AdSupportProvider} needs to be passed to the + * constructor {@link #DefaultMediaSourceFactory(Context, DataSource.Factory, AdSupportProvider)}. */ public final class DefaultMediaSourceFactory implements MediaSourceFactory { + /** + * Provides {@link AdsLoader ads loaders} and an {@link AdsLoader.AdViewProvider} to created + * {@link AdsMediaSource AdsMediaSources}. + */ + public interface AdSupportProvider { + + /** + * Returns an {@link AdsLoader} for the given {@link Uri ad tag uri} or null if no ads loader is + * available for the given ad tag uri. + * + *

This method is called for each media item for which a media source is created. + */ + @Nullable + AdsLoader getAdsLoader(Uri adTagUri); + + /** + * Returns an {@link AdsLoader.AdViewProvider} which is used to create {@link AdsMediaSource + * AdsMediaSources}. + */ + AdsLoader.AdViewProvider getAdViewProvider(); + } + /** * Creates a new instance with the given {@link Context}. * @@ -115,10 +145,13 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { */ public static DefaultMediaSourceFactory newInstance( Context context, DataSource.Factory dataSourceFactory) { - return new DefaultMediaSourceFactory(context, dataSourceFactory); + return new DefaultMediaSourceFactory(context, dataSourceFactory, /* adSupportProvider= */ null); } + private static final String TAG = "DefaultMediaSourceFactory"; + private final DataSource.Factory dataSourceFactory; + @Nullable private final AdSupportProvider adSupportProvider; private final SparseArray mediaSourceFactories; @C.ContentType private final int[] supportedTypes; private final String userAgent; @@ -127,8 +160,20 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { private HttpDataSource.Factory drmHttpDataSourceFactory; @Nullable private List streamKeys; - private DefaultMediaSourceFactory(Context context, DataSource.Factory dataSourceFactory) { + /** + * Creates a new instance with the given {@link Context} and {@link DataSource.Factory}. + * + * @param context The {@link Context}. + * @param dataSourceFactory A {@link DataSource.Factory} to be used to create media sources. + * @param adSupportProvider An {@link AdSupportProvider} to get ads loaders and ad view providers + * to be used to create {@link AdsMediaSource AdsMediaSources}. + */ + public DefaultMediaSourceFactory( + Context context, + DataSource.Factory dataSourceFactory, + @Nullable AdSupportProvider adSupportProvider) { this.dataSourceFactory = dataSourceFactory; + this.adSupportProvider = adSupportProvider; drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); userAgent = Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY); drmHttpDataSourceFactory = new DefaultHttpDataSourceFactory(userAgent); @@ -214,30 +259,29 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { ? mediaItem.playbackProperties.streamKeys : streamKeys); - MediaSource leafMediaSource = mediaSourceFactory.createMediaSource(mediaItem); + MediaSource mediaSource = mediaSourceFactory.createMediaSource(mediaItem); List subtitles = mediaItem.playbackProperties.subtitles; - if (subtitles.isEmpty()) { - return maybeClipMediaSource(mediaItem, leafMediaSource); + if (!subtitles.isEmpty()) { + MediaSource[] mediaSources = new MediaSource[subtitles.size() + 1]; + mediaSources[0] = mediaSource; + SingleSampleMediaSource.Factory singleSampleSourceFactory = + new SingleSampleMediaSource.Factory(dataSourceFactory); + for (int i = 0; i < subtitles.size(); i++) { + MediaItem.Subtitle subtitle = subtitles.get(i); + Format subtitleFormat = + new Format.Builder() + .setSampleMimeType(subtitle.mimeType) + .setLanguage(subtitle.language) + .setSelectionFlags(subtitle.selectionFlags) + .build(); + mediaSources[i + 1] = + singleSampleSourceFactory.createMediaSource( + subtitle.uri, subtitleFormat, /* durationUs= */ C.TIME_UNSET); + } + mediaSource = new MergingMediaSource(mediaSources); } - - MediaSource[] mediaSources = new MediaSource[subtitles.size() + 1]; - mediaSources[0] = leafMediaSource; - SingleSampleMediaSource.Factory singleSampleSourceFactory = - new SingleSampleMediaSource.Factory(dataSourceFactory); - for (int i = 0; i < subtitles.size(); i++) { - MediaItem.Subtitle subtitle = subtitles.get(i); - Format subtitleFormat = - new Format.Builder() - .setSampleMimeType(subtitle.mimeType) - .setLanguage(subtitle.language) - .setSelectionFlags(subtitle.selectionFlags) - .build(); - mediaSources[i + 1] = - singleSampleSourceFactory.createMediaSource( - subtitle.uri, subtitleFormat, /* durationUs= */ C.TIME_UNSET); - } - return maybeClipMediaSource(mediaItem, new MergingMediaSource(mediaSources)); + return maybeWrapWithAdsMediaSource(mediaItem, maybeClipMediaSource(mediaItem, mediaSource)); } // internal methods @@ -285,6 +329,34 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { mediaItem.clippingProperties.relativeToDefaultPosition); } + private MediaSource maybeWrapWithAdsMediaSource(MediaItem mediaItem, MediaSource mediaSource) { + Assertions.checkNotNull(mediaItem.playbackProperties); + if (mediaItem.playbackProperties.adTagUri == null) { + return mediaSource; + } + if (adSupportProvider == null) { + Log.w( + TAG, + "Playing media without ads. Pass an AdsSupportProvider to the constructor for supporting" + + " media items with an ad tag uri."); + return mediaSource; + } + AdsLoader adsLoader = adSupportProvider.getAdsLoader(mediaItem.playbackProperties.adTagUri); + if (adsLoader == null) { + Log.w( + TAG, + String.format( + "Playing media without ads. No AdsLoader for media item with mediaId '%s'.", + mediaItem.mediaId)); + return mediaSource; + } + return new AdsMediaSource( + mediaSource, + /* adMediaSourceFactory= */ this, + adsLoader, + adSupportProvider.getAdViewProvider()); + } + private static SparseArray loadDelegates( DataSource.Factory dataSourceFactory) { SparseArray factories = new SparseArray<>(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index b170a71f9b..cd438c978f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -27,6 +27,8 @@ import android.graphics.SurfaceTexture; import android.media.AudioManager; import android.os.Looper; import android.view.Surface; +import android.view.View; +import android.view.ViewGroup; import androidx.annotation.Nullable; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -48,6 +50,8 @@ import com.google.android.exoplayer2.source.SampleStream; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.ads.AdPlaybackState; +import com.google.android.exoplayer2.source.ads.AdsLoader; +import com.google.android.exoplayer2.source.ads.AdsMediaSource; import com.google.android.exoplayer2.testutil.ActionSchedule; import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerRunnable; import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerTarget; @@ -72,10 +76,12 @@ import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.Allocation; import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.upstream.Loader; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; +import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; @@ -4378,6 +4384,121 @@ public final class ExoPlayerTest { assertThat(positionAfterSetShuffleOrder.get()).isAtLeast(5000); } + @Test + public void setMediaSources_secondAdMediaSource_throws() throws Exception { + AdsMediaSource adsMediaSource = + new AdsMediaSource( + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new DefaultDataSourceFactory( + context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)), + new DummyAdsLoader(), + new DummyAdViewProvider()); + Exception[] exception = {null}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + try { + player.setMediaSource(adsMediaSource); + player.addMediaSource(adsMediaSource); + } catch (Exception e) { + exception[0] = e; + } + player.prepare(); + } + }) + .build(); + + new ExoPlayerTestRunner.Builder(context) + .setActionSchedule(actionSchedule) + .build() + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertThat(exception[0]).isInstanceOf(IllegalStateException.class); + } + + @Test + public void setMediaSources_multipleMediaSourcesWithAd_throws() throws Exception { + MediaSource mediaSource = new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)); + AdsMediaSource adsMediaSource = + new AdsMediaSource( + mediaSource, + new DefaultDataSourceFactory( + context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)), + new DummyAdsLoader(), + new DummyAdViewProvider()); + final Exception[] exception = {null}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + try { + List sources = new ArrayList<>(); + sources.add(mediaSource); + sources.add(adsMediaSource); + player.setMediaSources(sources); + } catch (Exception e) { + exception[0] = e; + } + player.prepare(); + } + }) + .build(); + + new ExoPlayerTestRunner.Builder(context) + .setActionSchedule(actionSchedule) + .build() + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertThat(exception[0]).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void setMediaSources_addingMediaSourcesWithAdToNonEmptyPlaylist_throws() throws Exception { + MediaSource mediaSource = new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)); + AdsMediaSource adsMediaSource = + new AdsMediaSource( + mediaSource, + new DefaultDataSourceFactory( + context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)), + new DummyAdsLoader(), + new DummyAdViewProvider()); + final Exception[] exception = {null}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + try { + player.addMediaSource(adsMediaSource); + } catch (Exception e) { + exception[0] = e; + } + } + }) + .build(); + + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertThat(exception[0]).isInstanceOf(IllegalArgumentException.class); + } + @Test public void setMediaSources_empty_whenEmpty_correctMaskingWindowIndex() throws Exception { Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); @@ -6493,4 +6614,38 @@ public final class ExoPlayerTest { return Loader.RETRY; } } + + private static class DummyAdsLoader implements AdsLoader { + + @Override + public void setPlayer(@Nullable Player player) {} + + @Override + public void release() {} + + @Override + public void setSupportedContentTypes(int... contentTypes) {} + + @Override + public void start(AdsLoader.EventListener eventListener, AdViewProvider adViewProvider) {} + + @Override + public void stop() {} + + @Override + public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception) {} + } + + private static class DummyAdViewProvider implements AdsLoader.AdViewProvider { + + @Override + public ViewGroup getAdViewGroup() { + return null; + } + + @Override + public View[] getAdOverlayViews() { + return new View[0]; + } + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java index 3c9d5182f8..717d716ce6 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java @@ -16,12 +16,18 @@ package com.google.android.exoplayer2.source; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; +import android.content.Context; import android.net.Uri; +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.MediaItem; +import com.google.android.exoplayer2.source.ads.AdsLoader; +import com.google.android.exoplayer2.source.ads.AdsMediaSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.util.MimeTypes; import java.util.Arrays; import java.util.Collections; @@ -184,4 +190,65 @@ public final class DefaultMediaSourceFactoryTest { assertThat(supportedTypes).asList().containsExactly(C.TYPE_OTHER); } + + @Test + public void createMediaSource_withAdTagUri_callsAdsLoader() { + Context applicationContext = ApplicationProvider.getApplicationContext(); + Uri adTagUri = Uri.parse(URI_MEDIA); + MediaItem mediaItem = + new MediaItem.Builder().setSourceUri(URI_MEDIA).setAdTagUri(adTagUri).build(); + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory( + applicationContext, + new DefaultDataSourceFactory(applicationContext, "userAgent"), + createAdSupportProvider(mock(AdsLoader.class), mock(AdsLoader.AdViewProvider.class))); + + MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); + + assertThat(mediaSource).isInstanceOf(AdsMediaSource.class); + } + + @Test + public void createMediaSource_withAdTagUriAdsLoaderNull_playsWithoutAdNoException() { + Context applicationContext = ApplicationProvider.getApplicationContext(); + MediaItem mediaItem = + new MediaItem.Builder().setSourceUri(URI_MEDIA).setAdTagUri(Uri.parse(URI_MEDIA)).build(); + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory( + applicationContext, + new DefaultDataSourceFactory(applicationContext, "userAgent"), + createAdSupportProvider(/* adsLoader= */ null, mock(AdsLoader.AdViewProvider.class))); + + MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); + + assertThat(mediaSource).isNotInstanceOf(AdsMediaSource.class); + } + + @Test + public void createMediaSource_withAdTagUriProvidersNull_playsWithoutAdNoException() { + Context applicationContext = ApplicationProvider.getApplicationContext(); + MediaItem mediaItem = + new MediaItem.Builder().setSourceUri(URI_MEDIA).setAdTagUri(Uri.parse(URI_MEDIA)).build(); + + MediaSource mediaSource = + DefaultMediaSourceFactory.newInstance(applicationContext).createMediaSource(mediaItem); + + assertThat(mediaSource).isNotInstanceOf(AdsMediaSource.class); + } + + private static DefaultMediaSourceFactory.AdSupportProvider createAdSupportProvider( + @Nullable AdsLoader adsLoader, AdsLoader.AdViewProvider adViewProvider) { + return new DefaultMediaSourceFactory.AdSupportProvider() { + @Nullable + @Override + public AdsLoader getAdsLoader(Uri adTagUri) { + return adsLoader; + } + + @Override + public AdsLoader.AdViewProvider getAdViewProvider() { + return adViewProvider; + } + }; + } }