diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdsMediaSource.java
new file mode 100644
index 0000000000..922fac6c87
--- /dev/null
+++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdsMediaSource.java
@@ -0,0 +1,1108 @@
+/*
+ * Copyright 2021 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.source.ads;
+
+import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.getAdCountInGroup;
+import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.getMediaPeriodPositionUs;
+import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.getMediaPeriodPositionUsForAd;
+import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.getMediaPeriodPositionUsForContent;
+import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.getStreamPositionUs;
+import static com.google.android.exoplayer2.util.Assertions.checkArgument;
+import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
+import static com.google.android.exoplayer2.util.Util.castNonNull;
+
+import android.os.Handler;
+import android.util.Pair;
+import androidx.annotation.GuardedBy;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.Format;
+import com.google.android.exoplayer2.FormatHolder;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.SeekParameters;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.decoder.DecoderInputBuffer;
+import com.google.android.exoplayer2.drm.DrmSession;
+import com.google.android.exoplayer2.drm.DrmSessionEventListener;
+import com.google.android.exoplayer2.offline.StreamKey;
+import com.google.android.exoplayer2.source.BaseMediaSource;
+import com.google.android.exoplayer2.source.EmptySampleStream;
+import com.google.android.exoplayer2.source.ForwardingTimeline;
+import com.google.android.exoplayer2.source.LoadEventInfo;
+import com.google.android.exoplayer2.source.MediaLoadData;
+import com.google.android.exoplayer2.source.MediaPeriod;
+import com.google.android.exoplayer2.source.MediaSource;
+import com.google.android.exoplayer2.source.MediaSourceEventListener;
+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.trackselection.ExoTrackSelection;
+import com.google.android.exoplayer2.upstream.Allocator;
+import com.google.android.exoplayer2.upstream.TransferListener;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Util;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.checkerframework.checker.nullness.compatqual.NullableType;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+/**
+ * A {@link MediaSource} for server-side inserted ad breaks.
+ *
+ *
The media source publishes a {@link Timeline} for the wrapped {@link MediaSource} with the
+ * server-side inserted ad breaks and ensures that playback continues seamlessly with the wrapped
+ * media across all transitions.
+ *
+ *
The ad breaks need to be specified using {@link #setAdPlaybackState} and can be updated during
+ * playback.
+ */
+public final class ServerSideInsertedAdsMediaSource extends BaseMediaSource
+ implements MediaSource.MediaSourceCaller, MediaSourceEventListener, DrmSessionEventListener {
+
+ private final MediaSource mediaSource;
+ private final ListMultimap mediaPeriods;
+ private final MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcherWithoutId;
+ private final DrmSessionEventListener.EventDispatcher drmEventDispatcherWithoutId;
+
+ @GuardedBy("this")
+ @Nullable
+ private Handler playbackHandler;
+
+ @Nullable private SharedMediaPeriod lastUsedMediaPeriod;
+ @Nullable private Timeline contentTimeline;
+ private AdPlaybackState adPlaybackState;
+
+ /**
+ * Creates the media source.
+ *
+ * @param mediaSource The {@link MediaSource} to wrap.
+ */
+ // Calling BaseMediaSource.createEventDispatcher from the constructor.
+ @SuppressWarnings("nullness:method.invocation.invalid")
+ public ServerSideInsertedAdsMediaSource(MediaSource mediaSource) {
+ this.mediaSource = mediaSource;
+ mediaPeriods = ArrayListMultimap.create();
+ adPlaybackState = AdPlaybackState.NONE;
+ mediaSourceEventDispatcherWithoutId = createEventDispatcher(/* mediaPeriodId= */ null);
+ drmEventDispatcherWithoutId = createDrmEventDispatcher(/* mediaPeriodId= */ null);
+ }
+
+ /**
+ * Sets the {@link AdPlaybackState} published by this source.
+ *
+ * May be called from any thread.
+ *
+ *
Must only contain server-side inserted ad groups. The number of ad groups and the number of
+ * ads within an ad group may only increase. The durations of ads may change and the positions of
+ * future ad groups may change. Post-roll ad groups with {@link C#TIME_END_OF_SOURCE} must be
+ * empty and can be used as a placeholder for a future ad group.
+ *
+ * @param adPlaybackState The new {@link AdPlaybackState}.
+ */
+ public void setAdPlaybackState(AdPlaybackState adPlaybackState) {
+ checkArgument(adPlaybackState.adGroupCount >= this.adPlaybackState.adGroupCount);
+ for (int i = 0; i < adPlaybackState.adGroupCount; i++) {
+ checkArgument(adPlaybackState.adGroups[i].isServerSideInserted);
+ if (i < this.adPlaybackState.adGroupCount) {
+ checkArgument(
+ getAdCountInGroup(adPlaybackState, /* adGroupIndex= */ i)
+ >= getAdCountInGroup(this.adPlaybackState, /* adGroupIndex= */ i));
+ }
+ if (adPlaybackState.adGroupTimesUs[i] == C.TIME_END_OF_SOURCE) {
+ checkArgument(getAdCountInGroup(adPlaybackState, /* adGroupIndex= */ i) == 0);
+ }
+ }
+ synchronized (this) {
+ if (playbackHandler == null) {
+ this.adPlaybackState = adPlaybackState;
+ } else {
+ playbackHandler.post(
+ () -> {
+ for (SharedMediaPeriod mediaPeriod : mediaPeriods.values()) {
+ mediaPeriod.updateAdPlaybackState(adPlaybackState);
+ }
+ if (lastUsedMediaPeriod != null) {
+ lastUsedMediaPeriod.updateAdPlaybackState(adPlaybackState);
+ }
+ this.adPlaybackState = adPlaybackState;
+ if (contentTimeline != null) {
+ refreshSourceInfo(
+ new ServerSideInsertedAdsTimeline(contentTimeline, adPlaybackState));
+ }
+ });
+ }
+ }
+ }
+
+ @Override
+ public MediaItem getMediaItem() {
+ return mediaSource.getMediaItem();
+ }
+
+ @Override
+ protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) {
+ Handler handler = Util.createHandlerForCurrentLooper();
+ synchronized (this) {
+ playbackHandler = handler;
+ }
+ mediaSource.addEventListener(handler, /* eventListener= */ this);
+ mediaSource.addDrmEventListener(handler, /* eventListener= */ this);
+ mediaSource.prepareSource(/* caller= */ this, mediaTransferListener);
+ }
+
+ @Override
+ public void maybeThrowSourceInfoRefreshError() throws IOException {
+ mediaSource.maybeThrowSourceInfoRefreshError();
+ }
+
+ @Override
+ protected void enableInternal() {
+ mediaSource.enable(/* caller= */ this);
+ }
+
+ @Override
+ protected void disableInternal() {
+ releaseLastUsedMediaPeriod();
+ mediaSource.disable(/* caller= */ this);
+ }
+
+ @Override
+ public void onSourceInfoRefreshed(MediaSource mediaSource, Timeline timeline) {
+ this.contentTimeline = timeline;
+ if (AdPlaybackState.NONE.equals(adPlaybackState)) {
+ return;
+ }
+ refreshSourceInfo(new ServerSideInsertedAdsTimeline(timeline, adPlaybackState));
+ }
+
+ @Override
+ protected void releaseSourceInternal() {
+ releaseLastUsedMediaPeriod();
+ contentTimeline = null;
+ synchronized (this) {
+ playbackHandler = null;
+ }
+ mediaSource.releaseSource(/* caller= */ this);
+ mediaSource.removeEventListener(/* eventListener= */ this);
+ mediaSource.removeDrmEventListener(/* eventListener= */ this);
+ }
+
+ @Override
+ public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long positionUs) {
+ SharedMediaPeriod sharedPeriod;
+ if (lastUsedMediaPeriod != null) {
+ sharedPeriod = lastUsedMediaPeriod;
+ lastUsedMediaPeriod = null;
+ mediaPeriods.put(id.windowSequenceNumber, sharedPeriod);
+ } else {
+ @Nullable
+ SharedMediaPeriod lastExistingPeriod =
+ Iterables.getLast(mediaPeriods.get(id.windowSequenceNumber), /* defaultValue= */ null);
+ if (lastExistingPeriod != null && lastExistingPeriod.canReuseMediaPeriod(id, positionUs)) {
+ sharedPeriod = lastExistingPeriod;
+ } else {
+ long startPositionUs = getStreamPositionUs(positionUs, id, adPlaybackState);
+ sharedPeriod =
+ new SharedMediaPeriod(
+ mediaSource.createPeriod(
+ new MediaPeriodId(id.periodUid, id.windowSequenceNumber),
+ allocator,
+ startPositionUs),
+ adPlaybackState);
+ mediaPeriods.put(id.windowSequenceNumber, sharedPeriod);
+ }
+ }
+ MediaPeriodImpl mediaPeriod =
+ new MediaPeriodImpl(
+ sharedPeriod, id, createEventDispatcher(id), createDrmEventDispatcher(id));
+ sharedPeriod.add(mediaPeriod);
+ return mediaPeriod;
+ }
+
+ @Override
+ public void releasePeriod(MediaPeriod mediaPeriod) {
+ MediaPeriodImpl mediaPeriodImpl = (MediaPeriodImpl) mediaPeriod;
+ mediaPeriodImpl.sharedPeriod.remove(mediaPeriodImpl);
+ if (mediaPeriodImpl.sharedPeriod.isUnused()) {
+ mediaPeriods.remove(
+ mediaPeriodImpl.mediaPeriodId.windowSequenceNumber, mediaPeriodImpl.sharedPeriod);
+ if (mediaPeriods.isEmpty()) {
+ // Keep until disabled.
+ lastUsedMediaPeriod = mediaPeriodImpl.sharedPeriod;
+ } else {
+ mediaPeriodImpl.sharedPeriod.release(mediaSource);
+ }
+ }
+ }
+
+ @Override
+ public void onDrmSessionAcquired(
+ int windowIndex, @Nullable MediaPeriodId mediaPeriodId, @DrmSession.State int state) {
+ @Nullable
+ MediaPeriodImpl mediaPeriod =
+ getMediaPeriodForEvent(
+ mediaPeriodId, /* mediaLoadData= */ null, /* useLoadingPeriod= */ true);
+ if (mediaPeriod == null) {
+ drmEventDispatcherWithoutId.drmSessionAcquired(state);
+ } else {
+ mediaPeriod.drmEventDispatcher.drmSessionAcquired(state);
+ }
+ }
+
+ @Override
+ public void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {
+ @Nullable
+ MediaPeriodImpl mediaPeriod =
+ getMediaPeriodForEvent(
+ mediaPeriodId, /* mediaLoadData= */ null, /* useLoadingPeriod= */ false);
+ if (mediaPeriod == null) {
+ drmEventDispatcherWithoutId.drmKeysLoaded();
+ } else {
+ mediaPeriod.drmEventDispatcher.drmKeysLoaded();
+ }
+ }
+
+ @Override
+ public void onDrmSessionManagerError(
+ int windowIndex, @Nullable MediaPeriodId mediaPeriodId, Exception error) {
+ @Nullable
+ MediaPeriodImpl mediaPeriod =
+ getMediaPeriodForEvent(
+ mediaPeriodId, /* mediaLoadData= */ null, /* useLoadingPeriod= */ false);
+ if (mediaPeriod == null) {
+ drmEventDispatcherWithoutId.drmSessionManagerError(error);
+ } else {
+ mediaPeriod.drmEventDispatcher.drmSessionManagerError(error);
+ }
+ }
+
+ @Override
+ public void onDrmKeysRestored(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {
+ @Nullable
+ MediaPeriodImpl mediaPeriod =
+ getMediaPeriodForEvent(
+ mediaPeriodId, /* mediaLoadData= */ null, /* useLoadingPeriod= */ false);
+ if (mediaPeriod == null) {
+ drmEventDispatcherWithoutId.drmKeysRestored();
+ } else {
+ mediaPeriod.drmEventDispatcher.drmKeysRestored();
+ }
+ }
+
+ @Override
+ public void onDrmKeysRemoved(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {
+ @Nullable
+ MediaPeriodImpl mediaPeriod =
+ getMediaPeriodForEvent(
+ mediaPeriodId, /* mediaLoadData= */ null, /* useLoadingPeriod= */ false);
+ if (mediaPeriod == null) {
+ drmEventDispatcherWithoutId.drmKeysRemoved();
+ } else {
+ mediaPeriod.drmEventDispatcher.drmKeysRemoved();
+ }
+ }
+
+ @Override
+ public void onDrmSessionReleased(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {
+ @Nullable
+ MediaPeriodImpl mediaPeriod =
+ getMediaPeriodForEvent(
+ mediaPeriodId, /* mediaLoadData= */ null, /* useLoadingPeriod= */ false);
+ if (mediaPeriod == null) {
+ drmEventDispatcherWithoutId.drmSessionReleased();
+ } else {
+ mediaPeriod.drmEventDispatcher.drmSessionReleased();
+ }
+ }
+
+ @Override
+ public void onLoadStarted(
+ int windowIndex,
+ @Nullable MediaPeriodId mediaPeriodId,
+ LoadEventInfo loadEventInfo,
+ MediaLoadData mediaLoadData) {
+ @Nullable
+ MediaPeriodImpl mediaPeriod =
+ getMediaPeriodForEvent(mediaPeriodId, mediaLoadData, /* useLoadingPeriod= */ true);
+ if (mediaPeriod == null) {
+ mediaSourceEventDispatcherWithoutId.loadStarted(loadEventInfo, mediaLoadData);
+ } else {
+ mediaPeriod.sharedPeriod.onLoadStarted(loadEventInfo, mediaLoadData);
+ mediaPeriod.mediaSourceEventDispatcher.loadStarted(
+ loadEventInfo, correctMediaLoadData(mediaPeriod, mediaLoadData, adPlaybackState));
+ }
+ }
+
+ @Override
+ public void onLoadCompleted(
+ int windowIndex,
+ @Nullable MediaPeriodId mediaPeriodId,
+ LoadEventInfo loadEventInfo,
+ MediaLoadData mediaLoadData) {
+ @Nullable
+ MediaPeriodImpl mediaPeriod =
+ getMediaPeriodForEvent(mediaPeriodId, mediaLoadData, /* useLoadingPeriod= */ true);
+ if (mediaPeriod == null) {
+ mediaSourceEventDispatcherWithoutId.loadCompleted(loadEventInfo, mediaLoadData);
+ } else {
+ mediaPeriod.sharedPeriod.onLoadFinished(loadEventInfo);
+ mediaPeriod.mediaSourceEventDispatcher.loadCompleted(
+ loadEventInfo, correctMediaLoadData(mediaPeriod, mediaLoadData, adPlaybackState));
+ }
+ }
+
+ @Override
+ public void onLoadCanceled(
+ int windowIndex,
+ @Nullable MediaPeriodId mediaPeriodId,
+ LoadEventInfo loadEventInfo,
+ MediaLoadData mediaLoadData) {
+ @Nullable
+ MediaPeriodImpl mediaPeriod =
+ getMediaPeriodForEvent(mediaPeriodId, mediaLoadData, /* useLoadingPeriod= */ true);
+ if (mediaPeriod == null) {
+ mediaSourceEventDispatcherWithoutId.loadCanceled(loadEventInfo, mediaLoadData);
+ } else {
+ mediaPeriod.sharedPeriod.onLoadFinished(loadEventInfo);
+ mediaPeriod.mediaSourceEventDispatcher.loadCanceled(
+ loadEventInfo, correctMediaLoadData(mediaPeriod, mediaLoadData, adPlaybackState));
+ }
+ }
+
+ @Override
+ public void onLoadError(
+ int windowIndex,
+ @Nullable MediaPeriodId mediaPeriodId,
+ LoadEventInfo loadEventInfo,
+ MediaLoadData mediaLoadData,
+ IOException error,
+ boolean wasCanceled) {
+ @Nullable
+ MediaPeriodImpl mediaPeriod =
+ getMediaPeriodForEvent(mediaPeriodId, mediaLoadData, /* useLoadingPeriod= */ true);
+ if (mediaPeriod == null) {
+ mediaSourceEventDispatcherWithoutId.loadError(
+ loadEventInfo, mediaLoadData, error, wasCanceled);
+ } else {
+ if (wasCanceled) {
+ mediaPeriod.sharedPeriod.onLoadFinished(loadEventInfo);
+ }
+ mediaPeriod.mediaSourceEventDispatcher.loadError(
+ loadEventInfo,
+ correctMediaLoadData(mediaPeriod, mediaLoadData, adPlaybackState),
+ error,
+ wasCanceled);
+ }
+ }
+
+ @Override
+ public void onUpstreamDiscarded(
+ int windowIndex, MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) {
+ @Nullable
+ MediaPeriodImpl mediaPeriod =
+ getMediaPeriodForEvent(mediaPeriodId, mediaLoadData, /* useLoadingPeriod= */ false);
+ if (mediaPeriod == null) {
+ mediaSourceEventDispatcherWithoutId.upstreamDiscarded(mediaLoadData);
+ } else {
+ mediaPeriod.mediaSourceEventDispatcher.upstreamDiscarded(
+ correctMediaLoadData(mediaPeriod, mediaLoadData, adPlaybackState));
+ }
+ }
+
+ @Override
+ public void onDownstreamFormatChanged(
+ int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) {
+ @Nullable
+ MediaPeriodImpl mediaPeriod =
+ getMediaPeriodForEvent(mediaPeriodId, mediaLoadData, /* useLoadingPeriod= */ false);
+ if (mediaPeriod == null) {
+ mediaSourceEventDispatcherWithoutId.downstreamFormatChanged(mediaLoadData);
+ } else {
+ mediaPeriod.sharedPeriod.onDownstreamFormatChanged(mediaPeriod, mediaLoadData);
+ mediaPeriod.mediaSourceEventDispatcher.downstreamFormatChanged(
+ correctMediaLoadData(mediaPeriod, mediaLoadData, adPlaybackState));
+ }
+ }
+
+ private void releaseLastUsedMediaPeriod() {
+ if (lastUsedMediaPeriod != null) {
+ lastUsedMediaPeriod.release(mediaSource);
+ lastUsedMediaPeriod = null;
+ }
+ }
+
+ @Nullable
+ private MediaPeriodImpl getMediaPeriodForEvent(
+ @Nullable MediaPeriodId mediaPeriodId,
+ @Nullable MediaLoadData mediaLoadData,
+ boolean useLoadingPeriod) {
+ if (mediaPeriodId == null) {
+ return null;
+ }
+ List periods = mediaPeriods.get(mediaPeriodId.windowSequenceNumber);
+ if (periods.isEmpty()) {
+ return null;
+ }
+ if (useLoadingPeriod) {
+ SharedMediaPeriod loadingPeriod = Iterables.getLast(periods);
+ return loadingPeriod.loadingPeriod != null
+ ? loadingPeriod.loadingPeriod
+ : Iterables.getLast(loadingPeriod.mediaPeriods);
+ }
+ for (int i = 0; i < periods.size(); i++) {
+ @Nullable MediaPeriodImpl period = periods.get(i).getMediaPeriodForEvent(mediaLoadData);
+ if (period != null) {
+ return period;
+ }
+ }
+ return periods.get(0).mediaPeriods.get(0);
+ }
+
+ private static long getMediaPeriodEndPositionUs(
+ MediaPeriodImpl mediaPeriod, AdPlaybackState adPlaybackState) {
+ MediaPeriodId id = mediaPeriod.mediaPeriodId;
+ if (id.isAd()) {
+ return adPlaybackState.adGroups[id.adGroupIndex].count == C.LENGTH_UNSET
+ ? 0
+ : adPlaybackState.adGroups[id.adGroupIndex].durationsUs[id.adIndexInAdGroup];
+ }
+ return id.nextAdGroupIndex == C.INDEX_UNSET
+ || adPlaybackState.adGroupTimesUs[id.nextAdGroupIndex] == C.TIME_END_OF_SOURCE
+ ? Long.MAX_VALUE
+ : adPlaybackState.adGroupTimesUs[id.nextAdGroupIndex];
+ }
+
+ private static MediaLoadData correctMediaLoadData(
+ MediaPeriodImpl mediaPeriod, MediaLoadData mediaLoadData, AdPlaybackState adPlaybackState) {
+ return new MediaLoadData(
+ mediaLoadData.dataType,
+ mediaLoadData.trackType,
+ mediaLoadData.trackFormat,
+ mediaLoadData.trackSelectionReason,
+ mediaLoadData.trackSelectionData,
+ correctMediaLoadDataPositionMs(
+ mediaLoadData.mediaStartTimeMs, mediaPeriod, adPlaybackState),
+ correctMediaLoadDataPositionMs(mediaLoadData.mediaEndTimeMs, mediaPeriod, adPlaybackState));
+ }
+
+ private static long correctMediaLoadDataPositionMs(
+ long mediaPositionMs, MediaPeriodImpl mediaPeriod, AdPlaybackState adPlaybackState) {
+ if (mediaPositionMs == C.TIME_UNSET) {
+ return C.TIME_UNSET;
+ }
+ long mediaPositionUs = C.msToUs(mediaPositionMs);
+ MediaPeriodId id = mediaPeriod.mediaPeriodId;
+ long correctedPositionUs =
+ id.isAd()
+ ? getMediaPeriodPositionUsForAd(
+ mediaPositionUs, id.adGroupIndex, id.adIndexInAdGroup, adPlaybackState)
+ // Ignore nextAdGroupIndex for content ids to correct timestamps that fall into future
+ // content pieces (beyond nextAdGroupIndex).
+ : getMediaPeriodPositionUsForContent(
+ mediaPositionUs, /* nextAdGroupIndex= */ C.INDEX_UNSET, adPlaybackState);
+ return C.usToMs(correctedPositionUs);
+ }
+
+ private static final class SharedMediaPeriod implements MediaPeriod.Callback {
+
+ private final MediaPeriod actualMediaPeriod;
+ private final List mediaPeriods;
+ private final Map> activeLoads;
+
+ private AdPlaybackState adPlaybackState;
+ @Nullable private MediaPeriodImpl loadingPeriod;
+ private boolean hasStartedPreparing;
+ private boolean isPrepared;
+ public @NullableType ExoTrackSelection[] trackSelections;
+ public @NullableType SampleStream[] sampleStreams;
+ public @NullableType MediaLoadData[] lastDownstreamFormatChangeData;
+
+ public SharedMediaPeriod(MediaPeriod actualMediaPeriod, AdPlaybackState adPlaybackState) {
+ this.actualMediaPeriod = actualMediaPeriod;
+ this.adPlaybackState = adPlaybackState;
+ mediaPeriods = new ArrayList<>();
+ activeLoads = new HashMap<>();
+ trackSelections = new ExoTrackSelection[0];
+ sampleStreams = new SampleStream[0];
+ lastDownstreamFormatChangeData = new MediaLoadData[0];
+ }
+
+ public void updateAdPlaybackState(AdPlaybackState adPlaybackState) {
+ this.adPlaybackState = adPlaybackState;
+ }
+
+ public void add(MediaPeriodImpl mediaPeriod) {
+ mediaPeriods.add(mediaPeriod);
+ }
+
+ public void remove(MediaPeriodImpl mediaPeriod) {
+ if (mediaPeriod.equals(loadingPeriod)) {
+ loadingPeriod = null;
+ activeLoads.clear();
+ }
+ mediaPeriods.remove(mediaPeriod);
+ }
+
+ public boolean isUnused() {
+ return mediaPeriods.isEmpty();
+ }
+
+ public void release(MediaSource mediaSource) {
+ mediaSource.releasePeriod(actualMediaPeriod);
+ }
+
+ public boolean canReuseMediaPeriod(MediaPeriodId id, long positionUs) {
+ MediaPeriodImpl previousPeriod = Iterables.getLast(mediaPeriods);
+ long previousEndPositionUs =
+ getStreamPositionUs(
+ getMediaPeriodEndPositionUs(previousPeriod, adPlaybackState),
+ previousPeriod.mediaPeriodId,
+ adPlaybackState);
+ long startPositionUs = getStreamPositionUs(positionUs, id, adPlaybackState);
+ return startPositionUs == previousEndPositionUs;
+ }
+
+ @Nullable
+ public MediaPeriodImpl getMediaPeriodForEvent(@Nullable MediaLoadData mediaLoadData) {
+ if (mediaLoadData != null && mediaLoadData.mediaStartTimeMs != C.TIME_UNSET) {
+ for (int i = 0; i < mediaPeriods.size(); i++) {
+ MediaPeriodImpl mediaPeriod = mediaPeriods.get(i);
+ long startTimeInPeriodUs =
+ getMediaPeriodPositionUs(
+ C.msToUs(mediaLoadData.mediaStartTimeMs),
+ mediaPeriod.mediaPeriodId,
+ adPlaybackState);
+ long mediaPeriodEndPositionUs = getMediaPeriodEndPositionUs(mediaPeriod, adPlaybackState);
+ if (startTimeInPeriodUs >= 0 && startTimeInPeriodUs < mediaPeriodEndPositionUs) {
+ return mediaPeriod;
+ }
+ }
+ }
+ return null;
+ }
+
+ public void prepare(MediaPeriodImpl mediaPeriod, long positionUs) {
+ mediaPeriod.lastStartPositionUs = positionUs;
+ if (hasStartedPreparing) {
+ if (isPrepared) {
+ checkNotNull(mediaPeriod.callback).onPrepared(mediaPeriod);
+ }
+ return;
+ }
+ hasStartedPreparing = true;
+ long preparePositionUs =
+ getStreamPositionUs(positionUs, mediaPeriod.mediaPeriodId, adPlaybackState);
+ actualMediaPeriod.prepare(/* callback= */ this, preparePositionUs);
+ }
+
+ public void maybeThrowPrepareError() throws IOException {
+ actualMediaPeriod.maybeThrowPrepareError();
+ }
+
+ public TrackGroupArray getTrackGroups() {
+ return actualMediaPeriod.getTrackGroups();
+ }
+
+ public List getStreamKeys(List trackSelections) {
+ return actualMediaPeriod.getStreamKeys(trackSelections);
+ }
+
+ public boolean continueLoading(MediaPeriodImpl mediaPeriod, long positionUs) {
+ @Nullable MediaPeriodImpl loadingPeriod = this.loadingPeriod;
+ if (loadingPeriod != null && !mediaPeriod.equals(loadingPeriod)) {
+ for (Pair loadData : activeLoads.values()) {
+ loadingPeriod.mediaSourceEventDispatcher.loadCompleted(
+ loadData.first,
+ correctMediaLoadData(loadingPeriod, loadData.second, adPlaybackState));
+ mediaPeriod.mediaSourceEventDispatcher.loadStarted(
+ loadData.first, correctMediaLoadData(mediaPeriod, loadData.second, adPlaybackState));
+ }
+ }
+ this.loadingPeriod = mediaPeriod;
+ long actualPlaybackPositionUs =
+ getStreamPositionUsWithNotYetStartedHandling(mediaPeriod, positionUs);
+ return actualMediaPeriod.continueLoading(actualPlaybackPositionUs);
+ }
+
+ public boolean isLoading(MediaPeriodImpl mediaPeriod) {
+ return mediaPeriod.equals(loadingPeriod) && actualMediaPeriod.isLoading();
+ }
+
+ public long getBufferedPositionUs(MediaPeriodImpl mediaPeriod) {
+ return getMediaPeriodPositionUsWithEndOfSourceHandling(
+ mediaPeriod, actualMediaPeriod.getBufferedPositionUs());
+ }
+
+ public long getNextLoadPositionUs(MediaPeriodImpl mediaPeriod) {
+ return getMediaPeriodPositionUsWithEndOfSourceHandling(
+ mediaPeriod, actualMediaPeriod.getNextLoadPositionUs());
+ }
+
+ public long seekToUs(MediaPeriodImpl mediaPeriod, long positionUs) {
+ long actualRequestedPositionUs =
+ getStreamPositionUs(positionUs, mediaPeriod.mediaPeriodId, adPlaybackState);
+ long newActualPositionUs = actualMediaPeriod.seekToUs(actualRequestedPositionUs);
+ return getMediaPeriodPositionUs(
+ newActualPositionUs, mediaPeriod.mediaPeriodId, adPlaybackState);
+ }
+
+ public long getAdjustedSeekPositionUs(
+ MediaPeriodImpl mediaPeriod, long positionUs, SeekParameters seekParameters) {
+ long actualRequestedPositionUs =
+ getStreamPositionUs(positionUs, mediaPeriod.mediaPeriodId, adPlaybackState);
+ long adjustedActualPositionUs =
+ actualMediaPeriod.getAdjustedSeekPositionUs(actualRequestedPositionUs, seekParameters);
+ return getMediaPeriodPositionUs(
+ adjustedActualPositionUs, mediaPeriod.mediaPeriodId, adPlaybackState);
+ }
+
+ public void discardBuffer(MediaPeriodImpl mediaPeriod, long positionUs, boolean toKeyframe) {
+ long actualPositionUs =
+ getStreamPositionUs(positionUs, mediaPeriod.mediaPeriodId, adPlaybackState);
+ actualMediaPeriod.discardBuffer(actualPositionUs, toKeyframe);
+ }
+
+ public void reevaluateBuffer(MediaPeriodImpl mediaPeriod, long positionUs) {
+ actualMediaPeriod.reevaluateBuffer(
+ getStreamPositionUsWithNotYetStartedHandling(mediaPeriod, positionUs));
+ }
+
+ public long readDiscontinuity(MediaPeriodImpl mediaPeriod) {
+ if (!mediaPeriod.equals(mediaPeriods.get(0))) {
+ return C.TIME_UNSET;
+ }
+ long actualDiscontinuityPositionUs = actualMediaPeriod.readDiscontinuity();
+ return actualDiscontinuityPositionUs == C.TIME_UNSET
+ ? C.TIME_UNSET
+ : getMediaPeriodPositionUs(
+ actualDiscontinuityPositionUs, mediaPeriod.mediaPeriodId, adPlaybackState);
+ }
+
+ public long selectTracks(
+ MediaPeriodImpl mediaPeriod,
+ @NullableType ExoTrackSelection[] selections,
+ boolean[] mayRetainStreamFlags,
+ @NullableType SampleStream[] streams,
+ boolean[] streamResetFlags,
+ long positionUs) {
+ mediaPeriod.lastStartPositionUs = positionUs;
+ if (mediaPeriod.equals(mediaPeriods.get(0))) {
+ // Do the real selection for the current first period in the list.
+ trackSelections = Arrays.copyOf(selections, selections.length);
+ long requestedPositionUs =
+ getStreamPositionUs(positionUs, mediaPeriod.mediaPeriodId, adPlaybackState);
+ @NullableType
+ SampleStream[] realStreams =
+ sampleStreams.length == 0
+ ? new SampleStream[selections.length]
+ : Arrays.copyOf(sampleStreams, sampleStreams.length);
+ long startPositionUs =
+ actualMediaPeriod.selectTracks(
+ selections,
+ mayRetainStreamFlags,
+ realStreams,
+ streamResetFlags,
+ requestedPositionUs);
+ this.sampleStreams = Arrays.copyOf(realStreams, realStreams.length);
+ lastDownstreamFormatChangeData =
+ Arrays.copyOf(lastDownstreamFormatChangeData, realStreams.length);
+ for (int i = 0; i < realStreams.length; i++) {
+ if (realStreams[i] == null) {
+ streams[i] = null;
+ lastDownstreamFormatChangeData[i] = null;
+ } else if (streams[i] == null || streamResetFlags[i]) {
+ streams[i] = new SampleStreamImpl(mediaPeriod, /* streamIndex= */ i);
+ lastDownstreamFormatChangeData[i] = null;
+ }
+ }
+ return getMediaPeriodPositionUs(
+ startPositionUs, mediaPeriod.mediaPeriodId, adPlaybackState);
+ }
+ // All subsequent periods need to have the same selection. Ignore tracks or add empty tracks
+ // if this isn't the case.
+ for (int i = 0; i < selections.length; i++) {
+ if (selections[i] != null) {
+ streamResetFlags[i] = !mayRetainStreamFlags[i] || streams[i] == null;
+ if (streamResetFlags[i]) {
+ streams[i] =
+ Util.areEqual(trackSelections[i], selections[i])
+ ? new SampleStreamImpl(mediaPeriod, /* streamIndex= */ i)
+ : new EmptySampleStream();
+ }
+ } else {
+ streams[i] = null;
+ streamResetFlags[i] = true;
+ }
+ }
+ return positionUs;
+ }
+
+ @SampleStream.ReadDataResult
+ public int readData(
+ MediaPeriodImpl mediaPeriod,
+ int streamIndex,
+ FormatHolder formatHolder,
+ DecoderInputBuffer buffer,
+ @SampleStream.ReadFlags int readFlags) {
+ @SampleStream.ReadFlags
+ int peekingFlags = readFlags | SampleStream.FLAG_PEEK | SampleStream.FLAG_OMIT_SAMPLE_DATA;
+ @SampleStream.ReadDataResult
+ int result =
+ castNonNull(sampleStreams[streamIndex]).readData(formatHolder, buffer, peekingFlags);
+ long adjustedTimeUs =
+ getMediaPeriodPositionUsWithEndOfSourceHandling(mediaPeriod, buffer.timeUs);
+ if ((result == C.RESULT_BUFFER_READ && adjustedTimeUs == C.TIME_END_OF_SOURCE)
+ || (result == C.RESULT_NOTHING_READ
+ && getBufferedPositionUs(mediaPeriod) == C.TIME_END_OF_SOURCE
+ && !buffer.waitingForKeys)) {
+ maybeNotifyDownstreamFormatChanged(mediaPeriod, streamIndex);
+ buffer.clear();
+ buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM);
+ return C.RESULT_BUFFER_READ;
+ }
+ if (result == C.RESULT_BUFFER_READ) {
+ maybeNotifyDownstreamFormatChanged(mediaPeriod, streamIndex);
+ castNonNull(sampleStreams[streamIndex]).readData(formatHolder, buffer, readFlags);
+ buffer.timeUs = adjustedTimeUs;
+ }
+ return result;
+ }
+
+ public int skipData(MediaPeriodImpl mediaPeriod, int streamIndex, long positionUs) {
+ long actualPositionUs =
+ getStreamPositionUs(positionUs, mediaPeriod.mediaPeriodId, adPlaybackState);
+ return castNonNull(sampleStreams[streamIndex]).skipData(actualPositionUs);
+ }
+
+ public boolean isReady(int streamIndex) {
+ return castNonNull(sampleStreams[streamIndex]).isReady();
+ }
+
+ public void maybeThrowError(int streamIndex) throws IOException {
+ castNonNull(sampleStreams[streamIndex]).maybeThrowError();
+ }
+
+ public void onDownstreamFormatChanged(
+ MediaPeriodImpl mediaPeriod, MediaLoadData mediaLoadData) {
+ int streamIndex = findMatchingStreamIndex(mediaLoadData);
+ if (streamIndex != C.INDEX_UNSET) {
+ lastDownstreamFormatChangeData[streamIndex] = mediaLoadData;
+ mediaPeriod.hasNotifiedDownstreamFormatChange[streamIndex] = true;
+ }
+ }
+
+ public void onLoadStarted(LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) {
+ activeLoads.put(loadEventInfo.loadTaskId, Pair.create(loadEventInfo, mediaLoadData));
+ }
+
+ public void onLoadFinished(LoadEventInfo loadEventInfo) {
+ activeLoads.remove(loadEventInfo.loadTaskId);
+ }
+
+ @Override
+ public void onPrepared(MediaPeriod actualMediaPeriod) {
+ isPrepared = true;
+ for (int i = 0; i < mediaPeriods.size(); i++) {
+ MediaPeriodImpl mediaPeriod = mediaPeriods.get(i);
+ if (mediaPeriod.callback != null) {
+ mediaPeriod.callback.onPrepared(mediaPeriod);
+ }
+ }
+ }
+
+ @Override
+ public void onContinueLoadingRequested(MediaPeriod source) {
+ if (loadingPeriod == null) {
+ return;
+ }
+ checkNotNull(loadingPeriod.callback).onContinueLoadingRequested(loadingPeriod);
+ }
+
+ private long getStreamPositionUsWithNotYetStartedHandling(
+ MediaPeriodImpl mediaPeriod, long positionUs) {
+ if (positionUs < mediaPeriod.lastStartPositionUs) {
+ long actualStartPositionUs =
+ getStreamPositionUs(
+ mediaPeriod.lastStartPositionUs, mediaPeriod.mediaPeriodId, adPlaybackState);
+ return actualStartPositionUs - (mediaPeriod.lastStartPositionUs - positionUs);
+ }
+ return getStreamPositionUs(positionUs, mediaPeriod.mediaPeriodId, adPlaybackState);
+ }
+
+ private long getMediaPeriodPositionUsWithEndOfSourceHandling(
+ MediaPeriodImpl mediaPeriod, long positionUs) {
+ if (positionUs == C.TIME_END_OF_SOURCE) {
+ return C.TIME_END_OF_SOURCE;
+ }
+ long mediaPeriodPositionUs =
+ getMediaPeriodPositionUs(positionUs, mediaPeriod.mediaPeriodId, adPlaybackState);
+ long endPositionUs = getMediaPeriodEndPositionUs(mediaPeriod, adPlaybackState);
+ return mediaPeriodPositionUs >= endPositionUs ? C.TIME_END_OF_SOURCE : mediaPeriodPositionUs;
+ }
+
+ private int findMatchingStreamIndex(MediaLoadData mediaLoadData) {
+ if (mediaLoadData.trackFormat == null) {
+ return C.INDEX_UNSET;
+ }
+ for (int i = 0; i < trackSelections.length; i++) {
+ if (trackSelections[i] != null) {
+ TrackGroup trackGroup = trackSelections[i].getTrackGroup();
+ // Muxed primary track group should be the first in the list. We need to match Formats on
+ // their id only as the muxed format and the format in the track group won't match.
+ boolean isPrimaryTrackGroup =
+ mediaLoadData.trackType == C.TRACK_TYPE_DEFAULT
+ && trackGroup.equals(getTrackGroups().get(0));
+ for (int j = 0; j < trackGroup.length; j++) {
+ Format format = trackGroup.getFormat(j);
+ if (format.equals(mediaLoadData.trackFormat)
+ || (isPrimaryTrackGroup
+ && format.id != null
+ && format.id.equals(mediaLoadData.trackFormat.id))) {
+ return i;
+ }
+ }
+ }
+ }
+ return C.INDEX_UNSET;
+ }
+
+ private void maybeNotifyDownstreamFormatChanged(MediaPeriodImpl mediaPeriod, int streamIndex) {
+ if (!mediaPeriod.hasNotifiedDownstreamFormatChange[streamIndex]
+ && lastDownstreamFormatChangeData[streamIndex] != null) {
+ mediaPeriod.hasNotifiedDownstreamFormatChange[streamIndex] = true;
+ mediaPeriod.mediaSourceEventDispatcher.downstreamFormatChanged(
+ correctMediaLoadData(
+ mediaPeriod, lastDownstreamFormatChangeData[streamIndex], adPlaybackState));
+ }
+ }
+ }
+
+ private static final class ServerSideInsertedAdsTimeline extends ForwardingTimeline {
+
+ private final AdPlaybackState adPlaybackState;
+
+ public ServerSideInsertedAdsTimeline(
+ Timeline contentTimeline, AdPlaybackState adPlaybackState) {
+ super(contentTimeline);
+ Assertions.checkState(contentTimeline.getPeriodCount() == 1);
+ Assertions.checkState(contentTimeline.getWindowCount() == 1);
+ this.adPlaybackState = adPlaybackState;
+ }
+
+ @Override
+ public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) {
+ super.getWindow(windowIndex, window, defaultPositionProjectionUs);
+ long positionInPeriodUs =
+ getMediaPeriodPositionUsForContent(
+ window.positionInFirstPeriodUs,
+ /* nextAdGroupIndex= */ C.INDEX_UNSET,
+ adPlaybackState);
+ if (window.durationUs == C.TIME_UNSET) {
+ if (adPlaybackState.contentDurationUs != C.TIME_UNSET) {
+ window.durationUs = adPlaybackState.contentDurationUs - positionInPeriodUs;
+ }
+ } else {
+ long actualWindowEndPositionInPeriodUs = window.positionInFirstPeriodUs + window.durationUs;
+ long windowEndPositionInPeriodUs =
+ getMediaPeriodPositionUsForContent(
+ actualWindowEndPositionInPeriodUs,
+ /* nextAdGroupIndex= */ C.INDEX_UNSET,
+ adPlaybackState);
+ window.durationUs = windowEndPositionInPeriodUs - positionInPeriodUs;
+ }
+ window.positionInFirstPeriodUs = positionInPeriodUs;
+ return window;
+ }
+
+ @Override
+ public Period getPeriod(int periodIndex, Period period, boolean setIds) {
+ super.getPeriod(periodIndex, period, setIds);
+ long durationUs = period.durationUs;
+ if (durationUs == C.TIME_UNSET) {
+ durationUs = adPlaybackState.contentDurationUs;
+ } else {
+ durationUs =
+ getMediaPeriodPositionUsForContent(
+ durationUs, /* nextAdGroupIndex= */ C.INDEX_UNSET, adPlaybackState);
+ }
+ long positionInWindowUs =
+ -getMediaPeriodPositionUsForContent(
+ -period.getPositionInWindowUs(),
+ /* nextAdGroupIndex= */ C.INDEX_UNSET,
+ adPlaybackState);
+ period.set(
+ period.id,
+ period.uid,
+ period.windowIndex,
+ durationUs,
+ positionInWindowUs,
+ adPlaybackState,
+ period.isPlaceholder);
+ return period;
+ }
+ }
+
+ private static final class MediaPeriodImpl implements MediaPeriod {
+
+ public final SharedMediaPeriod sharedPeriod;
+ public final MediaPeriodId mediaPeriodId;
+ public final MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher;
+ public final DrmSessionEventListener.EventDispatcher drmEventDispatcher;
+
+ public @MonotonicNonNull Callback callback;
+ public long lastStartPositionUs;
+ public boolean[] hasNotifiedDownstreamFormatChange;
+
+ public MediaPeriodImpl(
+ SharedMediaPeriod sharedPeriod,
+ MediaPeriodId mediaPeriodId,
+ MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher,
+ DrmSessionEventListener.EventDispatcher drmEventDispatcher) {
+ this.sharedPeriod = sharedPeriod;
+ this.mediaPeriodId = mediaPeriodId;
+ this.mediaSourceEventDispatcher = mediaSourceEventDispatcher;
+ this.drmEventDispatcher = drmEventDispatcher;
+ hasNotifiedDownstreamFormatChange = new boolean[0];
+ }
+
+ @Override
+ public void prepare(Callback callback, long positionUs) {
+ this.callback = callback;
+ sharedPeriod.prepare(/* mediaPeriod= */ this, positionUs);
+ }
+
+ @Override
+ public void maybeThrowPrepareError() throws IOException {
+ sharedPeriod.maybeThrowPrepareError();
+ }
+
+ @Override
+ public TrackGroupArray getTrackGroups() {
+ return sharedPeriod.getTrackGroups();
+ }
+
+ @Override
+ public List getStreamKeys(List trackSelections) {
+ return sharedPeriod.getStreamKeys(trackSelections);
+ }
+
+ @Override
+ public long selectTracks(
+ @NullableType ExoTrackSelection[] selections,
+ boolean[] mayRetainStreamFlags,
+ @NullableType SampleStream[] streams,
+ boolean[] streamResetFlags,
+ long positionUs) {
+ if (hasNotifiedDownstreamFormatChange.length == 0) {
+ hasNotifiedDownstreamFormatChange = new boolean[streams.length];
+ }
+ return sharedPeriod.selectTracks(
+ /* mediaPeriod= */ this,
+ selections,
+ mayRetainStreamFlags,
+ streams,
+ streamResetFlags,
+ positionUs);
+ }
+
+ @Override
+ public void discardBuffer(long positionUs, boolean toKeyframe) {
+ sharedPeriod.discardBuffer(/* mediaPeriod= */ this, positionUs, toKeyframe);
+ }
+
+ @Override
+ public long readDiscontinuity() {
+ return sharedPeriod.readDiscontinuity(/* mediaPeriod= */ this);
+ }
+
+ @Override
+ public long seekToUs(long positionUs) {
+ return sharedPeriod.seekToUs(/* mediaPeriod= */ this, positionUs);
+ }
+
+ @Override
+ public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) {
+ return sharedPeriod.getAdjustedSeekPositionUs(
+ /* mediaPeriod= */ this, positionUs, seekParameters);
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ return sharedPeriod.getBufferedPositionUs(/* mediaPeriod= */ this);
+ }
+
+ @Override
+ public long getNextLoadPositionUs() {
+ return sharedPeriod.getNextLoadPositionUs(/* mediaPeriod= */ this);
+ }
+
+ @Override
+ public boolean continueLoading(long positionUs) {
+ return sharedPeriod.continueLoading(/* mediaPeriod= */ this, positionUs);
+ }
+
+ @Override
+ public boolean isLoading() {
+ return sharedPeriod.isLoading(/* mediaPeriod= */ this);
+ }
+
+ @Override
+ public void reevaluateBuffer(long positionUs) {
+ sharedPeriod.reevaluateBuffer(/* mediaPeriod= */ this, positionUs);
+ }
+ }
+
+ private static final class SampleStreamImpl implements SampleStream {
+
+ private final MediaPeriodImpl mediaPeriod;
+ private final int streamIndex;
+
+ public SampleStreamImpl(MediaPeriodImpl mediaPeriod, int streamIndex) {
+ this.mediaPeriod = mediaPeriod;
+ this.streamIndex = streamIndex;
+ }
+
+ @Override
+ public boolean isReady() {
+ return mediaPeriod.sharedPeriod.isReady(streamIndex);
+ }
+
+ @Override
+ public void maybeThrowError() throws IOException {
+ mediaPeriod.sharedPeriod.maybeThrowError(streamIndex);
+ }
+
+ @Override
+ @ReadDataResult
+ public int readData(
+ FormatHolder formatHolder, DecoderInputBuffer buffer, @ReadFlags int readFlags) {
+ return mediaPeriod.sharedPeriod.readData(
+ mediaPeriod, streamIndex, formatHolder, buffer, readFlags);
+ }
+
+ @Override
+ public int skipData(long positionUs) {
+ return mediaPeriod.sharedPeriod.skipData(mediaPeriod, streamIndex, positionUs);
+ }
+ }
+}
diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdMediaSourceTest.java
new file mode 100644
index 0000000000..6fa66a93be
--- /dev/null
+++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdMediaSourceTest.java
@@ -0,0 +1,388 @@
+/*
+ * Copyright 2021 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.source.ads;
+
+import static com.google.android.exoplayer2.robolectric.RobolectricUtil.runMainLooperUntil;
+import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.playUntilPosition;
+import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled;
+import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.runUntilPlaybackState;
+import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.addAdGroupToAdPlaybackState;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.content.Context;
+import android.graphics.SurfaceTexture;
+import android.view.Surface;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.SimpleExoPlayer;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.analytics.AnalyticsListener;
+import com.google.android.exoplayer2.robolectric.PlaybackOutput;
+import com.google.android.exoplayer2.robolectric.ShadowMediaCodecConfig;
+import com.google.android.exoplayer2.source.DefaultMediaSourceFactory;
+import com.google.android.exoplayer2.testutil.CapturingRenderersFactory;
+import com.google.android.exoplayer2.testutil.DumpFileAsserts;
+import com.google.android.exoplayer2.testutil.FakeClock;
+import com.google.android.exoplayer2.testutil.FakeMediaSource;
+import com.google.android.exoplayer2.testutil.FakeTimeline;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Unit test for {@link ServerSideInsertedAdsMediaSource}. */
+// TODO(b/143232359): Remove once https://issuetracker.google.com/143232359 is resolved.
+@Config(sdk = 29)
+@RunWith(AndroidJUnit4.class)
+public final class ServerSideInsertedAdMediaSourceTest {
+
+ @Rule
+ public ShadowMediaCodecConfig mediaCodecConfig =
+ ShadowMediaCodecConfig.forAllSupportedMimeTypes();
+
+ private static final String TEST_ASSET = "asset:///media/mp4/sample.mp4";
+ private static final String TEST_ASSET_DUMP = "playbackdumps/mp4/sample.mp4.dump";
+
+ @Test
+ public void timeline_containsAdsDefinedInAdPlaybackState() throws Exception {
+ FakeTimeline wrappedTimeline =
+ new FakeTimeline(
+ new FakeTimeline.TimelineWindowDefinition(
+ /* periodCount= */ 1,
+ /* id= */ 0,
+ /* isSeekable= */ true,
+ /* isDynamic= */ true,
+ /* isLive= */ true,
+ /* isPlaceholder= */ false,
+ /* durationUs= */ 10_000_000,
+ /* defaultPositionUs= */ 3_000_000,
+ /* windowOffsetInFirstPeriodUs= */ 42_000_000L,
+ AdPlaybackState.NONE));
+ ServerSideInsertedAdsMediaSource mediaSource =
+ new ServerSideInsertedAdsMediaSource(new FakeMediaSource(wrappedTimeline));
+ // Test with one ad group before the window, and the window starting within the second ad group.
+ AdPlaybackState adPlaybackState =
+ new AdPlaybackState(
+ /* adsId= */ new Object(), /* adGroupTimesUs...= */
+ 15_000_000,
+ 41_500_000,
+ 42_200_000)
+ .withIsServerSideInserted(/* adGroupIndex= */ 0, /* isServerSideInserted= */ true)
+ .withIsServerSideInserted(/* adGroupIndex= */ 1, /* isServerSideInserted= */ true)
+ .withIsServerSideInserted(/* adGroupIndex= */ 2, /* isServerSideInserted= */ true)
+ .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
+ .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 2)
+ .withAdCount(/* adGroupIndex= */ 2, /* adCount= */ 1)
+ .withAdDurationsUs(/* adGroupIndex= */ 0, /* adDurationsUs...= */ 500_000)
+ .withAdDurationsUs(/* adGroupIndex= */ 1, /* adDurationsUs...= */ 300_000, 100_000)
+ .withAdDurationsUs(/* adGroupIndex= */ 2, /* adDurationsUs...= */ 400_000)
+ .withContentResumeOffsetUs(/* adGroupIndex= */ 0, /* contentResumeOffsetUs= */ 100_000)
+ .withContentResumeOffsetUs(/* adGroupIndex= */ 1, /* contentResumeOffsetUs= */ 400_000)
+ .withContentResumeOffsetUs(/* adGroupIndex= */ 2, /* contentResumeOffsetUs= */ 200_000);
+ AtomicReference timelineReference = new AtomicReference<>();
+
+ mediaSource.setAdPlaybackState(adPlaybackState);
+ mediaSource.prepareSource(
+ (source, timeline) -> timelineReference.set(timeline), /* mediaTransferListener= */ null);
+ runMainLooperUntil(() -> timelineReference.get() != null);
+
+ Timeline timeline = timelineReference.get();
+ assertThat(timeline.getPeriodCount()).isEqualTo(1);
+ Timeline.Period period = timeline.getPeriod(/* periodIndex= */ 0, new Timeline.Period());
+ assertThat(period.getAdGroupCount()).isEqualTo(3);
+ assertThat(period.getAdCountInAdGroup(/* adGroupIndex= */ 0)).isEqualTo(1);
+ assertThat(period.getAdCountInAdGroup(/* adGroupIndex= */ 1)).isEqualTo(2);
+ assertThat(period.getAdCountInAdGroup(/* adGroupIndex= */ 2)).isEqualTo(1);
+ assertThat(period.getAdGroupTimeUs(/* adGroupIndex= */ 0)).isEqualTo(15_000_000);
+ assertThat(period.getAdGroupTimeUs(/* adGroupIndex= */ 1)).isEqualTo(41_500_000);
+ assertThat(period.getAdGroupTimeUs(/* adGroupIndex= */ 2)).isEqualTo(42_200_000);
+ assertThat(period.getAdDurationUs(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0))
+ .isEqualTo(500_000);
+ assertThat(period.getAdDurationUs(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0))
+ .isEqualTo(300_000);
+ assertThat(period.getAdDurationUs(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 1))
+ .isEqualTo(100_000);
+ assertThat(period.getAdDurationUs(/* adGroupIndex= */ 2, /* adIndexInAdGroup= */ 0))
+ .isEqualTo(400_000);
+ assertThat(period.getContentResumeOffsetUs(/* adGroupIndex= */ 0)).isEqualTo(100_000);
+ assertThat(period.getContentResumeOffsetUs(/* adGroupIndex= */ 1)).isEqualTo(400_000);
+ assertThat(period.getContentResumeOffsetUs(/* adGroupIndex= */ 2)).isEqualTo(200_000);
+ // windowDurationUs + windowOffsetInFirstPeriodUs - sum(adDurations) + sum(contentResumeOffsets)
+ assertThat(period.getDurationUs()).isEqualTo(51_400_000);
+ // positionInWindowUs + sum(adDurationsBeforeWindow) - sum(contentResumeOffsetsBeforeWindow)
+ assertThat(period.getPositionInWindowUs()).isEqualTo(-41_600_000);
+ Timeline.Window window = timeline.getWindow(/* windowIndex= */ 0, new Timeline.Window());
+ assertThat(window.positionInFirstPeriodUs).isEqualTo(41_600_000);
+ // windowDurationUs - sum(adDurationsInWindow) + sum(applicableContentResumeOffsetUs)
+ assertThat(window.durationUs).isEqualTo(9_800_000);
+ }
+
+ @Test
+ public void playbackWithPredefinedAds_playsSuccessfulWithoutRendererResets() throws Exception {
+ Context context = ApplicationProvider.getApplicationContext();
+ CapturingRenderersFactory renderersFactory = new CapturingRenderersFactory(context);
+ SimpleExoPlayer player =
+ new SimpleExoPlayer.Builder(context, renderersFactory)
+ .setClock(new FakeClock(/* isAutoAdvancing= */ true))
+ .build();
+ player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1)));
+ PlaybackOutput playbackOutput = PlaybackOutput.register(player, renderersFactory);
+
+ ServerSideInsertedAdsMediaSource mediaSource =
+ new ServerSideInsertedAdsMediaSource(
+ new DefaultMediaSourceFactory(context)
+ .createMediaSource(MediaItem.fromUri(TEST_ASSET)));
+ AdPlaybackState adPlaybackState = new AdPlaybackState(/* adsId= */ new Object());
+ adPlaybackState =
+ addAdGroupToAdPlaybackState(
+ adPlaybackState,
+ /* fromPositionUs= */ 0,
+ /* toPositionUs= */ 200_000,
+ /* contentResumeOffsetUs= */ 0);
+ adPlaybackState =
+ addAdGroupToAdPlaybackState(
+ adPlaybackState,
+ /* fromPositionUs= */ 400_000,
+ /* toPositionUs= */ 700_000,
+ /* contentResumeOffsetUs= */ 1_000_000);
+ adPlaybackState =
+ addAdGroupToAdPlaybackState(
+ adPlaybackState,
+ /* fromPositionUs= */ 900_000,
+ /* toPositionUs= */ 1_000_000,
+ /* contentResumeOffsetUs= */ 0);
+ mediaSource.setAdPlaybackState(adPlaybackState);
+
+ AnalyticsListener listener = mock(AnalyticsListener.class);
+ player.addAnalyticsListener(listener);
+ player.setMediaSource(mediaSource);
+ player.prepare();
+ player.play();
+ runUntilPlaybackState(player, Player.STATE_ENDED);
+ player.release();
+
+ // Assert all samples have been played.
+ DumpFileAsserts.assertOutput(context, playbackOutput, TEST_ASSET_DUMP);
+ // Assert playback has been reported with ads: [ad0][content][ad1][content][ad2][content]
+ // 6*2(audio+video) format changes, 5 discontinuities between parts.
+ verify(listener, times(5))
+ .onPositionDiscontinuity(
+ any(), any(), any(), eq(Player.DISCONTINUITY_REASON_AUTO_TRANSITION));
+ verify(listener, times(12)).onDownstreamFormatChanged(any(), any());
+ // Assert renderers played through without reset (=decoders have been enabled only once).
+ verify(listener).onVideoEnabled(any(), any());
+ verify(listener).onAudioEnabled(any(), any());
+ // Assert playback progression was smooth (=no unexpected delays that cause audio to underrun)
+ verify(listener, never()).onAudioUnderrun(any(), anyInt(), anyLong(), anyLong());
+ }
+
+ @Test
+ public void playbackWithNewlyInsertedAds_playsSuccessfulWithoutRendererResets() throws Exception {
+ Context context = ApplicationProvider.getApplicationContext();
+ CapturingRenderersFactory renderersFactory = new CapturingRenderersFactory(context);
+ SimpleExoPlayer player =
+ new SimpleExoPlayer.Builder(context, renderersFactory)
+ .setClock(new FakeClock(/* isAutoAdvancing= */ true))
+ .build();
+ player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1)));
+ PlaybackOutput playbackOutput = PlaybackOutput.register(player, renderersFactory);
+
+ ServerSideInsertedAdsMediaSource mediaSource =
+ new ServerSideInsertedAdsMediaSource(
+ new DefaultMediaSourceFactory(context)
+ .createMediaSource(MediaItem.fromUri(TEST_ASSET)));
+ AdPlaybackState adPlaybackState = new AdPlaybackState(/* adsId= */ new Object());
+ adPlaybackState =
+ addAdGroupToAdPlaybackState(
+ adPlaybackState,
+ /* fromPositionUs= */ 900_000,
+ /* toPositionUs= */ 1_000_000,
+ /* contentResumeOffsetUs= */ 0);
+ mediaSource.setAdPlaybackState(adPlaybackState);
+
+ AnalyticsListener listener = mock(AnalyticsListener.class);
+ player.addAnalyticsListener(listener);
+ player.setMediaSource(mediaSource);
+ player.prepare();
+
+ // Add ad at the current playback position during playback.
+ runUntilPlaybackState(player, Player.STATE_READY);
+ adPlaybackState =
+ addAdGroupToAdPlaybackState(
+ adPlaybackState,
+ /* fromPositionUs= */ 0,
+ /* toPositionUs= */ 500_000,
+ /* contentResumeOffsetUs= */ 0);
+ mediaSource.setAdPlaybackState(adPlaybackState);
+ runUntilPendingCommandsAreFullyHandled(player);
+
+ player.play();
+ runUntilPlaybackState(player, Player.STATE_ENDED);
+ player.release();
+
+ // Assert all samples have been played.
+ DumpFileAsserts.assertOutput(context, playbackOutput, TEST_ASSET_DUMP);
+ // Assert playback has been reported with ads: [content][ad0][content][ad1][content]
+ // 5*2(audio+video) format changes, 4 discontinuities between parts.
+ verify(listener, times(4))
+ .onPositionDiscontinuity(
+ any(), any(), any(), eq(Player.DISCONTINUITY_REASON_AUTO_TRANSITION));
+ verify(listener, times(10)).onDownstreamFormatChanged(any(), any());
+ // Assert renderers played through without reset (=decoders have been enabled only once).
+ verify(listener).onVideoEnabled(any(), any());
+ verify(listener).onAudioEnabled(any(), any());
+ // Assert playback progression was smooth (=no unexpected delays that cause audio to underrun)
+ verify(listener, never()).onAudioUnderrun(any(), anyInt(), anyLong(), anyLong());
+ }
+
+ @Test
+ public void playbackWithAdditionalAdsInAdGroup_playsSuccessfulWithoutRendererResets()
+ throws Exception {
+ Context context = ApplicationProvider.getApplicationContext();
+ CapturingRenderersFactory renderersFactory = new CapturingRenderersFactory(context);
+ SimpleExoPlayer player =
+ new SimpleExoPlayer.Builder(context, renderersFactory)
+ .setClock(new FakeClock(/* isAutoAdvancing= */ true))
+ .build();
+ player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1)));
+ PlaybackOutput playbackOutput = PlaybackOutput.register(player, renderersFactory);
+
+ ServerSideInsertedAdsMediaSource mediaSource =
+ new ServerSideInsertedAdsMediaSource(
+ new DefaultMediaSourceFactory(context)
+ .createMediaSource(MediaItem.fromUri(TEST_ASSET)));
+ AdPlaybackState adPlaybackState = new AdPlaybackState(/* adsId= */ new Object());
+ adPlaybackState =
+ addAdGroupToAdPlaybackState(
+ adPlaybackState,
+ /* fromPositionUs= */ 0,
+ /* toPositionUs= */ 500_000,
+ /* contentResumeOffsetUs= */ 0);
+ mediaSource.setAdPlaybackState(adPlaybackState);
+
+ AnalyticsListener listener = mock(AnalyticsListener.class);
+ player.addAnalyticsListener(listener);
+ player.setMediaSource(mediaSource);
+ player.prepare();
+
+ // Wait until playback is ready with first ad and then replace by 3 ads.
+ runUntilPlaybackState(player, Player.STATE_READY);
+ adPlaybackState =
+ adPlaybackState
+ .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 3)
+ .withAdDurationsUs(
+ /* adGroupIndex= */ 0, /* adDurationsUs...= */ 50_000, 250_000, 200_000);
+ mediaSource.setAdPlaybackState(adPlaybackState);
+ runUntilPendingCommandsAreFullyHandled(player);
+
+ player.play();
+ runUntilPlaybackState(player, Player.STATE_ENDED);
+ player.release();
+
+ // Assert all samples have been played.
+ DumpFileAsserts.assertOutput(context, playbackOutput, TEST_ASSET_DUMP);
+ // Assert playback has been reported with ads: [ad0][ad1][ad2][content]
+ // 4*2(audio+video) format changes, 3 discontinuities between parts.
+ verify(listener, times(3))
+ .onPositionDiscontinuity(
+ any(), any(), any(), eq(Player.DISCONTINUITY_REASON_AUTO_TRANSITION));
+ verify(listener, times(8)).onDownstreamFormatChanged(any(), any());
+ // Assert renderers played through without reset (=decoders have been enabled only once).
+ verify(listener).onVideoEnabled(any(), any());
+ verify(listener).onAudioEnabled(any(), any());
+ // Assert playback progression was smooth (=no unexpected delays that cause audio to underrun)
+ verify(listener, never()).onAudioUnderrun(any(), anyInt(), anyLong(), anyLong());
+ }
+
+ @Test
+ public void playbackWithSeek_isHandledCorrectly() throws Exception {
+ Context context = ApplicationProvider.getApplicationContext();
+ SimpleExoPlayer player =
+ new SimpleExoPlayer.Builder(context)
+ .setClock(new FakeClock(/* isAutoAdvancing= */ true))
+ .build();
+ player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1)));
+
+ ServerSideInsertedAdsMediaSource mediaSource =
+ new ServerSideInsertedAdsMediaSource(
+ new DefaultMediaSourceFactory(context)
+ .createMediaSource(MediaItem.fromUri(TEST_ASSET)));
+ AdPlaybackState adPlaybackState = new AdPlaybackState(/* adsId= */ new Object());
+ adPlaybackState =
+ addAdGroupToAdPlaybackState(
+ adPlaybackState,
+ /* fromPositionUs= */ 0,
+ /* toPositionUs= */ 100_000,
+ /* contentResumeOffsetUs= */ 0);
+ adPlaybackState =
+ addAdGroupToAdPlaybackState(
+ adPlaybackState,
+ /* fromPositionUs= */ 600_000,
+ /* toPositionUs= */ 700_000,
+ /* contentResumeOffsetUs= */ 1_000_000);
+ adPlaybackState =
+ addAdGroupToAdPlaybackState(
+ adPlaybackState,
+ /* fromPositionUs= */ 900_000,
+ /* toPositionUs= */ 1_000_000,
+ /* contentResumeOffsetUs= */ 0);
+ mediaSource.setAdPlaybackState(adPlaybackState);
+
+ AnalyticsListener listener = mock(AnalyticsListener.class);
+ player.addAnalyticsListener(listener);
+ player.setMediaSource(mediaSource);
+ player.prepare();
+ // Play to the first content part, then seek past the midroll.
+ playUntilPosition(player, /* windowIndex= */ 0, /* positionMs= */ 100);
+ player.seekTo(/* positionMs= */ 1_600);
+ runUntilPendingCommandsAreFullyHandled(player);
+ long positionAfterSeekMs = player.getCurrentPosition();
+ long contentPositionAfterSeekMs = player.getContentPosition();
+ player.play();
+ runUntilPlaybackState(player, Player.STATE_ENDED);
+ player.release();
+
+ // Assert playback has been reported with ads: [ad0][content] seek [ad1][content][ad2][content]
+ // 6*2(audio+video) format changes, 4 auto-transitions between parts, 1 seek with adjustment.
+ verify(listener, times(4))
+ .onPositionDiscontinuity(
+ any(), any(), any(), eq(Player.DISCONTINUITY_REASON_AUTO_TRANSITION));
+ verify(listener, times(1))
+ .onPositionDiscontinuity(any(), any(), any(), eq(Player.DISCONTINUITY_REASON_SEEK));
+ verify(listener, times(1))
+ .onPositionDiscontinuity(
+ any(), any(), any(), eq(Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT));
+ verify(listener, times(12)).onDownstreamFormatChanged(any(), any());
+ assertThat(contentPositionAfterSeekMs).isEqualTo(1_600);
+ assertThat(positionAfterSeekMs).isEqualTo(0); // Beginning of second ad.
+ // Assert renderers played through without reset, except for the seek.
+ verify(listener, times(2)).onVideoEnabled(any(), any());
+ verify(listener, times(2)).onAudioEnabled(any(), any());
+ // Assert playback progression was smooth (=no unexpected delays that cause audio to underrun)
+ verify(listener, never()).onAudioUnderrun(any(), anyInt(), anyLong(), anyLong());
+ }
+}