From 764e5e814199fcb6fff179c5081dbe25dbf847bf Mon Sep 17 00:00:00 2001
From: andrewlewis <andrewlewis@google.com>
Date: Fri, 6 Nov 2020 11:38:22 +0000
Subject: [PATCH] Expose the ads identifier in the Timeline period

Issue: #3750
PiperOrigin-RevId: 341021084
---
 .../exoplayer2/ext/ima/AdTagLoader.java       | 22 ++---
 .../exoplayer2/ext/ima/ImaAdsLoader.java      | 13 ++-
 .../android/exoplayer2/ext/ima/ImaUtil.java   | 14 ++--
 .../exoplayer2/ext/ima/ImaAdsLoaderTest.java  | 41 ++++-----
 .../google/android/exoplayer2/Timeline.java   | 10 ++-
 .../source/ads/AdPlaybackState.java           | 83 +++++++++++++------
 .../android/exoplayer2/ExoPlayerTest.java     |  6 +-
 .../exoplayer2/MediaPeriodQueueTest.java      |  6 +-
 .../DefaultPlaybackSessionManagerTest.java    | 21 +++--
 .../source/ads/AdPlaybackStateTest.java       |  3 +-
 .../source/ads/AdsMediaSourceTest.java        |  2 +-
 .../exoplayer2/testutil/FakeTimeline.java     |  3 +-
 12 files changed, 143 insertions(+), 81 deletions(-)

diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java
index 0b10cce3b1b..ac939c56082 100644
--- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java
+++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java
@@ -128,6 +128,7 @@
   private final ImaUtil.ImaFactory imaFactory;
   private final List<String> supportedMimeTypes;
   private final DataSpec adTagDataSpec;
+  private final Object adsId;
   private final Timeline.Period period;
   private final Handler handler;
   private final ComponentListener componentListener;
@@ -146,7 +147,6 @@
 
   @Nullable private AdsManager adsManager;
   private boolean isAdsManagerInitialized;
-  private boolean hasAdPlaybackState;
   @Nullable private AdLoadException pendingAdLoadError;
   private Timeline timeline;
   private long contentDurationMs;
@@ -214,6 +214,7 @@ public AdTagLoader(
       ImaUtil.ImaFactory imaFactory,
       List<String> supportedMimeTypes,
       DataSpec adTagDataSpec,
+      Object adsId,
       @Nullable ViewGroup adViewGroup) {
     this.configuration = configuration;
     this.imaFactory = imaFactory;
@@ -228,6 +229,7 @@ public AdTagLoader(
     imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION);
     this.supportedMimeTypes = supportedMimeTypes;
     this.adTagDataSpec = adTagDataSpec;
+    this.adsId = adsId;
     period = new Timeline.Period();
     handler = Util.createHandler(getImaLooper(), /* callback= */ null);
     componentListener = new ComponentListener();
@@ -286,14 +288,16 @@ public void start(Player player, AdViewProvider adViewProvider, EventListener ev
     lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
     lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
     maybeNotifyPendingAdLoadError();
-    if (hasAdPlaybackState) {
+    if (!AdPlaybackState.NONE.equals(adPlaybackState)) {
       // Pass the ad playback state to the player, and resume ads if necessary.
       eventListener.onAdPlaybackState(adPlaybackState);
       if (adsManager != null && imaPausedContent && playWhenReady) {
         adsManager.resume();
       }
     } else if (adsManager != null) {
-      adPlaybackState = ImaUtil.getInitialAdPlaybackStateForCuePoints(adsManager.getAdCuePoints());
+      adPlaybackState =
+          new AdPlaybackState(
+              adsId, ImaUtil.getAdGroupTimesUsForCuePoints(adsManager.getAdCuePoints()));
       updateAdPlaybackState();
     }
     if (adDisplayContainer != null) {
@@ -348,8 +352,7 @@ public void release() {
     stopUpdatingAdProgress();
     imaAdInfo = null;
     pendingAdLoadError = null;
-    adPlaybackState = AdPlaybackState.NONE;
-    hasAdPlaybackState = true;
+    adPlaybackState = new AdPlaybackState(adsId);
     updateAdPlaybackState();
   }
 
@@ -496,7 +499,7 @@ private AdsLoader requestAds(
     try {
       request = ImaUtil.getAdsRequestForAdTagDataSpec(imaFactory, adTagDataSpec);
     } catch (IOException e) {
-      hasAdPlaybackState = true;
+      adPlaybackState = new AdPlaybackState(adsId);
       updateAdPlaybackState();
       pendingAdLoadError = AdLoadException.createForAllAds(e);
       maybeNotifyPendingAdLoadError();
@@ -1215,8 +1218,8 @@ public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) {
         // If a player is attached already, start playback immediately.
         try {
           adPlaybackState =
-              ImaUtil.getInitialAdPlaybackStateForCuePoints(adsManager.getAdCuePoints());
-          hasAdPlaybackState = true;
+              new AdPlaybackState(
+                  adsId, ImaUtil.getAdGroupTimesUsForCuePoints(adsManager.getAdCuePoints()));
           updateAdPlaybackState();
         } catch (RuntimeException e) {
           maybeNotifyInternalError("onAdsManagerLoaded", e);
@@ -1276,8 +1279,7 @@ public void onAdError(AdErrorEvent adErrorEvent) {
       if (adsManager == null) {
         // No ads were loaded, so allow playback to start without any ads.
         pendingAdRequestContext = null;
-        adPlaybackState = AdPlaybackState.NONE;
-        hasAdPlaybackState = true;
+        adPlaybackState = new AdPlaybackState(adsId);
         updateAdPlaybackState();
       } else if (ImaUtil.isAdGroupLoadError(error)) {
         try {
diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java
index ccffd1aca7d..a60b7147bce 100644
--- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java
+++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java
@@ -417,14 +417,21 @@ public AdDisplayContainer getAdDisplayContainer() {
    *
    * @param adTagDataSpec The data specification of the ad tag to load. See class javadoc for
    *     information about compatible ad tag formats.
+   * @param adsId A opaque identifier for the ad playback state across start/stop calls.
    * @param adViewGroup A {@link ViewGroup} on top of the player that will show any ad UI, or {@code
    *     null} if playing audio-only ads.
    */
-  public void requestAds(DataSpec adTagDataSpec, @Nullable ViewGroup adViewGroup) {
+  public void requestAds(DataSpec adTagDataSpec, Object adsId, @Nullable ViewGroup adViewGroup) {
     if (adTagLoader == null) {
       adTagLoader =
           new AdTagLoader(
-              context, configuration, imaFactory, supportedMimeTypes, adTagDataSpec, adViewGroup);
+              context,
+              configuration,
+              imaFactory,
+              supportedMimeTypes,
+              adTagDataSpec,
+              adsId,
+              adViewGroup);
     }
   }
 
@@ -488,7 +495,7 @@ public void start(
       return;
     }
     if (adTagLoader == null) {
-      requestAds(adTagDataSpec, adViewProvider.getAdViewGroup());
+      requestAds(adTagDataSpec, adsId, adViewProvider.getAdViewGroup());
     }
     checkNotNull(adTagLoader).start(player, adViewProvider, eventListener);
   }
diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java
index ae12819e841..ed3d3c74e13 100644
--- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java
+++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java
@@ -36,7 +36,6 @@
 import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer;
 import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate;
 import com.google.android.exoplayer2.C;
-import com.google.android.exoplayer2.source.ads.AdPlaybackState;
 import com.google.android.exoplayer2.source.ads.AdsLoader.OverlayInfo;
 import com.google.android.exoplayer2.upstream.DataSchemeDataSource;
 import com.google.android.exoplayer2.upstream.DataSpec;
@@ -154,15 +153,14 @@ public static FriendlyObstructionPurpose getFriendlyObstructionPurpose(
   }
 
   /**
-   * Returns an initial {@link AdPlaybackState} with ad groups at the provided {@code cuePoints}.
+   * Returns the microsecond ad group timestamps corresponding to the specified cue points.
    *
-   * @param cuePoints The cue points of the ads in seconds.
-   * @return The {@link AdPlaybackState}.
+   * @param cuePoints The cue points of the ads in seconds, provided by the IMA SDK.
+   * @return The corresponding microsecond ad group timestamps.
    */
-  public static AdPlaybackState getInitialAdPlaybackStateForCuePoints(List<Float> cuePoints) {
+  public static long[] getAdGroupTimesUsForCuePoints(List<Float> cuePoints) {
     if (cuePoints.isEmpty()) {
-      // If no cue points are specified, there is a preroll ad.
-      return new AdPlaybackState(/* adGroupTimesUs...= */ 0);
+      return new long[] {0L};
     }
 
     int count = cuePoints.size();
@@ -178,7 +176,7 @@ public static AdPlaybackState getInitialAdPlaybackStateForCuePoints(List<Float>
     }
     // Cue points may be out of order, so sort them.
     Arrays.sort(adGroupTimesUs, 0, adGroupIndex);
-    return new AdPlaybackState(adGroupTimesUs);
+    return adGroupTimesUs;
   }
 
   /** Returns an {@link AdsRequest} based on the specified ad tag {@link DataSpec}. */
diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java
index 59c9718c6ea..c8110ea4e4e 100644
--- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java
+++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java
@@ -16,6 +16,7 @@
 package com.google.android.exoplayer2.ext.ima;
 
 import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static com.google.android.exoplayer2.ext.ima.ImaUtil.getAdGroupTimesUsForCuePoints;
 import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyDouble;
@@ -226,7 +227,7 @@ public void start_updatesAdPlaybackState() {
 
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            new AdPlaybackState(/* adGroupTimesUs...= */ 0)
+            new AdPlaybackState(TEST_ADS_ID, /* adGroupTimesUs...= */ 0)
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US));
   }
 
@@ -242,7 +243,7 @@ public void startAfterRelease() {
   public void startAndCallbacksAfterRelease() {
     setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS);
     // Request ads in order to get a reference to the ad event listener.
-    imaAdsLoader.requestAds(TEST_DATA_SPEC, adViewGroup);
+    imaAdsLoader.requestAds(TEST_DATA_SPEC, TEST_ADS_ID, adViewGroup);
     imaAdsLoader.release();
     imaAdsLoader.start(
         adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener);
@@ -253,7 +254,7 @@ public void startAndCallbacksAfterRelease() {
     // Note: we can't currently call getContentProgress/getAdProgress as a VerifyError is thrown
     // when using Robolectric and accessing VideoProgressUpdate.VIDEO_TIME_NOT_READY, due to the IMA
     // SDK being proguarded.
-    imaAdsLoader.requestAds(TEST_DATA_SPEC, adViewGroup);
+    imaAdsLoader.requestAds(TEST_DATA_SPEC, TEST_ADS_ID, adViewGroup);
     adEventListener.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd));
     videoAdPlayer.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo);
     adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd));
@@ -300,7 +301,7 @@ public void playback_withPrerollAd_marksAdAsPlayed() {
     // Verify that the preroll ad has been marked as played.
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            new AdPlaybackState(/* adGroupTimesUs...= */ 0)
+            new AdPlaybackState(TEST_ADS_ID, /* adGroupTimesUs...= */ 0)
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
                 .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
                 .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, TEST_URI)
@@ -324,7 +325,7 @@ public void playback_withMidrollFetchError_marksAdAsInErrorState() {
 
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            new AdPlaybackState(/* adGroupTimesUs...= */ 20_500_000)
+            new AdPlaybackState(TEST_ADS_ID, /* adGroupTimesUs...= */ 20_500_000)
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
                 .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}})
                 .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
@@ -372,7 +373,7 @@ public void playback_withPostrollFetchError_marksAdAsInErrorState() {
 
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            new AdPlaybackState(/* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE)
+            new AdPlaybackState(TEST_ADS_ID, /* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE)
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
                 .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}})
                 .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
@@ -400,7 +401,7 @@ public void playback_withAdNotPreloadingBeforeTimeout_hasNoError() {
 
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints)
+            new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints))
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US));
   }
 
@@ -425,7 +426,7 @@ public void playback_withAdNotPreloadingAfterTimeout_hasErrorAdGroup() {
 
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints)
+            new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints))
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
                 .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}})
                 .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
@@ -448,7 +449,7 @@ public void resumePlaybackBeforeMidroll_playsPreroll() {
     verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble());
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints)
+            new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints))
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US));
   }
 
@@ -473,7 +474,7 @@ public void resumePlaybackAtMidroll_skipsPreroll() {
         .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND);
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints)
+            new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints))
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
                 .withSkippedAdGroup(/* adGroupIndex= */ 0));
   }
@@ -499,7 +500,7 @@ public void resumePlaybackAfterMidroll_skipsPreroll() {
         .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND);
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints)
+            new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints))
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
                 .withSkippedAdGroup(/* adGroupIndex= */ 0));
   }
@@ -527,7 +528,7 @@ public void resumePlaybackBeforeSecondMidroll_playsFirstMidroll() {
     verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble());
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints)
+            new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints))
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US));
   }
 
@@ -559,7 +560,7 @@ public void resumePlaybackAtSecondMidroll_skipsFirstMidroll() {
         .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND);
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints)
+            new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints))
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
                 .withSkippedAdGroup(/* adGroupIndex= */ 0));
   }
@@ -594,7 +595,7 @@ public void resumePlaybackBeforeMidroll_withoutPlayAdBeforeStartPosition_skipsPr
         .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND);
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints)
+            new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints))
                 .withSkippedAdGroup(/* adGroupIndex= */ 0)
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US));
   }
@@ -629,7 +630,7 @@ public void resumePlaybackAtMidroll_withoutPlayAdBeforeStartPosition_skipsPrerol
         .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND);
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints)
+            new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints))
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
                 .withSkippedAdGroup(/* adGroupIndex= */ 0));
   }
@@ -659,7 +660,7 @@ public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMid
     verify(mockAdsManager).destroy();
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints)
+            new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints))
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
                 .withSkippedAdGroup(/* adGroupIndex= */ 0)
                 .withSkippedAdGroup(/* adGroupIndex= */ 1));
@@ -703,7 +704,7 @@ public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMid
         .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND);
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints)
+            new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints))
                 .withSkippedAdGroup(/* adGroupIndex= */ 0)
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US));
   }
@@ -745,7 +746,7 @@ public void resumePlaybackAtSecondMidroll_withoutPlayAdBeforeStartPosition_skips
         .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND);
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints)
+            new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints))
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
                 .withSkippedAdGroup(/* adGroupIndex= */ 0));
   }
@@ -835,7 +836,7 @@ public void stop_unregistersAllVideoControlOverlays() {
     setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS);
     imaAdsLoader.start(
         adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener);
-    imaAdsLoader.requestAds(TEST_DATA_SPEC, adViewGroup);
+    imaAdsLoader.requestAds(TEST_DATA_SPEC, TEST_ADS_ID, adViewGroup);
     imaAdsLoader.stop(adsMediaSource);
 
     InOrder inOrder = inOrder(mockAdDisplayContainer);
@@ -887,7 +888,7 @@ public double getTimeOffset() {
 
     assertThat(adsLoaderListener.adPlaybackState)
         .isEqualTo(
-            ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints)
+            new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints))
                 .withContentDurationUs(CONTENT_PERIOD_DURATION_US)
                 .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
                 .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, TEST_URI)
diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java
index e992eb588d9..086ff817ea9 100644
--- a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java
+++ b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java
@@ -519,9 +519,13 @@ public long getPositionInWindowUs() {
       return positionInWindowUs;
     }
 
-    /**
-     * Returns the number of ad groups in the period.
-     */
+    /** Returns the opaque identifier for ads played with this period, or {@code null} if unset. */
+    @Nullable
+    public Object getAdsId() {
+      return adPlaybackState.adsId;
+    }
+
+    /** Returns the number of ad groups in the period. */
     public int getAdGroupCount() {
       return adPlaybackState.adGroupCount;
     }
diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java
index 9493746669c..a50fcd7d1dc 100644
--- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java
+++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java
@@ -258,7 +258,18 @@ private static long[] copyDurationsUsWithSpaceForAdCount(long[] durationsUs, int
   public static final int AD_STATE_ERROR = 4;
 
   /** Ad playback state with no ads. */
-  public static final AdPlaybackState NONE = new AdPlaybackState();
+  public static final AdPlaybackState NONE =
+      new AdPlaybackState(
+          /* adsId= */ null,
+          /* adGroupTimesUs= */ new long[0],
+          /* adGroups= */ null,
+          /* adResumePositionUs= */ 0L,
+          /* contentDurationUs= */ C.TIME_UNSET);
+
+  /**
+   * The opaque identifier for ads with which this instance is associated, or {@code null} if unset.
+   */
+  @Nullable public final Object adsId;
 
   /** The number of ad groups. */
   public final int adGroupCount;
@@ -280,29 +291,38 @@ private static long[] copyDurationsUsWithSpaceForAdCount(long[] durationsUs, int
   /**
    * Creates a new ad playback state with the specified ad group times.
    *
+   * @param adsId The opaque identifier for ads with which this instance is associated.
    * @param adGroupTimesUs The times of ad groups in microseconds, relative to the start of the
    *     {@link com.google.android.exoplayer2.Timeline.Period} they belong to. A final element with
    *     the value {@link C#TIME_END_OF_SOURCE} indicates that there is a postroll ad.
    */
-  public AdPlaybackState(long... adGroupTimesUs) {
-    int count = adGroupTimesUs.length;
-    adGroupCount = count;
-    this.adGroupTimesUs = Arrays.copyOf(adGroupTimesUs, count);
-    this.adGroups = new AdGroup[count];
-    for (int i = 0; i < count; i++) {
-      adGroups[i] = new AdGroup();
-    }
-    adResumePositionUs = 0;
-    contentDurationUs = C.TIME_UNSET;
+  public AdPlaybackState(Object adsId, long... adGroupTimesUs) {
+    this(
+        adsId,
+        adGroupTimesUs,
+        /* adGroups= */ null,
+        /* adResumePositionUs= */ 0,
+        /* contentDurationUs= */ C.TIME_UNSET);
   }
 
   private AdPlaybackState(
-      long[] adGroupTimesUs, AdGroup[] adGroups, long adResumePositionUs, long contentDurationUs) {
-    adGroupCount = adGroups.length;
+      @Nullable Object adsId,
+      long[] adGroupTimesUs,
+      @Nullable AdGroup[] adGroups,
+      long adResumePositionUs,
+      long contentDurationUs) {
+    this.adsId = adsId;
     this.adGroupTimesUs = adGroupTimesUs;
-    this.adGroups = adGroups;
     this.adResumePositionUs = adResumePositionUs;
     this.contentDurationUs = contentDurationUs;
+    adGroupCount = adGroupTimesUs.length;
+    if (adGroups == null) {
+      adGroups = new AdGroup[adGroupCount];
+      for (int i = 0; i < adGroupCount; i++) {
+        adGroups[i] = new AdGroup();
+      }
+    }
+    this.adGroups = adGroups;
   }
 
   /**
@@ -378,7 +398,8 @@ public AdPlaybackState withAdCount(int adGroupIndex, int adCount) {
     }
     AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length);
     adGroups[adGroupIndex] = this.adGroups[adGroupIndex].withAdCount(adCount);
-    return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
+    return new AdPlaybackState(
+        adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
   }
 
   /** Returns an instance with the specified ad URI. */
@@ -386,7 +407,8 @@ public AdPlaybackState withAdCount(int adGroupIndex, int adCount) {
   public AdPlaybackState withAdUri(int adGroupIndex, int adIndexInAdGroup, Uri uri) {
     AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length);
     adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdUri(uri, adIndexInAdGroup);
-    return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
+    return new AdPlaybackState(
+        adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
   }
 
   /** Returns an instance with the specified ad marked as played. */
@@ -394,7 +416,8 @@ public AdPlaybackState withAdUri(int adGroupIndex, int adIndexInAdGroup, Uri uri
   public AdPlaybackState withPlayedAd(int adGroupIndex, int adIndexInAdGroup) {
     AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length);
     adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdState(AD_STATE_PLAYED, adIndexInAdGroup);
-    return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
+    return new AdPlaybackState(
+        adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
   }
 
   /** Returns an instance with the specified ad marked as skipped. */
@@ -402,7 +425,8 @@ public AdPlaybackState withPlayedAd(int adGroupIndex, int adIndexInAdGroup) {
   public AdPlaybackState withSkippedAd(int adGroupIndex, int adIndexInAdGroup) {
     AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length);
     adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdState(AD_STATE_SKIPPED, adIndexInAdGroup);
-    return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
+    return new AdPlaybackState(
+        adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
   }
 
   /** Returns an instance with the specified ad marked as having a load error. */
@@ -410,7 +434,8 @@ public AdPlaybackState withSkippedAd(int adGroupIndex, int adIndexInAdGroup) {
   public AdPlaybackState withAdLoadError(int adGroupIndex, int adIndexInAdGroup) {
     AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length);
     adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdState(AD_STATE_ERROR, adIndexInAdGroup);
-    return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
+    return new AdPlaybackState(
+        adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
   }
 
   /**
@@ -421,7 +446,8 @@ public AdPlaybackState withAdLoadError(int adGroupIndex, int adIndexInAdGroup) {
   public AdPlaybackState withSkippedAdGroup(int adGroupIndex) {
     AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length);
     adGroups[adGroupIndex] = adGroups[adGroupIndex].withAllAdsSkipped();
-    return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
+    return new AdPlaybackState(
+        adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
   }
 
   /** Returns an instance with the specified ad durations, in microseconds. */
@@ -431,7 +457,8 @@ public AdPlaybackState withAdDurationsUs(long[][] adDurationUs) {
     for (int adGroupIndex = 0; adGroupIndex < adGroupCount; adGroupIndex++) {
       adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdDurationsUs(adDurationUs[adGroupIndex]);
     }
-    return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
+    return new AdPlaybackState(
+        adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
   }
 
   /**
@@ -443,7 +470,8 @@ public AdPlaybackState withAdResumePositionUs(long adResumePositionUs) {
     if (this.adResumePositionUs == adResumePositionUs) {
       return this;
     } else {
-      return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
+      return new AdPlaybackState(
+          adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
     }
   }
 
@@ -453,7 +481,8 @@ public AdPlaybackState withContentDurationUs(long contentDurationUs) {
     if (this.contentDurationUs == contentDurationUs) {
       return this;
     } else {
-      return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
+      return new AdPlaybackState(
+          adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs);
     }
   }
 
@@ -466,7 +495,8 @@ public boolean equals(@Nullable Object o) {
       return false;
     }
     AdPlaybackState that = (AdPlaybackState) o;
-    return adGroupCount == that.adGroupCount
+    return Util.areEqual(adsId, that.adsId)
+        && adGroupCount == that.adGroupCount
         && adResumePositionUs == that.adResumePositionUs
         && contentDurationUs == that.contentDurationUs
         && Arrays.equals(adGroupTimesUs, that.adGroupTimesUs)
@@ -476,6 +506,7 @@ public boolean equals(@Nullable Object o) {
   @Override
   public int hashCode() {
     int result = adGroupCount;
+    result = 31 * result + (adsId == null ? 0 : adsId.hashCode());
     result = 31 * result + (int) adResumePositionUs;
     result = 31 * result + (int) contentDurationUs;
     result = 31 * result + Arrays.hashCode(adGroupTimesUs);
@@ -486,7 +517,9 @@ public int hashCode() {
   @Override
   public String toString() {
     StringBuilder sb = new StringBuilder();
-    sb.append("AdPlaybackState(adResumePositionUs=");
+    sb.append("AdPlaybackState(adsId=");
+    sb.append(adsId);
+    sb.append(", adResumePositionUs=");
     sb.append(adResumePositionUs);
     sb.append(", adGroups=[");
     for (int i = 0; i < adGroups.length; i++) {
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 d5ebe951c30..e4f17b351d1 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
@@ -4349,7 +4349,8 @@ protected FakeMediaPeriod createFakeMediaPeriod(
   public void addMediaSource_whilePlayingAd_correctMasking() throws Exception {
     long contentDurationMs = 10_000;
     long adDurationMs = 100_000;
-    AdPlaybackState adPlaybackState = new AdPlaybackState(/* adGroupTimesUs...= */ 0);
+    AdPlaybackState adPlaybackState =
+        new AdPlaybackState(/* adsId= */ new Object(), /* adGroupTimesUs...= */ 0);
     adPlaybackState = adPlaybackState.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1);
     adPlaybackState =
         adPlaybackState.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, Uri.EMPTY);
@@ -4455,7 +4456,8 @@ adsMediaSource, new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)))
   public void seekTo_whilePlayingAd_correctMasking() throws Exception {
     long contentDurationMs = 10_000;
     long adDurationMs = 4_000;
-    AdPlaybackState adPlaybackState = new AdPlaybackState(/* adGroupTimesUs...= */ 0);
+    AdPlaybackState adPlaybackState =
+        new AdPlaybackState(/* adsId= */ new Object(), /* adGroupTimesUs...= */ 0);
     adPlaybackState = adPlaybackState.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1);
     adPlaybackState =
         adPlaybackState.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, Uri.EMPTY);
diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java
index 6b90dfab15b..41b980d7e66 100644
--- a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java
+++ b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java
@@ -404,7 +404,8 @@ public void getNextMediaPeriodInfo_inMultiPeriodWindow_returnsCorrectMediaPeriod
 
   private void setupAdTimeline(long... adGroupTimesUs) {
     adPlaybackState =
-        new AdPlaybackState(adGroupTimesUs).withContentDurationUs(CONTENT_DURATION_US);
+        new AdPlaybackState(/* adsId= */ new Object(), adGroupTimesUs)
+            .withContentDurationUs(CONTENT_DURATION_US);
     SinglePeriodAdTimeline adTimeline =
         new SinglePeriodAdTimeline(CONTENT_TIMELINE, adPlaybackState);
     setupTimeline(adTimeline);
@@ -498,7 +499,8 @@ private void setAdGroupFailedToLoad(int adGroupIndex) {
 
   private void updateAdPlaybackStateAndTimeline(long... adGroupTimesUs) {
     adPlaybackState =
-        new AdPlaybackState(adGroupTimesUs).withContentDurationUs(CONTENT_DURATION_US);
+        new AdPlaybackState(/* adsId= */ new Object(), adGroupTimesUs)
+            .withContentDurationUs(CONTENT_DURATION_US);
     updateTimeline();
   }
 
diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java
index 5f97ad78f23..d804479dfa5 100644
--- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java
+++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java
@@ -419,7 +419,9 @@ public void updateSessions_withoutMediaPeriodId_afterSessionForMediaPeriodId_ret
                 /* isDynamic= */ false,
                 /* durationUs =*/ 10 * C.MICROS_PER_SECOND,
                 new AdPlaybackState(
-                        /* adGroupTimesUs=... */ 2 * C.MICROS_PER_SECOND, 5 * C.MICROS_PER_SECOND)
+                        /* adsId= */ new Object(),
+                        /* adGroupTimesUs=... */ 2 * C.MICROS_PER_SECOND,
+                        5 * C.MICROS_PER_SECOND)
                     .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
                     .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1)));
     EventTime adEventTime1 =
@@ -701,7 +703,8 @@ public void timelineUpdate_withContent_doesNotFinishFuturePostrollAd() {
                 /* isSeekable= */ true,
                 /* isDynamic= */ false,
                 /* durationUs =*/ 10 * C.MICROS_PER_SECOND,
-                new AdPlaybackState(/* adGroupTimesUs=... */ C.TIME_END_OF_SOURCE)
+                new AdPlaybackState(
+                        /* adsId= */ new Object(), /* adGroupTimesUs=... */ C.TIME_END_OF_SOURCE)
                     .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)));
     EventTime adEventTime =
         createEventTime(
@@ -903,7 +906,10 @@ public void positionDiscontinuity_fromAdToContent_finishesAd() {
                 /* isSeekable= */ true,
                 /* isDynamic= */ false,
                 /* durationUs =*/ 10 * C.MICROS_PER_SECOND,
-                new AdPlaybackState(/* adGroupTimesUs=... */ 0, 5 * C.MICROS_PER_SECOND)
+                new AdPlaybackState(
+                        /* adsId= */ new Object(), /* adGroupTimesUs=... */
+                        0,
+                        5 * C.MICROS_PER_SECOND)
                     .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
                     .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1)));
     EventTime adEventTime1 =
@@ -983,7 +989,9 @@ public void positionDiscontinuity_fromContentToAd_doesNotFinishSessions() {
                 /* isDynamic= */ false,
                 /* durationUs =*/ 10 * C.MICROS_PER_SECOND,
                 new AdPlaybackState(
-                        /* adGroupTimesUs=... */ 2 * C.MICROS_PER_SECOND, 5 * C.MICROS_PER_SECOND)
+                        /* adsId= */ new Object(), /* adGroupTimesUs=... */
+                        2 * C.MICROS_PER_SECOND,
+                        5 * C.MICROS_PER_SECOND)
                     .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
                     .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1)));
     EventTime adEventTime1 =
@@ -1032,7 +1040,10 @@ public void positionDiscontinuity_fromAdToAd_finishesPastAds_andNotifiesAdPlayba
                 /* isSeekable= */ true,
                 /* isDynamic= */ false,
                 /* durationUs =*/ 10 * C.MICROS_PER_SECOND,
-                new AdPlaybackState(/* adGroupTimesUs=... */ 0, 5 * C.MICROS_PER_SECOND)
+                new AdPlaybackState(
+                        /* adsId= */ new Object(), /* adGroupTimesUs=... */
+                        0,
+                        5 * C.MICROS_PER_SECOND)
                     .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
                     .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1)));
     EventTime adEventTime1 =
diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java
index 3a253b29761..de998bb8b16 100644
--- a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java
+++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java
@@ -31,12 +31,13 @@ public final class AdPlaybackStateTest {
 
   private static final long[] TEST_AD_GROUP_TMES_US = new long[] {0, C.msToUs(10_000)};
   private static final Uri TEST_URI = Uri.EMPTY;
+  private static final Object TEST_ADS_ID = new Object();
 
   private AdPlaybackState state;
 
   @Before
   public void setUp() {
-    state = new AdPlaybackState(TEST_AD_GROUP_TMES_US);
+    state = new AdPlaybackState(TEST_ADS_ID, TEST_AD_GROUP_TMES_US);
   }
 
   @Test
diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java
index 7fcd740d5f8..83386673af2 100644
--- a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java
+++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java
@@ -77,7 +77,7 @@ public final class AdsMediaSourceTest {
       CONTENT_TIMELINE.getUidOfPeriod(/* periodIndex= */ 0);
 
   private static final AdPlaybackState AD_PLAYBACK_STATE =
-      new AdPlaybackState(/* adGroupTimesUs...= */ 0)
+      new AdPlaybackState(/* adsId= */ new Object(), /* adGroupTimesUs...= */ 0)
           .withContentDurationUs(CONTENT_DURATION_US)
           .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
           .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, Uri.EMPTY)
diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java
index a5f94202da0..3fb29f284d5 100644
--- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java
+++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java
@@ -246,7 +246,8 @@ public TimelineWindowDefinition(
    */
   public static AdPlaybackState createAdPlaybackState(int adsPerAdGroup, long... adGroupTimesUs) {
     int adGroupCount = adGroupTimesUs.length;
-    AdPlaybackState adPlaybackState = new AdPlaybackState(adGroupTimesUs);
+    AdPlaybackState adPlaybackState =
+        new AdPlaybackState(/* adsId= */ new Object(), adGroupTimesUs);
     long[][] adDurationsUs = new long[adGroupCount][];
     for (int i = 0; i < adGroupCount; i++) {
       adPlaybackState = adPlaybackState.withAdCount(/* adGroupIndex= */ i, adsPerAdGroup);