Update ExoPlayer version to ToT am: 9af07bc62f am: 1ea0fd81d8 am: 728c5496af

Change-Id: I05831620a2afc19db3415143c70020cd67b36d48
diff --git a/METADATA b/METADATA
index 62bb7a0..bb88731 100644
--- a/METADATA
+++ b/METADATA
@@ -16,7 +16,7 @@
     type: GIT
     value: "https://github.com/google/ExoPlayer.git"
   }
-  version: "abadc768725929df0f4eb1ef0aacf53893e45d6d"
-  last_upgrade_date { year: 2020 month: 4 day: 19 }
+  version: "2e9ed51503ba491a19f605e6994fa5839633c74f"
+  last_upgrade_date { year: 2020 month: 4 day: 29 }
   license_type: NOTICE
 }
\ No newline at end of file
diff --git a/tree/RELEASENOTES.md b/tree/RELEASENOTES.md
index 9daedd6..66641aa 100644
--- a/tree/RELEASENOTES.md
+++ b/tree/RELEASENOTES.md
@@ -26,6 +26,8 @@
         consistency.
     *   Deprecate and rename `onLoadingChanged` to `onIsLoadingChanged` for
         consistency.
+    *   Deprecate `onSeekProcessed` because all seek changes happen instantly
+        now and listening to `onPositionDiscontinuity` is sufficient.
     *   Add `ExoPlayer.setPauseAtEndOfMediaItems` to let the player pause at the
         end of each media item
         ([#5660](https://github.com/google/ExoPlayer/issues/5660)).
@@ -38,6 +40,8 @@
     *   Rename `MediaCodecRenderer.onOutputFormatChanged` to
         `MediaCodecRenderer.onOutputMediaFormatChanged`, further clarifying the
         distinction between `Format` and `MediaFormat`.
+    *   Improve `Format` propagation within the media codec renderer
+        ([#6646](https://github.com/google/ExoPlayer/issues/6646)).
     *   Move player message-related constants from `C` to `Renderer`, to avoid
         having the constants class depend on player/renderer classes.
     *   Split out `common` and `extractor` submodules.
@@ -60,8 +64,6 @@
         `DecoderVideoRenderer` and `DecoderAudioRenderer` respectively, and
         generalized to work with `Decoder` rather than `SimpleDecoder`.
     *   Add media item based playlist API to Player.
-    *   Update `CachedContentIndex` to use `SecureRandom` for generating the
-        initialization vector used to encrypt the cache contents.
     *   Remove deprecated members in `DefaultTrackSelector`.
     *   Add `Player.DeviceComponent` and implement it for `SimpleExoPlayer` so
         that the device volume can be controlled by player.
@@ -70,6 +72,11 @@
         ([#7207](https://github.com/google/ExoPlayer/issues/7207)).
     *   Add `SilenceMediaSource.Factory` to support tags
         ([PR #7245](https://github.com/google/ExoPlayer/pull/7245)).
+    *   Fix `AdsMediaSource` child `MediaSource`s not being released.
+    *   Parse track titles from Matroska files
+        ([#7247](https://github.com/google/ExoPlayer/pull/7247)).
+    *   Replace `CacheDataSinkFactory` and `CacheDataSourceFactory` with
+        `CacheDataSink.Factory` and `CacheDataSource.Factory` respectively.
 *   Text:
     *   Parse `<ruby>` and `<rt>` tags in WebVTT subtitles (rendering is coming
         later).
@@ -86,6 +93,17 @@
         [issue #6581](https://github.com/google/ExoPlayer/issues/6581)).
     *   Parse `tts:ruby` and `tts:rubyPosition` properties in TTML subtitles
         (rendering is coming later).
+    *   Update WebVTT position alignment parsing to recognise `line-left`,
+        `center` and `line-right` as per the
+        [released spec](https://www.w3.org/TR/webvtt1/#webvtt-position-cue-setting)
+        (a
+        [previous draft](https://www.w3.org/TR/2014/WD-webvtt1-20141111/#dfn-webvtt-text-position-cue-setting)
+        used `start`, `middle` and `end`).
+    *   Use anti-aliasing and bitmap filtering when displaying bitmap subtitles
+        ([#6950](https://github.com/google/ExoPlayer/pull/6950)).
+    *   Implement timing-out of stuck CEA-608 captions (as permitted by
+        ANSI/CTA-608-E R-2014 Annex C.9) and set the default timeout to 16
+        seconds ([#7181](https://github.com/google/ExoPlayer/issues/7181)).
 *   DRM:
     *   Add support for attaching DRM sessions to clear content in the demo app.
     *   Remove `DrmSessionManager` references from all renderers.
@@ -95,8 +113,22 @@
         `OfflineLicenseHelper`
         ([#7078](https://github.com/google/ExoPlayer/issues/7078)).
     *   Remove generics from DRM components.
-*   Downloads: Merge downloads in `SegmentDownloader` to improve overall
-    download speed ([#5978](https://github.com/google/ExoPlayer/issues/5978)).
+*   Downloads and caching:
+    *   Merge downloads in `SegmentDownloader` to improve overall download speed
+        ([#5978](https://github.com/google/ExoPlayer/issues/5978)).
+    *   Replace `CacheDataSinkFactory` and `CacheDataSourceFactory` with
+        `CacheDataSink.Factory` and `CacheDataSource.Factory` respectively.
+    *   Remove `DownloadConstructorHelper` and use `CacheDataSource.Factory`
+        directly instead.
+    *   Update `CachedContentIndex` to use `SecureRandom` for generating the
+        initialization vector used to encrypt the cache contents.
+*   DASH:
+    *   Merge trick play adaptation sets (i.e., adaptation sets marked with
+        `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as
+        the main adaptation sets to which they refer. Trick play tracks are
+        marked with the `C.ROLE_FLAG_TRICK_PLAY` flag.
+    *   Fix assertion failure in `SampleQueue` when playing DASH streams with
+        EMSG tracks ([#7273](https://github.com/google/ExoPlayer/issues/7273)).
 *   MP3: Add `IndexSeeker` for accurate seeks in VBR streams
     ([#6787](https://github.com/google/ExoPlayer/issues/6787)). This seeker is
     enabled by passing `FLAG_ENABLE_INDEX_SEEKING` to the `Mp3Extractor`. It may
@@ -104,6 +136,8 @@
     costly on large files.
 *   MP4: Store the Android capture frame rate only in `Format.metadata`.
     `Format.frameRate` now stores the calculated frame rate.
+*   MPEG-TS: Fix issue where SEI NAL units were incorrectly dropped from H.265
+    samples ([#7113](https://github.com/google/ExoPlayer/issues/7113)).
 *   Testing
     *   Add `TestExoPlayer`, a utility class with APIs to create
         `SimpleExoPlayer` instances with fake components for testing.
@@ -121,6 +155,8 @@
 *   Change the order of extractors for sniffing to reduce start-up latency in
     `DefaultExtractorsFactory` and `DefaultHlsExtractorsFactory`
     ([#6410](https://github.com/google/ExoPlayer/issues/6410)).
+*   Add missing `@Nullable` annotations to `MediaSessionConnector`
+    ([#7234](https://github.com/google/ExoPlayer/issues/7234)).
 
 ### 2.11.4 (2020-04-08)
 
diff --git a/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java b/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java
index bd74eb5..c36d370 100644
--- a/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java
+++ b/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java
@@ -22,18 +22,14 @@
 import com.google.android.exoplayer2.database.ExoDatabaseProvider;
 import com.google.android.exoplayer2.offline.ActionFileUpgradeUtil;
 import com.google.android.exoplayer2.offline.DefaultDownloadIndex;
-import com.google.android.exoplayer2.offline.DefaultDownloaderFactory;
 import com.google.android.exoplayer2.offline.DownloadManager;
-import com.google.android.exoplayer2.offline.DownloaderConstructorHelper;
 import com.google.android.exoplayer2.ui.DownloadNotificationHelper;
 import com.google.android.exoplayer2.upstream.DataSource;
 import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
 import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
-import com.google.android.exoplayer2.upstream.FileDataSource;
 import com.google.android.exoplayer2.upstream.HttpDataSource;
 import com.google.android.exoplayer2.upstream.cache.Cache;
 import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
-import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory;
 import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor;
 import com.google.android.exoplayer2.upstream.cache.SimpleCache;
 import com.google.android.exoplayer2.util.Log;
@@ -131,11 +127,9 @@
           DOWNLOAD_ACTION_FILE, downloadIndex, /* addNewDownloadsAsCompleted= */ false);
       upgradeActionFile(
           DOWNLOAD_TRACKER_ACTION_FILE, downloadIndex, /* addNewDownloadsAsCompleted= */ true);
-      DownloaderConstructorHelper downloaderConstructorHelper =
-          new DownloaderConstructorHelper(getDownloadCache(), buildHttpDataSourceFactory());
       downloadManager =
           new DownloadManager(
-              this, downloadIndex, new DefaultDownloaderFactory(downloaderConstructorHelper));
+              this, getDatabaseProvider(), getDownloadCache(), buildHttpDataSourceFactory());
       downloadTracker =
           new DownloadTracker(/* context= */ this, buildDataSourceFactory(), downloadManager);
     }
@@ -172,14 +166,12 @@
     return downloadDirectory;
   }
 
-  protected static CacheDataSourceFactory buildReadOnlyCacheDataSource(
+  protected static CacheDataSource.Factory buildReadOnlyCacheDataSource(
       DataSource.Factory upstreamFactory, Cache cache) {
-    return new CacheDataSourceFactory(
-        cache,
-        upstreamFactory,
-        new FileDataSource.Factory(),
-        /* cacheWriteDataSinkFactory= */ null,
-        CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR,
-        /* eventListener= */ null);
+    return new CacheDataSource.Factory()
+        .setCache(cache)
+        .setUpstreamDataSourceFactory(upstreamFactory)
+        .setCacheWriteDataSinkFactory(null)
+        .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR);
   }
 }
diff --git a/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java b/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java
index 2b79071..5199e1d 100644
--- a/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java
+++ b/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java
@@ -15,6 +15,8 @@
  */
 package com.google.android.exoplayer2.demo;
 
+import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
+
 import android.content.Context;
 import android.content.DialogInterface;
 import android.net.Uri;
@@ -23,8 +25,8 @@
 import androidx.annotation.Nullable;
 import androidx.fragment.app.FragmentManager;
 import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.MediaItem;
 import com.google.android.exoplayer2.RenderersFactory;
-import com.google.android.exoplayer2.demo.Sample.UriSample;
 import com.google.android.exoplayer2.offline.Download;
 import com.google.android.exoplayer2.offline.DownloadCursor;
 import com.google.android.exoplayer2.offline.DownloadHelper;
@@ -82,8 +84,8 @@
     listeners.remove(listener);
   }
 
-  public boolean isDownloaded(Uri uri) {
-    Download download = downloads.get(uri);
+  public boolean isDownloaded(MediaItem mediaItem) {
+    Download download = downloads.get(checkNotNull(mediaItem.playbackProperties).sourceUri);
     return download != null && download.state != Download.STATE_FAILED;
   }
 
@@ -93,8 +95,8 @@
   }
 
   public void toggleDownload(
-      FragmentManager fragmentManager, UriSample sample, RenderersFactory renderersFactory) {
-    Download download = downloads.get(sample.uri);
+      FragmentManager fragmentManager, MediaItem mediaItem, RenderersFactory renderersFactory) {
+    Download download = downloads.get(checkNotNull(mediaItem.playbackProperties).sourceUri);
     if (download != null) {
       DownloadService.sendRemoveDownload(
           context, DemoDownloadService.class, download.request.id, /* foreground= */ false);
@@ -104,9 +106,7 @@
       }
       startDownloadDialogHelper =
           new StartDownloadDialogHelper(
-              fragmentManager,
-              getDownloadHelper(sample.uri, sample.extension, renderersFactory),
-              sample);
+              fragmentManager, getDownloadHelper(mediaItem, renderersFactory), mediaItem);
     }
   }
 
@@ -121,18 +121,24 @@
     }
   }
 
-  private DownloadHelper getDownloadHelper(
-      Uri uri, String extension, RenderersFactory renderersFactory) {
-    int type = Util.inferContentType(uri, extension);
+  private DownloadHelper getDownloadHelper(MediaItem mediaItem, RenderersFactory renderersFactory) {
+    MediaItem.PlaybackProperties playbackProperties = checkNotNull(mediaItem.playbackProperties);
+    @C.ContentType
+    int type =
+        Util.inferContentTypeWithMimeType(
+            playbackProperties.sourceUri, playbackProperties.mimeType);
     switch (type) {
       case C.TYPE_DASH:
-        return DownloadHelper.forDash(context, uri, dataSourceFactory, renderersFactory);
+        return DownloadHelper.forDash(
+            context, playbackProperties.sourceUri, dataSourceFactory, renderersFactory);
       case C.TYPE_SS:
-        return DownloadHelper.forSmoothStreaming(context, uri, dataSourceFactory, renderersFactory);
+        return DownloadHelper.forSmoothStreaming(
+            context, playbackProperties.sourceUri, dataSourceFactory, renderersFactory);
       case C.TYPE_HLS:
-        return DownloadHelper.forHls(context, uri, dataSourceFactory, renderersFactory);
+        return DownloadHelper.forHls(
+            context, playbackProperties.sourceUri, dataSourceFactory, renderersFactory);
       case C.TYPE_OTHER:
-        return DownloadHelper.forProgressive(context, uri);
+        return DownloadHelper.forProgressive(context, playbackProperties.sourceUri);
       default:
         throw new IllegalStateException("Unsupported type: " + type);
     }
@@ -166,16 +172,16 @@
 
     private final FragmentManager fragmentManager;
     private final DownloadHelper downloadHelper;
-    private final UriSample sample;
+    private final MediaItem mediaItem;
 
     private TrackSelectionDialog trackSelectionDialog;
     private MappedTrackInfo mappedTrackInfo;
 
     public StartDownloadDialogHelper(
-        FragmentManager fragmentManager, DownloadHelper downloadHelper, UriSample sample) {
+        FragmentManager fragmentManager, DownloadHelper downloadHelper, MediaItem mediaItem) {
       this.fragmentManager = fragmentManager;
       this.downloadHelper = downloadHelper;
-      this.sample = sample;
+      this.mediaItem = mediaItem;
       downloadHelper.prepare(this);
     }
 
@@ -270,7 +276,8 @@
     }
 
     private DownloadRequest buildDownloadRequest() {
-      return downloadHelper.getDownloadRequest(Util.getUtf8Bytes(sample.name));
+      return downloadHelper.getDownloadRequest(
+          Util.getUtf8Bytes(checkNotNull(mediaItem.mediaMetadata.title)));
     }
   }
 }
diff --git a/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java b/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java
new file mode 100644
index 0000000..b4c0e2a
--- /dev/null
+++ b/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java
@@ -0,0 +1,294 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.demo;
+
+import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
+import static com.google.android.exoplayer2.util.Assertions.checkState;
+
+import android.content.Intent;
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.offline.DownloadRequest;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.MimeTypes;
+import com.google.android.exoplayer2.util.Util;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+
+/** Util to read from and populate an intent. */
+public class IntentUtil {
+
+  /** A tag to hold custom playback configuration attributes. */
+  public static class Tag {
+
+    /** Whether the stream is a live stream. */
+    public final boolean isLive;
+    /** The spherical stereo mode or null. */
+    @Nullable public final String sphericalStereoMode;
+
+    /** Creates an instance. */
+    public Tag(boolean isLive, @Nullable String sphericalStereoMode) {
+      this.isLive = isLive;
+      this.sphericalStereoMode = sphericalStereoMode;
+    }
+  }
+
+  // Actions.
+
+  public static final String ACTION_VIEW = "com.google.android.exoplayer.demo.action.VIEW";
+  public static final String ACTION_VIEW_LIST =
+      "com.google.android.exoplayer.demo.action.VIEW_LIST";
+
+  // Activity extras.
+
+  public static final String SPHERICAL_STEREO_MODE_EXTRA = "spherical_stereo_mode";
+  public static final String SPHERICAL_STEREO_MODE_MONO = "mono";
+  public static final String SPHERICAL_STEREO_MODE_TOP_BOTTOM = "top_bottom";
+  public static final String SPHERICAL_STEREO_MODE_LEFT_RIGHT = "left_right";
+
+  // Player configuration extras.
+
+  public static final String ABR_ALGORITHM_EXTRA = "abr_algorithm";
+  public static final String ABR_ALGORITHM_DEFAULT = "default";
+  public static final String ABR_ALGORITHM_RANDOM = "random";
+
+  // Media item configuration extras.
+
+  public static final String URI_EXTRA = "uri";
+  public static final String IS_LIVE_EXTRA = "is_live";
+  public static final String MIME_TYPE_EXTRA = "mime_type";
+  // For backwards compatibility only.
+  public static final String EXTENSION_EXTRA = "extension";
+
+  public static final String DRM_SCHEME_EXTRA = "drm_scheme";
+  public static final String DRM_LICENSE_URL_EXTRA = "drm_license_url";
+  public static final String DRM_KEY_REQUEST_PROPERTIES_EXTRA = "drm_key_request_properties";
+  public static final String DRM_SESSION_FOR_CLEAR_TYPES_EXTRA = "drm_session_for_clear_types";
+  public static final String DRM_MULTI_SESSION_EXTRA = "drm_multi_session";
+  public static final String AD_TAG_URI_EXTRA = "ad_tag_uri";
+  public static final String SUBTITLE_URI_EXTRA = "subtitle_uri";
+  public static final String SUBTITLE_MIME_TYPE_EXTRA = "subtitle_mime_type";
+  public static final String SUBTITLE_LANGUAGE_EXTRA = "subtitle_language";
+  // For backwards compatibility only.
+  public static final String DRM_SCHEME_UUID_EXTRA = "drm_scheme_uuid";
+
+  public static final String PREFER_EXTENSION_DECODERS_EXTRA = "prefer_extension_decoders";
+  public static final String TUNNELING_EXTRA = "tunneling";
+
+  /** Creates a list of {@link MediaItem media items} from an {@link Intent}. */
+  public static List<MediaItem> createMediaItemsFromIntent(
+      Intent intent, DownloadTracker downloadTracker) {
+    List<MediaItem> mediaItems = new ArrayList<>();
+    if (ACTION_VIEW_LIST.equals(intent.getAction())) {
+      int index = 0;
+      while (intent.hasExtra(URI_EXTRA + "_" + index)) {
+        Uri uri = Uri.parse(intent.getStringExtra(URI_EXTRA + "_" + index));
+        mediaItems.add(
+            createMediaItemFromIntent(
+                uri,
+                intent,
+                /* extrasKeySuffix= */ "_" + index,
+                downloadTracker.getDownloadRequest(uri)));
+        index++;
+      }
+    } else {
+      Uri uri = intent.getData();
+      mediaItems.add(
+          createMediaItemFromIntent(
+              uri, intent, /* extrasKeySuffix= */ "", downloadTracker.getDownloadRequest(uri)));
+    }
+    return mediaItems;
+  }
+
+  /** Populates the intent with the given list of {@link MediaItem media items}. */
+  public static void addToIntent(List<MediaItem> mediaItems, Intent intent) {
+    Assertions.checkArgument(!mediaItems.isEmpty());
+    if (mediaItems.size() == 1) {
+      MediaItem.PlaybackProperties playbackProperties =
+          checkNotNull(mediaItems.get(0).playbackProperties);
+      intent.setAction(IntentUtil.ACTION_VIEW).setData(playbackProperties.sourceUri);
+      addPlaybackPropertiesToIntent(playbackProperties, intent, /* extrasKeySuffix= */ "");
+    } else {
+      intent.setAction(IntentUtil.ACTION_VIEW_LIST);
+      for (int i = 0; i < mediaItems.size(); i++) {
+        MediaItem.PlaybackProperties playbackProperties =
+            checkNotNull(mediaItems.get(i).playbackProperties);
+        intent.putExtra(IntentUtil.URI_EXTRA + ("_" + i), playbackProperties.sourceUri.toString());
+        addPlaybackPropertiesToIntent(playbackProperties, intent, /* extrasKeySuffix= */ "_" + i);
+      }
+    }
+  }
+
+  /** Makes a best guess to infer the MIME type from a {@link Uri} and an optional extension. */
+  @Nullable
+  public static String inferAdaptiveStreamMimeType(Uri uri, @Nullable String extension) {
+    @C.ContentType int contentType = Util.inferContentType(uri, extension);
+    switch (contentType) {
+      case C.TYPE_DASH:
+        return MimeTypes.APPLICATION_MPD;
+      case C.TYPE_HLS:
+        return MimeTypes.APPLICATION_M3U8;
+      case C.TYPE_SS:
+        return MimeTypes.APPLICATION_SS;
+      case C.TYPE_OTHER:
+      default:
+        return null;
+    }
+  }
+
+  private static MediaItem createMediaItemFromIntent(
+      Uri uri, Intent intent, String extrasKeySuffix, @Nullable DownloadRequest downloadRequest) {
+    String mimeType = intent.getStringExtra(MIME_TYPE_EXTRA + extrasKeySuffix);
+    if (mimeType == null) {
+      // Try to use extension for backwards compatibility.
+      String extension = intent.getStringExtra(EXTENSION_EXTRA + extrasKeySuffix);
+      mimeType = inferAdaptiveStreamMimeType(uri, extension);
+    }
+    MediaItem.Builder builder =
+        new MediaItem.Builder()
+            .setSourceUri(uri)
+            .setStreamKeys(downloadRequest != null ? downloadRequest.streamKeys : null)
+            .setCustomCacheKey(downloadRequest != null ? downloadRequest.customCacheKey : null)
+            .setMimeType(mimeType)
+            .setAdTagUri(intent.getStringExtra(AD_TAG_URI_EXTRA + extrasKeySuffix))
+            .setSubtitles(createSubtitlesFromIntent(intent, extrasKeySuffix));
+    return populateDrmPropertiesFromIntent(builder, intent, extrasKeySuffix).build();
+  }
+
+  private static List<MediaItem.Subtitle> createSubtitlesFromIntent(
+      Intent intent, String extrasKeySuffix) {
+    if (!intent.hasExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)) {
+      return Collections.emptyList();
+    }
+    return Collections.singletonList(
+        new MediaItem.Subtitle(
+            Uri.parse(intent.getStringExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)),
+            checkNotNull(intent.getStringExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix)),
+            intent.getStringExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix),
+            C.SELECTION_FLAG_DEFAULT));
+  }
+
+  private static MediaItem.Builder populateDrmPropertiesFromIntent(
+      MediaItem.Builder builder, Intent intent, String extrasKeySuffix) {
+    String schemeKey = DRM_SCHEME_EXTRA + extrasKeySuffix;
+    String schemeUuidKey = DRM_SCHEME_UUID_EXTRA + extrasKeySuffix;
+    if (!intent.hasExtra(schemeKey) && !intent.hasExtra(schemeUuidKey)) {
+      return builder;
+    }
+    String drmSchemeExtra =
+        intent.hasExtra(schemeKey)
+            ? intent.getStringExtra(schemeKey)
+            : intent.getStringExtra(schemeUuidKey);
+    String[] drmSessionForClearTypesExtra =
+        intent.getStringArrayExtra(DRM_SESSION_FOR_CLEAR_TYPES_EXTRA + extrasKeySuffix);
+    Map<String, String> headers = new HashMap<>();
+    String[] keyRequestPropertiesArray =
+        intent.getStringArrayExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix);
+    if (keyRequestPropertiesArray != null) {
+      for (int i = 0; i < keyRequestPropertiesArray.length; i += 2) {
+        headers.put(keyRequestPropertiesArray[i], keyRequestPropertiesArray[i + 1]);
+      }
+    }
+    builder
+        .setDrmUuid(Util.getDrmUuid(Util.castNonNull(drmSchemeExtra)))
+        .setDrmLicenseUri(intent.getStringExtra(DRM_LICENSE_URL_EXTRA + extrasKeySuffix))
+        .setDrmSessionForClearTypes(toTrackTypeList(drmSessionForClearTypesExtra))
+        .setDrmMultiSession(
+            intent.getBooleanExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, false))
+        .setDrmLicenseRequestHeaders(headers);
+    return builder;
+  }
+
+  private static List<Integer> toTrackTypeList(@Nullable String[] trackTypeStringsArray) {
+    if (trackTypeStringsArray == null) {
+      return Collections.emptyList();
+    }
+    HashSet<Integer> trackTypes = new HashSet<>();
+    for (String trackTypeString : trackTypeStringsArray) {
+      switch (Util.toLowerInvariant(trackTypeString)) {
+        case "audio":
+          trackTypes.add(C.TRACK_TYPE_AUDIO);
+          break;
+        case "video":
+          trackTypes.add(C.TRACK_TYPE_VIDEO);
+          break;
+        default:
+          throw new IllegalArgumentException("Invalid track type: " + trackTypeString);
+      }
+    }
+    return new ArrayList<>(trackTypes);
+  }
+
+  private static void addPlaybackPropertiesToIntent(
+      MediaItem.PlaybackProperties playbackProperties, Intent intent, String extrasKeySuffix) {
+    boolean isLive = false;
+    String sphericalStereoMode = null;
+    if (playbackProperties.tag instanceof Tag) {
+      Tag tag = (Tag) playbackProperties.tag;
+      isLive = tag.isLive;
+      sphericalStereoMode = tag.sphericalStereoMode;
+    }
+    intent
+        .putExtra(MIME_TYPE_EXTRA + extrasKeySuffix, playbackProperties.mimeType)
+        .putExtra(
+            AD_TAG_URI_EXTRA + extrasKeySuffix,
+            playbackProperties.adTagUri != null ? playbackProperties.adTagUri.toString() : null)
+        .putExtra(IS_LIVE_EXTRA + extrasKeySuffix, isLive)
+        .putExtra(SPHERICAL_STEREO_MODE_EXTRA, sphericalStereoMode);
+    if (playbackProperties.drmConfiguration != null) {
+      addDrmConfigurationToIntent(playbackProperties.drmConfiguration, intent, extrasKeySuffix);
+    }
+    if (!playbackProperties.subtitles.isEmpty()) {
+      checkState(playbackProperties.subtitles.size() == 1);
+      MediaItem.Subtitle subtitle = playbackProperties.subtitles.get(0);
+      intent.putExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix, subtitle.uri.toString());
+      intent.putExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix, subtitle.mimeType);
+      intent.putExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix, subtitle.language);
+    }
+  }
+
+  private static void addDrmConfigurationToIntent(
+      MediaItem.DrmConfiguration drmConfiguration, Intent intent, String extrasKeySuffix) {
+    intent.putExtra(DRM_SCHEME_EXTRA + extrasKeySuffix, drmConfiguration.uuid.toString());
+    intent.putExtra(
+        DRM_LICENSE_URL_EXTRA + extrasKeySuffix,
+        checkNotNull(drmConfiguration.licenseUri).toString());
+    intent.putExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, drmConfiguration.multiSession);
+
+    String[] drmKeyRequestProperties = new String[drmConfiguration.requestHeaders.size() * 2];
+    int index = 0;
+    for (Map.Entry<String, String> entry : drmConfiguration.requestHeaders.entrySet()) {
+      drmKeyRequestProperties[index++] = entry.getKey();
+      drmKeyRequestProperties[index++] = entry.getValue();
+    }
+    intent.putExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix, drmKeyRequestProperties);
+
+    ArrayList<String> typeStrings = new ArrayList<>();
+    for (int type : drmConfiguration.sessionForClearTypes) {
+      // Only audio and video are supported.
+      Assertions.checkState(type == C.TRACK_TYPE_AUDIO || type == C.TRACK_TYPE_VIDEO);
+      typeStrings.add(type == C.TRACK_TYPE_AUDIO ? "audio" : "video");
+    }
+    intent.putExtra(
+        DRM_SESSION_FOR_CLEAR_TYPES_EXTRA + extrasKeySuffix, typeStrings.toArray(new String[0]));
+  }
+}
diff --git a/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java
index f941234..47d7966 100644
--- a/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java
+++ b/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java
@@ -39,16 +39,12 @@
 import com.google.android.exoplayer2.RenderersFactory;
 import com.google.android.exoplayer2.SimpleExoPlayer;
 import com.google.android.exoplayer2.audio.AudioAttributes;
-import com.google.android.exoplayer2.demo.Sample.UriSample;
 import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.DecoderInitializationException;
 import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException;
-import com.google.android.exoplayer2.offline.DownloadRequest;
 import com.google.android.exoplayer2.source.BehindLiveWindowException;
 import com.google.android.exoplayer2.source.DefaultMediaSourceFactory;
-import com.google.android.exoplayer2.source.MediaSource;
 import com.google.android.exoplayer2.source.TrackGroupArray;
 import com.google.android.exoplayer2.source.ads.AdsLoader;
-import com.google.android.exoplayer2.source.ads.AdsMediaSource;
 import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
 import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
 import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo;
@@ -60,7 +56,6 @@
 import com.google.android.exoplayer2.ui.PlayerView;
 import com.google.android.exoplayer2.ui.spherical.SphericalGLSurfaceView;
 import com.google.android.exoplayer2.upstream.DataSource;
-import com.google.android.exoplayer2.upstream.HttpDataSource;
 import com.google.android.exoplayer2.util.Assertions;
 import com.google.android.exoplayer2.util.ErrorMessageProvider;
 import com.google.android.exoplayer2.util.EventLogger;
@@ -69,7 +64,6 @@
 import java.net.CookieHandler;
 import java.net.CookieManager;
 import java.net.CookiePolicy;
-import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
@@ -77,45 +71,6 @@
 public class PlayerActivity extends AppCompatActivity
     implements OnClickListener, PlaybackPreparer, PlayerControlView.VisibilityListener {
 
-  // Activity extras.
-
-  public static final String SPHERICAL_STEREO_MODE_EXTRA = "spherical_stereo_mode";
-  public static final String SPHERICAL_STEREO_MODE_MONO = "mono";
-  public static final String SPHERICAL_STEREO_MODE_TOP_BOTTOM = "top_bottom";
-  public static final String SPHERICAL_STEREO_MODE_LEFT_RIGHT = "left_right";
-
-  // Actions.
-
-  public static final String ACTION_VIEW = "com.google.android.exoplayer.demo.action.VIEW";
-  public static final String ACTION_VIEW_LIST =
-      "com.google.android.exoplayer.demo.action.VIEW_LIST";
-
-  // Player configuration extras.
-
-  public static final String ABR_ALGORITHM_EXTRA = "abr_algorithm";
-  public static final String ABR_ALGORITHM_DEFAULT = "default";
-  public static final String ABR_ALGORITHM_RANDOM = "random";
-
-  // Media item configuration extras.
-
-  public static final String URI_EXTRA = "uri";
-  public static final String EXTENSION_EXTRA = "extension";
-  public static final String IS_LIVE_EXTRA = "is_live";
-
-  public static final String DRM_SCHEME_EXTRA = "drm_scheme";
-  public static final String DRM_LICENSE_URL_EXTRA = "drm_license_url";
-  public static final String DRM_KEY_REQUEST_PROPERTIES_EXTRA = "drm_key_request_properties";
-  public static final String DRM_SESSION_FOR_CLEAR_TYPES_EXTRA = "drm_session_for_clear_types";
-  public static final String DRM_MULTI_SESSION_EXTRA = "drm_multi_session";
-  public static final String PREFER_EXTENSION_DECODERS_EXTRA = "prefer_extension_decoders";
-  public static final String TUNNELING_EXTRA = "tunneling";
-  public static final String AD_TAG_URI_EXTRA = "ad_tag_uri";
-  public static final String SUBTITLE_URI_EXTRA = "subtitle_uri";
-  public static final String SUBTITLE_MIME_TYPE_EXTRA = "subtitle_mime_type";
-  public static final String SUBTITLE_LANGUAGE_EXTRA = "subtitle_language";
-  // For backwards compatibility only.
-  public static final String DRM_SCHEME_UUID_EXTRA = "drm_scheme_uuid";
-
   // Saved instance state keys.
 
   private static final String KEY_TRACK_SELECTOR_PARAMETERS = "track_selector_parameters";
@@ -138,12 +93,11 @@
 
   private DataSource.Factory dataSourceFactory;
   private SimpleExoPlayer player;
-  private List<MediaSource> mediaSources;
+  private List<MediaItem> mediaItems;
   private DefaultTrackSelector trackSelector;
   private DefaultTrackSelector.Parameters trackSelectorParameters;
   private DebugTextViewHelper debugViewHelper;
   private TrackGroupArray lastSeenTrackGroupArray;
-  private DefaultMediaSourceFactory mediaSourceFactory;
   private boolean startAutoPlay;
   private int startWindow;
   private long startPosition;
@@ -158,14 +112,12 @@
   @Override
   public void onCreate(Bundle savedInstanceState) {
     Intent intent = getIntent();
-    String sphericalStereoMode = intent.getStringExtra(SPHERICAL_STEREO_MODE_EXTRA);
+    String sphericalStereoMode = intent.getStringExtra(IntentUtil.SPHERICAL_STEREO_MODE_EXTRA);
     if (sphericalStereoMode != null) {
       setTheme(R.style.PlayerTheme_Spherical);
     }
     super.onCreate(savedInstanceState);
     dataSourceFactory = buildDataSourceFactory();
-    mediaSourceFactory =
-        DefaultMediaSourceFactory.newInstance(/* context= */ this, dataSourceFactory);
     if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) {
       CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER);
     }
@@ -182,11 +134,11 @@
     playerView.requestFocus();
     if (sphericalStereoMode != null) {
       int stereoMode;
-      if (SPHERICAL_STEREO_MODE_MONO.equals(sphericalStereoMode)) {
+      if (IntentUtil.SPHERICAL_STEREO_MODE_MONO.equals(sphericalStereoMode)) {
         stereoMode = C.STEREO_MODE_MONO;
-      } else if (SPHERICAL_STEREO_MODE_TOP_BOTTOM.equals(sphericalStereoMode)) {
+      } else if (IntentUtil.SPHERICAL_STEREO_MODE_TOP_BOTTOM.equals(sphericalStereoMode)) {
         stereoMode = C.STEREO_MODE_TOP_BOTTOM;
-      } else if (SPHERICAL_STEREO_MODE_LEFT_RIGHT.equals(sphericalStereoMode)) {
+      } else if (IntentUtil.SPHERICAL_STEREO_MODE_LEFT_RIGHT.equals(sphericalStereoMode)) {
         stereoMode = C.STEREO_MODE_LEFT_RIGHT;
       } else {
         showToast(R.string.error_unrecognized_stereo_mode);
@@ -204,7 +156,7 @@
     } else {
       DefaultTrackSelector.ParametersBuilder builder =
           new DefaultTrackSelector.ParametersBuilder(/* context= */ this);
-      boolean tunneling = intent.getBooleanExtra(TUNNELING_EXTRA, false);
+      boolean tunneling = intent.getBooleanExtra(IntentUtil.TUNNELING_EXTRA, false);
       if (Util.SDK_INT >= 21 && tunneling) {
         builder.setTunnelingAudioSessionId(C.generateAudioSessionIdV21(/* context= */ this));
       }
@@ -328,7 +280,7 @@
 
   @Override
   public void preparePlayback() {
-    player.retry();
+    player.prepare();
   }
 
   // PlaybackControlView.VisibilityListener implementation
@@ -343,15 +295,17 @@
   private void initializePlayer() {
     if (player == null) {
       Intent intent = getIntent();
-      mediaSources = createTopLevelMediaSources(intent);
-      if (mediaSources.isEmpty()) {
+
+      mediaItems = createMediaItems(intent);
+      if (mediaItems.isEmpty()) {
         return;
       }
+
       TrackSelection.Factory trackSelectionFactory;
-      String abrAlgorithm = intent.getStringExtra(ABR_ALGORITHM_EXTRA);
-      if (abrAlgorithm == null || ABR_ALGORITHM_DEFAULT.equals(abrAlgorithm)) {
+      String abrAlgorithm = intent.getStringExtra(IntentUtil.ABR_ALGORITHM_EXTRA);
+      if (abrAlgorithm == null || IntentUtil.ABR_ALGORITHM_DEFAULT.equals(abrAlgorithm)) {
         trackSelectionFactory = new AdaptiveTrackSelection.Factory();
-      } else if (ABR_ALGORITHM_RANDOM.equals(abrAlgorithm)) {
+      } else if (IntentUtil.ABR_ALGORITHM_RANDOM.equals(abrAlgorithm)) {
         trackSelectionFactory = new RandomTrackSelection.Factory();
       } else {
         showToast(R.string.error_unrecognized_abr_algorithm);
@@ -360,7 +314,7 @@
       }
 
       boolean preferExtensionDecoders =
-          intent.getBooleanExtra(PREFER_EXTENSION_DECODERS_EXTRA, false);
+          intent.getBooleanExtra(IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA, false);
       RenderersFactory renderersFactory =
           ((DemoApplication) getApplication()).buildRenderersFactory(preferExtensionDecoders);
 
@@ -370,6 +324,9 @@
 
       player =
           new SimpleExoPlayer.Builder(/* context= */ this, renderersFactory)
+              .setMediaSourceFactory(
+                  new DefaultMediaSourceFactory(
+                      /* context= */ this, dataSourceFactory, new AdSupportProvider()))
               .setTrackSelector(trackSelector)
               .build();
       player.addListener(new PlayerEventListener());
@@ -380,39 +337,32 @@
       playerView.setPlaybackPreparer(this);
       debugViewHelper = new DebugTextViewHelper(player, debugTextView);
       debugViewHelper.start();
-      if (adsLoader != null) {
-        adsLoader.setPlayer(player);
-      }
     }
     boolean haveStartPosition = startWindow != C.INDEX_UNSET;
     if (haveStartPosition) {
       player.seekTo(startWindow, startPosition);
     }
-    player.setMediaSources(mediaSources, /* resetPosition= */ !haveStartPosition);
+    player.setMediaItems(mediaItems, /* resetPosition= */ !haveStartPosition);
     player.prepare();
     updateButtonVisibility();
   }
 
-  private List<MediaSource> createTopLevelMediaSources(Intent intent) {
+  private List<MediaItem> createMediaItems(Intent intent) {
     String action = intent.getAction();
-    boolean actionIsListView = ACTION_VIEW_LIST.equals(action);
-    if (!actionIsListView && !ACTION_VIEW.equals(action)) {
+    boolean actionIsListView = IntentUtil.ACTION_VIEW_LIST.equals(action);
+    if (!actionIsListView && !IntentUtil.ACTION_VIEW.equals(action)) {
       showToast(getString(R.string.unexpected_intent_action, action));
       finish();
       return Collections.emptyList();
     }
 
-    Sample intentAsSample = Sample.createFromIntent(intent);
-    UriSample[] samples =
-        intentAsSample instanceof Sample.PlaylistSample
-            ? ((Sample.PlaylistSample) intentAsSample).children
-            : new UriSample[] {(UriSample) intentAsSample};
+    List<MediaItem> mediaItems =
+        IntentUtil.createMediaItemsFromIntent(
+            intent, ((DemoApplication) getApplication()).getDownloadTracker());
+    boolean hasAds = false;
+    for (int i = 0; i < mediaItems.size(); i++) {
+      MediaItem mediaItem = mediaItems.get(i);
 
-    List<MediaSource> mediaSources = new ArrayList<>();
-    Uri adTagUri = null;
-    for (UriSample sample : samples) {
-      MediaItem mediaItem = sample.toMediaItem();
-      Assertions.checkNotNull(mediaItem.playbackProperties);
       if (!Util.checkCleartextTrafficPermitted(mediaItem)) {
         showToast(R.string.error_cleartext_not_permitted);
         return Collections.emptyList();
@@ -421,67 +371,26 @@
         // The player will be reinitialized if the permission is granted.
         return Collections.emptyList();
       }
-      MediaSource mediaSource = createLeafMediaSource(mediaItem);
-      if (mediaSource != null) {
-        adTagUri = sample.adTagUri;
-        mediaSources.add(mediaSource);
-      }
-    }
 
-    if (adTagUri == null) {
-      releaseAdsLoader();
-    } else if (mediaSources.size() == 1) {
-      if (!adTagUri.equals(loadedAdTagUri)) {
-        releaseAdsLoader();
-        loadedAdTagUri = adTagUri;
+      MediaItem.DrmConfiguration drmConfiguration =
+          Assertions.checkNotNull(mediaItem.playbackProperties).drmConfiguration;
+      if (drmConfiguration != null) {
+        if (Util.SDK_INT < 18) {
+          showToast(R.string.error_drm_unsupported_before_api_18);
+          finish();
+          return Collections.emptyList();
+        } else if (!MediaDrm.isCryptoSchemeSupported(drmConfiguration.uuid)) {
+          showToast(R.string.error_drm_unsupported_scheme);
+          finish();
+          return Collections.emptyList();
+        }
       }
-      MediaSource adsMediaSource = createAdsMediaSource(mediaSources.get(0), adTagUri);
-      if (adsMediaSource != null) {
-        mediaSources.set(0, adsMediaSource);
-      } else {
-        showToast(R.string.ima_not_loaded);
-      }
-    } else if (mediaSources.size() > 1) {
-      showToast(R.string.unsupported_ads_in_concatenation);
+      hasAds |= mediaItem.playbackProperties.adTagUri != null;
+    }
+    if (!hasAds) {
       releaseAdsLoader();
     }
-
-    return mediaSources;
-  }
-
-  @Nullable
-  private MediaSource createLeafMediaSource(MediaItem mediaItem) {
-    Assertions.checkNotNull(mediaItem.playbackProperties);
-    HttpDataSource.Factory drmDataSourceFactory = null;
-    if (mediaItem.playbackProperties.drmConfiguration != null) {
-      if (Util.SDK_INT < 18) {
-        showToast(R.string.error_drm_unsupported_before_api_18);
-        finish();
-        return null;
-      } else if (!MediaDrm.isCryptoSchemeSupported(
-          mediaItem.playbackProperties.drmConfiguration.uuid)) {
-        showToast(R.string.error_drm_unsupported_scheme);
-        finish();
-        return null;
-      }
-      drmDataSourceFactory = ((DemoApplication) getApplication()).buildHttpDataSourceFactory();
-    }
-
-    DownloadRequest downloadRequest =
-        ((DemoApplication) getApplication())
-            .getDownloadTracker()
-            .getDownloadRequest(mediaItem.playbackProperties.sourceUri);
-    if (downloadRequest != null) {
-      mediaItem =
-          mediaItem
-              .buildUpon()
-              .setStreamKeys(downloadRequest.streamKeys)
-              .setCustomCacheKey(downloadRequest.customCacheKey)
-              .build();
-    }
-    return mediaSourceFactory
-        .setDrmHttpDataSourceFactory(drmDataSourceFactory)
-        .createMediaSource(mediaItem);
+    return mediaItems;
   }
 
   private void releasePlayer() {
@@ -492,7 +401,7 @@
       debugViewHelper = null;
       player.release();
       player = null;
-      mediaSources = Collections.emptyList();
+      mediaItems = Collections.emptyList();
       trackSelector = null;
     }
     if (adsLoader != null) {
@@ -534,24 +443,23 @@
     return ((DemoApplication) getApplication()).buildDataSourceFactory();
   }
 
-  /** Returns an ads media source, reusing the ads loader if one exists. */
+  /**
+   * Returns an ads loader for the Interactive Media Ads SDK if found in the classpath, or null
+   * otherwise.
+   */
   @Nullable
-  private MediaSource createAdsMediaSource(MediaSource mediaSource, Uri adTagUri) {
+  private AdsLoader maybeCreateAdsLoader(Uri adTagUri) {
     // Load the extension source using reflection so the demo app doesn't have to depend on it.
-    // The ads loader is reused for multiple playbacks, so that ad playback can resume.
     try {
       Class<?> loaderClass = Class.forName("com.google.android.exoplayer2.ext.ima.ImaAdsLoader");
-      if (adsLoader == null) {
-        // Full class names used so the lint rule triggers should any of the classes move.
-        // LINT.IfChange
-        Constructor<? extends AdsLoader> loaderConstructor =
-            loaderClass
-                .asSubclass(AdsLoader.class)
-                .getConstructor(android.content.Context.class, android.net.Uri.class);
-        // LINT.ThenChange(../../../../../../../../proguard-rules.txt)
-        adsLoader = loaderConstructor.newInstance(this, adTagUri);
-      }
-      return new AdsMediaSource(mediaSource, mediaSourceFactory, adsLoader, playerView);
+      // Full class names used so the lint rule triggers should any of the classes move.
+      // LINT.IfChange
+      Constructor<? extends AdsLoader> loaderConstructor =
+          loaderClass
+              .asSubclass(AdsLoader.class)
+              .getConstructor(android.content.Context.class, android.net.Uri.class);
+      // LINT.ThenChange(../../../../../../../../proguard-rules.txt)
+      return loaderConstructor.newInstance(this, adTagUri);
     } catch (ClassNotFoundException e) {
       // IMA extension not loaded.
       return null;
@@ -670,4 +578,36 @@
       return Pair.create(0, errorString);
     }
   }
+
+  private class AdSupportProvider implements DefaultMediaSourceFactory.AdSupportProvider {
+
+    @Nullable
+    @Override
+    public AdsLoader getAdsLoader(Uri adTagUri) {
+      if (mediaItems.size() > 1) {
+        showToast(R.string.unsupported_ads_in_concatenation);
+        releaseAdsLoader();
+        return null;
+      }
+      if (!adTagUri.equals(loadedAdTagUri)) {
+        releaseAdsLoader();
+        loadedAdTagUri = adTagUri;
+      }
+      // The ads loader is reused for multiple playbacks, so that ad playback can resume.
+      if (adsLoader == null) {
+        adsLoader = maybeCreateAdsLoader(adTagUri);
+      }
+      if (adsLoader != null) {
+        adsLoader.setPlayer(player);
+      } else {
+        showToast(R.string.ima_not_loaded);
+      }
+      return adsLoader;
+    }
+
+    @Override
+    public AdsLoader.AdViewProvider getAdViewProvider() {
+      return Assertions.checkNotNull(playerView);
+    }
+  }
 }
diff --git a/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/Sample.java b/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/Sample.java
deleted file mode 100644
index 1225c8b..0000000
--- a/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/Sample.java
+++ /dev/null
@@ -1,336 +0,0 @@
-/*
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.google.android.exoplayer2.demo;
-
-import static com.google.android.exoplayer2.demo.PlayerActivity.ACTION_VIEW_LIST;
-import static com.google.android.exoplayer2.demo.PlayerActivity.AD_TAG_URI_EXTRA;
-import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_KEY_REQUEST_PROPERTIES_EXTRA;
-import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_LICENSE_URL_EXTRA;
-import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_MULTI_SESSION_EXTRA;
-import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_SCHEME_EXTRA;
-import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_SCHEME_UUID_EXTRA;
-import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_SESSION_FOR_CLEAR_TYPES_EXTRA;
-import static com.google.android.exoplayer2.demo.PlayerActivity.EXTENSION_EXTRA;
-import static com.google.android.exoplayer2.demo.PlayerActivity.IS_LIVE_EXTRA;
-import static com.google.android.exoplayer2.demo.PlayerActivity.SUBTITLE_LANGUAGE_EXTRA;
-import static com.google.android.exoplayer2.demo.PlayerActivity.SUBTITLE_MIME_TYPE_EXTRA;
-import static com.google.android.exoplayer2.demo.PlayerActivity.SUBTITLE_URI_EXTRA;
-import static com.google.android.exoplayer2.demo.PlayerActivity.URI_EXTRA;
-
-import android.content.Intent;
-import android.net.Uri;
-import androidx.annotation.Nullable;
-import com.google.android.exoplayer2.C;
-import com.google.android.exoplayer2.MediaItem;
-import com.google.android.exoplayer2.util.Assertions;
-import com.google.android.exoplayer2.util.MimeTypes;
-import com.google.android.exoplayer2.util.Util;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.UUID;
-
-/* package */ abstract class Sample {
-
-  /**
-   * Returns the mime type which is one of {@link MimeTypes#APPLICATION_MPD} for DASH, {@link
-   * MimeTypes#APPLICATION_M3U8} for HLS, {@link MimeTypes#APPLICATION_SS} for SmoothStreaming or
-   * {@code null} for all other streams.
-   *
-   * @param uri The uri of the stream.
-   * @param extension The extension
-   * @return The adaptive mime type or {@code null} for non-adaptive streams.
-   */
-  @Nullable
-  public static String inferAdaptiveStreamMimeType(Uri uri, @Nullable String extension) {
-    @C.ContentType int contentType = Util.inferContentType(uri, extension);
-    switch (contentType) {
-      case C.TYPE_DASH:
-        return MimeTypes.APPLICATION_MPD;
-      case C.TYPE_HLS:
-        return MimeTypes.APPLICATION_M3U8;
-      case C.TYPE_SS:
-        return MimeTypes.APPLICATION_SS;
-      case C.TYPE_OTHER:
-      default:
-        return null;
-    }
-  }
-
-  public static final class UriSample extends Sample {
-
-    public static UriSample createFromIntent(Uri uri, Intent intent, String extrasKeySuffix) {
-      String extension = intent.getStringExtra(EXTENSION_EXTRA + extrasKeySuffix);
-      String adsTagUriString = intent.getStringExtra(AD_TAG_URI_EXTRA + extrasKeySuffix);
-      boolean isLive =
-          intent.getBooleanExtra(IS_LIVE_EXTRA + extrasKeySuffix, /* defaultValue= */ false);
-      Uri adTagUri = adsTagUriString != null ? Uri.parse(adsTagUriString) : null;
-      return new UriSample(
-          /* name= */ null,
-          uri,
-          extension,
-          isLive,
-          DrmInfo.createFromIntent(intent, extrasKeySuffix),
-          adTagUri,
-          /* sphericalStereoMode= */ null,
-          SubtitleInfo.createFromIntent(intent, extrasKeySuffix));
-    }
-
-    public final Uri uri;
-    public final String extension;
-    public final boolean isLive;
-    public final DrmInfo drmInfo;
-    public final Uri adTagUri;
-    @Nullable public final String sphericalStereoMode;
-    @Nullable SubtitleInfo subtitleInfo;
-
-    public UriSample(
-        String name,
-        Uri uri,
-        String extension,
-        boolean isLive,
-        DrmInfo drmInfo,
-        Uri adTagUri,
-        @Nullable String sphericalStereoMode,
-        @Nullable SubtitleInfo subtitleInfo) {
-      super(name);
-      this.uri = uri;
-      this.extension = extension;
-      this.isLive = isLive;
-      this.drmInfo = drmInfo;
-      this.adTagUri = adTagUri;
-      this.sphericalStereoMode = sphericalStereoMode;
-      this.subtitleInfo = subtitleInfo;
-    }
-
-    @Override
-    public void addToIntent(Intent intent) {
-      intent.setAction(PlayerActivity.ACTION_VIEW).setData(uri);
-      intent.putExtra(PlayerActivity.IS_LIVE_EXTRA, isLive);
-      intent.putExtra(PlayerActivity.SPHERICAL_STEREO_MODE_EXTRA, sphericalStereoMode);
-      addPlayerConfigToIntent(intent, /* extrasKeySuffix= */ "");
-    }
-
-    public void addToPlaylistIntent(Intent intent, String extrasKeySuffix) {
-      intent.putExtra(PlayerActivity.URI_EXTRA + extrasKeySuffix, uri.toString());
-      intent.putExtra(PlayerActivity.IS_LIVE_EXTRA + extrasKeySuffix, isLive);
-      addPlayerConfigToIntent(intent, extrasKeySuffix);
-    }
-
-    private void addPlayerConfigToIntent(Intent intent, String extrasKeySuffix) {
-      intent
-          .putExtra(EXTENSION_EXTRA + extrasKeySuffix, extension)
-          .putExtra(
-              AD_TAG_URI_EXTRA + extrasKeySuffix, adTagUri != null ? adTagUri.toString() : null);
-      if (drmInfo != null) {
-        drmInfo.addToIntent(intent, extrasKeySuffix);
-      }
-      if (subtitleInfo != null) {
-        subtitleInfo.addToIntent(intent, extrasKeySuffix);
-      }
-    }
-
-    public MediaItem toMediaItem() {
-      MediaItem.Builder builder = new MediaItem.Builder().setSourceUri(uri);
-      builder.setMimeType(inferAdaptiveStreamMimeType(uri, extension));
-      if (drmInfo != null) {
-        Map<String, String> headers = new HashMap<>();
-        if (drmInfo.drmKeyRequestProperties != null) {
-          for (int i = 0; i < drmInfo.drmKeyRequestProperties.length; i += 2) {
-            headers.put(drmInfo.drmKeyRequestProperties[i], drmInfo.drmKeyRequestProperties[i + 1]);
-          }
-        }
-        builder
-            .setDrmLicenseUri(drmInfo.drmLicenseUrl)
-            .setDrmLicenseRequestHeaders(headers)
-            .setDrmUuid(drmInfo.drmScheme)
-            .setDrmMultiSession(drmInfo.drmMultiSession)
-            .setDrmSessionForClearTypes(Util.toList(drmInfo.drmSessionForClearTypes));
-      }
-      if (subtitleInfo != null) {
-        builder.setSubtitles(
-            Collections.singletonList(
-                new MediaItem.Subtitle(
-                    subtitleInfo.uri,
-                    subtitleInfo.mimeType,
-                    subtitleInfo.language,
-                    C.SELECTION_FLAG_DEFAULT)));
-      }
-      return builder.build();
-    }
-  }
-
-  public static final class PlaylistSample extends Sample {
-
-    public final UriSample[] children;
-
-    public PlaylistSample(String name, UriSample... children) {
-      super(name);
-      this.children = children;
-    }
-
-    @Override
-    public void addToIntent(Intent intent) {
-      intent.setAction(PlayerActivity.ACTION_VIEW_LIST);
-      for (int i = 0; i < children.length; i++) {
-        children[i].addToPlaylistIntent(intent, /* extrasKeySuffix= */ "_" + i);
-      }
-    }
-  }
-
-  public static final class DrmInfo {
-
-    public static DrmInfo createFromIntent(Intent intent, String extrasKeySuffix) {
-      String schemeKey = DRM_SCHEME_EXTRA + extrasKeySuffix;
-      String schemeUuidKey = DRM_SCHEME_UUID_EXTRA + extrasKeySuffix;
-      if (!intent.hasExtra(schemeKey) && !intent.hasExtra(schemeUuidKey)) {
-        return null;
-      }
-      String drmSchemeExtra =
-          intent.hasExtra(schemeKey)
-              ? intent.getStringExtra(schemeKey)
-              : intent.getStringExtra(schemeUuidKey);
-      UUID drmScheme = Util.getDrmUuid(drmSchemeExtra);
-      String drmLicenseUrl = intent.getStringExtra(DRM_LICENSE_URL_EXTRA + extrasKeySuffix);
-      String[] keyRequestPropertiesArray =
-          intent.getStringArrayExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix);
-      String[] drmSessionForClearTypesExtra =
-          intent.getStringArrayExtra(DRM_SESSION_FOR_CLEAR_TYPES_EXTRA + extrasKeySuffix);
-      int[] drmSessionForClearTypes = toTrackTypeArray(drmSessionForClearTypesExtra);
-      boolean drmMultiSession =
-          intent.getBooleanExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, false);
-      return new DrmInfo(
-          drmScheme,
-          drmLicenseUrl,
-          keyRequestPropertiesArray,
-          drmSessionForClearTypes,
-          drmMultiSession);
-    }
-
-    public final UUID drmScheme;
-    public final String drmLicenseUrl;
-    public final String[] drmKeyRequestProperties;
-    public final int[] drmSessionForClearTypes;
-    public final boolean drmMultiSession;
-
-    public DrmInfo(
-        UUID drmScheme,
-        String drmLicenseUrl,
-        String[] drmKeyRequestProperties,
-        int[] drmSessionForClearTypes,
-        boolean drmMultiSession) {
-      this.drmScheme = drmScheme;
-      this.drmLicenseUrl = drmLicenseUrl;
-      this.drmKeyRequestProperties = drmKeyRequestProperties;
-      this.drmSessionForClearTypes = drmSessionForClearTypes;
-      this.drmMultiSession = drmMultiSession;
-    }
-
-    public void addToIntent(Intent intent, String extrasKeySuffix) {
-      Assertions.checkNotNull(intent);
-      intent.putExtra(DRM_SCHEME_EXTRA + extrasKeySuffix, drmScheme.toString());
-      intent.putExtra(DRM_LICENSE_URL_EXTRA + extrasKeySuffix, drmLicenseUrl);
-      intent.putExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix, drmKeyRequestProperties);
-      ArrayList<String> typeStrings = new ArrayList<>();
-      for (int type : drmSessionForClearTypes) {
-        // Only audio and video are supported.
-        typeStrings.add(type == C.TRACK_TYPE_AUDIO ? "audio" : "video");
-      }
-      intent.putExtra(
-          DRM_SESSION_FOR_CLEAR_TYPES_EXTRA + extrasKeySuffix, typeStrings.toArray(new String[0]));
-      intent.putExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, drmMultiSession);
-    }
-  }
-
-  public static final class SubtitleInfo {
-
-    @Nullable
-    public static SubtitleInfo createFromIntent(Intent intent, String extrasKeySuffix) {
-      if (!intent.hasExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)) {
-        return null;
-      }
-      return new SubtitleInfo(
-          Uri.parse(intent.getStringExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)),
-          intent.getStringExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix),
-          intent.getStringExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix));
-    }
-
-    public final Uri uri;
-    public final String mimeType;
-    @Nullable public final String language;
-
-    public SubtitleInfo(Uri uri, String mimeType, @Nullable String language) {
-      this.uri = Assertions.checkNotNull(uri);
-      this.mimeType = Assertions.checkNotNull(mimeType);
-      this.language = language;
-    }
-
-    public void addToIntent(Intent intent, String extrasKeySuffix) {
-      intent.putExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix, uri.toString());
-      intent.putExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix, mimeType);
-      intent.putExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix, language);
-    }
-  }
-
-  public static int[] toTrackTypeArray(@Nullable String[] trackTypeStringsArray) {
-    if (trackTypeStringsArray == null) {
-      return new int[0];
-    }
-    HashSet<Integer> trackTypes = new HashSet<>();
-    for (String trackTypeString : trackTypeStringsArray) {
-      switch (Util.toLowerInvariant(trackTypeString)) {
-        case "audio":
-          trackTypes.add(C.TRACK_TYPE_AUDIO);
-          break;
-        case "video":
-          trackTypes.add(C.TRACK_TYPE_VIDEO);
-          break;
-        default:
-          throw new IllegalArgumentException("Invalid track type: " + trackTypeString);
-      }
-    }
-    return Util.toArray(new ArrayList<>(trackTypes));
-  }
-
-  public static Sample createFromIntent(Intent intent) {
-    if (ACTION_VIEW_LIST.equals(intent.getAction())) {
-      ArrayList<String> intentUris = new ArrayList<>();
-      int index = 0;
-      while (intent.hasExtra(URI_EXTRA + "_" + index)) {
-        intentUris.add(intent.getStringExtra(URI_EXTRA + "_" + index));
-        index++;
-      }
-      UriSample[] children = new UriSample[intentUris.size()];
-      for (int i = 0; i < children.length; i++) {
-        Uri uri = Uri.parse(intentUris.get(i));
-        children[i] = UriSample.createFromIntent(uri, intent, /* extrasKeySuffix= */ "_" + i);
-      }
-      return new PlaylistSample(/* name= */ null, children);
-    } else {
-      return UriSample.createFromIntent(intent.getData(), intent, /* extrasKeySuffix= */ "");
-    }
-  }
-
-  public final String name;
-
-  public Sample(String name) {
-    this.name = name;
-  }
-
-  public abstract void addToIntent(Intent intent);
-}
diff --git a/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java
index 740f016..bfc476f 100644
--- a/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java
+++ b/tree/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java
@@ -15,10 +15,13 @@
  */
 package com.google.android.exoplayer2.demo;
 
+import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
+
 import android.content.Context;
 import android.content.Intent;
 import android.content.SharedPreferences;
 import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
 import android.content.res.AssetManager;
 import android.net.Uri;
 import android.os.AsyncTask;
@@ -39,11 +42,11 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatActivity;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.MediaMetadata;
 import com.google.android.exoplayer2.ParserException;
 import com.google.android.exoplayer2.RenderersFactory;
-import com.google.android.exoplayer2.demo.Sample.DrmInfo;
-import com.google.android.exoplayer2.demo.Sample.PlaylistSample;
-import com.google.android.exoplayer2.demo.Sample.UriSample;
 import com.google.android.exoplayer2.offline.DownloadService;
 import com.google.android.exoplayer2.upstream.DataSource;
 import com.google.android.exoplayer2.upstream.DataSourceInputStream;
@@ -58,7 +61,10 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 
 /** An activity for selecting from a list of media samples. */
 public class SampleChooserActivity extends AppCompatActivity
@@ -182,7 +188,7 @@
   }
 
   private void loadSample() {
-    Assertions.checkNotNull(uris);
+    checkNotNull(uris);
 
     for (int i = 0; i < uris.length; i++) {
       Uri uri = Uri.parse(uris[i]);
@@ -195,12 +201,12 @@
     loaderTask.execute(uris);
   }
 
-  private void onSampleGroups(final List<SampleGroup> groups, boolean sawError) {
+  private void onPlaylistGroups(final List<PlaylistGroup> groups, boolean sawError) {
     if (sawError) {
       Toast.makeText(getApplicationContext(), R.string.sample_list_load_error, Toast.LENGTH_LONG)
           .show();
     }
-    sampleAdapter.setSampleGroups(groups);
+    sampleAdapter.setPlaylistGroups(groups);
 
     SharedPreferences preferences = getPreferences(MODE_PRIVATE);
 
@@ -227,24 +233,24 @@
     prefEditor.putInt(CHILD_POSITION_PREFERENCE_KEY, childPosition);
     prefEditor.apply();
 
-    Sample sample = (Sample) view.getTag();
+    PlaylistHolder playlistHolder = (PlaylistHolder) view.getTag();
     Intent intent = new Intent(this, PlayerActivity.class);
     intent.putExtra(
-        PlayerActivity.PREFER_EXTENSION_DECODERS_EXTRA,
+        IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA,
         isNonNullAndChecked(preferExtensionDecodersMenuItem));
     String abrAlgorithm =
         isNonNullAndChecked(randomAbrMenuItem)
-            ? PlayerActivity.ABR_ALGORITHM_RANDOM
-            : PlayerActivity.ABR_ALGORITHM_DEFAULT;
-    intent.putExtra(PlayerActivity.ABR_ALGORITHM_EXTRA, abrAlgorithm);
-    intent.putExtra(PlayerActivity.TUNNELING_EXTRA, isNonNullAndChecked(tunnelingMenuItem));
-    sample.addToIntent(intent);
+            ? IntentUtil.ABR_ALGORITHM_RANDOM
+            : IntentUtil.ABR_ALGORITHM_DEFAULT;
+    intent.putExtra(IntentUtil.ABR_ALGORITHM_EXTRA, abrAlgorithm);
+    intent.putExtra(IntentUtil.TUNNELING_EXTRA, isNonNullAndChecked(tunnelingMenuItem));
+    IntentUtil.addToIntent(playlistHolder.mediaItems, intent);
     startActivity(intent);
     return true;
   }
 
-  private void onSampleDownloadButtonClicked(Sample sample) {
-    int downloadUnsupportedStringId = getDownloadUnsupportedStringId(sample);
+  private void onSampleDownloadButtonClicked(PlaylistHolder playlistHolder) {
+    int downloadUnsupportedStringId = getDownloadUnsupportedStringId(playlistHolder);
     if (downloadUnsupportedStringId != 0) {
       Toast.makeText(getApplicationContext(), downloadUnsupportedStringId, Toast.LENGTH_LONG)
           .show();
@@ -253,25 +259,26 @@
           ((DemoApplication) getApplication())
               .buildRenderersFactory(isNonNullAndChecked(preferExtensionDecodersMenuItem));
       downloadTracker.toggleDownload(
-          getSupportFragmentManager(), (UriSample) sample, renderersFactory);
+          getSupportFragmentManager(), playlistHolder.mediaItems.get(0), renderersFactory);
     }
   }
 
-  private int getDownloadUnsupportedStringId(Sample sample) {
-    if (sample instanceof PlaylistSample) {
+  private int getDownloadUnsupportedStringId(PlaylistHolder playlistHolder) {
+    if (playlistHolder.mediaItems.size() > 1) {
       return R.string.download_playlist_unsupported;
     }
-    UriSample uriSample = (UriSample) sample;
-    if (uriSample.drmInfo != null) {
+    MediaItem.PlaybackProperties playbackProperties =
+        checkNotNull(playlistHolder.mediaItems.get(0).playbackProperties);
+    if (playbackProperties.drmConfiguration != null) {
       return R.string.download_drm_unsupported;
     }
-    if (uriSample.isLive) {
+    if (((IntentUtil.Tag) checkNotNull(playbackProperties.tag)).isLive) {
       return R.string.download_live_unsupported;
     }
-    if (uriSample.adTagUri != null) {
+    if (playbackProperties.adTagUri != null) {
       return R.string.download_ads_unsupported;
     }
-    String scheme = uriSample.uri.getScheme();
+    String scheme = playbackProperties.sourceUri.getScheme();
     if (!("http".equals(scheme) || "https".equals(scheme))) {
       return R.string.download_scheme_unsupported;
     }
@@ -283,13 +290,13 @@
     return menuItem != null && menuItem.isChecked();
   }
 
-  private final class SampleListLoader extends AsyncTask<String, Void, List<SampleGroup>> {
+  private final class SampleListLoader extends AsyncTask<String, Void, List<PlaylistGroup>> {
 
     private boolean sawError;
 
     @Override
-    protected List<SampleGroup> doInBackground(String... uris) {
-      List<SampleGroup> result = new ArrayList<>();
+    protected List<PlaylistGroup> doInBackground(String... uris) {
+      List<PlaylistGroup> result = new ArrayList<>();
       Context context = getApplicationContext();
       String userAgent = Util.getUserAgent(context, "ExoPlayerDemo");
       DataSource dataSource =
@@ -298,7 +305,7 @@
         DataSpec dataSpec = new DataSpec(Uri.parse(uri));
         InputStream inputStream = new DataSourceInputStream(dataSource, dataSpec);
         try {
-          readSampleGroups(new JsonReader(new InputStreamReader(inputStream, "UTF-8")), result);
+          readPlaylistGroups(new JsonReader(new InputStreamReader(inputStream, "UTF-8")), result);
         } catch (Exception e) {
           Log.e(TAG, "Error loading sample list: " + uri, e);
           sawError = true;
@@ -310,21 +317,23 @@
     }
 
     @Override
-    protected void onPostExecute(List<SampleGroup> result) {
-      onSampleGroups(result, sawError);
+    protected void onPostExecute(List<PlaylistGroup> result) {
+      onPlaylistGroups(result, sawError);
     }
 
-    private void readSampleGroups(JsonReader reader, List<SampleGroup> groups) throws IOException {
+    private void readPlaylistGroups(JsonReader reader, List<PlaylistGroup> groups)
+        throws IOException {
       reader.beginArray();
       while (reader.hasNext()) {
-        readSampleGroup(reader, groups);
+        readPlaylistGroup(reader, groups);
       }
       reader.endArray();
     }
 
-    private void readSampleGroup(JsonReader reader, List<SampleGroup> groups) throws IOException {
+    private void readPlaylistGroup(JsonReader reader, List<PlaylistGroup> groups)
+        throws IOException {
       String groupName = "";
-      ArrayList<Sample> samples = new ArrayList<>();
+      ArrayList<PlaylistHolder> playlistHolders = new ArrayList<>();
 
       reader.beginObject();
       while (reader.hasNext()) {
@@ -336,7 +345,7 @@
           case "samples":
             reader.beginArray();
             while (reader.hasNext()) {
-              samples.add(readEntry(reader, false));
+              playlistHolders.add(readEntry(reader, false));
             }
             reader.endArray();
             break;
@@ -349,34 +358,28 @@
       }
       reader.endObject();
 
-      SampleGroup group = getGroup(groupName, groups);
-      group.samples.addAll(samples);
+      PlaylistGroup group = getGroup(groupName, groups);
+      group.playlists.addAll(playlistHolders);
     }
 
-    private Sample readEntry(JsonReader reader, boolean insidePlaylist) throws IOException {
-      String sampleName = null;
+    private PlaylistHolder readEntry(JsonReader reader, boolean insidePlaylist) throws IOException {
       Uri uri = null;
       String extension = null;
+      String title = null;
       boolean isLive = false;
-      String drmScheme = null;
-      String drmLicenseUrl = null;
-      String[] drmKeyRequestProperties = null;
-      String[] drmSessionForClearTypes = null;
-      boolean drmMultiSession = false;
-      ArrayList<UriSample> playlistSamples = null;
-      String adTagUri = null;
       String sphericalStereoMode = null;
-      List<Sample.SubtitleInfo> subtitleInfos = new ArrayList<>();
+      ArrayList<PlaylistHolder> children = null;
       Uri subtitleUri = null;
       String subtitleMimeType = null;
       String subtitleLanguage = null;
 
+      MediaItem.Builder mediaItem = new MediaItem.Builder();
       reader.beginObject();
       while (reader.hasNext()) {
         String name = reader.nextName();
         switch (name) {
           case "name":
-            sampleName = reader.nextString();
+            title = reader.nextString();
             break;
           case "uri":
             uri = Uri.parse(reader.nextString());
@@ -385,47 +388,46 @@
             extension = reader.nextString();
             break;
           case "drm_scheme":
-            drmScheme = reader.nextString();
+            mediaItem.setDrmUuid(Util.getDrmUuid(reader.nextString()));
             break;
           case "is_live":
             isLive = reader.nextBoolean();
             break;
           case "drm_license_url":
-            drmLicenseUrl = reader.nextString();
+            mediaItem.setDrmLicenseUri(reader.nextString());
             break;
           case "drm_key_request_properties":
-            ArrayList<String> drmKeyRequestPropertiesList = new ArrayList<>();
+            Map<String, String> requestHeaders = new HashMap<>();
             reader.beginObject();
             while (reader.hasNext()) {
-              drmKeyRequestPropertiesList.add(reader.nextName());
-              drmKeyRequestPropertiesList.add(reader.nextString());
+              requestHeaders.put(reader.nextName(), reader.nextString());
             }
             reader.endObject();
-            drmKeyRequestProperties = drmKeyRequestPropertiesList.toArray(new String[0]);
+            mediaItem.setDrmLicenseRequestHeaders(requestHeaders);
             break;
           case "drm_session_for_clear_types":
-            ArrayList<String> drmSessionForClearTypesList = new ArrayList<>();
+            HashSet<Integer> drmSessionForClearTypes = new HashSet<>();
             reader.beginArray();
             while (reader.hasNext()) {
-              drmSessionForClearTypesList.add(reader.nextString());
+              drmSessionForClearTypes.add(toTrackType(reader.nextString()));
             }
             reader.endArray();
-            drmSessionForClearTypes = drmSessionForClearTypesList.toArray(new String[0]);
+            mediaItem.setDrmSessionForClearTypes(new ArrayList<>(drmSessionForClearTypes));
             break;
           case "drm_multi_session":
-            drmMultiSession = reader.nextBoolean();
+            mediaItem.setDrmMultiSession(reader.nextBoolean());
             break;
           case "playlist":
             Assertions.checkState(!insidePlaylist, "Invalid nesting of playlists");
-            playlistSamples = new ArrayList<>();
+            children = new ArrayList<>();
             reader.beginArray();
             while (reader.hasNext()) {
-              playlistSamples.add((UriSample) readEntry(reader, /* insidePlaylist= */ true));
+              children.add(readEntry(reader, /* insidePlaylist= */ true));
             }
             reader.endArray();
             break;
           case "ad_tag_uri":
-            adTagUri = reader.nextString();
+            mediaItem.setAdTagUri(reader.nextString());
             break;
           case "spherical_stereo_mode":
             Assertions.checkState(
@@ -446,67 +448,71 @@
         }
       }
       reader.endObject();
-      DrmInfo drmInfo =
-          drmScheme == null
-              ? null
-              : new DrmInfo(
-                  Util.getDrmUuid(drmScheme),
-                  drmLicenseUrl,
-                  drmKeyRequestProperties,
-                  Sample.toTrackTypeArray(drmSessionForClearTypes),
-                  drmMultiSession);
-      Sample.SubtitleInfo subtitleInfo =
-          subtitleUri == null
-              ? null
-              : new Sample.SubtitleInfo(
+
+      if (children != null) {
+        List<MediaItem> mediaItems = new ArrayList<>();
+        for (int i = 0; i < children.size(); i++) {
+          mediaItems.addAll(children.get(i).mediaItems);
+        }
+        return new PlaylistHolder(title, mediaItems);
+      } else {
+        mediaItem
+            .setSourceUri(uri)
+            .setMediaMetadata(new MediaMetadata.Builder().setTitle(title).build())
+            .setMimeType(IntentUtil.inferAdaptiveStreamMimeType(uri, extension))
+            .setTag(new IntentUtil.Tag(isLive, sphericalStereoMode));
+        if (subtitleUri != null) {
+          MediaItem.Subtitle subtitle =
+              new MediaItem.Subtitle(
                   subtitleUri,
-                  Assertions.checkNotNull(
+                  checkNotNull(
                       subtitleMimeType, "subtitle_mime_type is required if subtitle_uri is set."),
                   subtitleLanguage);
-      if (playlistSamples != null) {
-        UriSample[] playlistSamplesArray = playlistSamples.toArray(new UriSample[0]);
-        return new PlaylistSample(sampleName, playlistSamplesArray);
-      } else {
-        return new UriSample(
-            sampleName,
-            uri,
-            extension,
-            isLive,
-            drmInfo,
-            adTagUri != null ? Uri.parse(adTagUri) : null,
-            sphericalStereoMode,
-            subtitleInfo);
+          mediaItem.setSubtitles(Collections.singletonList(subtitle));
+        }
+        return new PlaylistHolder(title, Collections.singletonList(mediaItem.build()));
       }
     }
 
-    private SampleGroup getGroup(String groupName, List<SampleGroup> groups) {
+    private PlaylistGroup getGroup(String groupName, List<PlaylistGroup> groups) {
       for (int i = 0; i < groups.size(); i++) {
         if (Util.areEqual(groupName, groups.get(i).title)) {
           return groups.get(i);
         }
       }
-      SampleGroup group = new SampleGroup(groupName);
+      PlaylistGroup group = new PlaylistGroup(groupName);
       groups.add(group);
       return group;
     }
+
+    private int toTrackType(String trackTypeString) {
+      switch (Util.toLowerInvariant(trackTypeString)) {
+        case "audio":
+          return C.TRACK_TYPE_AUDIO;
+        case "video":
+          return C.TRACK_TYPE_VIDEO;
+        default:
+          throw new IllegalArgumentException("Invalid track type: " + trackTypeString);
+      }
+    }
   }
 
   private final class SampleAdapter extends BaseExpandableListAdapter implements OnClickListener {
 
-    private List<SampleGroup> sampleGroups;
+    private List<PlaylistGroup> playlistGroups;
 
     public SampleAdapter() {
-      sampleGroups = Collections.emptyList();
+      playlistGroups = Collections.emptyList();
     }
 
-    public void setSampleGroups(List<SampleGroup> sampleGroups) {
-      this.sampleGroups = sampleGroups;
+    public void setPlaylistGroups(List<PlaylistGroup> playlistGroups) {
+      this.playlistGroups = playlistGroups;
       notifyDataSetChanged();
     }
 
     @Override
-    public Sample getChild(int groupPosition, int childPosition) {
-      return getGroup(groupPosition).samples.get(childPosition);
+    public PlaylistHolder getChild(int groupPosition, int childPosition) {
+      return getGroup(groupPosition).playlists.get(childPosition);
     }
 
     @Override
@@ -534,12 +540,12 @@
 
     @Override
     public int getChildrenCount(int groupPosition) {
-      return getGroup(groupPosition).samples.size();
+      return getGroup(groupPosition).playlists.size();
     }
 
     @Override
-    public SampleGroup getGroup(int groupPosition) {
-      return sampleGroups.get(groupPosition);
+    public PlaylistGroup getGroup(int groupPosition) {
+      return playlistGroups.get(groupPosition);
     }
 
     @Override
@@ -562,7 +568,7 @@
 
     @Override
     public int getGroupCount() {
-      return sampleGroups.size();
+      return playlistGroups.size();
     }
 
     @Override
@@ -577,18 +583,19 @@
 
     @Override
     public void onClick(View view) {
-      onSampleDownloadButtonClicked((Sample) view.getTag());
+      onSampleDownloadButtonClicked((PlaylistHolder) view.getTag());
     }
 
-    private void initializeChildView(View view, Sample sample) {
-      view.setTag(sample);
+    private void initializeChildView(View view, PlaylistHolder playlistHolder) {
+      view.setTag(playlistHolder);
       TextView sampleTitle = view.findViewById(R.id.sample_title);
-      sampleTitle.setText(sample.name);
+      sampleTitle.setText(playlistHolder.title);
 
-      boolean canDownload = getDownloadUnsupportedStringId(sample) == 0;
-      boolean isDownloaded = canDownload && downloadTracker.isDownloaded(((UriSample) sample).uri);
+      boolean canDownload = getDownloadUnsupportedStringId(playlistHolder) == 0;
+      boolean isDownloaded =
+          canDownload && downloadTracker.isDownloaded(playlistHolder.mediaItems.get(0));
       ImageButton downloadButton = view.findViewById(R.id.download_button);
-      downloadButton.setTag(sample);
+      downloadButton.setTag(playlistHolder);
       downloadButton.setColorFilter(
           canDownload ? (isDownloaded ? 0xFF42A5F5 : 0xFFBDBDBD) : 0xFF666666);
       downloadButton.setImageResource(
@@ -596,14 +603,26 @@
     }
   }
 
-  private static final class SampleGroup {
+  private static final class PlaylistHolder {
 
     public final String title;
-    public final List<Sample> samples;
+    public final List<MediaItem> mediaItems;
 
-    public SampleGroup(String title) {
+    private PlaylistHolder(String title, List<MediaItem> mediaItems) {
+      Assertions.checkArgument(!mediaItems.isEmpty());
       this.title = title;
-      this.samples = new ArrayList<>();
+      this.mediaItems = Collections.unmodifiableList(new ArrayList<>(mediaItems));
+    }
+  }
+
+  private static final class PlaylistGroup {
+
+    public final String title;
+    public final List<PlaylistHolder> playlists;
+
+    public PlaylistGroup(String title) {
+      this.title = title;
+      this.playlists = new ArrayList<>();
     }
   }
 }
diff --git a/tree/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java b/tree/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java
index 2c25c32..a05dda1 100644
--- a/tree/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java
+++ b/tree/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java
@@ -166,7 +166,7 @@
     private final boolean preferGMSCoreCronet;
 
     // Multi-catch can only be used for API 19+ in this case.
-    // incompatible types in argument.
+    // Field#get(null) is blocked by the null-checker, but is safe because the field is static.
     @SuppressWarnings({"UseMultiCatch", "nullness:argument.type.incompatible"})
     public CronetProviderComparator(boolean preferGMSCoreCronet) {
       // GMSCore CronetProvider classes are only available in some configurations.
diff --git a/tree/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java b/tree/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java
index e203849..ed28a22 100644
--- a/tree/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java
+++ b/tree/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java
@@ -17,7 +17,6 @@
 
 import static org.junit.Assert.fail;
 
-import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.testutil.ExtractorAsserts;
 import org.junit.Before;
@@ -25,6 +24,8 @@
 import org.junit.runner.RunWith;
 
 /** Unit test for {@link FlacExtractor}. */
+// TODO(internal: b/26110951): Use org.junit.runners.Parameterized (and corresponding methods on
+//  ExtractorAsserts) when it's supported by our testing infrastructure.
 @RunWith(AndroidJUnit4.class)
 public class FlacExtractorTest {
 
@@ -37,91 +38,79 @@
 
   @Test
   public void sample() throws Exception {
-    ExtractorAsserts.assertBehavior(
-        FlacExtractor::new,
-        /* file= */ "flac/bear.flac",
-        ApplicationProvider.getApplicationContext(),
-        /* dumpFilesPrefix= */ "flac/bear_raw");
+    ExtractorAsserts.assertAllBehaviors(
+        FlacExtractor::new, /* file= */ "flac/bear.flac", /* dumpFilesPrefix= */ "flac/bear_raw");
   }
 
   @Test
   public void sampleWithId3HeaderAndId3Enabled() throws Exception {
-    ExtractorAsserts.assertBehavior(
+    ExtractorAsserts.assertAllBehaviors(
         FlacExtractor::new,
         /* file= */ "flac/bear_with_id3.flac",
-        ApplicationProvider.getApplicationContext(),
         /* dumpFilesPrefix= */ "flac/bear_with_id3_enabled_raw");
   }
 
   @Test
   public void sampleWithId3HeaderAndId3Disabled() throws Exception {
-    ExtractorAsserts.assertBehavior(
+    ExtractorAsserts.assertAllBehaviors(
         () -> new FlacExtractor(FlacExtractor.FLAG_DISABLE_ID3_METADATA),
         /* file= */ "flac/bear_with_id3.flac",
-        ApplicationProvider.getApplicationContext(),
         /* dumpFilesPrefix= */ "flac/bear_with_id3_disabled_raw");
   }
 
   @Test
   public void sampleUnseekable() throws Exception {
-    ExtractorAsserts.assertBehavior(
+    ExtractorAsserts.assertAllBehaviors(
         FlacExtractor::new,
         /* file= */ "flac/bear_no_seek_table_no_num_samples.flac",
-        ApplicationProvider.getApplicationContext(),
         /* dumpFilesPrefix= */ "flac/bear_no_seek_table_no_num_samples_raw");
   }
 
   @Test
   public void sampleWithVorbisComments() throws Exception {
-    ExtractorAsserts.assertBehavior(
+    ExtractorAsserts.assertAllBehaviors(
         FlacExtractor::new,
         /* file= */ "flac/bear_with_vorbis_comments.flac",
-        ApplicationProvider.getApplicationContext(),
         /* dumpFilesPrefix= */ "flac/bear_with_vorbis_comments_raw");
   }
 
   @Test
   public void sampleWithPicture() throws Exception {
-    ExtractorAsserts.assertBehavior(
+    ExtractorAsserts.assertAllBehaviors(
         FlacExtractor::new,
         /* file= */ "flac/bear_with_picture.flac",
-        ApplicationProvider.getApplicationContext(),
         /* dumpFilesPrefix= */ "flac/bear_with_picture_raw");
   }
 
   @Test
   public void oneMetadataBlock() throws Exception {
-    ExtractorAsserts.assertBehavior(
+    ExtractorAsserts.assertAllBehaviors(
         FlacExtractor::new,
         /* file= */ "flac/bear_one_metadata_block.flac",
-        ApplicationProvider.getApplicationContext(),
         /* dumpFilesPrefix= */ "flac/bear_one_metadata_block_raw");
   }
 
   @Test
   public void noMinMaxFrameSize() throws Exception {
-    ExtractorAsserts.assertBehavior(
+    ExtractorAsserts.assertAllBehaviors(
         FlacExtractor::new,
         /* file= */ "flac/bear_no_min_max_frame_size.flac",
-        ApplicationProvider.getApplicationContext(),
         /* dumpFilesPrefix= */ "flac/bear_no_min_max_frame_size_raw");
   }
 
   @Test
   public void noNumSamples() throws Exception {
-    ExtractorAsserts.assertBehavior(
+    ExtractorAsserts.assertAllBehaviors(
         FlacExtractor::new,
         /* file= */ "flac/bear_no_num_samples.flac",
-        ApplicationProvider.getApplicationContext(),
         /* dumpFilesPrefix= */ "flac/bear_no_num_samples_raw");
   }
 
   @Test
   public void uncommonSampleRate() throws Exception {
-    ExtractorAsserts.assertBehavior(
+    ExtractorAsserts.assertAllBehaviors(
         FlacExtractor::new,
         /* file= */ "flac/bear_uncommon_sample_rate.flac",
-        ApplicationProvider.getApplicationContext(),
         /* dumpFilesPrefix= */ "flac/bear_uncommon_sample_rate_raw");
   }
 }
diff --git a/tree/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/tree/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java
index 04a12e5..947e891 100644
--- a/tree/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java
+++ b/tree/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java
@@ -472,7 +472,9 @@
     adCallbacks = new ArrayList<>(/* initialCapacity= */ 1);
     adDisplayContainer = imaFactory.createAdDisplayContainer();
     adDisplayContainer.setPlayer(/* videoAdPlayer= */ this);
-    adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer);
+    adsLoader =
+        imaFactory.createAdsLoader(
+            context.getApplicationContext(), imaSdkSettings, adDisplayContainer);
     adsLoader.addAdErrorListener(/* adErrorListener= */ this);
     adsLoader.addAdsLoadedListener(/* adsLoadedListener= */ this);
     fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET;
diff --git a/tree/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java b/tree/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java
index 913c1d8..fc75d4f 100644
--- a/tree/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java
+++ b/tree/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java
@@ -207,25 +207,25 @@
      *
      * @param mediaId The media id of the media item to be prepared.
      * @param playWhenReady Whether playback should be started after preparation.
-     * @param extras A {@link Bundle} of extras passed by the media controller.
+     * @param extras A {@link Bundle} of extras passed by the media controller, may be null.
      */
-    void onPrepareFromMediaId(String mediaId, boolean playWhenReady, Bundle extras);
+    void onPrepareFromMediaId(String mediaId, boolean playWhenReady, @Nullable Bundle extras);
     /**
      * See {@link MediaSessionCompat.Callback#onPrepareFromSearch(String, Bundle)}.
      *
      * @param query The search query.
      * @param playWhenReady Whether playback should be started after preparation.
-     * @param extras A {@link Bundle} of extras passed by the media controller.
+     * @param extras A {@link Bundle} of extras passed by the media controller, may be null.
      */
-    void onPrepareFromSearch(String query, boolean playWhenReady, Bundle extras);
+    void onPrepareFromSearch(String query, boolean playWhenReady, @Nullable Bundle extras);
     /**
      * See {@link MediaSessionCompat.Callback#onPrepareFromUri(Uri, Bundle)}.
      *
      * @param uri The {@link Uri} of the media item to be prepared.
      * @param playWhenReady Whether playback should be started after preparation.
-     * @param extras A {@link Bundle} of extras passed by the media controller.
+     * @param extras A {@link Bundle} of extras passed by the media controller, may be null.
      */
-    void onPrepareFromUri(Uri uri, boolean playWhenReady, Bundle extras);
+    void onPrepareFromUri(Uri uri, boolean playWhenReady, @Nullable Bundle extras);
   }
 
   /**
@@ -325,7 +325,7 @@
     void onSetRating(Player player, RatingCompat rating);
 
     /** See {@link MediaSessionCompat.Callback#onSetRating(RatingCompat, Bundle)}. */
-    void onSetRating(Player player, RatingCompat rating, Bundle extras);
+    void onSetRating(Player player, RatingCompat rating, @Nullable Bundle extras);
   }
 
   /** Handles requests for enabling or disabling captions. */
@@ -370,7 +370,7 @@
      * @param controlDispatcher A {@link ControlDispatcher} that should be used for dispatching
      *     changes to the player.
      * @param action The name of the action which was sent by a media controller.
-     * @param extras Optional extras sent by a media controller.
+     * @param extras Optional extras sent by a media controller, may be null.
      */
     void onCustomAction(
         Player player, ControlDispatcher controlDispatcher, String action, @Nullable Bundle extras);
@@ -1284,42 +1284,42 @@
     }
 
     @Override
-    public void onPrepareFromMediaId(String mediaId, Bundle extras) {
+    public void onPrepareFromMediaId(String mediaId, @Nullable Bundle extras) {
       if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID)) {
         playbackPreparer.onPrepareFromMediaId(mediaId, /* playWhenReady= */ false, extras);
       }
     }
 
     @Override
-    public void onPrepareFromSearch(String query, Bundle extras) {
+    public void onPrepareFromSearch(String query, @Nullable Bundle extras) {
       if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH)) {
         playbackPreparer.onPrepareFromSearch(query, /* playWhenReady= */ false, extras);
       }
     }
 
     @Override
-    public void onPrepareFromUri(Uri uri, Bundle extras) {
+    public void onPrepareFromUri(Uri uri, @Nullable Bundle extras) {
       if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_URI)) {
         playbackPreparer.onPrepareFromUri(uri, /* playWhenReady= */ false, extras);
       }
     }
 
     @Override
-    public void onPlayFromMediaId(String mediaId, Bundle extras) {
+    public void onPlayFromMediaId(String mediaId, @Nullable Bundle extras) {
       if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID)) {
         playbackPreparer.onPrepareFromMediaId(mediaId, /* playWhenReady= */ true, extras);
       }
     }
 
     @Override
-    public void onPlayFromSearch(String query, Bundle extras) {
+    public void onPlayFromSearch(String query, @Nullable Bundle extras) {
       if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH)) {
         playbackPreparer.onPrepareFromSearch(query, /* playWhenReady= */ true, extras);
       }
     }
 
     @Override
-    public void onPlayFromUri(Uri uri, Bundle extras) {
+    public void onPlayFromUri(Uri uri, @Nullable Bundle extras) {
       if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_URI)) {
         playbackPreparer.onPrepareFromUri(uri, /* playWhenReady= */ true, extras);
       }
@@ -1333,7 +1333,7 @@
     }
 
     @Override
-    public void onSetRating(RatingCompat rating, Bundle extras) {
+    public void onSetRating(RatingCompat rating, @Nullable Bundle extras) {
       if (canDispatchSetRating()) {
         ratingCallback.onSetRating(player, rating, extras);
       }
diff --git a/tree/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java b/tree/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java
index f484d3f..e3fc32e 100644
--- a/tree/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java
+++ b/tree/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java
@@ -68,9 +68,11 @@
     private boolean drmMultiSession;
     private boolean drmPlayClearContentWithoutKey;
     private List<Integer> drmSessionForClearTypes;
+    @Nullable private byte[] drmKeySetId;
     private List<StreamKey> streamKeys;
     @Nullable private String customCacheKey;
     private List<Subtitle> subtitles;
+    @Nullable private Uri adTagUri;
     @Nullable private Object tag;
     @Nullable private MediaMetadata mediaMetadata;
 
@@ -88,12 +90,13 @@
       clipEndPositionMs = mediaItem.clippingProperties.endPositionMs;
       clipRelativeToLiveWindow = mediaItem.clippingProperties.relativeToLiveWindow;
       clipRelativeToDefaultPosition = mediaItem.clippingProperties.relativeToDefaultPosition;
-      clipStartsAtKeyFrame = mediaItem.clippingProperties.startsAtKeyFrame;
       clipStartPositionMs = mediaItem.clippingProperties.startPositionMs;
+      clipStartsAtKeyFrame = mediaItem.clippingProperties.startsAtKeyFrame;
       mediaId = mediaItem.mediaId;
       mediaMetadata = mediaItem.mediaMetadata;
       @Nullable PlaybackProperties playbackProperties = mediaItem.playbackProperties;
       if (playbackProperties != null) {
+        adTagUri = playbackProperties.adTagUri;
         customCacheKey = playbackProperties.customCacheKey;
         mimeType = playbackProperties.mimeType;
         sourceUri = playbackProperties.sourceUri;
@@ -108,6 +111,7 @@
           drmPlayClearContentWithoutKey = drmConfiguration.playClearContentWithoutKey;
           drmSessionForClearTypes = drmConfiguration.sessionForClearTypes;
           drmUuid = drmConfiguration.uuid;
+          drmKeySetId = drmConfiguration.getKeySetId();
         }
       }
     }
@@ -310,6 +314,20 @@
     }
 
     /**
+     * Sets the key set ID of the offline license.
+     *
+     * <p>The key set ID identifies an offline license. The ID is required to query, renew or
+     * release an existing offline license (see {@code DefaultDrmSessionManager#setMode(int
+     * mode,byte[] offlineLicenseKeySetId)}).
+     *
+     * <p>If no valid DRM configuration is specified, the key set ID is ignored.
+     */
+    public Builder setDrmKeySetId(@Nullable byte[] keySetId) {
+      this.drmKeySetId = keySetId != null ? Arrays.copyOf(keySetId, keySetId.length) : null;
+      return this;
+    }
+
+    /**
      * Sets the optional stream keys by which the manifest is filtered (only used for adaptive
      * streams).
      *
@@ -354,6 +372,28 @@
     }
 
     /**
+     * Sets the optional ad tag URI.
+     *
+     * <p>If a {@link PlaybackProperties#sourceUri} is set, the ad tag URI is used to create a
+     * {@link PlaybackProperties} object. Otherwise it will be ignored.
+     */
+    public Builder setAdTagUri(@Nullable String adTagUri) {
+      this.adTagUri = adTagUri != null ? Uri.parse(adTagUri) : null;
+      return this;
+    }
+
+    /**
+     * Sets the optional ad tag {@link Uri}.
+     *
+     * <p>If a {@link PlaybackProperties#sourceUri} is set, the ad tag URI is used to create a
+     * {@link PlaybackProperties} object. Otherwise it will be ignored.
+     */
+    public Builder setAdTagUri(@Nullable Uri adTagUri) {
+      this.adTagUri = adTagUri;
+      return this;
+    }
+
+    /**
      * Sets the optional tag for custom attributes. The tag for the media source which will be
      * published in the {@code com.google.android.exoplayer2.Timeline} of the source as {@code
      * com.google.android.exoplayer2.Timeline.Window#tag}.
@@ -390,11 +430,13 @@
                         drmLicenseRequestHeaders,
                         drmMultiSession,
                         drmPlayClearContentWithoutKey,
-                        drmSessionForClearTypes)
+                        drmSessionForClearTypes,
+                        drmKeySetId)
                     : null,
                 streamKeys,
                 customCacheKey,
                 subtitles,
+                adTagUri,
                 tag);
         mediaId = mediaId != null ? mediaId : sourceUri.toString();
       }
@@ -438,19 +480,29 @@
     /** The types of clear tracks for which to use a drm session. */
     public final List<Integer> sessionForClearTypes;
 
+    @Nullable private final byte[] keySetId;
+
     private DrmConfiguration(
         UUID uuid,
         @Nullable Uri licenseUri,
         Map<String, String> requestHeaders,
         boolean multiSession,
         boolean playClearContentWithoutKey,
-        List<Integer> drmSessionForClearTypes) {
+        List<Integer> drmSessionForClearTypes,
+        @Nullable byte[] keySetId) {
       this.uuid = uuid;
       this.licenseUri = licenseUri;
       this.requestHeaders = requestHeaders;
       this.multiSession = multiSession;
       this.playClearContentWithoutKey = playClearContentWithoutKey;
       this.sessionForClearTypes = drmSessionForClearTypes;
+      this.keySetId = keySetId != null ? Arrays.copyOf(keySetId, keySetId.length) : null;
+    }
+
+    /** Returns the key set ID of the offline license. */
+    @Nullable
+    public byte[] getKeySetId() {
+      return keySetId != null ? Arrays.copyOf(keySetId, keySetId.length) : null;
     }
 
     @Override
@@ -468,7 +520,8 @@
           && Util.areEqual(requestHeaders, other.requestHeaders)
           && multiSession == other.multiSession
           && playClearContentWithoutKey == other.playClearContentWithoutKey
-          && sessionForClearTypes.equals(other.sessionForClearTypes);
+          && sessionForClearTypes.equals(other.sessionForClearTypes)
+          && Arrays.equals(keySetId, other.keySetId);
     }
 
     @Override
@@ -479,6 +532,7 @@
       result = 31 * result + (multiSession ? 1 : 0);
       result = 31 * result + (playClearContentWithoutKey ? 1 : 0);
       result = 31 * result + sessionForClearTypes.hashCode();
+      result = 31 * result + Arrays.hashCode(keySetId);
       return result;
     }
   }
@@ -509,6 +563,9 @@
     /** Optional subtitles to be sideloaded. */
     public final List<Subtitle> subtitles;
 
+    /** Optional ad tag {@link Uri}. */
+    @Nullable public final Uri adTagUri;
+
     /**
      * Optional tag for custom attributes. The tag for the media source which will be published in
      * the {@code com.google.android.exoplayer2.Timeline} of the source as {@code
@@ -523,6 +580,7 @@
         List<StreamKey> streamKeys,
         @Nullable String customCacheKey,
         List<Subtitle> subtitles,
+        @Nullable Uri adTagUri,
         @Nullable Object tag) {
       this.sourceUri = sourceUri;
       this.mimeType = mimeType;
@@ -530,6 +588,7 @@
       this.streamKeys = streamKeys;
       this.customCacheKey = customCacheKey;
       this.subtitles = subtitles;
+      this.adTagUri = adTagUri;
       this.tag = tag;
     }
 
@@ -549,6 +608,7 @@
           && streamKeys.equals(other.streamKeys)
           && Util.areEqual(customCacheKey, other.customCacheKey)
           && subtitles.equals(other.subtitles)
+          && Util.areEqual(adTagUri, other.adTagUri)
           && Util.areEqual(tag, other.tag);
     }
 
@@ -560,6 +620,7 @@
       result = 31 * result + streamKeys.hashCode();
       result = 31 * result + (customCacheKey == null ? 0 : customCacheKey.hashCode());
       result = 31 * result + subtitles.hashCode();
+      result = 31 * result + (adTagUri == null ? 0 : adTagUri.hashCode());
       result = 31 * result + (tag == null ? 0 : tag.hashCode());
       return result;
     }
diff --git a/tree/library/common/src/main/java/com/google/android/exoplayer2/audio/AacUtil.java b/tree/library/common/src/main/java/com/google/android/exoplayer2/audio/AacUtil.java
index 024f848..8de4230 100644
--- a/tree/library/common/src/main/java/com/google/android/exoplayer2/audio/AacUtil.java
+++ b/tree/library/common/src/main/java/com/google/android/exoplayer2/audio/AacUtil.java
@@ -15,11 +15,15 @@
  */
 package com.google.android.exoplayer2.audio;
 
+import androidx.annotation.IntDef;
 import com.google.android.exoplayer2.C;
 import com.google.android.exoplayer2.ParserException;
 import com.google.android.exoplayer2.util.Assertions;
 import com.google.android.exoplayer2.util.Log;
 import com.google.android.exoplayer2.util.ParsableBitArray;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
 
 /** Utility methods for handling AAC audio streams. */
 public final class AacUtil {
@@ -132,19 +136,37 @@
   private static final String CODECS_STRING_PREFIX = "mp4a.40.";
 
   // Advanced Audio Coding Low-Complexity profile.
-  private static final int AUDIO_OBJECT_TYPE_AAC_LC = 2;
+  public static final int AUDIO_OBJECT_TYPE_AAC_LC = 2;
   // Spectral Band Replication.
-  private static final int AUDIO_OBJECT_TYPE_AAC_SBR = 5;
+  public static final int AUDIO_OBJECT_TYPE_AAC_SBR = 5;
   // Error Resilient Bit-Sliced Arithmetic Coding.
-  private static final int AUDIO_OBJECT_TYPE_AAC_ER_BSAC = 22;
+  public static final int AUDIO_OBJECT_TYPE_AAC_ER_BSAC = 22;
   // Enhanced low delay.
-  private static final int AUDIO_OBJECT_TYPE_AAC_ELD = 23;
+  public static final int AUDIO_OBJECT_TYPE_AAC_ELD = 23;
   // Parametric Stereo.
-  private static final int AUDIO_OBJECT_TYPE_AAC_PS = 29;
+  public static final int AUDIO_OBJECT_TYPE_AAC_PS = 29;
   // Escape code for extended audio object types.
   private static final int AUDIO_OBJECT_TYPE_ESCAPE = 31;
   // Extended high efficiency.
-  private static final int AUDIO_OBJECT_TYPE_AAC_XHE = 42;
+  public static final int AUDIO_OBJECT_TYPE_AAC_XHE = 42;
+
+  /**
+   * Valid AAC Audio object types. One of {@link #AUDIO_OBJECT_TYPE_AAC_LC}, {@link
+   * #AUDIO_OBJECT_TYPE_AAC_SBR}, {@link #AUDIO_OBJECT_TYPE_AAC_ER_BSAC}, {@link
+   * #AUDIO_OBJECT_TYPE_AAC_ELD}, {@link #AUDIO_OBJECT_TYPE_AAC_PS} or {@link
+   * #AUDIO_OBJECT_TYPE_AAC_XHE}.
+   */
+  @Documented
+  @Retention(RetentionPolicy.SOURCE)
+  @IntDef({
+    AUDIO_OBJECT_TYPE_AAC_LC,
+    AUDIO_OBJECT_TYPE_AAC_SBR,
+    AUDIO_OBJECT_TYPE_AAC_ER_BSAC,
+    AUDIO_OBJECT_TYPE_AAC_ELD,
+    AUDIO_OBJECT_TYPE_AAC_PS,
+    AUDIO_OBJECT_TYPE_AAC_XHE
+  })
+  public @interface AacAudioObjectType {}
 
   /**
    * Parses an AAC AudioSpecificConfig, as defined in ISO 14496-3 1.6.2.1
@@ -275,7 +297,7 @@
 
   /** Returns the encoding for a given AAC audio object type. */
   @C.Encoding
-  public static int getEncodingForAudioObjectType(int audioObjectType) {
+  public static int getEncodingForAudioObjectType(@AacAudioObjectType int audioObjectType) {
     switch (audioObjectType) {
       case AUDIO_OBJECT_TYPE_AAC_LC:
         return C.ENCODING_AAC_LC;
diff --git a/tree/library/common/src/main/java/com/google/android/exoplayer2/audio/Ac3Util.java b/tree/library/common/src/main/java/com/google/android/exoplayer2/audio/Ac3Util.java
index d4042a9..f9a97d9 100644
--- a/tree/library/common/src/main/java/com/google/android/exoplayer2/audio/Ac3Util.java
+++ b/tree/library/common/src/main/java/com/google/android/exoplayer2/audio/Ac3Util.java
@@ -24,6 +24,7 @@
 import com.google.android.exoplayer2.util.MimeTypes;
 import com.google.android.exoplayer2.util.ParsableBitArray;
 import com.google.android.exoplayer2.util.ParsableByteArray;
+import com.google.android.exoplayer2.util.Util;
 import java.lang.annotation.Documented;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -516,7 +517,7 @@
     int endIndex = buffer.limit() - TRUEHD_SYNCFRAME_PREFIX_LENGTH;
     for (int i = startIndex; i <= endIndex; i++) {
       // The syncword ends 0xBA for TrueHD or 0xBB for MLP.
-      if ((buffer.getInt(i + 4) & 0xFEFFFFFF) == 0xBA6F72F8) {
+      if ((Util.getBigEndianInt(buffer, i + 4) & 0xFFFFFFFE) == 0xF8726FBA) {
         return i - startIndex;
       }
     }
diff --git a/tree/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/tree/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java
index 60fe1a3..a074324 100644
--- a/tree/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java
+++ b/tree/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java
@@ -60,6 +60,8 @@
 import java.io.InputStream;
 import java.lang.reflect.Method;
 import java.math.BigDecimal;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
 import java.nio.charset.Charset;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -1667,6 +1669,29 @@
   }
 
   /**
+   * Makes a best guess to infer the type from a {@link Uri} and MIME type.
+   *
+   * @param uri The {@link Uri}.
+   * @param mimeType If not null, used to infer the type.
+   * @return The content type.
+   */
+  public static int inferContentTypeWithMimeType(Uri uri, @Nullable String mimeType) {
+    if (mimeType == null) {
+      return Util.inferContentType(uri);
+    }
+    switch (mimeType) {
+      case MimeTypes.APPLICATION_MPD:
+        return C.TYPE_DASH;
+      case MimeTypes.APPLICATION_M3U8:
+        return C.TYPE_HLS;
+      case MimeTypes.APPLICATION_SS:
+        return C.TYPE_SS;
+      default:
+        return Util.inferContentType(uri);
+    }
+  }
+
+  /**
    * Returns the specified millisecond time formatted as a string.
    *
    * @param builder The builder that {@code formatter} will write to.
@@ -1860,6 +1885,21 @@
   }
 
   /**
+   * Absolute <i>get</i> method for reading an int value in {@link ByteOrder#BIG_ENDIAN} in a {@link
+   * ByteBuffer}. Same as {@link ByteBuffer#getInt(int)} except the buffer's order as returned by
+   * {@link ByteBuffer#order()} is ignored and {@link ByteOrder#BIG_ENDIAN} is used instead.
+   *
+   * @param buffer The buffer from which to read an int in big endian.
+   * @param index The index from which the bytes will be read.
+   * @return The int value at the given index with the buffer bytes ordered most significant to
+   *     least significant.
+   */
+  public static int getBigEndianInt(ByteBuffer buffer, int index) {
+    int value = buffer.getInt(index);
+    return buffer.order() == ByteOrder.BIG_ENDIAN ? value : Integer.reverseBytes(value);
+  }
+
+  /**
    * Returns the {@link C.NetworkType} of the current network connection.
    *
    * @param context A context to access the connectivity manager.
diff --git a/tree/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java b/tree/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java
index adfbc60..c7a55b8 100644
--- a/tree/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java
+++ b/tree/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java
@@ -91,6 +91,7 @@
     Uri licenseUri = Uri.parse(URI_STRING);
     Map<String, String> requestHeaders = new HashMap<>();
     requestHeaders.put("Referer", "http://www.google.com");
+    byte[] keySetId = new byte[] {1, 2, 3};
     MediaItem mediaItem =
         new MediaItem.Builder()
             .setSourceUri(URI_STRING)
@@ -100,6 +101,7 @@
             .setDrmMultiSession(/* multiSession= */ true)
             .setDrmPlayClearContentWithoutKey(true)
             .setDrmSessionForClearTypes(Collections.singletonList(C.TRACK_TYPE_AUDIO))
+            .setDrmKeySetId(keySetId)
             .build();
 
     assertThat(mediaItem.playbackProperties.drmConfiguration).isNotNull();
@@ -111,6 +113,7 @@
     assertThat(mediaItem.playbackProperties.drmConfiguration.playClearContentWithoutKey).isTrue();
     assertThat(mediaItem.playbackProperties.drmConfiguration.sessionForClearTypes)
         .containsExactly(C.TRACK_TYPE_AUDIO);
+    assertThat(mediaItem.playbackProperties.drmConfiguration.getKeySetId()).isEqualTo(keySetId);
   }
 
   @Test
@@ -267,6 +270,16 @@
   }
 
   @Test
+  public void builderSetAdTagUri_setsAdTagUri() {
+    Uri adTagUri = Uri.parse(URI_STRING + "/ad");
+
+    MediaItem mediaItem =
+        new MediaItem.Builder().setSourceUri(URI_STRING).setAdTagUri(adTagUri).build();
+
+    assertThat(mediaItem.playbackProperties.adTagUri).isEqualTo(adTagUri);
+  }
+
+  @Test
   public void builderSetMediaMetadata_setsMetadata() {
     MediaMetadata mediaMetadata = new MediaMetadata.Builder().setTitle("title").build();
 
@@ -280,6 +293,7 @@
   public void buildUpon_equalsToOriginal() {
     MediaItem mediaItem =
         new MediaItem.Builder()
+            .setAdTagUri(URI_STRING)
             .setClipEndPositionMs(1000)
             .setClipRelativeToDefaultPosition(true)
             .setClipRelativeToLiveWindow(true)
@@ -293,6 +307,7 @@
             .setDrmMultiSession(true)
             .setDrmPlayClearContentWithoutKey(true)
             .setDrmSessionForClearTypes(Collections.singletonList(C.TRACK_TYPE_AUDIO))
+            .setDrmKeySetId(new byte[] {1, 2, 3})
             .setMediaId("mediaId")
             .setMediaMetadata(new MediaMetadata.Builder().setTitle("title").build())
             .setMimeType(MimeTypes.APPLICATION_MP4)
diff --git a/tree/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java b/tree/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java
index 825988c..2e523a3 100644
--- a/tree/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java
+++ b/tree/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java
@@ -27,6 +27,8 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.C;
 import com.google.android.exoplayer2.testutil.TestUtil;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Random;
@@ -790,6 +792,30 @@
   }
 
   @Test
+  public void getBigEndianInt_fromBigEndian() {
+    byte[] bytes = {0x1F, 0x2E, 0x3D, 0x4C};
+    ByteBuffer byteBuffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN);
+
+    assertThat(Util.getBigEndianInt(byteBuffer, 0)).isEqualTo(0x1F2E3D4C);
+  }
+
+  @Test
+  public void getBigEndianInt_fromLittleEndian() {
+    byte[] bytes = {(byte) 0xC2, (byte) 0xD3, (byte) 0xE4, (byte) 0xF5};
+    ByteBuffer byteBuffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
+
+    assertThat(Util.getBigEndianInt(byteBuffer, 0)).isEqualTo(0xC2D3E4F5);
+  }
+
+  @Test
+  public void getBigEndianInt_unaligned() {
+    byte[] bytes = {9, 8, 7, 6, 5};
+    ByteBuffer byteBuffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
+
+    assertThat(Util.getBigEndianInt(byteBuffer, 1)).isEqualTo(0x08070605);
+  }
+
+  @Test
   public void inflate_withDeflatedData_success() {
     byte[] testData = TestUtil.buildTestData(/*arbitrary test data size*/ 256 * 1024);
     byte[] compressedData = new byte[testData.length * 2];
diff --git a/tree/library/core/proguard-rules.txt b/tree/library/core/proguard-rules.txt
index 36038b9..cbeb74c 100644
--- a/tree/library/core/proguard-rules.txt
+++ b/tree/library/core/proguard-rules.txt
@@ -51,15 +51,15 @@
 # Constructors accessed via reflection in DefaultDownloaderFactory
 -dontnote com.google.android.exoplayer2.source.dash.offline.DashDownloader
 -keepclassmembers class com.google.android.exoplayer2.source.dash.offline.DashDownloader {
-  <init>(android.net.Uri, java.util.List, com.google.android.exoplayer2.offline.DownloaderConstructorHelper);
+  <init>(android.net.Uri, java.util.List, com.google.android.exoplayer2.upstream.cache.CacheDataSource.Factory, java.util.concurrent.Executor);
 }
 -dontnote com.google.android.exoplayer2.source.hls.offline.HlsDownloader
 -keepclassmembers class com.google.android.exoplayer2.source.hls.offline.HlsDownloader {
-  <init>(android.net.Uri, java.util.List, com.google.android.exoplayer2.offline.DownloaderConstructorHelper);
+  <init>(android.net.Uri, java.util.List, com.google.android.exoplayer2.upstream.cache.CacheDataSource.Factory, java.util.concurrent.Executor);
 }
 -dontnote com.google.android.exoplayer2.source.smoothstreaming.offline.SsDownloader
 -keepclassmembers class com.google.android.exoplayer2.source.smoothstreaming.offline.SsDownloader {
-  <init>(android.net.Uri, java.util.List, com.google.android.exoplayer2.offline.DownloaderConstructorHelper);
+  <init>(android.net.Uri, java.util.List, com.google.android.exoplayer2.upstream.cache.CacheDataSource.Factory, java.util.concurrent.Executor);
 }
 
 # Constructors accessed via reflection in DefaultMediaSourceFactory and DownloadHelper
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java
index ce35c89..5eb1402 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java
@@ -370,7 +370,8 @@
   }
 
   @Override
-  public boolean shouldContinueLoading(long bufferedDurationUs, float playbackSpeed) {
+  public boolean shouldContinueLoading(
+      long playbackPositionUs, long bufferedDurationUs, float playbackSpeed) {
     boolean targetBufferSizeReached = allocator.getTotalBytesAllocated() >= targetBufferBytes;
     long minBufferUs = this.minBufferUs;
     if (playbackSpeed > 1) {
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java
index ccdddb8..a09f85d 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java
@@ -89,7 +89,7 @@
   private long allowedVideoJoiningTimeMs;
   private boolean enableDecoderFallback;
   private MediaCodecSelector mediaCodecSelector;
-  @MediaCodecRenderer.MediaCodecOperationMode private int mediaCodecOperationMode;
+  private @MediaCodecRenderer.MediaCodecOperationMode int mediaCodecOperationMode;
 
   /** @param context A {@link Context}. */
   public DefaultRenderersFactory(Context context) {
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java
index 0344b09..2d8ea2e 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java
@@ -15,6 +15,8 @@
  */
 package com.google.android.exoplayer2;
 
+import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
+
 import android.annotation.SuppressLint;
 import android.os.Handler;
 import android.os.Looper;
@@ -28,6 +30,7 @@
 import com.google.android.exoplayer2.source.MediaSourceFactory;
 import com.google.android.exoplayer2.source.ShuffleOrder;
 import com.google.android.exoplayer2.source.TrackGroupArray;
+import com.google.android.exoplayer2.source.ads.AdsMediaSource;
 import com.google.android.exoplayer2.trackselection.TrackSelection;
 import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
 import com.google.android.exoplayer2.trackselection.TrackSelector;
@@ -84,6 +87,7 @@
   private SeekParameters seekParameters;
   private ShuffleOrder shuffleOrder;
   private boolean pauseAtEndOfMediaItems;
+  private boolean hasAdsMediaSource;
 
   // Playback information when there is no pending seek/set source operation.
   private PlaybackInfo playbackInfo;
@@ -123,8 +127,8 @@
     Log.i(TAG, "Init " + Integer.toHexString(System.identityHashCode(this)) + " ["
         + ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "]");
     Assertions.checkState(renderers.length > 0);
-    this.renderers = Assertions.checkNotNull(renderers);
-    this.trackSelector = Assertions.checkNotNull(trackSelector);
+    this.renderers = checkNotNull(renderers);
+    this.trackSelector = checkNotNull(trackSelector);
     this.mediaSourceFactory = mediaSourceFactory;
     this.useLazyPreparation = useLazyPreparation;
     repeatMode = Player.REPEAT_MODE_OFF;
@@ -397,9 +401,7 @@
   @Override
   public void addMediaSources(int index, List<MediaSource> mediaSources) {
     Assertions.checkArgument(index >= 0);
-    for (int i = 0; i < mediaSources.size(); i++) {
-      Assertions.checkArgument(mediaSources.get(i) != null);
-    }
+    validateMediaSources(mediaSources, /* mediaSourceReplacement= */ false);
     int currentWindowIndex = getCurrentWindowIndex();
     long currentPositionMs = getCurrentPosition();
     Timeline oldTimeline = getCurrentTimeline();
@@ -973,9 +975,7 @@
       int startWindowIndex,
       long startPositionMs,
       boolean resetToDefaultPosition) {
-    for (int i = 0; i < mediaSources.size(); i++) {
-      Assertions.checkArgument(mediaSources.get(i) != null);
-    }
+    validateMediaSources(mediaSources, /* mediaSourceReplacement= */ true);
     int currentWindowIndex = getCurrentWindowIndexInternal();
     long currentPositionMs = getCurrentPosition();
     pendingOperationAcks++;
@@ -1076,9 +1076,42 @@
       removed.add(mediaSourceHolders.remove(i));
     }
     shuffleOrder = shuffleOrder.cloneAndRemove(fromIndex, toIndexExclusive);
+    if (mediaSourceHolders.isEmpty()) {
+      hasAdsMediaSource = false;
+    }
     return removed;
   }
 
+  /**
+   * Validates media sources before any modification of the existing list of media sources is made.
+   * This way we can throw an exception before changing the state of the player in case of a
+   * validation failure.
+   *
+   * @param mediaSources The media sources to set or add.
+   * @param mediaSourceReplacement Whether the given media sources will replace existing ones.
+   */
+  private void validateMediaSources(
+      List<MediaSource> mediaSources, boolean mediaSourceReplacement) {
+    if (hasAdsMediaSource && !mediaSourceReplacement && !mediaSources.isEmpty()) {
+      // Adding media sources to an ads media source is not allowed
+      // (see https://github.com/google/ExoPlayer/issues/3750).
+      throw new IllegalStateException();
+    }
+    int sizeAfterModification =
+        mediaSources.size() + (mediaSourceReplacement ? 0 : mediaSourceHolders.size());
+    for (int i = 0; i < mediaSources.size(); i++) {
+      MediaSource mediaSource = checkNotNull(mediaSources.get(i));
+      if (mediaSource instanceof AdsMediaSource) {
+        if (sizeAfterModification > 1) {
+          // Ads media sources only allowed with a single source
+          // (see https://github.com/google/ExoPlayer/issues/3750).
+          throw new IllegalArgumentException();
+        }
+        hasAdsMediaSource = true;
+      }
+    }
+  }
+
   private PlaybackInfo maskTimeline() {
     return playbackInfo.copyWithTimeline(
         mediaSourceHolders.isEmpty()
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java
index 56cce6d..02e271e 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java
@@ -870,7 +870,7 @@
         }
       }
       if (throwWhenStuckBuffering
-          && !shouldContinueLoading
+          && !playbackInfo.isLoading
           && playbackInfo.totalBufferedDurationUs < 500_000
           && isLoadingPossible()) {
         // Throw if the LoadControl prevents loading even if the buffer is empty or almost empty. We
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/LoadControl.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/LoadControl.java
index d91830a..94f61bb 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/LoadControl.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/LoadControl.java
@@ -87,28 +87,20 @@
    */
   boolean retainBackBufferFromKeyframe();
 
-  /** @deprecated Use {@link LoadControl#shouldContinueLoading(long, long, float)}. */
-  @Deprecated
-  default boolean shouldContinueLoading(long bufferedDurationUs, float playbackSpeed) {
-    return false;
-  }
-
   /**
    * Called by the player to determine whether it should continue to load the source.
    *
    * @param playbackPositionUs The current playback position in microseconds, relative to the start
    *     of the {@link Timeline.Period period} that will continue to be loaded if this method
-   *     returns {@code true}. If the playback for this period has not yet started, the value will
+   *     returns {@code true}. If playback of this period has not yet started, the value will be
    *     negative and equal in magnitude to the duration of any media in previous periods still to
    *     be played.
    * @param bufferedDurationUs The duration of media that's currently buffered.
    * @param playbackSpeed The current playback speed.
    * @return Whether the loading should continue.
    */
-  default boolean shouldContinueLoading(
-      long playbackPositionUs, long bufferedDurationUs, float playbackSpeed) {
-    return shouldContinueLoading(bufferedDurationUs, playbackSpeed);
-  }
+  boolean shouldContinueLoading(
+      long playbackPositionUs, long bufferedDurationUs, float playbackSpeed);
 
   /**
    * Called repeatedly by the player when it's loading the source, has yet to start playback, and
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/Player.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/Player.java
index f692629..83609e9 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/Player.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/Player.java
@@ -582,10 +582,10 @@
     default void onPlaybackSpeedChanged(float playbackSpeed) {}
 
     /**
-     * Called when all pending seek requests have been processed by the player. This is guaranteed
-     * to happen after any necessary changes to the player state were reported to {@link
-     * #onPlaybackStateChanged(int)}.
+     * @deprecated Seeks are processed without delay. Listen to {@link
+     *     #onPositionDiscontinuity(int)} with reason {@link #DISCONTINUITY_REASON_SEEK} instead.
      */
+    @Deprecated
     default void onSeekProcessed() {}
   }
 
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java
index 67c4b88..bddfa65 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java
@@ -100,6 +100,7 @@
     private AnalyticsCollector analyticsCollector;
     private Looper looper;
     private boolean useLazyPreparation;
+    private boolean throwWhenStuckBuffering;
     private boolean buildCalled;
 
     /**
@@ -295,6 +296,19 @@
     }
 
     /**
+     * Sets whether the player should throw when it detects it's stuck buffering.
+     *
+     * <p>This method is experimental, and will be renamed or removed in a future release.
+     *
+     * @param throwWhenStuckBuffering Whether to throw when the player detects it's stuck buffering.
+     * @return This builder.
+     */
+    public Builder experimental_setThrowWhenStuckBuffering(boolean throwWhenStuckBuffering) {
+      this.throwWhenStuckBuffering = throwWhenStuckBuffering;
+      return this;
+    }
+
+    /**
      * Sets the {@link Clock} that will be used by the player. Should only be set for testing
      * purposes.
      *
@@ -384,6 +398,9 @@
         builder.useLazyPreparation,
         builder.clock,
         builder.looper);
+    if (builder.throwWhenStuckBuffering) {
+      player.experimental_throwWhenStuckBuffering();
+    }
   }
 
   /**
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java
index 59ab3f1..66216de 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java
@@ -53,6 +53,7 @@
   @C.StreamType private int streamType;
   private int volume;
   private boolean muted;
+  private boolean released;
 
   /** Creates a manager. */
   public StreamVolumeManager(Context context, Handler eventHandler, Listener listener) {
@@ -158,7 +159,11 @@
 
   /** Releases the manager. It must be called when the manager is no longer required. */
   public void release() {
+    if (released) {
+      return;
+    }
     applicationContext.unregisterReceiver(receiver);
+    released = true;
   }
 
   private void updateVolumeAndNotifyIfChanged() {
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java
index 2af577f..715a1c0 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java
@@ -554,6 +554,7 @@
     }
   }
 
+  @SuppressWarnings("deprecation")
   @Override
   public final void onSeekProcessed() {
     EventTime eventTime = generateCurrentPlayerMediaPeriodEventTime();
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java
index 77bc211..0b841ab 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java
@@ -190,10 +190,11 @@
   default void onSeekStarted(EventTime eventTime) {}
 
   /**
-   * Called when a seek operation was processed.
-   *
-   * @param eventTime The event time.
+   * @deprecated Seeks are processed without delay. Listen to {@link
+   *     #onPositionDiscontinuity(EventTime, int)} with reason {@link
+   *     Player#DISCONTINUITY_REASON_SEEK} instead.
    */
+  @Deprecated
   default void onSeekProcessed(EventTime eventTime) {}
 
   /**
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java
index e9baef3..04536bb 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java
@@ -202,6 +202,20 @@
     }
   }
 
+  @Override
+  public void finishAllSessions(EventTime eventTime) {
+    currentSessionId = null;
+    Iterator<SessionDescriptor> iterator = sessions.values().iterator();
+    while (iterator.hasNext()) {
+      SessionDescriptor session = iterator.next();
+      iterator.remove();
+      if (session.isCreated && listener != null) {
+        listener.onSessionFinished(
+            eventTime, session.sessionId, /* automaticTransitionToNextPlayback= */ false);
+      }
+    }
+  }
+
   private SessionDescriptor getOrAddSession(
       int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {
     // There should only be one matching session if mediaPeriodId is non-null. If mediaPeriodId is
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java
index 53d63e2..7045779 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java
@@ -117,4 +117,12 @@
    * @param reason The {@link DiscontinuityReason}.
    */
   void handlePositionDiscontinuity(EventTime eventTime, @DiscontinuityReason int reason);
+
+  /**
+   * Finishes all existing sessions and calls their respective {@link
+   * Listener#onSessionFinished(EventTime, String, boolean)} callback.
+   *
+   * @param eventTime The event time at which sessions are finished.
+   */
+  void finishAllSessions(EventTime eventTime);
 }
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStats.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStats.java
index 893ecb0..3b1e056 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStats.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStats.java
@@ -163,9 +163,8 @@
    * #PLAYBACK_STATE_JOINING_FOREGROUND}, {@link #PLAYBACK_STATE_JOINING_BACKGROUND}, {@link
    * #PLAYBACK_STATE_PLAYING}, {@link #PLAYBACK_STATE_PAUSED}, {@link #PLAYBACK_STATE_SEEKING},
    * {@link #PLAYBACK_STATE_BUFFERING}, {@link #PLAYBACK_STATE_PAUSED_BUFFERING}, {@link
-   * #PLAYBACK_STATE_SEEK_BUFFERING}, {@link #PLAYBACK_STATE_SUPPRESSED}, {@link
-   * #PLAYBACK_STATE_SUPPRESSED_BUFFERING}, {@link #PLAYBACK_STATE_ENDED}, {@link
-   * #PLAYBACK_STATE_STOPPED}, {@link #PLAYBACK_STATE_FAILED}, {@link
+   * #PLAYBACK_STATE_SUPPRESSED}, {@link #PLAYBACK_STATE_SUPPRESSED_BUFFERING}, {@link
+   * #PLAYBACK_STATE_ENDED}, {@link #PLAYBACK_STATE_STOPPED}, {@link #PLAYBACK_STATE_FAILED}, {@link
    * #PLAYBACK_STATE_INTERRUPTED_BY_AD} or {@link #PLAYBACK_STATE_ABANDONED}.
    */
   @Documented
@@ -180,7 +179,6 @@
     PLAYBACK_STATE_SEEKING,
     PLAYBACK_STATE_BUFFERING,
     PLAYBACK_STATE_PAUSED_BUFFERING,
-    PLAYBACK_STATE_SEEK_BUFFERING,
     PLAYBACK_STATE_SUPPRESSED,
     PLAYBACK_STATE_SUPPRESSED_BUFFERING,
     PLAYBACK_STATE_ENDED,
@@ -206,8 +204,6 @@
   public static final int PLAYBACK_STATE_BUFFERING = 6;
   /** Playback is buffering while paused. */
   public static final int PLAYBACK_STATE_PAUSED_BUFFERING = 7;
-  /** Playback is buffering after a seek. */
-  public static final int PLAYBACK_STATE_SEEK_BUFFERING = 8;
   /** Playback is suppressed (e.g. due to audio focus loss). */
   public static final int PLAYBACK_STATE_SUPPRESSED = 9;
   /** Playback is suppressed (e.g. due to audio focus loss) while buffering to resume a playback. */
@@ -769,8 +765,7 @@
    * milliseconds.
    */
   public long getTotalSeekTimeMs() {
-    return getPlaybackStateDurationMs(PLAYBACK_STATE_SEEKING)
-        + getPlaybackStateDurationMs(PLAYBACK_STATE_SEEK_BUFFERING);
+    return getPlaybackStateDurationMs(PLAYBACK_STATE_SEEKING);
   }
 
   /**
@@ -799,8 +794,7 @@
   public long getTotalWaitTimeMs() {
     return getPlaybackStateDurationMs(PLAYBACK_STATE_JOINING_FOREGROUND)
         + getPlaybackStateDurationMs(PLAYBACK_STATE_BUFFERING)
-        + getPlaybackStateDurationMs(PLAYBACK_STATE_SEEKING)
-        + getPlaybackStateDurationMs(PLAYBACK_STATE_SEEK_BUFFERING);
+        + getPlaybackStateDurationMs(PLAYBACK_STATE_SEEKING);
   }
 
   /**
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java
index 97805da..0524f4d 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java
@@ -83,7 +83,7 @@
   @Player.State private int playbackState;
   private boolean isSuppressed;
   private float playbackSpeed;
-  private boolean isSeeking;
+  private boolean onSeekStartedCalled;
 
   /**
    * Creates listener for playback stats.
@@ -150,7 +150,6 @@
     // TODO: Add AnalyticsListener.onAttachedToPlayer and onDetachedFromPlayer to auto-release with
     // an actual EventTime. Should also simplify other cases where the listener needs to be released
     // separately from the player.
-    HashMap<String, PlaybackStatsTracker> trackerCopy = new HashMap<>(playbackStatsTrackers);
     EventTime dummyEventTime =
         new EventTime(
             SystemClock.elapsedRealtime(),
@@ -160,9 +159,7 @@
             /* eventPlaybackPositionMs= */ 0,
             /* currentPlaybackPositionMs= */ 0,
             /* totalBufferedDurationMs= */ 0);
-    for (String session : trackerCopy.keySet()) {
-      onSessionFinished(dummyEventTime, session, /* automaticTransition= */ false);
-    }
+    sessionManager.finishAllSessions(dummyEventTime);
   }
 
   // PlaybackSessionManager.Listener implementation.
@@ -170,7 +167,7 @@
   @Override
   public void onSessionCreated(EventTime eventTime, String session) {
     PlaybackStatsTracker tracker = new PlaybackStatsTracker(keepHistory, eventTime);
-    if (isSeeking) {
+    if (onSeekStartedCalled) {
       tracker.onSeekStarted(eventTime, /* belongsToPlayback= */ true);
     }
     tracker.onPlaybackStateChanged(eventTime, playbackState, /* belongsToPlayback= */ true);
@@ -245,7 +242,7 @@
   @Override
   public void onPlaybackStateChanged(EventTime eventTime, @Player.State int state) {
     playbackState = state;
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (String session : playbackStatsTrackers.keySet()) {
       boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session);
       playbackStatsTrackers
@@ -258,7 +255,7 @@
   public void onPlayWhenReadyChanged(
       EventTime eventTime, boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) {
     this.playWhenReady = playWhenReady;
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (String session : playbackStatsTrackers.keySet()) {
       boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session);
       playbackStatsTrackers
@@ -269,9 +266,9 @@
 
   @Override
   public void onPlaybackSuppressionReasonChanged(
-      EventTime eventTime, int playbackSuppressionReason) {
+      EventTime eventTime, @Player.PlaybackSuppressionReason int playbackSuppressionReason) {
     isSuppressed = playbackSuppressionReason != Player.PLAYBACK_SUPPRESSION_REASON_NONE;
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (String session : playbackStatsTrackers.keySet()) {
       boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session);
       playbackStatsTrackers
@@ -281,50 +278,46 @@
   }
 
   @Override
-  public void onTimelineChanged(EventTime eventTime, int reason) {
+  public void onTimelineChanged(EventTime eventTime, @Player.TimelineChangeReason int reason) {
     sessionManager.handleTimelineUpdate(eventTime);
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (String session : playbackStatsTrackers.keySet()) {
       if (sessionManager.belongsToSession(eventTime, session)) {
-        playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime);
+        playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime, /* isSeek= */ false);
       }
     }
   }
 
   @Override
-  public void onPositionDiscontinuity(EventTime eventTime, int reason) {
+  public void onPositionDiscontinuity(EventTime eventTime, @Player.DiscontinuityReason int reason) {
     sessionManager.handlePositionDiscontinuity(eventTime, reason);
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
+    if (reason == Player.DISCONTINUITY_REASON_SEEK) {
+      onSeekStartedCalled = false;
+    }
     for (String session : playbackStatsTrackers.keySet()) {
       if (sessionManager.belongsToSession(eventTime, session)) {
-        playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime);
+        playbackStatsTrackers
+            .get(session)
+            .onPositionDiscontinuity(
+                eventTime, /* isSeek= */ reason == Player.DISCONTINUITY_REASON_SEEK);
       }
     }
   }
 
   @Override
   public void onSeekStarted(EventTime eventTime) {
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (String session : playbackStatsTrackers.keySet()) {
       boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session);
       playbackStatsTrackers.get(session).onSeekStarted(eventTime, belongsToPlayback);
     }
-    isSeeking = true;
-  }
-
-  @Override
-  public void onSeekProcessed(EventTime eventTime) {
-    sessionManager.updateSessions(eventTime);
-    for (String session : playbackStatsTrackers.keySet()) {
-      boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session);
-      playbackStatsTrackers.get(session).onSeekProcessed(eventTime, belongsToPlayback);
-    }
-    isSeeking = false;
+    onSeekStartedCalled = true;
   }
 
   @Override
   public void onPlayerError(EventTime eventTime, ExoPlaybackException error) {
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (String session : playbackStatsTrackers.keySet()) {
       if (sessionManager.belongsToSession(eventTime, session)) {
         playbackStatsTrackers.get(session).onFatalError(eventTime, error);
@@ -335,7 +328,7 @@
   @Override
   public void onPlaybackSpeedChanged(EventTime eventTime, float playbackSpeed) {
     this.playbackSpeed = playbackSpeed;
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (PlaybackStatsTracker tracker : playbackStatsTrackers.values()) {
       tracker.onPlaybackSpeedChanged(eventTime, playbackSpeed);
     }
@@ -344,7 +337,7 @@
   @Override
   public void onTracksChanged(
       EventTime eventTime, TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (String session : playbackStatsTrackers.keySet()) {
       if (sessionManager.belongsToSession(eventTime, session)) {
         playbackStatsTrackers.get(session).onTracksChanged(eventTime, trackSelections);
@@ -355,7 +348,7 @@
   @Override
   public void onLoadStarted(
       EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) {
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (String session : playbackStatsTrackers.keySet()) {
       if (sessionManager.belongsToSession(eventTime, session)) {
         playbackStatsTrackers.get(session).onLoadStarted(eventTime);
@@ -365,7 +358,7 @@
 
   @Override
   public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) {
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (String session : playbackStatsTrackers.keySet()) {
       if (sessionManager.belongsToSession(eventTime, session)) {
         playbackStatsTrackers.get(session).onDownstreamFormatChanged(eventTime, mediaLoadData);
@@ -380,7 +373,7 @@
       int height,
       int unappliedRotationDegrees,
       float pixelWidthHeightRatio) {
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (String session : playbackStatsTrackers.keySet()) {
       if (sessionManager.belongsToSession(eventTime, session)) {
         playbackStatsTrackers.get(session).onVideoSizeChanged(eventTime, width, height);
@@ -391,7 +384,7 @@
   @Override
   public void onBandwidthEstimate(
       EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) {
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (String session : playbackStatsTrackers.keySet()) {
       if (sessionManager.belongsToSession(eventTime, session)) {
         playbackStatsTrackers.get(session).onBandwidthData(totalLoadTimeMs, totalBytesLoaded);
@@ -402,7 +395,7 @@
   @Override
   public void onAudioUnderrun(
       EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (String session : playbackStatsTrackers.keySet()) {
       if (sessionManager.belongsToSession(eventTime, session)) {
         playbackStatsTrackers.get(session).onAudioUnderrun();
@@ -412,7 +405,7 @@
 
   @Override
   public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) {
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (String session : playbackStatsTrackers.keySet()) {
       if (sessionManager.belongsToSession(eventTime, session)) {
         playbackStatsTrackers.get(session).onDroppedVideoFrames(droppedFrames);
@@ -427,7 +420,7 @@
       MediaLoadData mediaLoadData,
       IOException error,
       boolean wasCanceled) {
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (String session : playbackStatsTrackers.keySet()) {
       if (sessionManager.belongsToSession(eventTime, session)) {
         playbackStatsTrackers.get(session).onNonFatalError(eventTime, error);
@@ -437,7 +430,7 @@
 
   @Override
   public void onDrmSessionManagerError(EventTime eventTime, Exception error) {
-    sessionManager.updateSessions(eventTime);
+    maybeAddSession(eventTime);
     for (String session : playbackStatsTrackers.keySet()) {
       if (sessionManager.belongsToSession(eventTime, session)) {
         playbackStatsTrackers.get(session).onNonFatalError(eventTime, error);
@@ -445,6 +438,13 @@
     }
   }
 
+  private void maybeAddSession(EventTime eventTime) {
+    boolean isCompletelyIdle = eventTime.timeline.isEmpty() && playbackState == Player.STATE_IDLE;
+    if (!isCompletelyIdle) {
+      sessionManager.updateSessions(eventTime);
+    }
+  }
+
   /** Tracker for playback stats of a single playback. */
   private static final class PlaybackStatsTracker {
 
@@ -544,6 +544,9 @@
       if (state != Player.STATE_IDLE) {
         hasFatalError = false;
       }
+      if (state != Player.STATE_BUFFERING) {
+        isSeeking = false;
+      }
       if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) {
         isInterruptedByAd = false;
       }
@@ -582,8 +585,12 @@
      * Notifies the tracker of a position discontinuity or timeline update for the current playback.
      *
      * @param eventTime The {@link EventTime}.
+     * @param isSeek Whether the position discontinuity is for a seek.
      */
-    public void onPositionDiscontinuity(EventTime eventTime) {
+    public void onPositionDiscontinuity(EventTime eventTime, boolean isSeek) {
+      if (isSeek && playerPlaybackState == Player.STATE_IDLE) {
+        isSeeking = false;
+      }
       isInterruptedByAd = false;
       maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true);
     }
@@ -601,18 +608,6 @@
     }
 
     /**
-     * Notifies the tracker that a seek has been processed, including all seeks while the playback
-     * is not in the foreground.
-     *
-     * @param eventTime The {@link EventTime}.
-     * @param belongsToPlayback Whether the {@code eventTime} belongs to the current playback.
-     */
-    public void onSeekProcessed(EventTime eventTime, boolean belongsToPlayback) {
-      isSeeking = false;
-      maybeUpdatePlaybackState(eventTime, belongsToPlayback);
-    }
-
-    /**
      * Notifies the tracker of fatal player error in the current playback.
      *
      * @param eventTime The {@link EventTime}.
@@ -927,10 +922,6 @@
             || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD) {
           return PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND;
         }
-        if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_SEEKING
-            || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_SEEK_BUFFERING) {
-          return PlaybackStats.PLAYBACK_STATE_SEEK_BUFFERING;
-        }
         if (!playWhenReady) {
           return PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING;
         }
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java
index a0aebdf..78699d4 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java
@@ -1242,7 +1242,8 @@
   private static int getFramesPerEncodedSample(@C.Encoding int encoding, ByteBuffer buffer) {
     switch (encoding) {
       case C.ENCODING_MP3:
-        return MpegAudioUtil.parseMpegAudioFrameSampleCount(buffer.get(buffer.position()));
+        int headerDataInBigEndian = Util.getBigEndianInt(buffer, buffer.position());
+        return MpegAudioUtil.parseMpegAudioFrameSampleCount(headerDataInBigEndian);
       case C.ENCODING_AAC_LC:
         return AacUtil.AAC_LC_AUDIO_SAMPLE_COUNT;
       case C.ENCODING_AAC_HE_V1:
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java
index 8d84325..f630c26 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java
@@ -155,18 +155,20 @@
   @Override
   protected void onFlush() {
     if (reconfigurationPending) {
-      // This is the initial flush after reconfiguration. Prepare to trim bytes from the start/end.
+      // Flushing activates the new configuration, so prepare to trim bytes from the start/end.
       reconfigurationPending = false;
       endBuffer = new byte[trimEndFrames * inputAudioFormat.bytesPerFrame];
       pendingTrimStartBytes = trimStartFrames * inputAudioFormat.bytesPerFrame;
-    } else {
-      // This is a flush during playback (after the initial flush). We assume this was caused by a
-      // seek to a non-zero position and clear pending start bytes. This assumption may be wrong (we
-      // may be seeking to zero), but playing data that should have been trimmed shouldn't be
-      // noticeable after a seek. Ideally we would check the timestamp of the first input buffer
-      // queued after flushing to decide whether to trim (see also [Internal: b/77292509]).
-      pendingTrimStartBytes = 0;
     }
+
+    // TODO(internal b/77292509): Flushing occurs to activate a configuration (handled above) but
+    // also when seeking within a stream. This implementation currently doesn't handle seek to start
+    // (where we need to trim at the start again), nor seeks to non-zero positions before start
+    // trimming has occurred (where we should set pendingTrimStartBytes to zero). These cases can be
+    // fixed by trimming in queueInput based on timestamp, once that information is available.
+
+    // Any data in the end buffer should no longer be output if we are playing from a different
+    // position, so discard it and refill the buffer using new input.
     endBufferSize = 0;
   }
 
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java
index bb772a7..eea8198 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java
@@ -368,7 +368,7 @@
   private final long[] pendingOutputStreamSwitchTimesUs;
 
   @Nullable private Format inputFormat;
-  private Format outputFormat;
+  @Nullable private Format outputFormat;
   @Nullable private DrmSession codecDrmSession;
   @Nullable private DrmSession sourceDrmSession;
   @Nullable private MediaCrypto mediaCrypto;
@@ -420,6 +420,7 @@
   protected DecoderCounters decoderCounters;
   private long outputStreamOffsetUs;
   private int pendingOutputStreamOffsetCount;
+  private boolean receivedOutputMediaFormatChange;
 
   /**
    * @param trackType The track type that the renderer handles. One of the {@code C.TRACK_TYPE_*}
@@ -635,13 +636,18 @@
    * method if they are taking over responsibility for output format propagation (e.g., when using
    * video tunneling).
    */
-  @Nullable
-  protected final Format updateOutputFormatForTime(long presentationTimeUs) {
-    Format format = formatQueue.pollFloor(presentationTimeUs);
+  protected final void updateOutputFormatForTime(long presentationTimeUs) {
+    @Nullable Format format = formatQueue.pollFloor(presentationTimeUs);
     if (format != null) {
       outputFormat = format;
+      onOutputFormatChanged(outputFormat);
+    } else if (receivedOutputMediaFormatChange && outputFormat != null) {
+      // No Format change with the MediaFormat change, so we need to update based on the existing
+      // Format.
+      configureOutput(outputFormat);
     }
-    return format;
+
+    receivedOutputMediaFormatChange = false;
   }
 
   @Nullable
@@ -1446,6 +1452,28 @@
   }
 
   /**
+   * Called when the output {@link Format} changes.
+   *
+   * <p>The default implementation is a no-op.
+   *
+   * @param outputFormat The new output {@link Format}.
+   */
+  protected void onOutputFormatChanged(Format outputFormat) {
+    // Do nothing.
+  }
+
+  /**
+   * Configures the renderer output based on a {@link Format}.
+   *
+   * <p>The default implementation is a no-op.
+   *
+   * @param outputFormat The format to configure the output with.
+   */
+  protected void configureOutput(Format outputFormat) {
+    // Do nothing.
+  }
+
+  /**
    * Handles supplemental data associated with an input buffer.
    *
    * <p>The default implementation is a no-op.
@@ -1650,6 +1678,7 @@
       if (outputIndex < 0) {
         if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED /* (-2) */) {
           processOutputMediaFormat();
+          receivedOutputMediaFormatChange = true;
           return true;
         } else if (outputIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED /* (-3) */) {
           processOutputBuffersChanged();
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java
index d8126d4..0b7434c 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java
@@ -17,8 +17,10 @@
 
 import android.net.Uri;
 import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
 import java.lang.reflect.Constructor;
 import java.util.List;
+import java.util.concurrent.Executor;
 
 /**
  * Default {@link DownloaderFactory}, supporting creation of progressive, DASH, HLS and
@@ -32,7 +34,7 @@
   @Nullable private static final Constructor<? extends Downloader> SS_DOWNLOADER_CONSTRUCTOR;
 
   static {
-    Constructor<? extends Downloader> dashDownloaderConstructor = null;
+    @Nullable Constructor<? extends Downloader> dashDownloaderConstructor = null;
     try {
       // LINT.IfChange
       dashDownloaderConstructor =
@@ -43,7 +45,7 @@
       // Expected if the app was built without the DASH module.
     }
     DASH_DOWNLOADER_CONSTRUCTOR = dashDownloaderConstructor;
-    Constructor<? extends Downloader> hlsDownloaderConstructor = null;
+    @Nullable Constructor<? extends Downloader> hlsDownloaderConstructor = null;
     try {
       // LINT.IfChange
       hlsDownloaderConstructor =
@@ -54,7 +56,7 @@
       // Expected if the app was built without the HLS module.
     }
     HLS_DOWNLOADER_CONSTRUCTOR = hlsDownloaderConstructor;
-    Constructor<? extends Downloader> ssDownloaderConstructor = null;
+    @Nullable Constructor<? extends Downloader> ssDownloaderConstructor = null;
     try {
       // LINT.IfChange
       ssDownloaderConstructor =
@@ -68,11 +70,32 @@
     SS_DOWNLOADER_CONSTRUCTOR = ssDownloaderConstructor;
   }
 
-  private final DownloaderConstructorHelper downloaderConstructorHelper;
+  private final CacheDataSource.Factory cacheDataSourceFactory;
+  private final Executor executor;
 
-  /** @param downloaderConstructorHelper A helper for instantiating downloaders. */
-  public DefaultDownloaderFactory(DownloaderConstructorHelper downloaderConstructorHelper) {
-    this.downloaderConstructorHelper = downloaderConstructorHelper;
+  /**
+   * Creates an instance.
+   *
+   * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which
+   *     downloads will be written.
+   */
+  public DefaultDownloaderFactory(CacheDataSource.Factory cacheDataSourceFactory) {
+    this(cacheDataSourceFactory, Runnable::run);
+  }
+
+  /**
+   * Creates an instance.
+   *
+   * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which
+   *     downloads will be written.
+   * @param executor An {@link Executor} used to make requests for media being downloaded. Providing
+   *     an {@link Executor} that uses multiple threads will speed up download tasks that can be
+   *     split into smaller parts for parallel execution.
+   */
+  public DefaultDownloaderFactory(
+      CacheDataSource.Factory cacheDataSourceFactory, Executor executor) {
+    this.cacheDataSourceFactory = cacheDataSourceFactory;
+    this.executor = executor;
   }
 
   @Override
@@ -80,7 +103,7 @@
     switch (request.type) {
       case DownloadRequest.TYPE_PROGRESSIVE:
         return new ProgressiveDownloader(
-            request.uri, request.customCacheKey, downloaderConstructorHelper);
+            request.uri, request.customCacheKey, cacheDataSourceFactory, executor);
       case DownloadRequest.TYPE_DASH:
         return createDownloader(request, DASH_DOWNLOADER_CONSTRUCTOR);
       case DownloadRequest.TYPE_HLS:
@@ -98,7 +121,8 @@
       throw new IllegalStateException("Module missing for: " + request.type);
     }
     try {
-      return constructor.newInstance(request.uri, request.streamKeys, downloaderConstructorHelper);
+      return constructor.newInstance(
+          request.uri, request.streamKeys, cacheDataSourceFactory, executor);
     } catch (Exception e) {
       throw new RuntimeException("Failed to instantiate downloader for: " + request.type, e);
     }
@@ -109,7 +133,7 @@
     try {
       return clazz
           .asSubclass(Downloader.class)
-          .getConstructor(Uri.class, List.class, DownloaderConstructorHelper.class);
+          .getConstructor(Uri.class, List.class, CacheDataSource.Factory.class, Executor.class);
     } catch (NoSuchMethodException e) {
       // The downloader is present, but the expected constructor is missing.
       throw new RuntimeException("Downloader constructor missing", e);
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java
index 3724701..d050c1d 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java
@@ -40,6 +40,7 @@
 import com.google.android.exoplayer2.upstream.DataSource;
 import com.google.android.exoplayer2.upstream.DataSource.Factory;
 import com.google.android.exoplayer2.upstream.cache.Cache;
+import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
 import com.google.android.exoplayer2.upstream.cache.CacheEvictor;
 import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor;
 import com.google.android.exoplayer2.util.Assertions;
@@ -197,7 +198,10 @@
     this(
         context,
         new DefaultDownloadIndex(databaseProvider),
-        new DefaultDownloaderFactory(new DownloaderConstructorHelper(cache, upstreamFactory)));
+        new DefaultDownloaderFactory(
+            new CacheDataSource.Factory()
+                .setCache(cache)
+                .setUpstreamDataSourceFactory(upstreamFactory)));
   }
 
   /**
@@ -730,7 +734,7 @@
           break;
         case MSG_CONTENT_LENGTH_CHANGED:
           task = (Task) message.obj;
-          onContentLengthChanged(task);
+          onContentLengthChanged(task, Util.toLong(message.arg1, message.arg2));
           return; // No need to post back to mainHandler.
         case MSG_UPDATE_PROGRESS:
           updateProgress();
@@ -1026,9 +1030,8 @@
 
     // Task event processing.
 
-    private void onContentLengthChanged(Task task) {
+    private void onContentLengthChanged(Task task, long contentLength) {
       String downloadId = task.request.id;
-      long contentLength = task.contentLength;
       Download download =
           Assertions.checkNotNull(getDownload(downloadId, /* loadFromIndex= */ false));
       if (contentLength == download.contentLength || contentLength == C.LENGTH_UNSET) {
@@ -1321,7 +1324,13 @@
         this.contentLength = contentLength;
         @Nullable Handler internalHandler = this.internalHandler;
         if (internalHandler != null) {
-          internalHandler.obtainMessage(MSG_CONTENT_LENGTH_CHANGED, this).sendToTarget();
+          internalHandler
+              .obtainMessage(
+                  MSG_CONTENT_LENGTH_CHANGED,
+                  (int) (contentLength >> 32),
+                  (int) contentLength,
+                  this)
+              .sendToTarget();
         }
       }
     }
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadProgress.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadProgress.java
index 9d946da..ba226e6 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadProgress.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadProgress.java
@@ -21,8 +21,8 @@
 public class DownloadProgress {
 
   /** The number of bytes that have been downloaded. */
-  public long bytesDownloaded;
+  public volatile long bytesDownloaded;
 
   /** The percentage that has been downloaded, or {@link C#PERCENTAGE_UNSET} if unknown. */
-  public float percentDownloaded;
+  public volatile float percentDownloaded;
 }
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java
index fa10d58..53485ac 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java
@@ -28,6 +28,10 @@
     /**
      * Called when progress is made during a download operation.
      *
+     * <p>May be called directly from {@link #download}, or from any other thread used by the
+     * downloader. In all cases, {@link #download} is guaranteed not to return until after the last
+     * call to {@link #onProgress} has finished executing.
+     *
      * @param contentLength The length of the content in bytes, or {@link C#LENGTH_UNSET} if
      *     unknown.
      * @param bytesDownloaded The number of bytes that have been downloaded.
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java
deleted file mode 100644
index 0d53b3c..0000000
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java
+++ /dev/null
@@ -1,170 +0,0 @@
-/*
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.google.android.exoplayer2.offline;
-
-import androidx.annotation.Nullable;
-import com.google.android.exoplayer2.C;
-import com.google.android.exoplayer2.upstream.DataSink;
-import com.google.android.exoplayer2.upstream.DataSource;
-import com.google.android.exoplayer2.upstream.DummyDataSource;
-import com.google.android.exoplayer2.upstream.FileDataSource;
-import com.google.android.exoplayer2.upstream.PriorityDataSourceFactory;
-import com.google.android.exoplayer2.upstream.cache.Cache;
-import com.google.android.exoplayer2.upstream.cache.CacheDataSink;
-import com.google.android.exoplayer2.upstream.cache.CacheDataSinkFactory;
-import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
-import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory;
-import com.google.android.exoplayer2.upstream.cache.CacheKeyFactory;
-import com.google.android.exoplayer2.upstream.cache.CacheUtil;
-import com.google.android.exoplayer2.util.PriorityTaskManager;
-
-/** A helper class that holds necessary parameters for {@link Downloader} construction. */
-public final class DownloaderConstructorHelper {
-
-  private final Cache cache;
-  @Nullable private final CacheKeyFactory cacheKeyFactory;
-  @Nullable private final PriorityTaskManager priorityTaskManager;
-  private final CacheDataSourceFactory onlineCacheDataSourceFactory;
-  private final CacheDataSourceFactory offlineCacheDataSourceFactory;
-
-  /**
-   * @param cache Cache instance to be used to store downloaded data.
-   * @param upstreamFactory A {@link DataSource.Factory} for creating {@link DataSource}s for
-   *     downloading data.
-   */
-  public DownloaderConstructorHelper(Cache cache, DataSource.Factory upstreamFactory) {
-    this(
-        cache,
-        upstreamFactory,
-        /* cacheReadDataSourceFactory= */ null,
-        /* cacheWriteDataSinkFactory= */ null,
-        /* priorityTaskManager= */ null);
-  }
-
-  /**
-   * @param cache Cache instance to be used to store downloaded data.
-   * @param upstreamFactory A {@link DataSource.Factory} for creating {@link DataSource}s for
-   *     downloading data.
-   * @param cacheReadDataSourceFactory A {@link DataSource.Factory} for creating {@link DataSource}s
-   *     for reading data from the cache. If null then a {@link FileDataSource.Factory} will be
-   *     used.
-   * @param cacheWriteDataSinkFactory A {@link DataSink.Factory} for creating {@link DataSource}s
-   *     for writing data to the cache. If null then a {@link CacheDataSinkFactory} will be used.
-   * @param priorityTaskManager A {@link PriorityTaskManager} to use when downloading. If non-null,
-   *     downloaders will register as tasks with priority {@link C#PRIORITY_DOWNLOAD} whilst
-   *     downloading.
-   */
-  public DownloaderConstructorHelper(
-      Cache cache,
-      DataSource.Factory upstreamFactory,
-      @Nullable DataSource.Factory cacheReadDataSourceFactory,
-      @Nullable DataSink.Factory cacheWriteDataSinkFactory,
-      @Nullable PriorityTaskManager priorityTaskManager) {
-    this(
-        cache,
-        upstreamFactory,
-        cacheReadDataSourceFactory,
-        cacheWriteDataSinkFactory,
-        priorityTaskManager,
-        /* cacheKeyFactory= */ null);
-  }
-
-  /**
-   * @param cache Cache instance to be used to store downloaded data.
-   * @param upstreamFactory A {@link DataSource.Factory} for creating {@link DataSource}s for
-   *     downloading data.
-   * @param cacheReadDataSourceFactory A {@link DataSource.Factory} for creating {@link DataSource}s
-   *     for reading data from the cache. If null then a {@link FileDataSource.Factory} will be
-   *     used.
-   * @param cacheWriteDataSinkFactory A {@link DataSink.Factory} for creating {@link DataSource}s
-   *     for writing data to the cache. If null then a {@link CacheDataSinkFactory} will be used.
-   * @param priorityTaskManager A {@link PriorityTaskManager} to use when downloading. If non-null,
-   *     downloaders will register as tasks with priority {@link C#PRIORITY_DOWNLOAD} whilst
-   *     downloading.
-   * @param cacheKeyFactory An optional factory for cache keys.
-   */
-  public DownloaderConstructorHelper(
-      Cache cache,
-      DataSource.Factory upstreamFactory,
-      @Nullable DataSource.Factory cacheReadDataSourceFactory,
-      @Nullable DataSink.Factory cacheWriteDataSinkFactory,
-      @Nullable PriorityTaskManager priorityTaskManager,
-      @Nullable CacheKeyFactory cacheKeyFactory) {
-    if (priorityTaskManager != null) {
-      upstreamFactory =
-          new PriorityDataSourceFactory(upstreamFactory, priorityTaskManager, C.PRIORITY_DOWNLOAD);
-    }
-    DataSource.Factory readDataSourceFactory =
-        cacheReadDataSourceFactory != null
-            ? cacheReadDataSourceFactory
-            : new FileDataSource.Factory();
-    if (cacheWriteDataSinkFactory == null) {
-      cacheWriteDataSinkFactory =
-          new CacheDataSinkFactory(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE);
-    }
-    onlineCacheDataSourceFactory =
-        new CacheDataSourceFactory(
-            cache,
-            upstreamFactory,
-            readDataSourceFactory,
-            cacheWriteDataSinkFactory,
-            CacheDataSource.FLAG_BLOCK_ON_CACHE,
-            /* eventListener= */ null,
-            cacheKeyFactory);
-    offlineCacheDataSourceFactory =
-        new CacheDataSourceFactory(
-            cache,
-            DummyDataSource.FACTORY,
-            readDataSourceFactory,
-            null,
-            CacheDataSource.FLAG_BLOCK_ON_CACHE,
-            /* eventListener= */ null,
-            cacheKeyFactory);
-    this.cache = cache;
-    this.priorityTaskManager = priorityTaskManager;
-    this.cacheKeyFactory = cacheKeyFactory;
-  }
-
-  /** Returns the {@link Cache} instance. */
-  public Cache getCache() {
-    return cache;
-  }
-
-  /** Returns the {@link CacheKeyFactory}. */
-  public CacheKeyFactory getCacheKeyFactory() {
-    return cacheKeyFactory != null ? cacheKeyFactory : CacheUtil.DEFAULT_CACHE_KEY_FACTORY;
-  }
-
-  /** Returns a {@link PriorityTaskManager} instance. */
-  public PriorityTaskManager getPriorityTaskManager() {
-    // Return a dummy PriorityTaskManager if none is provided. Create a new PriorityTaskManager
-    // each time so clients don't affect each other over the dummy PriorityTaskManager instance.
-    return priorityTaskManager != null ? priorityTaskManager : new PriorityTaskManager();
-  }
-
-  /** Returns a new {@link CacheDataSource} instance. */
-  public CacheDataSource createCacheDataSource() {
-    return onlineCacheDataSourceFactory.createDataSource();
-  }
-
-  /**
-   * Returns a new {@link CacheDataSource} instance which accesses cache read-only and throws an
-   * exception on cache miss.
-   */
-  public CacheDataSource createOfflineCacheDataSource() {
-    return offlineCacheDataSourceFactory.createDataSource();
-  }
-}
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java
index 055410c..ecdd174 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java
@@ -19,73 +19,78 @@
 import androidx.annotation.Nullable;
 import com.google.android.exoplayer2.C;
 import com.google.android.exoplayer2.upstream.DataSpec;
-import com.google.android.exoplayer2.upstream.cache.Cache;
 import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
-import com.google.android.exoplayer2.upstream.cache.CacheKeyFactory;
 import com.google.android.exoplayer2.upstream.cache.CacheUtil;
 import com.google.android.exoplayer2.util.PriorityTaskManager;
 import java.io.IOException;
+import java.util.concurrent.Executor;
 import java.util.concurrent.atomic.AtomicBoolean;
 
-/**
- * A downloader for progressive media streams.
- *
- * <p>The downloader attempts to download the entire media bytes referenced by a {@link Uri} into a
- * cache as defined by {@link DownloaderConstructorHelper}. Callers can use the constructor to
- * specify a custom cache key for the downloaded bytes.
- *
- * <p>The downloader will avoid downloading already-downloaded media bytes.
- */
+/** A downloader for progressive media streams. */
 public final class ProgressiveDownloader implements Downloader {
 
   private static final int BUFFER_SIZE_BYTES = 128 * 1024;
 
   private final DataSpec dataSpec;
-  private final Cache cache;
   private final CacheDataSource dataSource;
-  private final CacheKeyFactory cacheKeyFactory;
-  private final PriorityTaskManager priorityTaskManager;
   private final AtomicBoolean isCanceled;
 
   /**
    * @param uri Uri of the data to be downloaded.
    * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache
    *     indexing. May be null.
-   * @param constructorHelper A {@link DownloaderConstructorHelper} instance.
+   * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the
+   *     download will be written.
    */
   public ProgressiveDownloader(
-      Uri uri, @Nullable String customCacheKey, DownloaderConstructorHelper constructorHelper) {
-    this.dataSpec =
+      Uri uri, @Nullable String customCacheKey, CacheDataSource.Factory cacheDataSourceFactory) {
+    this(uri, customCacheKey, cacheDataSourceFactory, Runnable::run);
+  }
+
+  /**
+   * @param uri Uri of the data to be downloaded.
+   * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache
+   *     indexing. May be null.
+   * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the
+   *     download will be written.
+   * @param executor An {@link Executor} used to make requests for the media being downloaded. In
+   *     the future, providing an {@link Executor} that uses multiple threads may speed up the
+   *     download by allowing parts of it to be executed in parallel.
+   */
+  public ProgressiveDownloader(
+      Uri uri,
+      @Nullable String customCacheKey,
+      CacheDataSource.Factory cacheDataSourceFactory,
+      Executor executor) {
+    dataSpec =
         new DataSpec.Builder()
             .setUri(uri)
             .setKey(customCacheKey)
             .setFlags(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION)
             .build();
-    this.cache = constructorHelper.getCache();
-    this.dataSource = constructorHelper.createCacheDataSource();
-    this.cacheKeyFactory = constructorHelper.getCacheKeyFactory();
-    this.priorityTaskManager = constructorHelper.getPriorityTaskManager();
+    dataSource = cacheDataSourceFactory.createDataSourceForDownloading();
     isCanceled = new AtomicBoolean();
   }
 
   @Override
   public void download(@Nullable ProgressListener progressListener)
       throws InterruptedException, IOException {
-    priorityTaskManager.add(C.PRIORITY_DOWNLOAD);
+    @Nullable PriorityTaskManager priorityTaskManager = dataSource.getUpstreamPriorityTaskManager();
+    if (priorityTaskManager != null) {
+      priorityTaskManager.add(C.PRIORITY_DOWNLOAD);
+    }
     try {
       CacheUtil.cache(
-          dataSpec,
-          cache,
-          cacheKeyFactory,
           dataSource,
-          new byte[BUFFER_SIZE_BYTES],
-          priorityTaskManager,
-          C.PRIORITY_DOWNLOAD,
+          dataSpec,
           progressListener == null ? null : new ProgressForwarder(progressListener),
           isCanceled,
-          /* enableEOFException= */ true);
+          /* enableEOFException= */ true,
+          /* temporaryBuffer= */ new byte[BUFFER_SIZE_BYTES]);
     } finally {
-      priorityTaskManager.remove(C.PRIORITY_DOWNLOAD);
+      if (priorityTaskManager != null) {
+        priorityTaskManager.remove(C.PRIORITY_DOWNLOAD);
+      }
     }
   }
 
@@ -96,7 +101,7 @@
 
   @Override
   public void remove() {
-    CacheUtil.remove(dataSpec, cache, cacheKeyFactory);
+    CacheUtil.remove(dataSpec, dataSource.getCache(), dataSource.getCacheKeyFactory());
   }
 
   private static final class ProgressForwarder implements CacheUtil.ProgressListener {
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java
index 299998e..f7b2fc7 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java
@@ -33,6 +33,7 @@
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
+import java.util.concurrent.Executor;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
@@ -67,29 +68,30 @@
   private static final long MAX_MERGED_SEGMENT_START_TIME_DIFF_US = 20 * C.MICROS_PER_SECOND;
 
   private final DataSpec manifestDataSpec;
-  private final Cache cache;
-  private final CacheDataSource dataSource;
-  private final CacheDataSource offlineDataSource;
-  private final CacheKeyFactory cacheKeyFactory;
-  private final PriorityTaskManager priorityTaskManager;
   private final ArrayList<StreamKey> streamKeys;
+  private final CacheDataSource.Factory cacheDataSourceFactory;
+  private final Executor executor;
   private final AtomicBoolean isCanceled;
 
   /**
    * @param manifestUri The {@link Uri} of the manifest to be downloaded.
    * @param streamKeys Keys defining which streams in the manifest should be selected for download.
    *     If empty, all streams are downloaded.
-   * @param constructorHelper A {@link DownloaderConstructorHelper} instance.
+   * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the
+   *     download will be written.
+   * @param executor An {@link Executor} used to make requests for the media being downloaded.
+   *     Providing an {@link Executor} that uses multiple threads will speed up the download by
+   *     allowing parts of it to be executed in parallel.
    */
   public SegmentDownloader(
-      Uri manifestUri, List<StreamKey> streamKeys, DownloaderConstructorHelper constructorHelper) {
+      Uri manifestUri,
+      List<StreamKey> streamKeys,
+      CacheDataSource.Factory cacheDataSourceFactory,
+      Executor executor) {
     this.manifestDataSpec = getCompressibleDataSpec(manifestUri);
     this.streamKeys = new ArrayList<>(streamKeys);
-    this.cache = constructorHelper.getCache();
-    this.dataSource = constructorHelper.createCacheDataSource();
-    this.offlineDataSource = constructorHelper.createOfflineCacheDataSource();
-    this.cacheKeyFactory = constructorHelper.getCacheKeyFactory();
-    this.priorityTaskManager = constructorHelper.getPriorityTaskManager();
+    this.cacheDataSourceFactory = cacheDataSourceFactory;
+    this.executor = executor;
     isCanceled = new AtomicBoolean();
   }
 
@@ -103,7 +105,11 @@
   @Override
   public final void download(@Nullable ProgressListener progressListener)
       throws IOException, InterruptedException {
-    priorityTaskManager.add(C.PRIORITY_DOWNLOAD);
+    CacheDataSource dataSource = cacheDataSourceFactory.createDataSourceForDownloading();
+    @Nullable PriorityTaskManager priorityTaskManager = dataSource.getUpstreamPriorityTaskManager();
+    if (priorityTaskManager != null) {
+      priorityTaskManager.add(C.PRIORITY_DOWNLOAD);
+    }
     try {
       // Get the manifest and all of the segments.
       M manifest = getManifest(dataSource, manifestDataSpec);
@@ -112,7 +118,7 @@
       }
       List<Segment> segments = getSegments(dataSource, manifest, /* allowIncompleteList= */ false);
       Collections.sort(segments);
-      mergeSegments(segments, cacheKeyFactory);
+      mergeSegments(segments, dataSource.getCacheKeyFactory());
 
       // Scan the segments, removing any that are fully downloaded.
       int totalSegments = segments.size();
@@ -122,7 +128,8 @@
       for (int i = segments.size() - 1; i >= 0; i--) {
         Segment segment = segments.get(i);
         Pair<Long, Long> segmentLengthAndBytesDownloaded =
-            CacheUtil.getCached(segment.dataSpec, cache, cacheKeyFactory);
+            CacheUtil.getCached(
+                segment.dataSpec, dataSource.getCache(), dataSource.getCacheKeyFactory());
         long segmentLength = segmentLengthAndBytesDownloaded.first;
         long segmentBytesDownloaded = segmentLengthAndBytesDownloaded.second;
         bytesDownloaded += segmentBytesDownloaded;
@@ -141,35 +148,33 @@
       }
 
       // Download the segments.
-      @Nullable ProgressNotifier progressNotifier = null;
-      if (progressListener != null) {
-        progressNotifier =
-            new ProgressNotifier(
-                progressListener,
-                contentLength,
-                totalSegments,
-                bytesDownloaded,
-                segmentsDownloaded);
-      }
-      byte[] buffer = new byte[BUFFER_SIZE_BYTES];
+      @Nullable
+      ProgressNotifier progressNotifier =
+          progressListener != null
+              ? new ProgressNotifier(
+                  progressListener,
+                  contentLength,
+                  totalSegments,
+                  bytesDownloaded,
+                  segmentsDownloaded)
+              : null;
+      byte[] temporaryBuffer = new byte[BUFFER_SIZE_BYTES];
       for (int i = 0; i < segments.size(); i++) {
         CacheUtil.cache(
-            segments.get(i).dataSpec,
-            cache,
-            cacheKeyFactory,
             dataSource,
-            buffer,
-            priorityTaskManager,
-            C.PRIORITY_DOWNLOAD,
+            segments.get(i).dataSpec,
             progressNotifier,
             isCanceled,
-            true);
+            /* enableEOFException= */ true,
+            temporaryBuffer);
         if (progressNotifier != null) {
           progressNotifier.onSegmentDownloaded();
         }
       }
     } finally {
-      priorityTaskManager.remove(C.PRIORITY_DOWNLOAD);
+      if (priorityTaskManager != null) {
+        priorityTaskManager.remove(C.PRIORITY_DOWNLOAD);
+      }
     }
   }
 
@@ -180,17 +185,20 @@
 
   @Override
   public final void remove() throws InterruptedException {
+    CacheDataSource dataSource = cacheDataSourceFactory.createDataSourceForRemovingDownload();
+    Cache cache = dataSource.getCache();
+    CacheKeyFactory cacheKeyFactory = dataSource.getCacheKeyFactory();
     try {
-      M manifest = getManifest(offlineDataSource, manifestDataSpec);
-      List<Segment> segments = getSegments(offlineDataSource, manifest, true);
+      M manifest = getManifest(dataSource, manifestDataSpec);
+      List<Segment> segments = getSegments(dataSource, manifest, true);
       for (int i = 0; i < segments.size(); i++) {
-        removeDataSpec(segments.get(i).dataSpec);
+        CacheUtil.remove(segments.get(i).dataSpec, cache, cacheKeyFactory);
       }
     } catch (IOException e) {
       // Ignore exceptions when removing.
     } finally {
       // Always attempt to remove the manifest.
-      removeDataSpec(manifestDataSpec);
+      CacheUtil.remove(manifestDataSpec, cache, cacheKeyFactory);
     }
   }
 
@@ -223,10 +231,6 @@
       DataSource dataSource, M manifest, boolean allowIncompleteList)
       throws InterruptedException, IOException;
 
-  private void removeDataSpec(DataSpec dataSpec) {
-    CacheUtil.remove(dataSpec, cache, cacheKeyFactory);
-  }
-
   protected static DataSpec getCompressibleDataSpec(Uri uri) {
     return new DataSpec.Builder().setUri(uri).setFlags(DataSpec.FLAG_ALLOW_GZIP).build();
   }
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java
index a10bd03..461b146 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java
@@ -22,6 +22,7 @@
 import com.google.android.exoplayer2.drm.DrmSessionEventListener;
 import com.google.android.exoplayer2.upstream.TransferListener;
 import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.MediaSourceEventDispatcher;
 import java.util.ArrayList;
 import java.util.HashSet;
 
@@ -133,22 +134,44 @@
 
   @Override
   public final void addEventListener(Handler handler, MediaSourceEventListener eventListener) {
-    eventDispatcher.addEventListener(handler, eventListener);
+    addEventListenerInternal(handler, eventListener, MediaSourceEventListener.class);
   }
 
   @Override
   public final void removeEventListener(MediaSourceEventListener eventListener) {
-    eventDispatcher.removeEventListener(eventListener);
+    removeEventListenerInternal(eventListener, MediaSourceEventListener.class);
   }
 
   @Override
   public final void addDrmEventListener(Handler handler, DrmSessionEventListener eventListener) {
-    eventDispatcher.addEventListener(handler, eventListener, DrmSessionEventListener.class);
+    addEventListenerInternal(handler, eventListener, DrmSessionEventListener.class);
   }
 
   @Override
   public final void removeDrmEventListener(DrmSessionEventListener eventListener) {
-    eventDispatcher.removeEventListener(eventListener, DrmSessionEventListener.class);
+    removeEventListenerInternal(eventListener, DrmSessionEventListener.class);
+  }
+
+  /**
+   * Adds a listener to the internal {@link MediaSourceEventDispatcher} with the provided type.
+   *
+   * <p>NOTE: Read the caveats on {@link MediaSourceEventDispatcher#addEventListener(Handler,
+   * Object, Class)} when deciding what value to pass for {@code listenerClass}.
+   *
+   * @see MediaSourceEventDispatcher#addEventListener(Handler, Object, Class)
+   */
+  protected final <T> void addEventListenerInternal(
+      Handler handler, T eventListener, Class<T> listenerClass) {
+    eventDispatcher.addEventListener(handler, eventListener, listenerClass);
+  }
+
+  /**
+   * Removes a listener from the internal {@link MediaSourceEventDispatcher}.
+   *
+   * @see MediaSourceEventDispatcher#removeEventListener(Object, Class)
+   */
+  protected final <T> void removeEventListenerInternal(T eventListener, Class<T> listenerClass) {
+    eventDispatcher.removeEventListener(eventListener, listenerClass);
   }
 
   @Override
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java
index c3beb0d..e6cff59 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java
@@ -29,6 +29,8 @@
 import com.google.android.exoplayer2.drm.HttpMediaDrmCallback;
 import com.google.android.exoplayer2.drm.MediaDrmCallback;
 import com.google.android.exoplayer2.offline.StreamKey;
+import com.google.android.exoplayer2.source.ads.AdsLoader;
+import com.google.android.exoplayer2.source.ads.AdsMediaSource;
 import com.google.android.exoplayer2.upstream.DataSource;
 import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
 import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
@@ -36,6 +38,7 @@
 import com.google.android.exoplayer2.upstream.HttpDataSource;
 import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy;
 import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Log;
 import com.google.android.exoplayer2.util.MimeTypes;
 import com.google.android.exoplayer2.util.Util;
 import java.util.Arrays;
@@ -89,10 +92,37 @@
  * alternative dummy, apps can pass a drm session manager to {@link
  * #setDrmSessionManager(DrmSessionManager)} which will be used for all items without a drm
  * configuration.
+ *
+ * <h3>Ad support for media items with ad tag uri</h3>
+ *
+ * <p>For a media item with an ad tag uri an {@link AdSupportProvider} needs to be passed to the
+ * constructor {@link #DefaultMediaSourceFactory(Context, DataSource.Factory, AdSupportProvider)}.
  */
 public final class DefaultMediaSourceFactory implements MediaSourceFactory {
 
   /**
+   * Provides {@link AdsLoader ads loaders} and an {@link AdsLoader.AdViewProvider} to created
+   * {@link AdsMediaSource AdsMediaSources}.
+   */
+  public interface AdSupportProvider {
+
+    /**
+     * Returns an {@link AdsLoader} for the given {@link Uri ad tag uri} or null if no ads loader is
+     * available for the given ad tag uri.
+     *
+     * <p>This method is called for each media item for which a media source is created.
+     */
+    @Nullable
+    AdsLoader getAdsLoader(Uri adTagUri);
+
+    /**
+     * Returns an {@link AdsLoader.AdViewProvider} which is used to create {@link AdsMediaSource
+     * AdsMediaSources}.
+     */
+    AdsLoader.AdViewProvider getAdViewProvider();
+  }
+
+  /**
    * Creates a new instance with the given {@link Context}.
    *
    * <p>This is functionally equivalent with calling {@code #newInstance(Context,
@@ -115,10 +145,13 @@
    */
   public static DefaultMediaSourceFactory newInstance(
       Context context, DataSource.Factory dataSourceFactory) {
-    return new DefaultMediaSourceFactory(context, dataSourceFactory);
+    return new DefaultMediaSourceFactory(context, dataSourceFactory, /* adSupportProvider= */ null);
   }
 
+  private static final String TAG = "DefaultMediaSourceFactory";
+
   private final DataSource.Factory dataSourceFactory;
+  @Nullable private final AdSupportProvider adSupportProvider;
   private final SparseArray<MediaSourceFactory> mediaSourceFactories;
   @C.ContentType private final int[] supportedTypes;
   private final String userAgent;
@@ -127,8 +160,20 @@
   private HttpDataSource.Factory drmHttpDataSourceFactory;
   @Nullable private List<StreamKey> streamKeys;
 
-  private DefaultMediaSourceFactory(Context context, DataSource.Factory dataSourceFactory) {
+  /**
+   * Creates a new instance with the given {@link Context} and {@link DataSource.Factory}.
+   *
+   * @param context The {@link Context}.
+   * @param dataSourceFactory A {@link DataSource.Factory} to be used to create media sources.
+   * @param adSupportProvider An {@link AdSupportProvider} to get ads loaders and ad view providers
+   *     to be used to create {@link AdsMediaSource AdsMediaSources}.
+   */
+  public DefaultMediaSourceFactory(
+      Context context,
+      DataSource.Factory dataSourceFactory,
+      @Nullable AdSupportProvider adSupportProvider) {
     this.dataSourceFactory = dataSourceFactory;
+    this.adSupportProvider = adSupportProvider;
     drmSessionManager = DrmSessionManager.getDummyDrmSessionManager();
     userAgent = Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY);
     drmHttpDataSourceFactory = new DefaultHttpDataSourceFactory(userAgent);
@@ -203,7 +248,7 @@
     Assertions.checkNotNull(mediaItem.playbackProperties);
     @C.ContentType
     int type =
-        inferContentType(
+        Util.inferContentTypeWithMimeType(
             mediaItem.playbackProperties.sourceUri, mediaItem.playbackProperties.mimeType);
     @Nullable MediaSourceFactory mediaSourceFactory = mediaSourceFactories.get(type);
     Assertions.checkNotNull(
@@ -214,30 +259,29 @@
             ? mediaItem.playbackProperties.streamKeys
             : streamKeys);
 
-    MediaSource leafMediaSource = mediaSourceFactory.createMediaSource(mediaItem);
+    MediaSource mediaSource = mediaSourceFactory.createMediaSource(mediaItem);
 
     List<MediaItem.Subtitle> subtitles = mediaItem.playbackProperties.subtitles;
-    if (subtitles.isEmpty()) {
-      return maybeClipMediaSource(mediaItem, leafMediaSource);
+    if (!subtitles.isEmpty()) {
+      MediaSource[] mediaSources = new MediaSource[subtitles.size() + 1];
+      mediaSources[0] = mediaSource;
+      SingleSampleMediaSource.Factory singleSampleSourceFactory =
+          new SingleSampleMediaSource.Factory(dataSourceFactory);
+      for (int i = 0; i < subtitles.size(); i++) {
+        MediaItem.Subtitle subtitle = subtitles.get(i);
+        Format subtitleFormat =
+            new Format.Builder()
+                .setSampleMimeType(subtitle.mimeType)
+                .setLanguage(subtitle.language)
+                .setSelectionFlags(subtitle.selectionFlags)
+                .build();
+        mediaSources[i + 1] =
+            singleSampleSourceFactory.createMediaSource(
+                subtitle.uri, subtitleFormat, /* durationUs= */ C.TIME_UNSET);
+      }
+      mediaSource = new MergingMediaSource(mediaSources);
     }
-
-    MediaSource[] mediaSources = new MediaSource[subtitles.size() + 1];
-    mediaSources[0] = leafMediaSource;
-    SingleSampleMediaSource.Factory singleSampleSourceFactory =
-        new SingleSampleMediaSource.Factory(dataSourceFactory);
-    for (int i = 0; i < subtitles.size(); i++) {
-      MediaItem.Subtitle subtitle = subtitles.get(i);
-      Format subtitleFormat =
-          new Format.Builder()
-              .setSampleMimeType(subtitle.mimeType)
-              .setLanguage(subtitle.language)
-              .setSelectionFlags(subtitle.selectionFlags)
-              .build();
-      mediaSources[i + 1] =
-          singleSampleSourceFactory.createMediaSource(
-              subtitle.uri, subtitleFormat, /* durationUs= */ C.TIME_UNSET);
-    }
-    return maybeClipMediaSource(mediaItem, new MergingMediaSource(mediaSources));
+    return maybeWrapWithAdsMediaSource(mediaItem, maybeClipMediaSource(mediaItem, mediaSource));
   }
 
   // internal methods
@@ -285,6 +329,34 @@
         mediaItem.clippingProperties.relativeToDefaultPosition);
   }
 
+  private MediaSource maybeWrapWithAdsMediaSource(MediaItem mediaItem, MediaSource mediaSource) {
+    Assertions.checkNotNull(mediaItem.playbackProperties);
+    if (mediaItem.playbackProperties.adTagUri == null) {
+      return mediaSource;
+    }
+    if (adSupportProvider == null) {
+      Log.w(
+          TAG,
+          "Playing media without ads. Pass an AdsSupportProvider to the constructor for supporting"
+              + " media items with an ad tag uri.");
+      return mediaSource;
+    }
+    AdsLoader adsLoader = adSupportProvider.getAdsLoader(mediaItem.playbackProperties.adTagUri);
+    if (adsLoader == null) {
+      Log.w(
+          TAG,
+          String.format(
+              "Playing media without ads. No AdsLoader for media item with mediaId '%s'.",
+              mediaItem.mediaId));
+      return mediaSource;
+    }
+    return new AdsMediaSource(
+        mediaSource,
+        /* adMediaSourceFactory= */ this,
+        adsLoader,
+        adSupportProvider.getAdViewProvider());
+  }
+
   private static SparseArray<MediaSourceFactory> loadDelegates(
       DataSource.Factory dataSourceFactory) {
     SparseArray<MediaSourceFactory> factories = new SparseArray<>();
@@ -324,20 +396,4 @@
     factories.put(C.TYPE_OTHER, new ProgressiveMediaSource.Factory(dataSourceFactory));
     return factories;
   }
-
-  private static int inferContentType(Uri sourceUri, @Nullable String mimeType) {
-    if (mimeType == null) {
-      return Util.inferContentType(sourceUri);
-    }
-    switch (mimeType) {
-      case MimeTypes.APPLICATION_MPD:
-        return C.TYPE_DASH;
-      case MimeTypes.APPLICATION_M3U8:
-        return C.TYPE_HLS;
-      case MimeTypes.APPLICATION_SS:
-        return C.TYPE_SS;
-      default:
-        return Util.inferContentType(sourceUri);
-    }
-  }
 }
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/LoadEventInfo.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/LoadEventInfo.java
index cfef4ee..6ad0585 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/LoadEventInfo.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/LoadEventInfo.java
@@ -18,6 +18,7 @@
 import android.net.Uri;
 import android.os.SystemClock;
 import com.google.android.exoplayer2.upstream.DataSpec;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicLong;
@@ -51,6 +52,20 @@
   public final long bytesLoaded;
 
   /**
+   * Equivalent to {@link #LoadEventInfo(DataSpec, Uri, Map, long, long, long)
+   * LoadEventInfo(dataSpec, dataSpec.uri, Collections.emptyMap(), elapsedRealtimeMs, 0, 0)}.
+   */
+  public LoadEventInfo(DataSpec dataSpec, long elapsedRealtimeMs) {
+    this(
+        dataSpec,
+        dataSpec.uri,
+        Collections.emptyMap(),
+        elapsedRealtimeMs,
+        /* loadDurationMs= */ 0,
+        /* bytesLoaded= */ 0);
+  }
+
+  /**
    * Creates load event info.
    *
    * @param dataSpec Defines the requested data.
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceEventListener.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceEventListener.java
index 61bb55d..7c9dc34 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceEventListener.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceEventListener.java
@@ -15,21 +15,15 @@
  */
 package com.google.android.exoplayer2.source;
 
-import android.net.Uri;
-import android.os.Handler;
 import androidx.annotation.Nullable;
 import com.google.android.exoplayer2.C;
 import com.google.android.exoplayer2.Format;
 import com.google.android.exoplayer2.Player;
 import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId;
-import com.google.android.exoplayer2.upstream.DataSpec;
 import com.google.android.exoplayer2.util.Assertions;
 import com.google.android.exoplayer2.util.CopyOnWriteMultiset;
 import com.google.android.exoplayer2.util.MediaSourceEventDispatcher;
 import java.io.IOException;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
 
 /** Interface for callbacks to be notified of {@link MediaSource} events. */
 public interface MediaSourceEventListener {
@@ -188,33 +182,6 @@
       return new EventDispatcher(listenerInfos, windowIndex, mediaPeriodId, mediaTimeOffsetMs);
     }
 
-    /**
-     * Adds a {@link MediaSourceEventListener} to the event dispatcher.
-     *
-     * <p>This is equivalent to {@link #addEventListener(Handler, Object, Class)} with {@code
-     * listenerClass = MediaSourceEventListener.class} and is intended to ease the transition to
-     * using {@link MediaSourceEventDispatcher} everywhere.
-     *
-     * @param handler A handler on the which listener events will be posted.
-     * @param eventListener The listener to be added.
-     */
-    public void addEventListener(Handler handler, MediaSourceEventListener eventListener) {
-      addEventListener(handler, eventListener, MediaSourceEventListener.class);
-    }
-
-    /**
-     * Removes a {@link MediaSourceEventListener} from the event dispatcher.
-     *
-     * <p>This is equivalent to {@link #removeEventListener(Object, Class)} with {@code
-     * listenerClass = MediaSourceEventListener.class} and is intended to ease the transition to
-     * using {@link MediaSourceEventDispatcher} everywhere.
-     *
-     * @param eventListener The listener to be removed.
-     */
-    public void removeEventListener(MediaSourceEventListener eventListener) {
-      removeEventListener(eventListener, MediaSourceEventListener.class);
-    }
-
     public void mediaPeriodCreated() {
       dispatch(
           (listener, windowIndex, mediaPeriodId) ->
@@ -229,37 +196,29 @@
           MediaSourceEventListener.class);
     }
 
-    public void loadStarted(DataSpec dataSpec, int dataType, long elapsedRealtimeMs) {
+    public void loadStarted(LoadEventInfo loadEventInfo, int dataType) {
       loadStarted(
-          dataSpec,
+          loadEventInfo,
           dataType,
-          C.TRACK_TYPE_UNKNOWN,
-          null,
-          C.SELECTION_REASON_UNKNOWN,
-          null,
-          C.TIME_UNSET,
-          C.TIME_UNSET,
-          elapsedRealtimeMs);
+          /* trackType= */ C.TRACK_TYPE_UNKNOWN,
+          /* trackFormat= */ null,
+          /* trackSelectionReason= */ C.SELECTION_REASON_UNKNOWN,
+          /* trackSelectionData= */ null,
+          /* mediaStartTimeUs= */ C.TIME_UNSET,
+          /* mediaEndTimeUs= */ C.TIME_UNSET);
     }
 
     public void loadStarted(
-        DataSpec dataSpec,
+        LoadEventInfo loadEventInfo,
         int dataType,
         int trackType,
         @Nullable Format trackFormat,
         int trackSelectionReason,
         @Nullable Object trackSelectionData,
         long mediaStartTimeUs,
-        long mediaEndTimeUs,
-        long elapsedRealtimeMs) {
+        long mediaEndTimeUs) {
       loadStarted(
-          new LoadEventInfo(
-              dataSpec,
-              dataSpec.uri,
-              /* responseHeaders= */ Collections.emptyMap(),
-              elapsedRealtimeMs,
-              /* loadDurationMs= */ 0,
-              /* bytesLoaded= */ 0),
+          loadEventInfo,
           new MediaLoadData(
               dataType,
               trackType,
@@ -277,47 +236,29 @@
           MediaSourceEventListener.class);
     }
 
-    public void loadCompleted(
-        DataSpec dataSpec,
-        Uri uri,
-        Map<String, List<String>> responseHeaders,
-        int dataType,
-        long elapsedRealtimeMs,
-        long loadDurationMs,
-        long bytesLoaded) {
+    public void loadCompleted(LoadEventInfo loadEventInfo, int dataType) {
       loadCompleted(
-          dataSpec,
-          uri,
-          responseHeaders,
+          loadEventInfo,
           dataType,
-          C.TRACK_TYPE_UNKNOWN,
-          null,
-          C.SELECTION_REASON_UNKNOWN,
-          null,
-          C.TIME_UNSET,
-          C.TIME_UNSET,
-          elapsedRealtimeMs,
-          loadDurationMs,
-          bytesLoaded);
+          /* trackType= */ C.TRACK_TYPE_UNKNOWN,
+          /* trackFormat= */ null,
+          /* trackSelectionReason= */ C.SELECTION_REASON_UNKNOWN,
+          /* trackSelectionData= */ null,
+          /* mediaStartTimeUs= */ C.TIME_UNSET,
+          /* mediaEndTimeUs= */ C.TIME_UNSET);
     }
 
     public void loadCompleted(
-        DataSpec dataSpec,
-        Uri uri,
-        Map<String, List<String>> responseHeaders,
+        LoadEventInfo loadEventInfo,
         int dataType,
         int trackType,
         @Nullable Format trackFormat,
         int trackSelectionReason,
         @Nullable Object trackSelectionData,
         long mediaStartTimeUs,
-        long mediaEndTimeUs,
-        long elapsedRealtimeMs,
-        long loadDurationMs,
-        long bytesLoaded) {
+        long mediaEndTimeUs) {
       loadCompleted(
-          new LoadEventInfo(
-              dataSpec, uri, responseHeaders, elapsedRealtimeMs, loadDurationMs, bytesLoaded),
+          loadEventInfo,
           new MediaLoadData(
               dataType,
               trackType,
@@ -335,47 +276,29 @@
           MediaSourceEventListener.class);
     }
 
-    public void loadCanceled(
-        DataSpec dataSpec,
-        Uri uri,
-        Map<String, List<String>> responseHeaders,
-        int dataType,
-        long elapsedRealtimeMs,
-        long loadDurationMs,
-        long bytesLoaded) {
+    public void loadCanceled(LoadEventInfo loadEventInfo, int dataType) {
       loadCanceled(
-          dataSpec,
-          uri,
-          responseHeaders,
+          loadEventInfo,
           dataType,
-          C.TRACK_TYPE_UNKNOWN,
-          null,
-          C.SELECTION_REASON_UNKNOWN,
-          null,
-          C.TIME_UNSET,
-          C.TIME_UNSET,
-          elapsedRealtimeMs,
-          loadDurationMs,
-          bytesLoaded);
+          /* trackType= */ C.TRACK_TYPE_UNKNOWN,
+          /* trackFormat= */ null,
+          /* trackSelectionReason= */ C.SELECTION_REASON_UNKNOWN,
+          /* trackSelectionData= */ null,
+          /* mediaStartTimeUs= */ C.TIME_UNSET,
+          /* mediaEndTimeUs= */ C.TIME_UNSET);
     }
 
     public void loadCanceled(
-        DataSpec dataSpec,
-        Uri uri,
-        Map<String, List<String>> responseHeaders,
+        LoadEventInfo loadEventInfo,
         int dataType,
         int trackType,
         @Nullable Format trackFormat,
         int trackSelectionReason,
         @Nullable Object trackSelectionData,
         long mediaStartTimeUs,
-        long mediaEndTimeUs,
-        long elapsedRealtimeMs,
-        long loadDurationMs,
-        long bytesLoaded) {
+        long mediaEndTimeUs) {
       loadCanceled(
-          new LoadEventInfo(
-              dataSpec, uri, responseHeaders, elapsedRealtimeMs, loadDurationMs, bytesLoaded),
+          loadEventInfo,
           new MediaLoadData(
               dataType,
               trackType,
@@ -394,37 +317,22 @@
     }
 
     public void loadError(
-        DataSpec dataSpec,
-        Uri uri,
-        Map<String, List<String>> responseHeaders,
-        int dataType,
-        long elapsedRealtimeMs,
-        long loadDurationMs,
-        long bytesLoaded,
-        IOException error,
-        boolean wasCanceled) {
+        LoadEventInfo loadEventInfo, int dataType, IOException error, boolean wasCanceled) {
       loadError(
-          dataSpec,
-          uri,
-          responseHeaders,
+          loadEventInfo,
           dataType,
-          C.TRACK_TYPE_UNKNOWN,
-          null,
-          C.SELECTION_REASON_UNKNOWN,
-          null,
-          C.TIME_UNSET,
-          C.TIME_UNSET,
-          elapsedRealtimeMs,
-          loadDurationMs,
-          bytesLoaded,
+          /* trackType= */ C.TRACK_TYPE_UNKNOWN,
+          /* trackFormat= */ null,
+          /* trackSelectionReason= */ C.SELECTION_REASON_UNKNOWN,
+          /* trackSelectionData= */ null,
+          /* mediaStartTimeUs= */ C.TIME_UNSET,
+          /* mediaEndTimeUs= */ C.TIME_UNSET,
           error,
           wasCanceled);
     }
 
     public void loadError(
-        DataSpec dataSpec,
-        Uri uri,
-        Map<String, List<String>> responseHeaders,
+        LoadEventInfo loadEventInfo,
         int dataType,
         int trackType,
         @Nullable Format trackFormat,
@@ -432,14 +340,10 @@
         @Nullable Object trackSelectionData,
         long mediaStartTimeUs,
         long mediaEndTimeUs,
-        long elapsedRealtimeMs,
-        long loadDurationMs,
-        long bytesLoaded,
         IOException error,
         boolean wasCanceled) {
       loadError(
-          new LoadEventInfo(
-              dataSpec, uri, responseHeaders, elapsedRealtimeMs, loadDurationMs, bytesLoaded),
+          loadEventInfo,
           new MediaLoadData(
               dataType,
               trackType,
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java
index 2bba84a..0c5af44 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java
@@ -96,8 +96,6 @@
     return Assertions.checkNotNull(trackGroups);
   }
 
-  // unboxing a possibly-null reference streamPeriodIndices.get(streams[i])
-  @SuppressWarnings("nullness:unboxing.of.nullable")
   @Override
   public long selectTracks(
       @NullableType TrackSelection[] selections,
@@ -109,8 +107,8 @@
     int[] streamChildIndices = new int[selections.length];
     int[] selectionChildIndices = new int[selections.length];
     for (int i = 0; i < selections.length; i++) {
-      streamChildIndices[i] = streams[i] == null ? C.INDEX_UNSET
-          : streamPeriodIndices.get(streams[i]);
+      Integer streamChildIndex = streams[i] == null ? null : streamPeriodIndices.get(streams[i]);
+      streamChildIndices[i] = streamChildIndex == null ? C.INDEX_UNSET : streamChildIndex;
       selectionChildIndices[i] = C.INDEX_UNSET;
       if (selections[i] != null) {
         TrackGroup trackGroup = selections[i].getTrackGroup();
@@ -160,8 +158,7 @@
     // Copy the new streams back into the streams array.
     System.arraycopy(newStreams, 0, streams, 0, newStreams.length);
     // Update the local state.
-    enabledPeriods = new MediaPeriod[enabledPeriodsList.size()];
-    enabledPeriodsList.toArray(enabledPeriods);
+    enabledPeriods = enabledPeriodsList.toArray(new MediaPeriod[0]);
     compositeSequenceableLoader =
         compositeSequenceableLoaderFactory.createCompositeSequenceableLoader(enabledPeriods);
     return positionUs;
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java
index 2cbfeb8..87430d8 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java
@@ -552,20 +552,22 @@
           : largestQueuedTimestampUs + DEFAULT_LAST_SAMPLE_DURATION_US;
       listener.onSourceInfoRefreshed(durationUs, isSeekable, isLive);
     }
+    StatsDataSource dataSource = loadable.dataSource;
     eventDispatcher.loadCompleted(
-        loadable.dataSpec,
-        loadable.dataSource.getLastOpenedUri(),
-        loadable.dataSource.getLastResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            dataSource.getLastOpenedUri(),
+            dataSource.getLastResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            dataSource.getBytesRead()),
         C.DATA_TYPE_MEDIA,
         C.TRACK_TYPE_UNKNOWN,
         /* trackFormat= */ null,
         C.SELECTION_REASON_UNKNOWN,
         /* trackSelectionData= */ null,
         /* mediaStartTimeUs= */ loadable.seekTimeUs,
-        durationUs,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.dataSource.getBytesRead());
+        durationUs);
     copyLengthFromLoader(loadable);
     loadingFinished = true;
     Assertions.checkNotNull(callback).onContinueLoadingRequested(this);
@@ -574,20 +576,22 @@
   @Override
   public void onLoadCanceled(
       ExtractingLoadable loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released) {
+    StatsDataSource dataSource = loadable.dataSource;
     eventDispatcher.loadCanceled(
-        loadable.dataSpec,
-        loadable.dataSource.getLastOpenedUri(),
-        loadable.dataSource.getLastResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            dataSource.getLastOpenedUri(),
+            dataSource.getLastResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            dataSource.getBytesRead()),
         C.DATA_TYPE_MEDIA,
         C.TRACK_TYPE_UNKNOWN,
         /* trackFormat= */ null,
         C.SELECTION_REASON_UNKNOWN,
         /* trackSelectionData= */ null,
         /* mediaStartTimeUs= */ loadable.seekTimeUs,
-        durationUs,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.dataSource.getBytesRead());
+        durationUs);
     if (!released) {
       copyLengthFromLoader(loadable);
       for (SampleQueue sampleQueue : sampleQueues) {
@@ -621,10 +625,15 @@
               : Loader.DONT_RETRY;
     }
 
+    StatsDataSource dataSource = loadable.dataSource;
     eventDispatcher.loadError(
-        loadable.dataSpec,
-        loadable.dataSource.getLastOpenedUri(),
-        loadable.dataSource.getLastResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            dataSource.getLastOpenedUri(),
+            dataSource.getLastResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            dataSource.getBytesRead()),
         C.DATA_TYPE_MEDIA,
         C.TRACK_TYPE_UNKNOWN,
         /* trackFormat= */ null,
@@ -632,9 +641,6 @@
         /* trackSelectionData= */ null,
         /* mediaStartTimeUs= */ loadable.seekTimeUs,
         durationUs,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.dataSource.getBytesRead(),
         error,
         !loadErrorAction.isRetry());
     return loadErrorAction;
@@ -680,7 +686,12 @@
         return sampleQueues[i];
       }
     }
-    SampleQueue trackOutput = new SampleQueue(allocator, drmSessionManager, eventDispatcher);
+    SampleQueue trackOutput =
+        new SampleQueue(
+            allocator,
+            /* playbackLooper= */ handler.getLooper(),
+            drmSessionManager,
+            eventDispatcher);
     trackOutput.setUpstreamFormatChangeListener(this);
     @NullableType
     TrackId[] sampleQueueTrackIds = Arrays.copyOf(this.sampleQueueTrackIds, trackCount + 1);
@@ -776,16 +787,16 @@
     long elapsedRealtimeMs =
         loader.startLoading(
             loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(dataType));
+    DataSpec dataSpec = loadable.dataSpec;
     eventDispatcher.loadStarted(
-        loadable.dataSpec,
+        new LoadEventInfo(dataSpec, elapsedRealtimeMs),
         C.DATA_TYPE_MEDIA,
         C.TRACK_TYPE_UNKNOWN,
         /* trackFormat= */ null,
         C.SELECTION_REASON_UNKNOWN,
         /* trackSelectionData= */ null,
         /* mediaStartTimeUs= */ loadable.seekTimeUs,
-        durationUs,
-        elapsedRealtimeMs);
+        durationUs);
   }
 
   /**
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java
index 484aca5..3c08012 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java
@@ -55,6 +55,7 @@
 
   private final SampleDataQueue sampleDataQueue;
   private final SampleExtrasHolder extrasHolder;
+  private final Looper playbackLooper;
   private final DrmSessionManager drmSessionManager;
   private final MediaSourceEventDispatcher eventDispatcher;
   @Nullable private UpstreamFormatChangedListener upstreamFormatChangeListener;
@@ -94,6 +95,7 @@
    * Creates a sample queue.
    *
    * @param allocator An {@link Allocator} from which allocations for sample data can be obtained.
+   * @param playbackLooper The looper associated with the media playback thread.
    * @param drmSessionManager The {@link DrmSessionManager} to obtain {@link DrmSession DrmSessions}
    *     from. The created instance does not take ownership of this {@link DrmSessionManager}.
    * @param eventDispatcher A {@link MediaSourceEventDispatcher} to notify of events related to this
@@ -101,11 +103,13 @@
    */
   public SampleQueue(
       Allocator allocator,
+      Looper playbackLooper,
       DrmSessionManager drmSessionManager,
       MediaSourceEventDispatcher eventDispatcher) {
-    sampleDataQueue = new SampleDataQueue(allocator);
+    this.playbackLooper = playbackLooper;
     this.drmSessionManager = drmSessionManager;
     this.eventDispatcher = eventDispatcher;
+    sampleDataQueue = new SampleDataQueue(allocator);
     extrasHolder = new SampleExtrasHolder();
     capacity = SAMPLE_CAPACITY_INCREMENT;
     sourceIds = new int[capacity];
@@ -477,13 +481,15 @@
   }
 
   @Override
-  public final int sampleData(DataReader input, int length, boolean allowEndOfInput)
+  public final int sampleData(
+      DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart)
       throws IOException {
     return sampleDataQueue.sampleData(input, length, allowEndOfInput);
   }
 
   @Override
-  public final void sampleData(ParsableByteArray buffer, int length) {
+  public final void sampleData(
+      ParsableByteArray buffer, int length, @SampleDataPart int sampleDataPart) {
     sampleDataQueue.sampleData(buffer, length);
   }
 
@@ -797,7 +803,6 @@
     // Ensure we acquire the new session before releasing the previous one in case the same session
     // is being used for both DrmInitData.
     @Nullable DrmSession previousSession = currentDrmSession;
-    Looper playbackLooper = Assertions.checkNotNull(Looper.myLooper());
     currentDrmSession =
         newDrmInitData != null
             ? drmSessionManager.acquireSession(playbackLooper, eventDispatcher, newDrmInitData)
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java
index 8fb5d38..4bc0c0b 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java
@@ -160,15 +160,14 @@
             /* callback= */ this,
             loadErrorHandlingPolicy.getMinimumLoadableRetryCount(C.DATA_TYPE_MEDIA));
     eventDispatcher.loadStarted(
-        dataSpec,
+        new LoadEventInfo(dataSpec, elapsedRealtimeMs),
         C.DATA_TYPE_MEDIA,
         C.TRACK_TYPE_UNKNOWN,
         format,
         C.SELECTION_REASON_UNKNOWN,
         /* trackSelectionData= */ null,
         /* mediaStartTimeUs= */ 0,
-        durationUs,
-        elapsedRealtimeMs);
+        durationUs);
     return true;
   }
 
@@ -217,39 +216,43 @@
     sampleSize = (int) loadable.dataSource.getBytesRead();
     sampleData = Assertions.checkNotNull(loadable.sampleData);
     loadingFinished = true;
+    StatsDataSource dataSource = loadable.dataSource;
     eventDispatcher.loadCompleted(
-        loadable.dataSpec,
-        loadable.dataSource.getLastOpenedUri(),
-        loadable.dataSource.getLastResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            dataSource.getLastOpenedUri(),
+            dataSource.getLastResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            sampleSize),
         C.DATA_TYPE_MEDIA,
         C.TRACK_TYPE_UNKNOWN,
         format,
         C.SELECTION_REASON_UNKNOWN,
         /* trackSelectionData= */ null,
         /* mediaStartTimeUs= */ 0,
-        durationUs,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        sampleSize);
+        durationUs);
   }
 
   @Override
   public void onLoadCanceled(
       SourceLoadable loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released) {
+    StatsDataSource dataSource = loadable.dataSource;
     eventDispatcher.loadCanceled(
-        loadable.dataSpec,
-        loadable.dataSource.getLastOpenedUri(),
-        loadable.dataSource.getLastResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            dataSource.getLastOpenedUri(),
+            dataSource.getLastResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            dataSource.getBytesRead()),
         C.DATA_TYPE_MEDIA,
         C.TRACK_TYPE_UNKNOWN,
         /* trackFormat= */ null,
         C.SELECTION_REASON_UNKNOWN,
         /* trackSelectionData= */ null,
         /* mediaStartTimeUs= */ 0,
-        durationUs,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.dataSource.getBytesRead());
+        durationUs);
   }
 
   @Override
@@ -277,10 +280,15 @@
               ? Loader.createRetryAction(/* resetErrorCount= */ false, retryDelay)
               : Loader.DONT_RETRY_FATAL;
     }
+    StatsDataSource dataSource = loadable.dataSource;
     eventDispatcher.loadError(
-        loadable.dataSpec,
-        loadable.dataSource.getLastOpenedUri(),
-        loadable.dataSource.getLastResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            dataSource.getLastOpenedUri(),
+            dataSource.getLastResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            dataSource.getBytesRead()),
         C.DATA_TYPE_MEDIA,
         C.TRACK_TYPE_UNKNOWN,
         format,
@@ -288,9 +296,6 @@
         /* trackSelectionData= */ null,
         /* mediaStartTimeUs= */ 0,
         durationUs,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.dataSource.getBytesRead(),
         error,
         /* wasCanceled= */ !action.isRetry());
     return action;
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java
index d1b5e84..3599b42 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java
@@ -18,6 +18,7 @@
 import android.net.Uri;
 import android.os.Handler;
 import android.os.Looper;
+import android.os.SystemClock;
 import androidx.annotation.IntDef;
 import androidx.annotation.Nullable;
 import com.google.android.exoplayer2.C;
@@ -44,11 +45,9 @@
 import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
-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} that inserts ads linearly with a provided content media source. This source
@@ -129,15 +128,13 @@
   private final AdsLoader adsLoader;
   private final AdsLoader.AdViewProvider adViewProvider;
   private final Handler mainHandler;
-  private final Map<MediaSource, List<MaskingMediaPeriod>> maskingMediaPeriodByAdMediaSource;
   private final Timeline.Period period;
 
   // Accessed on the player thread.
   @Nullable private ComponentListener componentListener;
   @Nullable private Timeline contentTimeline;
   @Nullable private AdPlaybackState adPlaybackState;
-  private @NullableType MediaSource[][] adGroupMediaSources;
-  private @NullableType Timeline[][] adGroupTimelines;
+  private @NullableType AdMediaSourceHolder[][] adMediaSourceHolders;
 
   /**
    * Constructs a new source that inserts ads linearly with the content specified by {@code
@@ -179,10 +176,8 @@
     this.adsLoader = adsLoader;
     this.adViewProvider = adViewProvider;
     mainHandler = new Handler(Looper.getMainLooper());
-    maskingMediaPeriodByAdMediaSource = new HashMap<>();
     period = new Timeline.Period();
-    adGroupMediaSources = new MediaSource[0][];
-    adGroupTimelines = new Timeline[0][];
+    adMediaSourceHolders = new AdMediaSourceHolder[0][];
     adsLoader.setSupportedContentTypes(adMediaSourceFactory.getSupportedTypes());
   }
 
@@ -209,36 +204,21 @@
       int adIndexInAdGroup = id.adIndexInAdGroup;
       Uri adUri =
           Assertions.checkNotNull(adPlaybackState.adGroups[adGroupIndex].uris[adIndexInAdGroup]);
-      if (adGroupMediaSources[adGroupIndex].length <= adIndexInAdGroup) {
+      if (adMediaSourceHolders[adGroupIndex].length <= adIndexInAdGroup) {
         int adCount = adIndexInAdGroup + 1;
-        adGroupMediaSources[adGroupIndex] =
-            Arrays.copyOf(adGroupMediaSources[adGroupIndex], adCount);
-        adGroupTimelines[adGroupIndex] = Arrays.copyOf(adGroupTimelines[adGroupIndex], adCount);
+        adMediaSourceHolders[adGroupIndex] =
+            Arrays.copyOf(adMediaSourceHolders[adGroupIndex], adCount);
       }
-      MediaSource mediaSource = adGroupMediaSources[adGroupIndex][adIndexInAdGroup];
-      if (mediaSource == null) {
-        mediaSource = adMediaSourceFactory.createMediaSource(adUri);
-        adGroupMediaSources[adGroupIndex][adIndexInAdGroup] = mediaSource;
-        maskingMediaPeriodByAdMediaSource.put(mediaSource, new ArrayList<>());
-        prepareChildSource(id, mediaSource);
+      @Nullable
+      AdMediaSourceHolder adMediaSourceHolder =
+          adMediaSourceHolders[adGroupIndex][adIndexInAdGroup];
+      if (adMediaSourceHolder == null) {
+        MediaSource adMediaSource = adMediaSourceFactory.createMediaSource(adUri);
+        adMediaSourceHolder = new AdMediaSourceHolder(adMediaSource);
+        adMediaSourceHolders[adGroupIndex][adIndexInAdGroup] = adMediaSourceHolder;
+        prepareChildSource(id, adMediaSource);
       }
-      MaskingMediaPeriod maskingMediaPeriod =
-          new MaskingMediaPeriod(mediaSource, id, allocator, startPositionUs);
-      maskingMediaPeriod.setPrepareErrorListener(
-          new AdPrepareErrorListener(adUri, adGroupIndex, adIndexInAdGroup));
-      List<MaskingMediaPeriod> mediaPeriods = maskingMediaPeriodByAdMediaSource.get(mediaSource);
-      if (mediaPeriods == null) {
-        Object periodUid =
-            Assertions.checkNotNull(adGroupTimelines[adGroupIndex][adIndexInAdGroup])
-                .getUidOfPeriod(/* periodIndex= */ 0);
-        MediaPeriodId adSourceMediaPeriodId = new MediaPeriodId(periodUid, id.windowSequenceNumber);
-        maskingMediaPeriod.createPeriod(adSourceMediaPeriodId);
-      } else {
-        // Keep track of the masking media period so it can be populated with the real media period
-        // when the source's info becomes available.
-        mediaPeriods.add(maskingMediaPeriod);
-      }
-      return maskingMediaPeriod;
+      return adMediaSourceHolder.createMediaPeriod(adUri, id, allocator, startPositionUs);
     } else {
       MaskingMediaPeriod mediaPeriod =
           new MaskingMediaPeriod(contentMediaSource, id, allocator, startPositionUs);
@@ -250,12 +230,18 @@
   @Override
   public void releasePeriod(MediaPeriod mediaPeriod) {
     MaskingMediaPeriod maskingMediaPeriod = (MaskingMediaPeriod) mediaPeriod;
-    List<MaskingMediaPeriod> mediaPeriods =
-        maskingMediaPeriodByAdMediaSource.get(maskingMediaPeriod.mediaSource);
-    if (mediaPeriods != null) {
-      mediaPeriods.remove(maskingMediaPeriod);
+    MediaPeriodId id = maskingMediaPeriod.id;
+    if (id.isAd()) {
+      AdMediaSourceHolder adMediaSourceHolder =
+          Assertions.checkNotNull(adMediaSourceHolders[id.adGroupIndex][id.adIndexInAdGroup]);
+      adMediaSourceHolder.releaseMediaPeriod(maskingMediaPeriod);
+      if (adMediaSourceHolder.isInactive()) {
+        releaseChildSource(id);
+        adMediaSourceHolders[id.adGroupIndex][id.adIndexInAdGroup] = null;
+      }
+    } else {
+      maskingMediaPeriod.releasePeriod();
     }
-    maskingMediaPeriod.releasePeriod();
   }
 
   @Override
@@ -263,11 +249,9 @@
     super.releaseSourceInternal();
     Assertions.checkNotNull(componentListener).release();
     componentListener = null;
-    maskingMediaPeriodByAdMediaSource.clear();
     contentTimeline = null;
     adPlaybackState = null;
-    adGroupMediaSources = new MediaSource[0][];
-    adGroupTimelines = new Timeline[0][];
+    adMediaSourceHolders = new AdMediaSourceHolder[0][];
     mainHandler.post(adsLoader::stop);
   }
 
@@ -277,10 +261,13 @@
     if (mediaPeriodId.isAd()) {
       int adGroupIndex = mediaPeriodId.adGroupIndex;
       int adIndexInAdGroup = mediaPeriodId.adIndexInAdGroup;
-      onAdSourceInfoRefreshed(mediaSource, adGroupIndex, adIndexInAdGroup, timeline);
+      Assertions.checkNotNull(adMediaSourceHolders[adGroupIndex][adIndexInAdGroup])
+          .handleSourceInfoRefresh(timeline);
     } else {
-      onContentSourceInfoRefreshed(timeline);
+      Assertions.checkArgument(timeline.getPeriodCount() == 1);
+      contentTimeline = timeline;
     }
+    maybeUpdateSourceInfo();
   }
 
   @Override
@@ -295,42 +282,17 @@
 
   private void onAdPlaybackState(AdPlaybackState adPlaybackState) {
     if (this.adPlaybackState == null) {
-      adGroupMediaSources = new MediaSource[adPlaybackState.adGroupCount][];
-      Arrays.fill(adGroupMediaSources, new MediaSource[0]);
-      adGroupTimelines = new Timeline[adPlaybackState.adGroupCount][];
-      Arrays.fill(adGroupTimelines, new Timeline[0]);
+      adMediaSourceHolders = new AdMediaSourceHolder[adPlaybackState.adGroupCount][];
+      Arrays.fill(adMediaSourceHolders, new AdMediaSourceHolder[0]);
     }
     this.adPlaybackState = adPlaybackState;
     maybeUpdateSourceInfo();
   }
 
-  private void onContentSourceInfoRefreshed(Timeline timeline) {
-    Assertions.checkArgument(timeline.getPeriodCount() == 1);
-    contentTimeline = timeline;
-    maybeUpdateSourceInfo();
-  }
-
-  private void onAdSourceInfoRefreshed(MediaSource mediaSource, int adGroupIndex,
-      int adIndexInAdGroup, Timeline timeline) {
-    Assertions.checkArgument(timeline.getPeriodCount() == 1);
-    adGroupTimelines[adGroupIndex][adIndexInAdGroup] = timeline;
-    List<MaskingMediaPeriod> mediaPeriods = maskingMediaPeriodByAdMediaSource.remove(mediaSource);
-    if (mediaPeriods != null) {
-      Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0);
-      for (int i = 0; i < mediaPeriods.size(); i++) {
-        MaskingMediaPeriod mediaPeriod = mediaPeriods.get(i);
-        MediaPeriodId adSourceMediaPeriodId =
-            new MediaPeriodId(periodUid, mediaPeriod.id.windowSequenceNumber);
-        mediaPeriod.createPeriod(adSourceMediaPeriodId);
-      }
-    }
-    maybeUpdateSourceInfo();
-  }
-
   private void maybeUpdateSourceInfo() {
-    Timeline contentTimeline = this.contentTimeline;
+    @Nullable Timeline contentTimeline = this.contentTimeline;
     if (adPlaybackState != null && contentTimeline != null) {
-      adPlaybackState = adPlaybackState.withAdDurationsUs(getAdDurations(adGroupTimelines, period));
+      adPlaybackState = adPlaybackState.withAdDurationsUs(getAdDurationsUs());
       Timeline timeline =
           adPlaybackState.adGroupCount == 0
               ? contentTimeline
@@ -339,19 +301,16 @@
     }
   }
 
-  private static long[][] getAdDurations(
-      @NullableType Timeline[][] adTimelines, Timeline.Period period) {
-    long[][] adDurations = new long[adTimelines.length][];
-    for (int i = 0; i < adTimelines.length; i++) {
-      adDurations[i] = new long[adTimelines[i].length];
-      for (int j = 0; j < adTimelines[i].length; j++) {
-        adDurations[i][j] =
-            adTimelines[i][j] == null
-                ? C.TIME_UNSET
-                : adTimelines[i][j].getPeriod(/* periodIndex= */ 0, period).getDurationUs();
+  private long[][] getAdDurationsUs() {
+    long[][] adDurationsUs = new long[adMediaSourceHolders.length][];
+    for (int i = 0; i < adMediaSourceHolders.length; i++) {
+      adDurationsUs[i] = new long[adMediaSourceHolders[i].length];
+      for (int j = 0; j < adMediaSourceHolders[i].length; j++) {
+        @Nullable AdMediaSourceHolder holder = adMediaSourceHolders[i][j];
+        adDurationsUs[i][j] = holder == null ? C.TIME_UNSET : holder.getDurationUs();
       }
     }
-    return adDurations;
+    return adDurationsUs;
   }
 
   /** Listener for component events. All methods are called on the main thread. */
@@ -396,13 +355,8 @@
       }
       createEventDispatcher(/* mediaPeriodId= */ null)
           .loadError(
-              dataSpec,
-              dataSpec.uri,
-              /* responseHeaders= */ Collections.emptyMap(),
+              new LoadEventInfo(dataSpec, /* elapsedRealtimeMs= */ SystemClock.elapsedRealtime()),
               C.DATA_TYPE_AD,
-              C.TRACK_TYPE_UNKNOWN,
-              /* loadDurationMs= */ 0,
-              /* bytesLoaded= */ 0,
               error,
               /* wasCanceled= */ true);
     }
@@ -424,17 +378,70 @@
     public void onPrepareError(MediaPeriodId mediaPeriodId, final IOException exception) {
       createEventDispatcher(mediaPeriodId)
           .loadError(
-              new DataSpec(adUri),
-              adUri,
-              /* responseHeaders= */ Collections.emptyMap(),
+              new LoadEventInfo(
+                  new DataSpec(adUri), /* elapsedRealtimeMs= */ SystemClock.elapsedRealtime()),
               C.DATA_TYPE_AD,
-              C.TRACK_TYPE_UNKNOWN,
-              /* loadDurationMs= */ 0,
-              /* bytesLoaded= */ 0,
               AdLoadException.createForAd(exception),
               /* wasCanceled= */ true);
       mainHandler.post(
           () -> adsLoader.handlePrepareError(adGroupIndex, adIndexInAdGroup, exception));
     }
   }
+
+  private final class AdMediaSourceHolder {
+
+    private final MediaSource adMediaSource;
+    private final List<MaskingMediaPeriod> activeMediaPeriods;
+
+    private @MonotonicNonNull Timeline timeline;
+
+    public AdMediaSourceHolder(MediaSource adMediaSource) {
+      this.adMediaSource = adMediaSource;
+      activeMediaPeriods = new ArrayList<>();
+    }
+
+    public MediaPeriod createMediaPeriod(
+        Uri adUri, MediaPeriodId id, Allocator allocator, long startPositionUs) {
+      MaskingMediaPeriod maskingMediaPeriod =
+          new MaskingMediaPeriod(adMediaSource, id, allocator, startPositionUs);
+      maskingMediaPeriod.setPrepareErrorListener(
+          new AdPrepareErrorListener(adUri, id.adGroupIndex, id.adIndexInAdGroup));
+      activeMediaPeriods.add(maskingMediaPeriod);
+      if (timeline != null) {
+        Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0);
+        MediaPeriodId adSourceMediaPeriodId = new MediaPeriodId(periodUid, id.windowSequenceNumber);
+        maskingMediaPeriod.createPeriod(adSourceMediaPeriodId);
+      }
+      return maskingMediaPeriod;
+    }
+
+    public void handleSourceInfoRefresh(Timeline timeline) {
+      Assertions.checkArgument(timeline.getPeriodCount() == 1);
+      if (this.timeline == null) {
+        Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0);
+        for (int i = 0; i < activeMediaPeriods.size(); i++) {
+          MaskingMediaPeriod mediaPeriod = activeMediaPeriods.get(i);
+          MediaPeriodId adSourceMediaPeriodId =
+              new MediaPeriodId(periodUid, mediaPeriod.id.windowSequenceNumber);
+          mediaPeriod.createPeriod(adSourceMediaPeriodId);
+        }
+      }
+      this.timeline = timeline;
+    }
+
+    public long getDurationUs() {
+      return timeline == null
+          ? C.TIME_UNSET
+          : timeline.getPeriod(/* periodIndex= */ 0, period).getDurationUs();
+    }
+
+    public void releaseMediaPeriod(MaskingMediaPeriod maskingMediaPeriod) {
+      activeMediaPeriods.remove(maskingMediaPeriod);
+      maskingMediaPeriod.releasePeriod();
+    }
+
+    public boolean isInactive() {
+      return activeMediaPeriods.isEmpty();
+    }
+  }
 }
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java
index 76a4665..f2362f2 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java
@@ -203,13 +203,14 @@
     }
 
     @Override
-    public int sampleData(DataReader input, int length, boolean allowEndOfInput)
+    public int sampleData(
+        DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart)
         throws IOException {
       return castNonNull(trackOutput).sampleData(input, length, allowEndOfInput);
     }
 
     @Override
-    public void sampleData(ParsableByteArray data, int length) {
+    public void sampleData(ParsableByteArray data, int length, @SampleDataPart int sampleDataPart) {
       castNonNull(trackOutput).sampleData(data, length);
     }
 
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java
index fe7c583..c67b35c 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java
@@ -15,6 +15,7 @@
  */
 package com.google.android.exoplayer2.source.chunk;
 
+import android.os.Looper;
 import androidx.annotation.Nullable;
 import com.google.android.exoplayer2.C;
 import com.google.android.exoplayer2.Format;
@@ -23,6 +24,7 @@
 import com.google.android.exoplayer2.decoder.DecoderInputBuffer;
 import com.google.android.exoplayer2.drm.DrmSession;
 import com.google.android.exoplayer2.drm.DrmSessionManager;
+import com.google.android.exoplayer2.source.LoadEventInfo;
 import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher;
 import com.google.android.exoplayer2.source.SampleQueue;
 import com.google.android.exoplayer2.source.SampleStream;
@@ -131,14 +133,22 @@
     int[] trackTypes = new int[1 + embeddedTrackCount];
     SampleQueue[] sampleQueues = new SampleQueue[1 + embeddedTrackCount];
 
-    primarySampleQueue = new SampleQueue(allocator, drmSessionManager, eventDispatcher);
+    primarySampleQueue =
+        new SampleQueue(
+            allocator,
+            /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()),
+            drmSessionManager,
+            eventDispatcher);
     trackTypes[0] = primaryTrackType;
     sampleQueues[0] = primarySampleQueue;
 
     for (int i = 0; i < embeddedTrackCount; i++) {
       SampleQueue sampleQueue =
           new SampleQueue(
-              allocator, DrmSessionManager.getDummyDrmSessionManager(), eventDispatcher);
+              allocator,
+              /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()),
+              DrmSessionManager.getDummyDrmSessionManager(),
+              eventDispatcher);
       embeddedSampleQueues[i] = sampleQueue;
       sampleQueues[i + 1] = sampleQueue;
       trackTypes[i + 1] = this.embeddedTrackTypes[i];
@@ -396,19 +406,20 @@
   public void onLoadCompleted(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs) {
     chunkSource.onChunkLoadCompleted(loadable);
     eventDispatcher.loadCompleted(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            loadable.bytesLoaded()),
         loadable.type,
         primaryTrackType,
         loadable.trackFormat,
         loadable.trackSelectionReason,
         loadable.trackSelectionData,
         loadable.startTimeUs,
-        loadable.endTimeUs,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.bytesLoaded());
+        loadable.endTimeUs);
     callback.onContinueLoadingRequested(this);
   }
 
@@ -416,19 +427,20 @@
   public void onLoadCanceled(
       Chunk loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released) {
     eventDispatcher.loadCanceled(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            loadable.bytesLoaded()),
         loadable.type,
         primaryTrackType,
         loadable.trackFormat,
         loadable.trackSelectionReason,
         loadable.trackSelectionData,
         loadable.startTimeUs,
-        loadable.endTimeUs,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.bytesLoaded());
+        loadable.endTimeUs);
     if (!released) {
       primarySampleQueue.reset();
       for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) {
@@ -484,9 +496,13 @@
 
     boolean canceled = !loadErrorAction.isRetry();
     eventDispatcher.loadError(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            bytesLoaded),
         loadable.type,
         primaryTrackType,
         loadable.trackFormat,
@@ -494,9 +510,6 @@
         loadable.trackSelectionData,
         loadable.startTimeUs,
         loadable.endTimeUs,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        bytesLoaded,
         error,
         canceled);
     if (canceled) {
@@ -555,15 +568,14 @@
         loader.startLoading(
             loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(loadable.type));
     eventDispatcher.loadStarted(
-        loadable.dataSpec,
+        new LoadEventInfo(loadable.dataSpec, elapsedRealtimeMs),
         loadable.type,
         primaryTrackType,
         loadable.trackFormat,
         loadable.trackSelectionReason,
         loadable.trackSelectionData,
         loadable.startTimeUs,
-        loadable.endTimeUs,
-        elapsedRealtimeMs);
+        loadable.endTimeUs);
     return true;
   }
 
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java
index eedcad3..8b954af 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java
@@ -39,7 +39,7 @@
 
   private final ChunkExtractorWrapper extractorWrapper;
 
-  @MonotonicNonNull private TrackOutputProvider trackOutputProvider;
+  private @MonotonicNonNull TrackOutputProvider trackOutputProvider;
   private long nextLoadPosition;
   private volatile boolean loadCanceled;
 
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java
index 927ee8b..bd652c6 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java
@@ -108,7 +108,10 @@
                 return new Tx3gDecoder(format.initializationData);
               case MimeTypes.APPLICATION_CEA608:
               case MimeTypes.APPLICATION_MP4CEA608:
-                return new Cea608Decoder(mimeType, format.accessibilityChannel);
+                return new Cea608Decoder(
+                    mimeType,
+                    format.accessibilityChannel,
+                    Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
               case MimeTypes.APPLICATION_CEA708:
                 return new Cea708Decoder(format.accessibilityChannel, format.initializationData);
               case MimeTypes.APPLICATION_DVBSUBS:
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java
index cce1bf6..75e86c4 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java
@@ -26,10 +26,13 @@
 import android.text.style.UnderlineSpan;
 import androidx.annotation.Nullable;
 import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.Format;
 import com.google.android.exoplayer2.text.Cue;
 import com.google.android.exoplayer2.text.Subtitle;
 import com.google.android.exoplayer2.text.SubtitleDecoder;
+import com.google.android.exoplayer2.text.SubtitleDecoderException;
 import com.google.android.exoplayer2.text.SubtitleInputBuffer;
+import com.google.android.exoplayer2.text.SubtitleOutputBuffer;
 import com.google.android.exoplayer2.util.Assertions;
 import com.google.android.exoplayer2.util.Log;
 import com.google.android.exoplayer2.util.MimeTypes;
@@ -40,11 +43,15 @@
 import java.util.List;
 import org.checkerframework.checker.nullness.compatqual.NullableType;
 
-/**
- * A {@link SubtitleDecoder} for CEA-608 (also known as "line 21 captions" and "EIA-608").
- */
+/** A {@link SubtitleDecoder} for CEA-608 (also known as "line 21 captions" and "EIA-608"). */
 public final class Cea608Decoder extends CeaDecoder {
 
+  /**
+   * The minimum value for the {@code validDataChannelTimeoutMs} constructor parameter permitted by
+   * ANSI/CTA-608-E R-2014 Annex C.9.
+   */
+  public static final long MIN_DATA_CHANNEL_TIMEOUT_MS = 16_000;
+
   private static final String TAG = "Cea608Decoder";
 
   private static final int CC_VALID_FLAG = 0x04;
@@ -237,6 +244,7 @@
   private final int packetLength;
   private final int selectedField;
   private final int selectedChannel;
+  private final long validDataChannelTimeoutUs;
   private final ArrayList<CueBuilder> cueBuilders;
 
   private CueBuilder currentCueBuilder;
@@ -257,11 +265,26 @@
   // service bytes and drops the rest.
   private boolean isInCaptionService;
 
-  public Cea608Decoder(String mimeType, int accessibilityChannel) {
+  private long lastCueUpdateUs;
+
+  /**
+   * Constructs an instance.
+   *
+   * @param mimeType The MIME type of the CEA-608 data.
+   * @param accessibilityChannel The Accessibility channel, or {@link
+   *     com.google.android.exoplayer2.Format#NO_VALUE} if unknown.
+   * @param validDataChannelTimeoutMs The timeout (in milliseconds) permitted by ANSI/CTA-608-E
+   *     R-2014 Annex C.9 to clear "stuck" captions where no removal control code is received. The
+   *     timeout should be at least {@link #MIN_DATA_CHANNEL_TIMEOUT_MS} or {@link C#TIME_UNSET} for
+   *     no timeout.
+   */
+  public Cea608Decoder(String mimeType, int accessibilityChannel, long validDataChannelTimeoutMs) {
     ccData = new ParsableByteArray();
     cueBuilders = new ArrayList<>();
     currentCueBuilder = new CueBuilder(CC_MODE_UNKNOWN, DEFAULT_CAPTIONS_ROW_COUNT);
     currentChannel = NTSC_CC_CHANNEL_1;
+    this.validDataChannelTimeoutUs =
+        validDataChannelTimeoutMs > 0 ? validDataChannelTimeoutMs * 1000 : C.TIME_UNSET;
     packetLength = MimeTypes.APPLICATION_MP4CEA608.equals(mimeType) ? 2 : 3;
     switch (accessibilityChannel) {
       case 1:
@@ -289,6 +312,7 @@
     setCaptionMode(CC_MODE_UNKNOWN);
     resetCueBuilders();
     isInCaptionService = true;
+    lastCueUpdateUs = C.TIME_UNSET;
   }
 
   @Override
@@ -310,6 +334,7 @@
     repeatableControlCc2 = 0;
     currentChannel = NTSC_CC_CHANNEL_1;
     isInCaptionService = true;
+    lastCueUpdateUs = C.TIME_UNSET;
   }
 
   @Override
@@ -317,6 +342,26 @@
     // Do nothing
   }
 
+  @Nullable
+  @Override
+  public SubtitleOutputBuffer dequeueOutputBuffer() throws SubtitleDecoderException {
+    SubtitleOutputBuffer outputBuffer = super.dequeueOutputBuffer();
+    if (outputBuffer != null) {
+      return outputBuffer;
+    }
+    if (shouldClearStuckCaptions()) {
+      outputBuffer = getAvailableOutputBuffer();
+      if (outputBuffer != null) {
+        cues = Collections.emptyList();
+        lastCueUpdateUs = C.TIME_UNSET;
+        Subtitle subtitle = createSubtitle();
+        outputBuffer.setContent(getPositionUs(), subtitle, Format.OFFSET_SAMPLE_RELATIVE);
+        return outputBuffer;
+      }
+    }
+    return null;
+  }
+
   @Override
   protected boolean isNewSubtitleDataAvailable() {
     return cues != lastCues;
@@ -423,6 +468,7 @@
     if (captionDataProcessed) {
       if (captionMode == CC_MODE_ROLL_UP || captionMode == CC_MODE_PAINT_ON) {
         cues = getDisplayCues();
+        lastCueUpdateUs = getPositionUs();
       }
     }
   }
@@ -1018,4 +1064,12 @@
 
   }
 
+  /** See ANSI/CTA-608-E R-2014 Annex C.9 for Caption Erase Logic. */
+  private boolean shouldClearStuckCaptions() {
+    if (validDataChannelTimeoutUs == C.TIME_UNSET || lastCueUpdateUs == C.TIME_UNSET) {
+      return false;
+    }
+    long elapsedUs = getPositionUs() - lastCueUpdateUs;
+    return elapsedUs >= validDataChannelTimeoutUs;
+  }
 }
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/text/cea/CeaDecoder.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/text/cea/CeaDecoder.java
index 03a7255..f42b2a9 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/text/cea/CeaDecoder.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/text/cea/CeaDecoder.java
@@ -179,6 +179,15 @@
    */
   protected abstract void decode(SubtitleInputBuffer inputBuffer);
 
+  @Nullable
+  protected final SubtitleOutputBuffer getAvailableOutputBuffer() {
+    return availableOutputBuffers.pollFirst();
+  }
+
+  protected final long getPositionUs() {
+    return playbackPositionUs;
+  }
+
   private static final class CeaInputBuffer extends SubtitleInputBuffer
       implements Comparable<CeaInputBuffer> {
 
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java
index 51f5973..6e25dfc 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java
@@ -135,8 +135,7 @@
       cues.add(Cue.EMPTY);
     }
 
-    Cue[] cuesArray = new Cue[cues.size()];
-    cues.toArray(cuesArray);
+    Cue[] cuesArray = cues.toArray(new Cue[0]);
     long[] cueTimesUsArray = cueTimesUs.toArray();
     return new SubripSubtitle(cuesArray, cueTimesUsArray);
   }
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java
index 80009d4..e9d6d88 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java
@@ -184,7 +184,7 @@
     }
   }
 
-  private FrameAndTickRate parseFrameAndTickRates(XmlPullParser xmlParser)
+  private static FrameAndTickRate parseFrameAndTickRates(XmlPullParser xmlParser)
       throws SubtitleDecoderException {
     int frameRate = DEFAULT_FRAME_RATE;
     String frameRateString = xmlParser.getAttributeValue(TTP, "frameRate");
@@ -218,8 +218,8 @@
     return new FrameAndTickRate(frameRate * frameRateMultiplier, subFrameRate, tickRate);
   }
 
-  private CellResolution parseCellResolution(XmlPullParser xmlParser, CellResolution defaultValue)
-      throws SubtitleDecoderException {
+  private static CellResolution parseCellResolution(
+      XmlPullParser xmlParser, CellResolution defaultValue) throws SubtitleDecoderException {
     String cellResolution = xmlParser.getAttributeValue(TTP, "cellResolution");
     if (cellResolution == null) {
       return defaultValue;
@@ -244,7 +244,7 @@
   }
 
   @Nullable
-  private TtsExtent parseTtsExtent(XmlPullParser xmlParser) {
+  private static TtsExtent parseTtsExtent(XmlPullParser xmlParser) {
     @Nullable
     String ttsExtent = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_EXTENT);
     if (ttsExtent == null) {
@@ -266,7 +266,7 @@
     }
   }
 
-  private Map<String, TtmlStyle> parseHeader(
+  private static Map<String, TtmlStyle> parseHeader(
       XmlPullParser xmlParser,
       Map<String, TtmlStyle> globalStyles,
       CellResolution cellResolution,
@@ -301,7 +301,7 @@
     return globalStyles;
   }
 
-  private void parseMetadata(XmlPullParser xmlParser, Map<String, String> imageMap)
+  private static void parseMetadata(XmlPullParser xmlParser, Map<String, String> imageMap)
       throws IOException, XmlPullParserException {
     do {
       xmlParser.next();
@@ -324,7 +324,7 @@
    * returned.
    */
   @Nullable
-  private TtmlRegion parseRegionAttributes(
+  private static TtmlRegion parseRegionAttributes(
       XmlPullParser xmlParser, CellResolution cellResolution, @Nullable TtsExtent ttsExtent) {
     @Nullable String regionId = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_ID);
     if (regionId == null) {
@@ -456,13 +456,13 @@
         /* textSize= */ regionTextHeight);
   }
 
-  private String[] parseStyleIds(String parentStyleIds) {
+  private static String[] parseStyleIds(String parentStyleIds) {
     parentStyleIds = parentStyleIds.trim();
     return parentStyleIds.isEmpty() ? new String[0] : Util.split(parentStyleIds, "\\s+");
   }
 
-  @PolyNull
-  private TtmlStyle parseStyleAttributes(XmlPullParser parser, @PolyNull TtmlStyle style) {
+  private static @PolyNull TtmlStyle parseStyleAttributes(
+      XmlPullParser parser, @PolyNull TtmlStyle style) {
     int attributeCount = parser.getAttributeCount();
     for (int i = 0; i < attributeCount; i++) {
       String attributeValue = parser.getAttributeValue(i);
@@ -611,11 +611,11 @@
     return style;
   }
 
-  private TtmlStyle createIfNull(@Nullable TtmlStyle style) {
+  private static TtmlStyle createIfNull(@Nullable TtmlStyle style) {
     return style == null ? new TtmlStyle() : style;
   }
 
-  private TtmlNode parseNode(
+  private static TtmlNode parseNode(
       XmlPullParser parser,
       @Nullable TtmlNode parent,
       Map<String, TtmlRegion> regionMap,
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java
index c8e9ed7..7b1dda1 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java
@@ -120,7 +120,7 @@
   private final HashMap<String, Integer> nodeStartsByRegion;
   private final HashMap<String, Integer> nodeEndsByRegion;
 
-  @MonotonicNonNull private List<TtmlNode> children;
+  private @MonotonicNonNull List<TtmlNode> children;
 
   public static TtmlNode buildTextNode(String text) {
     return new TtmlNode(
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java
index 7bd96b2..3c974d8 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java
@@ -387,7 +387,7 @@
   private static void parseLineAttribute(String s, WebvttCueInfoBuilder builder) {
     int commaIndex = s.indexOf(',');
     if (commaIndex != -1) {
-      builder.lineAnchor = parsePositionAnchor(s.substring(commaIndex + 1));
+      builder.lineAnchor = parseLineAnchor(s.substring(commaIndex + 1));
       s = s.substring(0, commaIndex);
     }
     if (s.endsWith("%")) {
@@ -405,6 +405,22 @@
     }
   }
 
+  @Cue.AnchorType
+  private static int parseLineAnchor(String s) {
+    switch (s) {
+      case "start":
+        return Cue.ANCHOR_TYPE_START;
+      case "center":
+      case "middle":
+        return Cue.ANCHOR_TYPE_MIDDLE;
+      case "end":
+        return Cue.ANCHOR_TYPE_END;
+      default:
+        Log.w(TAG, "Invalid anchor value: " + s);
+        return Cue.TYPE_UNSET;
+    }
+  }
+
   private static void parsePositionAttribute(String s, WebvttCueInfoBuilder builder) {
     int commaIndex = s.indexOf(',');
     if (commaIndex != -1) {
@@ -417,11 +433,13 @@
   @Cue.AnchorType
   private static int parsePositionAnchor(String s) {
     switch (s) {
+      case "line-left":
       case "start":
         return Cue.ANCHOR_TYPE_START;
       case "center":
       case "middle":
         return Cue.ANCHOR_TYPE_MIDDLE;
+      case "line-right":
       case "end":
         return Cue.ANCHOR_TYPE_END;
       default:
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java
index 92dff8b..454674f 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java
@@ -39,6 +39,78 @@
  */
 public final class CacheDataSink implements DataSink {
 
+  /** {@link DataSink.Factory} for {@link CacheDataSink} instances. */
+  public static final class Factory implements DataSink.Factory {
+
+    private @MonotonicNonNull Cache cache;
+    private long fragmentSize;
+    private int bufferSize;
+
+    /** Creates an instance. */
+    public Factory() {
+      fragmentSize = CacheDataSink.DEFAULT_FRAGMENT_SIZE;
+      bufferSize = CacheDataSink.DEFAULT_BUFFER_SIZE;
+    }
+
+    /**
+     * Sets the cache to which data will be written.
+     *
+     * <p>Must be called before the factory is used.
+     *
+     * @param cache The cache to which data will be written.
+     * @return This factory.
+     */
+    public Factory setCache(Cache cache) {
+      this.cache = cache;
+      return this;
+    }
+
+    /**
+     * Sets the cache file fragment size. For requests that should be fragmented into multiple cache
+     * files, this is the maximum size of a cache file in bytes. If set to {@link C#LENGTH_UNSET}
+     * then no fragmentation will occur. Using a small value allows for finer-grained cache eviction
+     * policies, at the cost of increased overhead both on the cache implementation and the file
+     * system. Values under {@code (2 * 1024 * 1024)} are not recommended.
+     *
+     * <p>The default value is {@link CacheDataSink#DEFAULT_FRAGMENT_SIZE}.
+     *
+     * @param fragmentSize The fragment size in bytes, or {@link C#LENGTH_UNSET} to disable
+     *     fragmentation.
+     * @return This factory.
+     */
+    public Factory setFragmentSize(long fragmentSize) {
+      this.fragmentSize = fragmentSize;
+      return this;
+    }
+
+    /**
+     * Sets the size of an in-memory buffer used when writing to a cache file. A zero or negative
+     * value disables buffering.
+     *
+     * <p>The default value is {@link CacheDataSink#DEFAULT_BUFFER_SIZE}.
+     *
+     * @param bufferSize The buffer size in bytes.
+     * @return This factory.
+     */
+    public Factory setBufferSize(int bufferSize) {
+      this.bufferSize = bufferSize;
+      return this;
+    }
+
+    @Override
+    public DataSink createDataSink() {
+      return new CacheDataSink(Assertions.checkNotNull(cache), fragmentSize, bufferSize);
+    }
+  }
+
+  /** Thrown when an {@link IOException} is encountered when writing data to the sink. */
+  public static final class CacheDataSinkException extends CacheException {
+
+    public CacheDataSinkException(IOException cause) {
+      super(cause);
+    }
+  }
+
   /** Default {@code fragmentSize} recommended for caching use cases. */
   public static final long DEFAULT_FRAGMENT_SIZE = 5 * 1024 * 1024;
   /** Default buffer size in bytes. */
@@ -60,17 +132,6 @@
   private @MonotonicNonNull ReusableBufferedOutputStream bufferedOutputStream;
 
   /**
-   * Thrown when IOException is encountered when writing data into sink.
-   */
-  public static class CacheDataSinkException extends CacheException {
-
-    public CacheDataSinkException(IOException cause) {
-      super(cause);
-    }
-
-  }
-
-  /**
    * Constructs an instance using {@link #DEFAULT_BUFFER_SIZE}.
    *
    * @param cache The cache into which data should be written.
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java
index ce9735b..effb5f2 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java
@@ -17,9 +17,8 @@
 
 import com.google.android.exoplayer2.upstream.DataSink;
 
-/**
- * A {@link DataSink.Factory} that produces {@link CacheDataSink}.
- */
+/** @deprecated Use {@link CacheDataSink.Factory}. */
+@Deprecated
 public final class CacheDataSinkFactory implements DataSink.Factory {
 
   private final Cache cache;
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java
index 5142f24..c316d75 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java
@@ -23,11 +23,14 @@
 import com.google.android.exoplayer2.upstream.DataSource;
 import com.google.android.exoplayer2.upstream.DataSourceException;
 import com.google.android.exoplayer2.upstream.DataSpec;
+import com.google.android.exoplayer2.upstream.DummyDataSource;
 import com.google.android.exoplayer2.upstream.FileDataSource;
+import com.google.android.exoplayer2.upstream.PriorityDataSource;
 import com.google.android.exoplayer2.upstream.TeeDataSource;
 import com.google.android.exoplayer2.upstream.TransferListener;
 import com.google.android.exoplayer2.upstream.cache.Cache.CacheException;
 import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.PriorityTaskManager;
 import java.io.IOException;
 import java.io.InterruptedIOException;
 import java.lang.annotation.Documented;
@@ -36,6 +39,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
 
 /**
  * A {@link DataSource} that reads and writes a {@link Cache}. Requests are fulfilled from the cache
@@ -44,6 +48,251 @@
  */
 public final class CacheDataSource implements DataSource {
 
+  /** {@link DataSource.Factory} for {@link CacheDataSource} instances. */
+  public static final class Factory implements DataSource.Factory {
+
+    private @MonotonicNonNull Cache cache;
+    private DataSource.Factory cacheReadDataSourceFactory;
+    @Nullable private DataSink.Factory cacheWriteDataSinkFactory;
+    private CacheKeyFactory cacheKeyFactory;
+    private boolean cacheIsReadOnly;
+    @Nullable private DataSource.Factory upstreamDataSourceFactory;
+    @Nullable private PriorityTaskManager upstreamPriorityTaskManager;
+    private int upstreamPriority;
+    @CacheDataSource.Flags private int flags;
+    @Nullable private CacheDataSource.EventListener eventListener;
+
+    public Factory() {
+      cacheReadDataSourceFactory = new FileDataSource.Factory();
+      cacheKeyFactory = CacheUtil.DEFAULT_CACHE_KEY_FACTORY;
+    }
+
+    /**
+     * Sets the cache that will be used.
+     *
+     * <p>Must be called before the factory is used.
+     *
+     * @param cache The cache that will be used.
+     * @return This factory.
+     */
+    public Factory setCache(Cache cache) {
+      this.cache = cache;
+      return this;
+    }
+
+    /**
+     * Sets the {@link DataSource.Factory} for {@link DataSource DataSources} for reading from the
+     * cache.
+     *
+     * <p>The default is a {@link FileDataSource.Factory} in its default configuration.
+     *
+     * @param cacheReadDataSourceFactory The {@link DataSource.Factory} for reading from the cache.
+     * @return This factory.
+     */
+    public Factory setCacheReadDataSourceFactory(DataSource.Factory cacheReadDataSourceFactory) {
+      this.cacheReadDataSourceFactory = cacheReadDataSourceFactory;
+      return this;
+    }
+
+    /**
+     * Sets the {@link DataSink.Factory} for generating {@link DataSink DataSinks} for writing data
+     * to the cache. Passing {@code null} causes the cache to be read-only.
+     *
+     * <p>The default is a {@link CacheDataSink.Factory} in its default configuration.
+     *
+     * @param cacheWriteDataSinkFactory The {@link DataSink.Factory} for generating {@link DataSink
+     *     DataSinks} for writing data to the cache, or {@code null} to disable writing.
+     * @return This factory.
+     */
+    public Factory setCacheWriteDataSinkFactory(
+        @Nullable DataSink.Factory cacheWriteDataSinkFactory) {
+      this.cacheWriteDataSinkFactory = cacheWriteDataSinkFactory;
+      this.cacheIsReadOnly = cacheWriteDataSinkFactory == null;
+      return this;
+    }
+
+    /**
+     * Sets the {@link CacheKeyFactory}.
+     *
+     * <p>The default is {@link CacheUtil#DEFAULT_CACHE_KEY_FACTORY}.
+     *
+     * @param cacheKeyFactory The {@link CacheKeyFactory}.
+     * @return This factory.
+     */
+    public Factory setCacheKeyFactory(CacheKeyFactory cacheKeyFactory) {
+      this.cacheKeyFactory = cacheKeyFactory;
+      return this;
+    }
+
+    /**
+     * Sets the {@link DataSource.Factory} for upstream {@link DataSource DataSources}, which are
+     * used to read data in the case of a cache miss.
+     *
+     * <p>The default is {@code null}, and so this method must be called before the factory is used
+     * in order for data to be read from upstream in the case of a cache miss.
+     *
+     * @param upstreamDataSourceFactory The upstream {@link DataSource} for reading data not in the
+     *     cache, or {@code null} to cause failure in the case of a cache miss.
+     * @return This factory.
+     */
+    public Factory setUpstreamDataSourceFactory(
+        @Nullable DataSource.Factory upstreamDataSourceFactory) {
+      this.upstreamDataSourceFactory = upstreamDataSourceFactory;
+      return this;
+    }
+
+    /**
+     * Sets an optional {@link PriorityTaskManager} to use when requesting data from upstream.
+     *
+     * <p>If set, reads from the upstream {@link DataSource} will only be allowed to proceed if
+     * there are no higher priority tasks registered to the {@link PriorityTaskManager}. If there
+     * exists a higher priority task then {@link PriorityTaskManager.PriorityTooLowException} will
+     * be thrown instead.
+     *
+     * <p>Note that requests to {@link CacheDataSource} instances are intended to be used as parts
+     * of (possibly larger) tasks that are registered with the {@link PriorityTaskManager}, and
+     * hence {@link CacheDataSource} does <em>not</em> register a task by itself. This must be done
+     * by the surrounding code that uses the {@link CacheDataSource} instances.
+     *
+     * <p>The default is {@code null}.
+     *
+     * @param upstreamPriorityTaskManager The upstream {@link PriorityTaskManager}.
+     * @return This factory.
+     */
+    public Factory setUpstreamPriorityTaskManager(
+        @Nullable PriorityTaskManager upstreamPriorityTaskManager) {
+      this.upstreamPriorityTaskManager = upstreamPriorityTaskManager;
+      return this;
+    }
+
+    /**
+     * Sets the priority to use when requesting data from upstream. The priority is only used if a
+     * {@link PriorityTaskManager} is set by calling {@link #setUpstreamPriorityTaskManager}.
+     *
+     * <p>The default is {@link C#PRIORITY_PLAYBACK}.
+     *
+     * @param upstreamPriority The priority to use when requesting data from upstream.
+     * @return This factory.
+     */
+    public Factory setUpstreamPriority(int upstreamPriority) {
+      this.upstreamPriority = upstreamPriority;
+      return this;
+    }
+
+    /**
+     * Sets the {@link CacheDataSource.Flags}.
+     *
+     * <p>The default is {@code 0}.
+     *
+     * @param flags The {@link CacheDataSource.Flags}.
+     * @return This factory.
+     */
+    public Factory setFlags(@CacheDataSource.Flags int flags) {
+      this.flags = flags;
+      return this;
+    }
+
+    /**
+     * Sets the {link EventListener} to which events are delivered.
+     *
+     * <p>The default is {@code null}.
+     *
+     * @param eventListener The {@link EventListener}.
+     * @return This factory.
+     */
+    public Factory setEventListener(@Nullable EventListener eventListener) {
+      this.eventListener = eventListener;
+      return this;
+    }
+
+    @Override
+    public CacheDataSource createDataSource() {
+      return createDataSourceInternal(
+          upstreamDataSourceFactory != null ? upstreamDataSourceFactory.createDataSource() : null,
+          flags,
+          upstreamPriority);
+    }
+
+    /**
+     * Returns an instance suitable for downloading content. The created instance is equivalent to
+     * one that would be created by {@link #createDataSource()}, except:
+     *
+     * <ul>
+     *   <li>The {@link #FLAG_BLOCK_ON_CACHE} is always set.
+     *   <li>The task priority is overridden to be {@link C#PRIORITY_DOWNLOAD}.
+     * </ul>
+     *
+     * @return An instance suitable for downloading content.
+     */
+    public CacheDataSource createDataSourceForDownloading() {
+      return createDataSourceInternal(
+          upstreamDataSourceFactory != null ? upstreamDataSourceFactory.createDataSource() : null,
+          flags | FLAG_BLOCK_ON_CACHE,
+          C.PRIORITY_DOWNLOAD);
+    }
+
+    /**
+     * Returns an instance suitable for reading cached content as part of removing a download. The
+     * created instance is equivalent to one that would be created by {@link #createDataSource()},
+     * except:
+     *
+     * <ul>
+     *   <li>The upstream is overridden to be {@code null}, since when removing content we don't
+     *       want to request anything that's not already cached.
+     *   <li>The {@link #FLAG_BLOCK_ON_CACHE} is always set.
+     *   <li>The task priority is overridden to be {@link C#PRIORITY_DOWNLOAD}.
+     * </ul>
+     *
+     * @return An instance suitable for reading cached content as part of removing a download.
+     */
+    public CacheDataSource createDataSourceForRemovingDownload() {
+      return createDataSourceInternal(
+          /* upstreamDataSource= */ null, flags | FLAG_BLOCK_ON_CACHE, C.PRIORITY_DOWNLOAD);
+    }
+
+    private CacheDataSource createDataSourceInternal(
+        @Nullable DataSource upstreamDataSource, @Flags int flags, int upstreamPriority) {
+      Cache cache = Assertions.checkNotNull(this.cache);
+      @Nullable DataSink cacheWriteDataSink;
+      if (cacheIsReadOnly || upstreamDataSource == null) {
+        cacheWriteDataSink = null;
+      } else if (cacheWriteDataSinkFactory != null) {
+        cacheWriteDataSink = cacheWriteDataSinkFactory.createDataSink();
+      } else {
+        cacheWriteDataSink = new CacheDataSink.Factory().setCache(cache).createDataSink();
+      }
+      return new CacheDataSource(
+          cache,
+          upstreamDataSource,
+          cacheReadDataSourceFactory.createDataSource(),
+          cacheWriteDataSink,
+          cacheKeyFactory,
+          flags,
+          upstreamPriorityTaskManager,
+          upstreamPriority,
+          eventListener);
+    }
+  }
+
+  /** Listener of {@link CacheDataSource} events. */
+  public interface EventListener {
+
+    /**
+     * Called when bytes have been read from the cache.
+     *
+     * @param cacheSizeBytes Current cache size in bytes.
+     * @param cachedBytesRead Total bytes read from the cache since this method was last called.
+     */
+    void onCachedBytesRead(long cacheSizeBytes, long cachedBytesRead);
+
+    /**
+     * Called when the current request ignores cache.
+     *
+     * @param reason Reason cache is bypassed.
+     */
+    void onCacheIgnored(@CacheIgnoredReason int reason);
+  }
+
   /**
    * Flags controlling the CacheDataSource's behavior. Possible flag values are {@link
    * #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} and {@link
@@ -96,27 +345,6 @@
   /** Cache ignored due to a request with an unset length. */
   public static final int CACHE_IGNORED_REASON_UNSET_LENGTH = 1;
 
-  /**
-   * Listener of {@link CacheDataSource} events.
-   */
-  public interface EventListener {
-
-    /**
-     * Called when bytes have been read from the cache.
-     *
-     * @param cacheSizeBytes Current cache size in bytes.
-     * @param cachedBytesRead Total bytes read from the cache since this method was last called.
-     */
-    void onCachedBytesRead(long cacheSizeBytes, long cachedBytesRead);
-
-    /**
-     * Called when the current request ignores cache.
-     *
-     * @param reason Reason cache is bypassed.
-     */
-    void onCacheIgnored(@CacheIgnoredReason int reason);
-  }
-
   /** Minimum number of bytes to read before checking cache for availability. */
   private static final long MIN_READ_BEFORE_CHECKING_CACHE = 100 * 1024;
 
@@ -125,6 +353,8 @@
   @Nullable private final DataSource cacheWriteDataSource;
   private final DataSource upstreamDataSource;
   private final CacheKeyFactory cacheKeyFactory;
+  @Nullable private final PriorityTaskManager upstreamPriorityTaskManager;
+  private final int upstreamPriority;
   @Nullable private final EventListener eventListener;
 
   private final boolean blockOnCache;
@@ -148,10 +378,11 @@
    * reading and writing the cache.
    *
    * @param cache The cache.
-   * @param upstream A {@link DataSource} for reading data not in the cache.
+   * @param upstreamDataSource A {@link DataSource} for reading data not in the cache. If null,
+   *     reading will fail if a cache miss occurs.
    */
-  public CacheDataSource(Cache cache, DataSource upstream) {
-    this(cache, upstream, /* flags= */ 0);
+  public CacheDataSource(Cache cache, @Nullable DataSource upstreamDataSource) {
+    this(cache, upstreamDataSource, /* flags= */ 0);
   }
 
   /**
@@ -159,14 +390,15 @@
    * reading and writing the cache.
    *
    * @param cache The cache.
-   * @param upstream A {@link DataSource} for reading data not in the cache.
+   * @param upstreamDataSource A {@link DataSource} for reading data not in the cache. If null,
+   *     reading will fail if a cache miss occurs.
    * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR}
    *     and {@link #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}, or 0.
    */
-  public CacheDataSource(Cache cache, DataSource upstream, @Flags int flags) {
+  public CacheDataSource(Cache cache, @Nullable DataSource upstreamDataSource, @Flags int flags) {
     this(
         cache,
-        upstream,
+        upstreamDataSource,
         new FileDataSource(),
         new CacheDataSink(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE),
         flags,
@@ -179,7 +411,8 @@
    * before it is written to disk.
    *
    * @param cache The cache.
-   * @param upstream A {@link DataSource} for reading data not in the cache.
+   * @param upstreamDataSource A {@link DataSource} for reading data not in the cache. If null,
+   *     reading will fail if a cache miss occurs.
    * @param cacheReadDataSource A {@link DataSource} for reading data from the cache.
    * @param cacheWriteDataSink A {@link DataSink} for writing data to the cache. If null, cache is
    *     accessed read-only.
@@ -189,14 +422,14 @@
    */
   public CacheDataSource(
       Cache cache,
-      DataSource upstream,
+      @Nullable DataSource upstreamDataSource,
       DataSource cacheReadDataSource,
       @Nullable DataSink cacheWriteDataSink,
       @Flags int flags,
       @Nullable EventListener eventListener) {
     this(
         cache,
-        upstream,
+        upstreamDataSource,
         cacheReadDataSource,
         cacheWriteDataSink,
         flags,
@@ -210,7 +443,8 @@
    * before it is written to disk.
    *
    * @param cache The cache.
-   * @param upstream A {@link DataSource} for reading data not in the cache.
+   * @param upstreamDataSource A {@link DataSource} for reading data not in the cache. If null,
+   *     reading will fail if a cache miss occurs.
    * @param cacheReadDataSource A {@link DataSource} for reading data from the cache.
    * @param cacheWriteDataSink A {@link DataSink} for writing data to the cache. If null, cache is
    *     accessed read-only.
@@ -221,12 +455,34 @@
    */
   public CacheDataSource(
       Cache cache,
-      DataSource upstream,
+      @Nullable DataSource upstreamDataSource,
       DataSource cacheReadDataSource,
       @Nullable DataSink cacheWriteDataSink,
       @Flags int flags,
       @Nullable EventListener eventListener,
       @Nullable CacheKeyFactory cacheKeyFactory) {
+    this(
+        cache,
+        upstreamDataSource,
+        cacheReadDataSource,
+        cacheWriteDataSink,
+        cacheKeyFactory,
+        flags,
+        /* upstreamPriorityTaskManager= */ null,
+        /* upstreamPriority= */ C.PRIORITY_PLAYBACK,
+        eventListener);
+  }
+
+  private CacheDataSource(
+      Cache cache,
+      @Nullable DataSource upstreamDataSource,
+      DataSource cacheReadDataSource,
+      @Nullable DataSink cacheWriteDataSink,
+      @Nullable CacheKeyFactory cacheKeyFactory,
+      @Flags int flags,
+      @Nullable PriorityTaskManager upstreamPriorityTaskManager,
+      int upstreamPriority,
+      @Nullable EventListener eventListener) {
     this.cache = cache;
     this.cacheReadDataSource = cacheReadDataSource;
     this.cacheKeyFactory =
@@ -235,15 +491,55 @@
     this.ignoreCacheOnError = (flags & FLAG_IGNORE_CACHE_ON_ERROR) != 0;
     this.ignoreCacheForUnsetLengthRequests =
         (flags & FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS) != 0;
-    this.upstreamDataSource = upstream;
-    if (cacheWriteDataSink != null) {
-      this.cacheWriteDataSource = new TeeDataSource(upstream, cacheWriteDataSink);
+    this.upstreamPriority = upstreamPriority;
+    if (upstreamDataSource != null) {
+      if (upstreamPriorityTaskManager != null) {
+        upstreamDataSource =
+            new PriorityDataSource(
+                upstreamDataSource, upstreamPriorityTaskManager, upstreamPriority);
+      }
+      this.upstreamDataSource = upstreamDataSource;
+      this.upstreamPriorityTaskManager = upstreamPriorityTaskManager;
+      this.cacheWriteDataSource =
+          cacheWriteDataSink != null
+              ? new TeeDataSource(upstreamDataSource, cacheWriteDataSink)
+              : null;
     } else {
+      this.upstreamDataSource = DummyDataSource.INSTANCE;
+      this.upstreamPriorityTaskManager = null;
       this.cacheWriteDataSource = null;
     }
     this.eventListener = eventListener;
   }
 
+  /** Returns the {@link Cache} used by this instance. */
+  public Cache getCache() {
+    return cache;
+  }
+
+  /** Returns the {@link CacheKeyFactory} used by this instance. */
+  public CacheKeyFactory getCacheKeyFactory() {
+    return cacheKeyFactory;
+  }
+
+  /**
+   * Returns the {@link PriorityTaskManager} used when there's a cache miss and requests need to be
+   * made to the upstream {@link DataSource}, or {@code null} if there is none.
+   */
+  @Nullable
+  public PriorityTaskManager getUpstreamPriorityTaskManager() {
+    return upstreamPriorityTaskManager;
+  }
+
+  /**
+   * Returns the priority used when there's a cache miss and requests need to be made to the
+   * upstream {@link DataSource}. The priority is only used if the source has a {@link
+   * PriorityTaskManager}.
+   */
+  public int getUpstreamPriority() {
+    return upstreamPriority;
+  }
+
   @Override
   public void addTransferListener(TransferListener transferListener) {
     cacheReadDataSource.addTransferListener(transferListener);
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java
index 21758bd..a9348b7 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java
@@ -20,7 +20,8 @@
 import com.google.android.exoplayer2.upstream.DataSource;
 import com.google.android.exoplayer2.upstream.FileDataSource;
 
-/** A {@link DataSource.Factory} that produces {@link CacheDataSource}. */
+/** @deprecated Use {@link CacheDataSource.Factory}. */
+@Deprecated
 public final class CacheDataSourceFactory implements DataSource.Factory {
 
   private final Cache cache;
@@ -44,13 +45,14 @@
   }
 
   /** @see CacheDataSource#CacheDataSource(Cache, DataSource, int) */
+  @SuppressWarnings("deprecation")
   public CacheDataSourceFactory(
       Cache cache, DataSource.Factory upstreamFactory, @CacheDataSource.Flags int flags) {
     this(
         cache,
         upstreamFactory,
         new FileDataSource.Factory(),
-        new CacheDataSinkFactory(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE),
+        new CacheDataSink.Factory().setCache(cache),
         flags,
         /* eventListener= */ null);
   }
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java
index 8da2fb1..5d99335 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java
@@ -107,10 +107,9 @@
    *
    * <p>This method may be slow and shouldn't normally be called on the main thread.
    *
-   * @param dataSpec Defines the data to be cached.
    * @param cache A {@link Cache} to store the data.
-   * @param cacheKeyFactory An optional factory for cache keys.
-   * @param upstream A {@link DataSource} for reading data not in the cache.
+   * @param dataSpec Defines the data to be cached.
+   * @param upstreamDataSource A {@link DataSource} for reading data not in the cache.
    * @param progressListener A listener to receive progress updates, or {@code null}.
    * @param isCanceled An optional flag that will interrupt caching if set to true.
    * @throws IOException If an error occurs reading from the source.
@@ -118,69 +117,55 @@
    */
   @WorkerThread
   public static void cache(
-      DataSpec dataSpec,
       Cache cache,
-      @Nullable CacheKeyFactory cacheKeyFactory,
-      DataSource upstream,
+      DataSpec dataSpec,
+      DataSource upstreamDataSource,
       @Nullable ProgressListener progressListener,
       @Nullable AtomicBoolean isCanceled)
       throws IOException, InterruptedException {
     cache(
+        new CacheDataSource(cache, upstreamDataSource),
         dataSpec,
-        cache,
-        cacheKeyFactory,
-        new CacheDataSource(cache, upstream),
-        new byte[DEFAULT_BUFFER_SIZE_BYTES],
-        /* priorityTaskManager= */ null,
-        /* priority= */ 0,
         progressListener,
         isCanceled,
-        /* enableEOFException= */ false);
+        /* enableEOFException= */ false,
+        new byte[DEFAULT_BUFFER_SIZE_BYTES]);
   }
 
   /**
-   * Caches the data defined by {@code dataSpec} while skipping already cached data. Caching stops
-   * early if end of input is reached and {@code enableEOFException} is false.
+   * Caches the data defined by {@code dataSpec}, skipping already cached data. Caching stops early
+   * if end of input is reached and {@code enableEOFException} is false.
    *
-   * <p>If a {@link PriorityTaskManager} is given, it's used to pause and resume caching depending
-   * on {@code priority} and the priority of other tasks registered to the PriorityTaskManager.
-   * Please note that it's the responsibility of the calling code to call {@link
-   * PriorityTaskManager#add} to register with the manager before calling this method, and to call
-   * {@link PriorityTaskManager#remove} afterwards to unregister.
+   * <p>If {@code dataSource} has a {@link PriorityTaskManager}, then it's the responsibility of the
+   * calling code to call {@link PriorityTaskManager#add} to register with the manager before
+   * calling this method, and to call {@link PriorityTaskManager#remove} afterwards to unregister.
    *
    * <p>This method may be slow and shouldn't normally be called on the main thread.
    *
+   * @param dataSource A {@link CacheDataSource} to be used for caching the data.
    * @param dataSpec Defines the data to be cached.
-   * @param cache A {@link Cache} to store the data.
-   * @param cacheKeyFactory An optional factory for cache keys.
-   * @param dataSource A {@link CacheDataSource} that works on the {@code cache}.
-   * @param buffer The buffer to be used while caching.
-   * @param priorityTaskManager If not null it's used to check whether it is allowed to proceed with
-   *     caching.
-   * @param priority The priority of this task. Used with {@code priorityTaskManager}.
    * @param progressListener A listener to receive progress updates, or {@code null}.
    * @param isCanceled An optional flag that will interrupt caching if set to true.
    * @param enableEOFException Whether to throw an {@link EOFException} if end of input has been
    *     reached unexpectedly.
+   * @param temporaryBuffer A temporary buffer to be used during caching.
    * @throws IOException If an error occurs reading from the source.
    * @throws InterruptedException If the thread was interrupted directly or via {@code isCanceled}.
    */
   @WorkerThread
   public static void cache(
-      DataSpec dataSpec,
-      Cache cache,
-      @Nullable CacheKeyFactory cacheKeyFactory,
       CacheDataSource dataSource,
-      byte[] buffer,
-      @Nullable PriorityTaskManager priorityTaskManager,
-      int priority,
+      DataSpec dataSpec,
       @Nullable ProgressListener progressListener,
       @Nullable AtomicBoolean isCanceled,
-      boolean enableEOFException)
+      boolean enableEOFException,
+      byte[] temporaryBuffer)
       throws IOException, InterruptedException {
     Assertions.checkNotNull(dataSource);
-    Assertions.checkNotNull(buffer);
+    Assertions.checkNotNull(temporaryBuffer);
 
+    Cache cache = dataSource.getCache();
+    CacheKeyFactory cacheKeyFactory = dataSource.getCacheKeyFactory();
     String key = buildCacheKey(dataSpec, cacheKeyFactory);
     long bytesLeft;
     @Nullable ProgressNotifier progressNotifier = null;
@@ -212,12 +197,10 @@
                 position,
                 length,
                 dataSource,
-                buffer,
-                priorityTaskManager,
-                priority,
+                isCanceled,
                 progressNotifier,
                 isLastBlock,
-                isCanceled);
+                temporaryBuffer);
         if (read < blockLength) {
           // Reached to the end of the data.
           if (enableEOFException && !lengthUnset) {
@@ -249,14 +232,11 @@
    *     overwritten by the following parameters.
    * @param position The position of the data to be read.
    * @param length Length of the data to be read, or {@link C#LENGTH_UNSET} if it is unknown.
-   * @param dataSource The {@link DataSource} to read the data from.
-   * @param buffer The buffer to be used while downloading.
-   * @param priorityTaskManager If not null it's used to check whether it is allowed to proceed with
-   *     caching.
-   * @param priority The priority of this task.
+   * @param dataSource The {@link CacheDataSource} to read the data from.
+   * @param isCanceled An optional flag that will interrupt caching if set to true.
    * @param progressNotifier A notifier through which to report progress updates, or {@code null}.
    * @param isLastBlock Whether this read block is the last block of the content.
-   * @param isCanceled An optional flag that will interrupt caching if set to true.
+   * @param temporaryBuffer A temporary buffer to be used during caching.
    * @return Number of read bytes, or 0 if no data is available because the end of the opened range
    *     has been reached.
    */
@@ -264,21 +244,20 @@
       DataSpec dataSpec,
       long position,
       long length,
-      DataSource dataSource,
-      byte[] buffer,
-      @Nullable PriorityTaskManager priorityTaskManager,
-      int priority,
+      CacheDataSource dataSource,
+      @Nullable AtomicBoolean isCanceled,
       @Nullable ProgressNotifier progressNotifier,
       boolean isLastBlock,
-      @Nullable AtomicBoolean isCanceled)
+      byte[] temporaryBuffer)
       throws IOException, InterruptedException {
     long positionOffset = position - dataSpec.position;
     long initialPositionOffset = positionOffset;
     long endOffset = length != C.LENGTH_UNSET ? positionOffset + length : C.POSITION_UNSET;
+    @Nullable PriorityTaskManager priorityTaskManager = dataSource.getUpstreamPriorityTaskManager();
     while (true) {
       if (priorityTaskManager != null) {
         // Wait for any other thread with higher priority to finish its job.
-        priorityTaskManager.proceed(priority);
+        priorityTaskManager.proceed(dataSource.getUpstreamPriority());
       }
       throwExceptionIfInterruptedOrCancelled(isCanceled);
       try {
@@ -310,11 +289,11 @@
           throwExceptionIfInterruptedOrCancelled(isCanceled);
           int bytesRead =
               dataSource.read(
-                  buffer,
+                  temporaryBuffer,
                   0,
                   endOffset != C.POSITION_UNSET
-                      ? (int) Math.min(buffer.length, endOffset - positionOffset)
-                      : buffer.length);
+                      ? (int) Math.min(temporaryBuffer.length, endOffset - positionOffset)
+                      : temporaryBuffer.length);
           if (bytesRead == C.RESULT_END_OF_INPUT) {
             if (progressNotifier != null) {
               progressNotifier.onRequestLengthResolved(positionOffset);
diff --git a/tree/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/tree/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java
index 794bc5f..91888cd 100644
--- a/tree/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java
+++ b/tree/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java
@@ -128,9 +128,9 @@
   private long totalVideoFrameProcessingOffsetUs;
   private int videoFrameProcessingOffsetCount;
 
-  private int pendingRotationDegrees;
-  private float pendingPixelWidthHeightRatio;
   @Nullable private MediaFormat currentMediaFormat;
+  private int mediaFormatWidth;
+  private int mediaFormatHeight;
   private int currentWidth;
   private int currentHeight;
   private int currentUnappliedRotationDegrees;
@@ -235,8 +235,9 @@
     currentWidth = Format.NO_VALUE;
     currentHeight = Format.NO_VALUE;
     currentPixelWidthHeightRatio = Format.NO_VALUE;
-    pendingPixelWidthHeightRatio = Format.NO_VALUE;
     scalingMode = VIDEO_SCALING_MODE_DEFAULT;
+    mediaFormatWidth = Format.NO_VALUE;
+    mediaFormatHeight = Format.NO_VALUE;
     clearReportedVideoSize();
   }
 
@@ -603,10 +604,7 @@
   @Override
   protected void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybackException {
     super.onInputFormatChanged(formatHolder);
-    Format newFormat = formatHolder.format;
-    eventDispatcher.inputFormatChanged(newFormat);
-    pendingPixelWidthHeightRatio = newFormat.pixelWidthHeightRatio;
-    pendingRotationDegrees = newFormat.rotationDegrees;
+    eventDispatcher.inputFormatChanged(formatHolder.format);
   }
 
   /**
@@ -637,23 +635,56 @@
             && outputMediaFormat.containsKey(KEY_CROP_LEFT)
             && outputMediaFormat.containsKey(KEY_CROP_BOTTOM)
             && outputMediaFormat.containsKey(KEY_CROP_TOP);
-    int mediaFormatWidth =
+    mediaFormatWidth =
         hasCrop
             ? outputMediaFormat.getInteger(KEY_CROP_RIGHT)
                 - outputMediaFormat.getInteger(KEY_CROP_LEFT)
                 + 1
             : outputMediaFormat.getInteger(MediaFormat.KEY_WIDTH);
-    int mediaFormatHeight =
+    mediaFormatHeight =
         hasCrop
             ? outputMediaFormat.getInteger(KEY_CROP_BOTTOM)
                 - outputMediaFormat.getInteger(KEY_CROP_TOP)
                 + 1
             : outputMediaFormat.getInteger(MediaFormat.KEY_HEIGHT);
-    processOutputFormat(codec, mediaFormatWidth, mediaFormatHeight);
+
+    // Must be applied each time the output MediaFormat changes.
+    codec.setVideoScalingMode(scalingMode);
     maybeNotifyVideoFrameProcessingOffset();
   }
 
   @Override
+  protected void onOutputFormatChanged(Format outputFormat) {
+    configureOutput(outputFormat);
+  }
+
+  @Override
+  protected void configureOutput(Format outputFormat) {
+    if (tunneling) {
+      currentWidth = outputFormat.width;
+      currentHeight = outputFormat.height;
+    } else {
+      currentWidth = mediaFormatWidth;
+      currentHeight = mediaFormatHeight;
+    }
+    currentPixelWidthHeightRatio = outputFormat.pixelWidthHeightRatio;
+    if (Util.SDK_INT >= 21) {
+      // On API level 21 and above the decoder applies the rotation when rendering to the surface.
+      // Hence currentUnappliedRotation should always be 0. For 90 and 270 degree rotations, we need
+      // to flip the width, height and pixel aspect ratio to reflect the rotation that was applied.
+      if (outputFormat.rotationDegrees == 90 || outputFormat.rotationDegrees == 270) {
+        int rotatedHeight = currentWidth;
+        currentWidth = currentHeight;
+        currentHeight = rotatedHeight;
+        currentPixelWidthHeightRatio = 1 / currentPixelWidthHeightRatio;
+      }
+    } else {
+      // On API level 20 and below the decoder does not apply the rotation.
+      currentUnappliedRotationDegrees = outputFormat.rotationDegrees;
+    }
+  }
+
+  @Override
   @TargetApi(29) // codecHandlesHdr10PlusOutOfBandMetadata is false if Util.SDK_INT < 29
   protected void handleInputBufferSupplementalData(DecoderInputBuffer buffer)
       throws ExoPlaybackException {
@@ -814,28 +845,6 @@
     return false;
   }
 
-  private void processOutputFormat(MediaCodec codec, int width, int height) {
-    currentWidth = width;
-    currentHeight = height;
-    currentPixelWidthHeightRatio = pendingPixelWidthHeightRatio;
-    if (Util.SDK_INT >= 21) {
-      // On API level 21 and above the decoder applies the rotation when rendering to the surface.
-      // Hence currentUnappliedRotation should always be 0. For 90 and 270 degree rotations, we need
-      // to flip the width, height and pixel aspect ratio to reflect the rotation that was applied.
-      if (pendingRotationDegrees == 90 || pendingRotationDegrees == 270) {
-        int rotatedHeight = currentWidth;
-        currentWidth = currentHeight;
-        currentHeight = rotatedHeight;
-        currentPixelWidthHeightRatio = 1 / currentPixelWidthHeightRatio;
-      }
-    } else {
-      // On API level 20 and below the decoder does not apply the rotation.
-      currentUnappliedRotationDegrees = pendingRotationDegrees;
-    }
-    // Must be applied each time the output MediaFormat changes.
-    codec.setVideoScalingMode(scalingMode);
-  }
-
   private void notifyFrameMetadataListener(
       long presentationTimeUs, long releaseTimeNs, Format format, MediaFormat mediaFormat) {
     if (frameMetadataListener != null) {
@@ -846,10 +855,7 @@
 
   /** Called when a buffer was processed in tunneling mode. */
   protected void onProcessedTunneledBuffer(long presentationTimeUs) {
-    @Nullable Format format = updateOutputFormatForTime(presentationTimeUs);
-    if (format != null) {
-      processOutputFormat(getCodec(), format.width, format.height);
-    }
+    updateOutputFormatForTime(presentationTimeUs);
     maybeNotifyVideoSizeChanged();
     decoderCounters.renderedOutputBufferCount++;
     maybeNotifyRenderedFirstFrame();
diff --git a/tree/library/core/src/test/java/com/google/android/exoplayer2/DefaultLoadControlTest.java b/tree/library/core/src/test/java/com/google/android/exoplayer2/DefaultLoadControlTest.java
index 8ed0b21..f7065fb 100644
--- a/tree/library/core/src/test/java/com/google/android/exoplayer2/DefaultLoadControlTest.java
+++ b/tree/library/core/src/test/java/com/google/android/exoplayer2/DefaultLoadControlTest.java
@@ -49,9 +49,16 @@
   public void shouldContinueLoading_untilMaxBufferExceeded() {
     createDefaultLoadControl();
 
-    assertThat(loadControl.shouldContinueLoading(/* bufferedDurationUs= */ 0, SPEED)).isTrue();
-    assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US - 1, SPEED)).isTrue();
-    assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US, SPEED)).isFalse();
+    assertThat(
+            loadControl.shouldContinueLoading(
+                /* playbackPositionUs= */ 0, /* bufferedDurationUs= */ 0, SPEED))
+        .isTrue();
+    assertThat(
+            loadControl.shouldContinueLoading(
+                /* playbackPositionUs= */ 0, MAX_BUFFER_US - 1, SPEED))
+        .isTrue();
+    assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MAX_BUFFER_US, SPEED))
+        .isFalse();
   }
 
   @Test
@@ -63,10 +70,18 @@
         /* bufferForPlaybackAfterRebufferMs= */ 0);
     createDefaultLoadControl();
 
-    assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US, SPEED)).isFalse();
-    assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US - 1, SPEED)).isFalse();
-    assertThat(loadControl.shouldContinueLoading(MIN_BUFFER_US, SPEED)).isFalse();
-    assertThat(loadControl.shouldContinueLoading(MIN_BUFFER_US - 1, SPEED)).isTrue();
+    assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MAX_BUFFER_US, SPEED))
+        .isFalse();
+    assertThat(
+            loadControl.shouldContinueLoading(
+                /* playbackPositionUs= */ 0, MAX_BUFFER_US - 1, SPEED))
+        .isFalse();
+    assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MIN_BUFFER_US, SPEED))
+        .isFalse();
+    assertThat(
+            loadControl.shouldContinueLoading(
+                /* playbackPositionUs= */ 0, MIN_BUFFER_US - 1, SPEED))
+        .isTrue();
   }
 
   @Test
@@ -78,9 +93,14 @@
         /* bufferForPlaybackAfterRebufferMs= */ 0);
     createDefaultLoadControl();
 
-    assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US, SPEED)).isFalse();
-    assertThat(loadControl.shouldContinueLoading(5 * C.MICROS_PER_SECOND, SPEED)).isFalse();
-    assertThat(loadControl.shouldContinueLoading(500L, SPEED)).isTrue();
+    assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MAX_BUFFER_US, SPEED))
+        .isFalse();
+    assertThat(
+            loadControl.shouldContinueLoading(
+                /* playbackPositionUs= */ 0, 5 * C.MICROS_PER_SECOND, SPEED))
+        .isFalse();
+    assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, 500L, SPEED))
+        .isTrue();
   }
 
   @Test
@@ -94,10 +114,18 @@
     createDefaultLoadControl();
     makeSureTargetBufferBytesReached();
 
-    assertThat(loadControl.shouldContinueLoading(/* bufferedDurationUs= */ 0, SPEED)).isTrue();
-    assertThat(loadControl.shouldContinueLoading(MIN_BUFFER_US - 1, SPEED)).isTrue();
-    assertThat(loadControl.shouldContinueLoading(MIN_BUFFER_US, SPEED)).isFalse();
-    assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US, SPEED)).isFalse();
+    assertThat(
+            loadControl.shouldContinueLoading(
+                /* playbackPositionUs= */ 0, /* bufferedDurationUs= */ 0, SPEED))
+        .isTrue();
+    assertThat(
+            loadControl.shouldContinueLoading(
+                /* playbackPositionUs= */ 0, MIN_BUFFER_US - 1, SPEED))
+        .isTrue();
+    assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MIN_BUFFER_US, SPEED))
+        .isFalse();
+    assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MAX_BUFFER_US, SPEED))
+        .isFalse();
   }
 
   @Test
@@ -107,13 +135,24 @@
     createDefaultLoadControl();
 
     // Put loadControl in buffering state.
-    assertThat(loadControl.shouldContinueLoading(/* bufferedDurationUs= */ 0, SPEED)).isTrue();
+    assertThat(
+            loadControl.shouldContinueLoading(
+                /* playbackPositionUs= */ 0, /* bufferedDurationUs= */ 0, SPEED))
+        .isTrue();
     makeSureTargetBufferBytesReached();
 
-    assertThat(loadControl.shouldContinueLoading(/* bufferedDurationUs= */ 0, SPEED)).isFalse();
-    assertThat(loadControl.shouldContinueLoading(MIN_BUFFER_US - 1, SPEED)).isFalse();
-    assertThat(loadControl.shouldContinueLoading(MIN_BUFFER_US, SPEED)).isFalse();
-    assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US, SPEED)).isFalse();
+    assertThat(
+            loadControl.shouldContinueLoading(
+                /* playbackPositionUs= */ 0, /* bufferedDurationUs= */ 0, SPEED))
+        .isFalse();
+    assertThat(
+            loadControl.shouldContinueLoading(
+                /* playbackPositionUs= */ 0, MIN_BUFFER_US - 1, SPEED))
+        .isFalse();
+    assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MIN_BUFFER_US, SPEED))
+        .isFalse();
+    assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MAX_BUFFER_US, SPEED))
+        .isFalse();
   }
 
   @Test
@@ -126,16 +165,22 @@
     createDefaultLoadControl();
 
     // At normal playback speed, we stop buffering when the buffer reaches the minimum.
-    assertThat(loadControl.shouldContinueLoading(MIN_BUFFER_US, SPEED)).isFalse();
+    assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MIN_BUFFER_US, SPEED))
+        .isFalse();
     // At double playback speed, we continue loading.
-    assertThat(loadControl.shouldContinueLoading(MIN_BUFFER_US, /* playbackSpeed= */ 2f)).isTrue();
+    assertThat(
+            loadControl.shouldContinueLoading(
+                /* playbackPositionUs= */ 0, MIN_BUFFER_US, /* playbackSpeed= */ 2f))
+        .isTrue();
   }
 
   @Test
   public void shouldNotContinueLoadingWithMaxBufferReached_inFastPlayback() {
     createDefaultLoadControl();
 
-    assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US, /* playbackSpeed= */ 100f))
+    assertThat(
+            loadControl.shouldContinueLoading(
+                /* playbackPositionUs= */ 0, MAX_BUFFER_US, /* playbackSpeed= */ 100f))
         .isFalse();
   }
 
@@ -153,7 +198,8 @@
     loadControl.onTracksSelected(new Renderer[0], TrackGroupArray.EMPTY, new TrackSelectionArray());
 
     assertThat(
-            loadControl.shouldContinueLoading(/* bufferedDurationUs= */ 0, /* playbackSpeed= */ 1f))
+            loadControl.shouldContinueLoading(
+                /* playbackPositionUs= */ 0, /* bufferedDurationUs= */ 0, /* playbackSpeed= */ 1f))
         .isTrue();
   }
 
diff --git a/tree/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/tree/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java
index b4101dc..d40052a 100644
--- a/tree/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java
+++ b/tree/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java
@@ -27,6 +27,8 @@
 import android.media.AudioManager;
 import android.os.Looper;
 import android.view.Surface;
+import android.view.View;
+import android.view.ViewGroup;
 import androidx.annotation.Nullable;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -48,6 +50,8 @@
 import com.google.android.exoplayer2.source.TrackGroup;
 import com.google.android.exoplayer2.source.TrackGroupArray;
 import com.google.android.exoplayer2.source.ads.AdPlaybackState;
+import com.google.android.exoplayer2.source.ads.AdsLoader;
+import com.google.android.exoplayer2.source.ads.AdsMediaSource;
 import com.google.android.exoplayer2.testutil.ActionSchedule;
 import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerRunnable;
 import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerTarget;
@@ -67,14 +71,17 @@
 import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition;
 import com.google.android.exoplayer2.testutil.FakeTrackSelection;
 import com.google.android.exoplayer2.testutil.FakeTrackSelector;
+import com.google.android.exoplayer2.testutil.TestExoPlayer;
 import com.google.android.exoplayer2.trackselection.TrackSelection;
 import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
 import com.google.android.exoplayer2.upstream.Allocation;
 import com.google.android.exoplayer2.upstream.Allocator;
+import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
 import com.google.android.exoplayer2.upstream.Loader;
 import com.google.android.exoplayer2.upstream.TransferListener;
 import com.google.android.exoplayer2.util.Assertions;
 import com.google.android.exoplayer2.util.Clock;
+import com.google.android.exoplayer2.util.Util;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -3500,11 +3507,12 @@
   // Disabled until the flag to throw exceptions for [internal: b/144538905] is enabled by default.
   @Ignore
   @Test
-  public void loadControlNeverWantsToLoad_throwsIllegalStateException() throws Exception {
+  public void loadControlNeverWantsToLoad_throwsIllegalStateException() {
     LoadControl neverLoadingLoadControl =
         new DefaultLoadControl() {
           @Override
-          public boolean shouldContinueLoading(long bufferedDurationUs, float playbackSpeed) {
+          public boolean shouldContinueLoading(
+              long playbackPositionUs, long bufferedDurationUs, float playbackSpeed) {
             return false;
           }
 
@@ -3540,11 +3548,85 @@
   }
 
   @Test
+  public void
+      nextLoadPositionExceedingLoadControlMaxBuffer_whileCurrentLoadInProgress_doesNotThrowException() {
+    long maxBufferUs = 2 * C.MICROS_PER_SECOND;
+    LoadControl loadControlWithMaxBufferUs =
+        new DefaultLoadControl() {
+          @Override
+          public boolean shouldContinueLoading(
+              long playbackPositionUs, long bufferedDurationUs, float playbackSpeed) {
+            return bufferedDurationUs < maxBufferUs;
+          }
+
+          @Override
+          public boolean shouldStartPlayback(
+              long bufferedDurationUs, float playbackSpeed, boolean rebuffering) {
+            return true;
+          }
+        };
+    MediaSource mediaSourceWithLoadInProgress =
+        new FakeMediaSource(
+            new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT) {
+          @Override
+          protected FakeMediaPeriod createFakeMediaPeriod(
+              MediaPeriodId id,
+              TrackGroupArray trackGroupArray,
+              Allocator allocator,
+              EventDispatcher eventDispatcher,
+              @Nullable TransferListener transferListener) {
+            return new FakeMediaPeriod(trackGroupArray, eventDispatcher) {
+              @Override
+              public long getBufferedPositionUs() {
+                // Pretend not to have buffered data yet.
+                return 0;
+              }
+
+              @Override
+              public long getNextLoadPositionUs() {
+                // Set next load position beyond the maxBufferUs configured in the LoadControl.
+                return Long.MAX_VALUE;
+              }
+
+              @Override
+              public boolean isLoading() {
+                return true;
+              }
+            };
+          }
+        };
+    FakeRenderer rendererWaitingForData =
+        new FakeRenderer(C.TRACK_TYPE_VIDEO) {
+          @Override
+          public boolean isReady() {
+            return false;
+          }
+        };
+
+    ExoPlayer player =
+        new TestExoPlayer.Builder(context)
+            .setRenderers(rendererWaitingForData)
+            .setLoadControl(loadControlWithMaxBufferUs)
+            .experimental_setThrowWhenStuckBuffering(true)
+            .build();
+    player.setMediaSource(mediaSourceWithLoadInProgress);
+    player.prepare();
+
+    // Wait until the MediaSource is prepared, i.e. returned its timeline, and at least one
+    // iteration of doSomeWork after this was run.
+    TestExoPlayer.runUntilTimelineChanged(player, /* expectedTimeline= */ null);
+    TestExoPlayer.runUntilPendingCommandsAreFullyHandled(player);
+
+    assertThat(player.getPlayerError()).isNull();
+  }
+
+  @Test
   public void loadControlNeverWantsToPlay_playbackDoesNotGetStuck() throws Exception {
     LoadControl neverLoadingOrPlayingLoadControl =
         new DefaultLoadControl() {
           @Override
-          public boolean shouldContinueLoading(long bufferedDurationUs, float playbackSpeed) {
+          public boolean shouldContinueLoading(
+              long playbackPositionUs, long bufferedDurationUs, float playbackSpeed) {
             return true;
           }
 
@@ -4306,6 +4388,121 @@
   }
 
   @Test
+  public void setMediaSources_secondAdMediaSource_throws() throws Exception {
+    AdsMediaSource adsMediaSource =
+        new AdsMediaSource(
+            new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)),
+            new DefaultDataSourceFactory(
+                context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)),
+            new DummyAdsLoader(),
+            new DummyAdViewProvider());
+    Exception[] exception = {null};
+    ActionSchedule actionSchedule =
+        new ActionSchedule.Builder(TAG)
+            .executeRunnable(
+                new PlayerRunnable() {
+                  @Override
+                  public void run(SimpleExoPlayer player) {
+                    try {
+                      player.setMediaSource(adsMediaSource);
+                      player.addMediaSource(adsMediaSource);
+                    } catch (Exception e) {
+                      exception[0] = e;
+                    }
+                    player.prepare();
+                  }
+                })
+            .build();
+
+    new ExoPlayerTestRunner.Builder(context)
+        .setActionSchedule(actionSchedule)
+        .build()
+        .start(/* doPrepare= */ false)
+        .blockUntilActionScheduleFinished(TIMEOUT_MS)
+        .blockUntilEnded(TIMEOUT_MS);
+
+    assertThat(exception[0]).isInstanceOf(IllegalStateException.class);
+  }
+
+  @Test
+  public void setMediaSources_multipleMediaSourcesWithAd_throws() throws Exception {
+    MediaSource mediaSource = new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1));
+    AdsMediaSource adsMediaSource =
+        new AdsMediaSource(
+            mediaSource,
+            new DefaultDataSourceFactory(
+                context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)),
+            new DummyAdsLoader(),
+            new DummyAdViewProvider());
+    final Exception[] exception = {null};
+    ActionSchedule actionSchedule =
+        new ActionSchedule.Builder(TAG)
+            .executeRunnable(
+                new PlayerRunnable() {
+                  @Override
+                  public void run(SimpleExoPlayer player) {
+                    try {
+                      List<MediaSource> sources = new ArrayList<>();
+                      sources.add(mediaSource);
+                      sources.add(adsMediaSource);
+                      player.setMediaSources(sources);
+                    } catch (Exception e) {
+                      exception[0] = e;
+                    }
+                    player.prepare();
+                  }
+                })
+            .build();
+
+    new ExoPlayerTestRunner.Builder(context)
+        .setActionSchedule(actionSchedule)
+        .build()
+        .start(/* doPrepare= */ false)
+        .blockUntilActionScheduleFinished(TIMEOUT_MS)
+        .blockUntilEnded(TIMEOUT_MS);
+
+    assertThat(exception[0]).isInstanceOf(IllegalArgumentException.class);
+  }
+
+  @Test
+  public void setMediaSources_addingMediaSourcesWithAdToNonEmptyPlaylist_throws() throws Exception {
+    MediaSource mediaSource = new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1));
+    AdsMediaSource adsMediaSource =
+        new AdsMediaSource(
+            mediaSource,
+            new DefaultDataSourceFactory(
+                context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)),
+            new DummyAdsLoader(),
+            new DummyAdViewProvider());
+    final Exception[] exception = {null};
+    ActionSchedule actionSchedule =
+        new ActionSchedule.Builder(TAG)
+            .waitForPlaybackState(Player.STATE_READY)
+            .executeRunnable(
+                new PlayerRunnable() {
+                  @Override
+                  public void run(SimpleExoPlayer player) {
+                    try {
+                      player.addMediaSource(adsMediaSource);
+                    } catch (Exception e) {
+                      exception[0] = e;
+                    }
+                  }
+                })
+            .build();
+
+    new ExoPlayerTestRunner.Builder(context)
+        .setMediaSources(mediaSource)
+        .setActionSchedule(actionSchedule)
+        .build()
+        .start()
+        .blockUntilActionScheduleFinished(TIMEOUT_MS)
+        .blockUntilEnded(TIMEOUT_MS);
+
+    assertThat(exception[0]).isInstanceOf(IllegalArgumentException.class);
+  }
+
+  @Test
   public void setMediaSources_empty_whenEmpty_correctMaskingWindowIndex() throws Exception {
     Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1);
     MediaSource secondMediaSource = new FakeMediaSource(secondTimeline);
@@ -6420,4 +6617,38 @@
       return Loader.RETRY;
     }
   }
+
+  private static class DummyAdsLoader implements AdsLoader {
+
+    @Override
+    public void setPlayer(@Nullable Player player) {}
+
+    @Override
+    public void release() {}
+
+    @Override
+    public void setSupportedContentTypes(int... contentTypes) {}
+
+    @Override
+    public void start(AdsLoader.EventListener eventListener, AdViewProvider adViewProvider) {}
+
+    @Override
+    public void stop() {}
+
+    @Override
+    public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception) {}
+  }
+
+  private static class DummyAdViewProvider implements AdsLoader.AdViewProvider {
+
+    @Override
+    public ViewGroup getAdViewGroup() {
+      return null;
+    }
+
+    @Override
+    public View[] getAdOverlayViews() {
+      return new View[0];
+    }
+  }
 }
diff --git a/tree/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java b/tree/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java
index d0a9a49..b241351 100644
--- a/tree/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java
+++ b/tree/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java
@@ -21,6 +21,7 @@
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 
@@ -1058,6 +1059,31 @@
     verify(mockListener, never()).onSessionActive(any(), eq(adSessionId2));
   }
 
+  @Test
+  public void finishAllSessions_callsOnSessionFinishedForAllCreatedSessions() {
+    Timeline timeline = new FakeTimeline(/* windowCount= */ 4);
+    EventTime eventTimeWindow0 =
+        createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null);
+    EventTime eventTimeWindow2 =
+        createEventTime(timeline, /* windowIndex= */ 2, /* mediaPeriodId= */ null);
+    // Actually create sessions for window 0 and 2.
+    sessionManager.updateSessions(eventTimeWindow0);
+    sessionManager.updateSessions(eventTimeWindow2);
+    // Query information about session for window 1, but don't create it.
+    sessionManager.getSessionForMediaPeriodId(
+        timeline,
+        new MediaPeriodId(
+            timeline.getPeriod(/* periodIndex= */ 1, new Timeline.Period(), /* setIds= */ true).uid,
+            /* windowSequenceNumber= */ 123));
+    verify(mockListener, times(2)).onSessionCreated(any(), anyString());
+
+    EventTime finishEventTime =
+        createEventTime(Timeline.EMPTY, /* windowIndex= */ 0, /* mediaPeriodId= */ null);
+    sessionManager.finishAllSessions(finishEventTime);
+
+    verify(mockListener, times(2)).onSessionFinished(eq(finishEventTime), anyString(), eq(false));
+  }
+
   private static EventTime createEventTime(
       Timeline timeline, int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {
     return new EventTime(
diff --git a/tree/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java b/tree/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java
index f08d634..c6d4a59 100644
--- a/tree/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java
+++ b/tree/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java
@@ -16,11 +16,20 @@
 package com.google.android.exoplayer2.analytics;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+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.os.SystemClock;
 import androidx.annotation.Nullable;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.Player;
 import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.source.MediaSource;
+import com.google.android.exoplayer2.testutil.FakeTimeline;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -28,7 +37,7 @@
 @RunWith(AndroidJUnit4.class)
 public final class PlaybackStatsListenerTest {
 
-  private static final AnalyticsListener.EventTime TEST_EVENT_TIME =
+  private static final AnalyticsListener.EventTime EMPTY_TIMELINE_EVENT_TIME =
       new AnalyticsListener.EventTime(
           /* realtimeMs= */ 500,
           Timeline.EMPTY,
@@ -37,6 +46,58 @@
           /* eventPlaybackPositionMs= */ 0,
           /* currentPlaybackPositionMs= */ 0,
           /* totalBufferedDurationMs= */ 0);
+  private static final Timeline TEST_TIMELINE = new FakeTimeline(/* windowCount= */ 1);
+  private static final AnalyticsListener.EventTime TEST_EVENT_TIME =
+      new AnalyticsListener.EventTime(
+          /* realtimeMs= */ 500,
+          TEST_TIMELINE,
+          /* windowIndex= */ 0,
+          new MediaSource.MediaPeriodId(
+              TEST_TIMELINE.getPeriod(
+                      /* periodIndex= */ 0, new Timeline.Period(), /* setIds= */ true)
+                  .uid,
+              /* windowSequenceNumber= */ 42),
+          /* eventPlaybackPositionMs= */ 123,
+          /* currentPlaybackPositionMs= */ 123,
+          /* totalBufferedDurationMs= */ 456);
+
+  @Test
+  public void events_duringInitialIdleState_dontCreateNewPlaybackStats() {
+    PlaybackStatsListener playbackStatsListener =
+        new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null);
+
+    playbackStatsListener.onPositionDiscontinuity(
+        EMPTY_TIMELINE_EVENT_TIME, Player.DISCONTINUITY_REASON_SEEK);
+    playbackStatsListener.onPlaybackSpeedChanged(
+        EMPTY_TIMELINE_EVENT_TIME, /* playbackSpeed= */ 2.0f);
+    playbackStatsListener.onPlayWhenReadyChanged(
+        EMPTY_TIMELINE_EVENT_TIME,
+        /* playWhenReady= */ true,
+        Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST);
+
+    assertThat(playbackStatsListener.getPlaybackStats()).isNull();
+  }
+
+  @Test
+  public void stateChangeEvent_toNonIdle_createsInitialPlaybackStats() {
+    PlaybackStatsListener playbackStatsListener =
+        new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null);
+
+    playbackStatsListener.onPlaybackStateChanged(EMPTY_TIMELINE_EVENT_TIME, Player.STATE_BUFFERING);
+
+    assertThat(playbackStatsListener.getPlaybackStats()).isNotNull();
+  }
+
+  @Test
+  public void timelineChangeEvent_toNonEmpty_createsInitialPlaybackStats() {
+    PlaybackStatsListener playbackStatsListener =
+        new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null);
+
+    playbackStatsListener.onTimelineChanged(
+        TEST_EVENT_TIME, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED);
+
+    assertThat(playbackStatsListener.getPlaybackStats()).isNotNull();
+  }
 
   @Test
   public void playback_withKeepHistory_updatesStats() {
@@ -65,4 +126,68 @@
     assertThat(playbackStats).isNotNull();
     assertThat(playbackStats.endedCount).isEqualTo(1);
   }
+
+  @Test
+  public void finishedSession_callsCallback() {
+    PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class);
+    PlaybackStatsListener playbackStatsListener =
+        new PlaybackStatsListener(/* keepHistory= */ true, callback);
+
+    // Create session with an event and finish it by simulating removal from playlist.
+    playbackStatsListener.onPlaybackStateChanged(TEST_EVENT_TIME, Player.STATE_BUFFERING);
+    verify(callback, never()).onPlaybackStatsReady(any(), any());
+    playbackStatsListener.onTimelineChanged(
+        EMPTY_TIMELINE_EVENT_TIME, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED);
+
+    verify(callback).onPlaybackStatsReady(eq(TEST_EVENT_TIME), any());
+  }
+
+  @Test
+  public void finishAllSessions_callsAllPendingCallbacks() {
+    AnalyticsListener.EventTime eventTimeWindow0 =
+        new AnalyticsListener.EventTime(
+            /* realtimeMs= */ 0,
+            Timeline.EMPTY,
+            /* windowIndex= */ 0,
+            /* mediaPeriodId= */ null,
+            /* eventPlaybackPositionMs= */ 0,
+            /* currentPlaybackPositionMs= */ 0,
+            /* totalBufferedDurationMs= */ 0);
+    AnalyticsListener.EventTime eventTimeWindow1 =
+        new AnalyticsListener.EventTime(
+            /* realtimeMs= */ 0,
+            Timeline.EMPTY,
+            /* windowIndex= */ 1,
+            /* mediaPeriodId= */ null,
+            /* eventPlaybackPositionMs= */ 0,
+            /* currentPlaybackPositionMs= */ 0,
+            /* totalBufferedDurationMs= */ 0);
+    PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class);
+    PlaybackStatsListener playbackStatsListener =
+        new PlaybackStatsListener(/* keepHistory= */ true, callback);
+    playbackStatsListener.onPlaybackStateChanged(eventTimeWindow0, Player.STATE_BUFFERING);
+    playbackStatsListener.onPlaybackStateChanged(eventTimeWindow1, Player.STATE_BUFFERING);
+
+    playbackStatsListener.finishAllSessions();
+
+    verify(callback, times(2)).onPlaybackStatsReady(any(), any());
+    verify(callback).onPlaybackStatsReady(eq(eventTimeWindow0), any());
+    verify(callback).onPlaybackStatsReady(eq(eventTimeWindow1), any());
+  }
+
+  @Test
+  public void finishAllSessions_doesNotCallCallbackAgainWhenSessionWouldBeAutomaticallyFinished() {
+    PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class);
+    PlaybackStatsListener playbackStatsListener =
+        new PlaybackStatsListener(/* keepHistory= */ true, callback);
+    playbackStatsListener.onPlaybackStateChanged(TEST_EVENT_TIME, Player.STATE_BUFFERING);
+    SystemClock.setCurrentTimeMillis(TEST_EVENT_TIME.realtimeMs + 100);
+
+    playbackStatsListener.finishAllSessions();
+    // Simulate removing the playback item to ensure the session would finish if it hadn't already.
+    playbackStatsListener.onTimelineChanged(
+        EMPTY_TIMELINE_EVENT_TIME, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED);
+
+    verify(callback).onPlaybackStatsReady(any(), any());
+  }
 }
diff --git a/tree/library/core/src/test/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessorTest.java b/tree/library/core/src/test/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessorTest.java
new file mode 100644
index 0000000..19a1ad1
--- /dev/null
+++ b/tree/library/core/src/test/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessorTest.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.audio;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.audio.AudioProcessor.AudioFormat;
+import java.nio.ByteBuffer;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for {@link TrimmingAudioProcessor}. */
+@RunWith(AndroidJUnit4.class)
+public final class TrimmingAudioProcessorTest {
+
+  private static final AudioFormat AUDIO_FORMAT =
+      new AudioFormat(/* sampleRate= */ 44100, /* channelCount= */ 2, C.ENCODING_PCM_16BIT);
+  private static final int TRACK_ONE_UNTRIMMED_FRAME_COUNT = 1024;
+  private static final int TRACK_ONE_TRIM_START_FRAME_COUNT = 64;
+  private static final int TRACK_ONE_TRIM_END_FRAME_COUNT = 32;
+  private static final int TRACK_TWO_TRIM_START_FRAME_COUNT = 128;
+  private static final int TRACK_TWO_TRIM_END_FRAME_COUNT = 16;
+
+  private static final int TRACK_ONE_BUFFER_SIZE_BYTES =
+      AUDIO_FORMAT.bytesPerFrame * TRACK_ONE_UNTRIMMED_FRAME_COUNT;
+  private static final int TRACK_ONE_TRIMMED_BUFFER_SIZE_BYTES =
+      TRACK_ONE_BUFFER_SIZE_BYTES
+          - AUDIO_FORMAT.bytesPerFrame
+              * (TRACK_ONE_TRIM_START_FRAME_COUNT + TRACK_ONE_TRIM_END_FRAME_COUNT);
+
+  private TrimmingAudioProcessor trimmingAudioProcessor;
+
+  @Before
+  public void setUp() {
+    trimmingAudioProcessor = new TrimmingAudioProcessor();
+  }
+
+  @After
+  public void tearDown() {
+    trimmingAudioProcessor.reset();
+  }
+
+  @Test
+  public void flushTwice_trimsStartAndEnd() throws Exception {
+    trimmingAudioProcessor.setTrimFrameCount(
+        TRACK_ONE_TRIM_START_FRAME_COUNT, TRACK_ONE_TRIM_END_FRAME_COUNT);
+    trimmingAudioProcessor.configure(AUDIO_FORMAT);
+    trimmingAudioProcessor.flush();
+    trimmingAudioProcessor.flush();
+
+    int outputSizeBytes = feedAndDrainAudioProcessorToEndOfTrackOne();
+
+    assertThat(trimmingAudioProcessor.getTrimmedFrameCount())
+        .isEqualTo(TRACK_ONE_TRIM_START_FRAME_COUNT + TRACK_ONE_TRIM_END_FRAME_COUNT);
+    assertThat(outputSizeBytes).isEqualTo(TRACK_ONE_TRIMMED_BUFFER_SIZE_BYTES);
+  }
+
+  /**
+   * Feeds and drains the audio processor up to the end of track one, returning the total output
+   * size in bytes.
+   */
+  private int feedAndDrainAudioProcessorToEndOfTrackOne() throws Exception {
+    // Feed and drain the processor, simulating a gapless transition to another track.
+    ByteBuffer inputBuffer = ByteBuffer.allocate(TRACK_ONE_BUFFER_SIZE_BYTES);
+    int outputSize = 0;
+    while (!trimmingAudioProcessor.isEnded()) {
+      if (inputBuffer.hasRemaining()) {
+        trimmingAudioProcessor.queueInput(inputBuffer);
+        if (!inputBuffer.hasRemaining()) {
+          // Reconfigure for a next track then begin draining.
+          trimmingAudioProcessor.setTrimFrameCount(
+              TRACK_TWO_TRIM_START_FRAME_COUNT, TRACK_TWO_TRIM_END_FRAME_COUNT);
+          trimmingAudioProcessor.configure(AUDIO_FORMAT);
+          trimmingAudioProcessor.queueEndOfStream();
+        }
+      }
+      ByteBuffer outputBuffer = trimmingAudioProcessor.getOutput();
+      outputSize += outputBuffer.remaining();
+      outputBuffer.clear();
+    }
+    trimmingAudioProcessor.reset();
+    return outputSize;
+  }
+}
diff --git a/tree/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java b/tree/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java
index c3d23c7..5955a94 100644
--- a/tree/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java
+++ b/tree/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java
@@ -21,6 +21,7 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.upstream.DummyDataSource;
 import com.google.android.exoplayer2.upstream.cache.Cache;
+import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
 import java.util.Collections;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -32,9 +33,11 @@
 
   @Test
   public void createProgressiveDownloader() throws Exception {
-    DownloaderConstructorHelper constructorHelper =
-        new DownloaderConstructorHelper(Mockito.mock(Cache.class), DummyDataSource.FACTORY);
-    DownloaderFactory factory = new DefaultDownloaderFactory(constructorHelper);
+    CacheDataSource.Factory cacheDataSourceFactory =
+        new CacheDataSource.Factory()
+            .setCache(Mockito.mock(Cache.class))
+            .setUpstreamDataSourceFactory(DummyDataSource.FACTORY);
+    DownloaderFactory factory = new DefaultDownloaderFactory(cacheDataSourceFactory);
 
     Downloader downloader =
         factory.createDownloader(
diff --git a/tree/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java b/tree/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java
index 3c9d518..717d716 100644
--- a/tree/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java
+++ b/tree/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java
@@ -16,12 +16,18 @@
 package com.google.android.exoplayer2.source;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
 
+import android.content.Context;
 import android.net.Uri;
+import androidx.annotation.Nullable;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.C;
 import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.source.ads.AdsLoader;
+import com.google.android.exoplayer2.source.ads.AdsMediaSource;
+import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
 import com.google.android.exoplayer2.util.MimeTypes;
 import java.util.Arrays;
 import java.util.Collections;
@@ -184,4 +190,65 @@
 
     assertThat(supportedTypes).asList().containsExactly(C.TYPE_OTHER);
   }
+
+  @Test
+  public void createMediaSource_withAdTagUri_callsAdsLoader() {
+    Context applicationContext = ApplicationProvider.getApplicationContext();
+    Uri adTagUri = Uri.parse(URI_MEDIA);
+    MediaItem mediaItem =
+        new MediaItem.Builder().setSourceUri(URI_MEDIA).setAdTagUri(adTagUri).build();
+    DefaultMediaSourceFactory defaultMediaSourceFactory =
+        new DefaultMediaSourceFactory(
+            applicationContext,
+            new DefaultDataSourceFactory(applicationContext, "userAgent"),
+            createAdSupportProvider(mock(AdsLoader.class), mock(AdsLoader.AdViewProvider.class)));
+
+    MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem);
+
+    assertThat(mediaSource).isInstanceOf(AdsMediaSource.class);
+  }
+
+  @Test
+  public void createMediaSource_withAdTagUriAdsLoaderNull_playsWithoutAdNoException() {
+    Context applicationContext = ApplicationProvider.getApplicationContext();
+    MediaItem mediaItem =
+        new MediaItem.Builder().setSourceUri(URI_MEDIA).setAdTagUri(Uri.parse(URI_MEDIA)).build();
+    DefaultMediaSourceFactory defaultMediaSourceFactory =
+        new DefaultMediaSourceFactory(
+            applicationContext,
+            new DefaultDataSourceFactory(applicationContext, "userAgent"),
+            createAdSupportProvider(/* adsLoader= */ null, mock(AdsLoader.AdViewProvider.class)));
+
+    MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem);
+
+    assertThat(mediaSource).isNotInstanceOf(AdsMediaSource.class);
+  }
+
+  @Test
+  public void createMediaSource_withAdTagUriProvidersNull_playsWithoutAdNoException() {
+    Context applicationContext = ApplicationProvider.getApplicationContext();
+    MediaItem mediaItem =
+        new MediaItem.Builder().setSourceUri(URI_MEDIA).setAdTagUri(Uri.parse(URI_MEDIA)).build();
+
+    MediaSource mediaSource =
+        DefaultMediaSourceFactory.newInstance(applicationContext).createMediaSource(mediaItem);
+
+    assertThat(mediaSource).isNotInstanceOf(AdsMediaSource.class);
+  }
+
+  private static DefaultMediaSourceFactory.AdSupportProvider createAdSupportProvider(
+      @Nullable AdsLoader adsLoader, AdsLoader.AdViewProvider adViewProvider) {
+    return new DefaultMediaSourceFactory.AdSupportProvider() {
+      @Nullable
+      @Override
+      public AdsLoader getAdsLoader(Uri adTagUri) {
+        return adsLoader;
+      }
+
+      @Override
+      public AdsLoader.AdViewProvider getAdViewProvider() {
+        return adViewProvider;
+      }
+    };
+  }
 }
diff --git a/tree/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java b/tree/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java
index 9a45248..41b953a 100644
--- a/tree/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java
+++ b/tree/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java
@@ -26,6 +26,7 @@
 import static org.junit.Assert.assertArrayEquals;
 import static org.mockito.Mockito.when;
 
+import android.os.Looper;
 import androidx.annotation.Nullable;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.C;
@@ -39,6 +40,7 @@
 import com.google.android.exoplayer2.testutil.TestUtil;
 import com.google.android.exoplayer2.upstream.Allocator;
 import com.google.android.exoplayer2.upstream.DefaultAllocator;
+import com.google.android.exoplayer2.util.Assertions;
 import com.google.android.exoplayer2.util.MediaSourceEventDispatcher;
 import com.google.android.exoplayer2.util.ParsableByteArray;
 import java.io.IOException;
@@ -139,7 +141,12 @@
             ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any()))
         .thenReturn(mockDrmSession);
     eventDispatcher = new MediaSourceEventDispatcher();
-    sampleQueue = new SampleQueue(allocator, mockDrmSessionManager, eventDispatcher);
+    sampleQueue =
+        new SampleQueue(
+            allocator,
+            /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()),
+            mockDrmSessionManager,
+            eventDispatcher);
     formatHolder = new FormatHolder();
     inputBuffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL);
   }
@@ -356,7 +363,12 @@
   public void isReadyReturnsTrueForClearSampleAndPlayClearSamplesWithoutKeysIsTrue() {
     when(mockDrmSession.playClearSamplesWithoutKeys()).thenReturn(true);
     // We recreate the queue to ensure the mock DRM session manager flags are taken into account.
-    sampleQueue = new SampleQueue(allocator, mockDrmSessionManager, eventDispatcher);
+    sampleQueue =
+        new SampleQueue(
+            allocator,
+            /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()),
+            mockDrmSessionManager,
+            eventDispatcher);
     writeTestDataWithEncryptedSections();
     assertThat(sampleQueue.isReady(/* loadingFinished= */ false)).isTrue();
   }
@@ -534,7 +546,12 @@
   public void allowPlayClearSamplesWithoutKeysReadsClearSamples() {
     when(mockDrmSession.playClearSamplesWithoutKeys()).thenReturn(true);
     // We recreate the queue to ensure the mock DRM session manager flags are taken into account.
-    sampleQueue = new SampleQueue(allocator, mockDrmSessionManager, eventDispatcher);
+    sampleQueue =
+        new SampleQueue(
+            allocator,
+            /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()),
+            mockDrmSessionManager,
+            eventDispatcher);
     when(mockDrmSession.getState()).thenReturn(DrmSession.STATE_OPENED);
     writeTestDataWithEncryptedSections();
 
@@ -924,7 +941,11 @@
   public void adjustUpstreamFormat() {
     String label = "label";
     sampleQueue =
-        new SampleQueue(allocator, mockDrmSessionManager, eventDispatcher) {
+        new SampleQueue(
+            allocator,
+            /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()),
+            mockDrmSessionManager,
+            eventDispatcher) {
           @Override
           public Format getAdjustedUpstreamFormat(Format format) {
             return super.getAdjustedUpstreamFormat(copyWithLabel(format, label));
@@ -940,7 +961,11 @@
   public void invalidateUpstreamFormatAdjustment() {
     AtomicReference<String> label = new AtomicReference<>("label1");
     sampleQueue =
-        new SampleQueue(allocator, mockDrmSessionManager, eventDispatcher) {
+        new SampleQueue(
+            allocator,
+            /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()),
+            mockDrmSessionManager,
+            eventDispatcher) {
           @Override
           public Format getAdjustedUpstreamFormat(Format format) {
             return super.getAdjustedUpstreamFormat(copyWithLabel(format, label.get()));
diff --git a/tree/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java b/tree/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java
new file mode 100644
index 0000000..255d129
--- /dev/null
+++ b/tree/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java
@@ -0,0 +1,216 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.source.ads;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.annotation.LooperMode.Mode.PAUSED;
+
+import android.net.Uri;
+import android.os.Looper;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.source.MediaPeriod;
+import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId;
+import com.google.android.exoplayer2.source.MediaSource.MediaSourceCaller;
+import com.google.android.exoplayer2.source.MediaSourceFactory;
+import com.google.android.exoplayer2.source.SinglePeriodTimeline;
+import com.google.android.exoplayer2.source.ads.AdsLoader.AdViewProvider;
+import com.google.android.exoplayer2.source.ads.AdsLoader.EventListener;
+import com.google.android.exoplayer2.testutil.FakeMediaSource;
+import com.google.android.exoplayer2.upstream.Allocator;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.annotation.LooperMode;
+
+/** Unit tests for {@link AdsMediaSource}. */
+@RunWith(AndroidJUnit4.class)
+@LooperMode(PAUSED)
+public final class AdsMediaSourceTest {
+
+  private static final long PREROLL_AD_DURATION_US = 10 * C.MICROS_PER_SECOND;
+  private static final Timeline PREROLL_AD_TIMELINE =
+      new SinglePeriodTimeline(
+          PREROLL_AD_DURATION_US,
+          /* isSeekable= */ true,
+          /* isDynamic= */ false,
+          /* isLive= */ false);
+  private static final Object PREROLL_AD_PERIOD_UID =
+      PREROLL_AD_TIMELINE.getUidOfPeriod(/* periodIndex= */ 0);
+
+  private static final long CONTENT_DURATION_US = 30 * C.MICROS_PER_SECOND;
+  private static final Timeline CONTENT_TIMELINE =
+      new SinglePeriodTimeline(
+          CONTENT_DURATION_US, /* isSeekable= */ true, /* isDynamic= */ false, /* isLive= */ false);
+  private static final Object CONTENT_PERIOD_UID =
+      CONTENT_TIMELINE.getUidOfPeriod(/* periodIndex= */ 0);
+
+  private static final AdPlaybackState AD_PLAYBACK_STATE =
+      new AdPlaybackState(/* adGroupTimesUs...= */ 0)
+          .withContentDurationUs(CONTENT_DURATION_US)
+          .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
+          .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, Uri.EMPTY)
+          .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)
+          .withAdResumePositionUs(/* adResumePositionUs= */ 0);
+
+  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+  private FakeMediaSource contentMediaSource;
+  private FakeMediaSource prerollAdMediaSource;
+  @Mock private MediaSourceCaller mockMediaSourceCaller;
+  private AdsMediaSource adsMediaSource;
+
+  @Before
+  public void setUp() {
+    // Set up content and ad media sources, passing a null timeline so tests can simulate setting it
+    // later.
+    contentMediaSource = new FakeMediaSource(/* timeline= */ null);
+    prerollAdMediaSource = new FakeMediaSource(/* timeline= */ null);
+    MediaSourceFactory adMediaSourceFactory = mock(MediaSourceFactory.class);
+    when(adMediaSourceFactory.createMediaSource(any(Uri.class))).thenReturn(prerollAdMediaSource);
+
+    // Prepare the AdsMediaSource and capture its ads loader listener.
+    AdsLoader mockAdsLoader = mock(AdsLoader.class);
+    AdViewProvider mockAdViewProvider = mock(AdViewProvider.class);
+    ArgumentCaptor<EventListener> eventListenerArgumentCaptor =
+        ArgumentCaptor.forClass(AdsLoader.EventListener.class);
+    adsMediaSource =
+        new AdsMediaSource(
+            contentMediaSource, adMediaSourceFactory, mockAdsLoader, mockAdViewProvider);
+    adsMediaSource.prepareSource(mockMediaSourceCaller, /* mediaTransferListener= */ null);
+    shadowOf(Looper.getMainLooper()).idle();
+    verify(mockAdsLoader).start(eventListenerArgumentCaptor.capture(), eq(mockAdViewProvider));
+
+    // Simulate loading a preroll ad.
+    AdsLoader.EventListener adsLoaderEventListener = eventListenerArgumentCaptor.getValue();
+    adsLoaderEventListener.onAdPlaybackState(AD_PLAYBACK_STATE);
+    shadowOf(Looper.getMainLooper()).idle();
+  }
+
+  @Test
+  public void createPeriod_preparesChildAdMediaSourceAndRefreshesSourceInfo() {
+    contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE);
+    adsMediaSource.createPeriod(
+        new MediaPeriodId(
+            CONTENT_PERIOD_UID,
+            /* adGroupIndex= */ 0,
+            /* adIndexInAdGroup= */ 0,
+            /* windowSequenceNumber= */ 0),
+        mock(Allocator.class),
+        /* startPositionUs= */ 0);
+    shadowOf(Looper.getMainLooper()).idle();
+
+    assertThat(prerollAdMediaSource.isPrepared()).isTrue();
+    verify(mockMediaSourceCaller)
+        .onSourceInfoRefreshed(
+            adsMediaSource, new SinglePeriodAdTimeline(CONTENT_TIMELINE, AD_PLAYBACK_STATE));
+  }
+
+  @Test
+  public void createPeriod_preparesChildAdMediaSourceAndRefreshesSourceInfoWithAdMediaSourceInfo() {
+    contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE);
+    adsMediaSource.createPeriod(
+        new MediaPeriodId(
+            CONTENT_PERIOD_UID,
+            /* adGroupIndex= */ 0,
+            /* adIndexInAdGroup= */ 0,
+            /* windowSequenceNumber= */ 0),
+        mock(Allocator.class),
+        /* startPositionUs= */ 0);
+    prerollAdMediaSource.setNewSourceInfo(PREROLL_AD_TIMELINE);
+    shadowOf(Looper.getMainLooper()).idle();
+
+    verify(mockMediaSourceCaller)
+        .onSourceInfoRefreshed(
+            adsMediaSource,
+            new SinglePeriodAdTimeline(
+                CONTENT_TIMELINE,
+                AD_PLAYBACK_STATE.withAdDurationsUs(new long[][] {{PREROLL_AD_DURATION_US}})));
+  }
+
+  @Test
+  public void createPeriod_createsChildPrerollAdMediaPeriod() {
+    contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE);
+    adsMediaSource.createPeriod(
+        new MediaPeriodId(
+            CONTENT_PERIOD_UID,
+            /* adGroupIndex= */ 0,
+            /* adIndexInAdGroup= */ 0,
+            /* windowSequenceNumber= */ 0),
+        mock(Allocator.class),
+        /* startPositionUs= */ 0);
+    prerollAdMediaSource.setNewSourceInfo(PREROLL_AD_TIMELINE);
+    shadowOf(Looper.getMainLooper()).idle();
+
+    prerollAdMediaSource.assertMediaPeriodCreated(
+        new MediaPeriodId(PREROLL_AD_PERIOD_UID, /* windowSequenceNumber= */ 0));
+  }
+
+  @Test
+  public void createPeriod_createsChildContentMediaPeriod() {
+    contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE);
+    shadowOf(Looper.getMainLooper()).idle();
+    adsMediaSource.createPeriod(
+        new MediaPeriodId(CONTENT_PERIOD_UID, /* windowSequenceNumber= */ 0),
+        mock(Allocator.class),
+        /* startPositionUs= */ 0);
+
+    contentMediaSource.assertMediaPeriodCreated(
+        new MediaPeriodId(CONTENT_PERIOD_UID, /* windowSequenceNumber= */ 0));
+  }
+
+  @Test
+  public void releasePeriod_releasesChildMediaPeriodsAndSources() {
+    contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE);
+    MediaPeriod prerollAdMediaPeriod =
+        adsMediaSource.createPeriod(
+            new MediaPeriodId(
+                CONTENT_PERIOD_UID,
+                /* adGroupIndex= */ 0,
+                /* adIndexInAdGroup= */ 0,
+                /* windowSequenceNumber= */ 0),
+            mock(Allocator.class),
+            /* startPositionUs= */ 0);
+    prerollAdMediaSource.setNewSourceInfo(PREROLL_AD_TIMELINE);
+    shadowOf(Looper.getMainLooper()).idle();
+    MediaPeriod contentMediaPeriod =
+        adsMediaSource.createPeriod(
+            new MediaPeriodId(CONTENT_PERIOD_UID, /* windowSequenceNumber= */ 0),
+            mock(Allocator.class),
+            /* startPositionUs= */ 0);
+    adsMediaSource.releasePeriod(prerollAdMediaPeriod);
+
+    prerollAdMediaSource.assertReleased();
+
+    adsMediaSource.releasePeriod(contentMediaPeriod);
+    adsMediaSource.releaseSource(mockMediaSourceCaller);
+    shadowOf(Looper.getMainLooper()).idle();
+    prerollAdMediaSource.assertReleased();
+    contentMediaSource.assertReleased();
+  }
+}
diff --git a/tree/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java b/tree/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java
index 48f1340..4bd26b8 100644
--- a/tree/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java
+++ b/tree/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java
@@ -184,14 +184,14 @@
   public void decodeWithPositioning() throws Exception {
     WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_POSITIONING_FILE);
 
-    assertThat(subtitle.getEventTimeCount()).isEqualTo(12);
+    assertThat(subtitle.getEventTimeCount()).isEqualTo(16);
 
     assertThat(subtitle.getEventTime(0)).isEqualTo(0L);
     assertThat(subtitle.getEventTime(1)).isEqualTo(1_234_000L);
     Cue firstCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(0)));
     assertThat(firstCue.text.toString()).isEqualTo("This is the first subtitle.");
-    assertThat(firstCue.position).isEqualTo(0.1f);
-    assertThat(firstCue.positionAnchor).isEqualTo(Cue.ANCHOR_TYPE_START);
+    assertThat(firstCue.position).isEqualTo(0.6f);
+    assertThat(firstCue.positionAnchor).isEqualTo(Cue.ANCHOR_TYPE_END);
     assertThat(firstCue.textAlignment).isEqualTo(Alignment.ALIGN_NORMAL);
     assertThat(firstCue.size).isEqualTo(0.35f);
     // Unspecified values should use WebVTT defaults
@@ -246,6 +246,18 @@
     // Derived from `align:center`:
     assertThat(sixthCue.position).isEqualTo(0.5f);
     assertThat(sixthCue.positionAnchor).isEqualTo(Cue.ANCHOR_TYPE_MIDDLE);
+
+    assertThat(subtitle.getEventTime(12)).isEqualTo(12_000_000L);
+    assertThat(subtitle.getEventTime(13)).isEqualTo(13_000_000L);
+    Cue seventhCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(12)));
+    assertThat(seventhCue.text.toString()).isEqualTo("This is the seventh subtitle.");
+    assertThat(seventhCue.positionAnchor).isEqualTo(Cue.ANCHOR_TYPE_START);
+
+    assertThat(subtitle.getEventTime(14)).isEqualTo(14_000_000L);
+    assertThat(subtitle.getEventTime(15)).isEqualTo(15_000_000L);
+    Cue eighthCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(14)));
+    assertThat(eighthCue.text.toString()).isEqualTo("This is the eighth subtitle.");
+    assertThat(eighthCue.positionAnchor).isEqualTo(Cue.ANCHOR_TYPE_END);
   }
 
   @Test
diff --git a/tree/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java b/tree/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java
index 707ea92..6562c17 100644
--- a/tree/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java
+++ b/tree/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java
@@ -358,12 +358,7 @@
                 .appendReadData(1024 * 1024)
                 .endData());
     CacheUtil.cache(
-        unboundedDataSpec,
-        cache,
-        /* cacheKeyFactory= */ null,
-        upstream2,
-        /* progressListener= */ null,
-        /* isCanceled= */ null);
+        cache, unboundedDataSpec, upstream2, /* progressListener= */ null, /* isCanceled= */ null);
 
     // Read the rest of the data.
     TestUtil.readToEnd(cacheDataSource);
@@ -407,12 +402,7 @@
                 .appendReadData(1024 * 1024)
                 .endData());
     CacheUtil.cache(
-        unboundedDataSpec,
-        cache,
-        /* cacheKeyFactory= */ null,
-        upstream2,
-        /* progressListener= */ null,
-        /* isCanceled= */ null);
+        cache, unboundedDataSpec, upstream2, /* progressListener= */ null, /* isCanceled= */ null);
 
     // Read the rest of the data.
     TestUtil.readToEnd(cacheDataSource);
@@ -431,12 +421,7 @@
     int halfDataLength = 512;
     DataSpec dataSpec = buildDataSpec(halfDataLength, C.LENGTH_UNSET);
     CacheUtil.cache(
-        dataSpec,
-        cache,
-        /* cacheKeyFactory= */ null,
-        upstream,
-        /* progressListener= */ null,
-        /* isCanceled= */ null);
+        cache, dataSpec, upstream, /* progressListener= */ null, /* isCanceled= */ null);
 
     // Create cache read-only CacheDataSource.
     CacheDataSource cacheDataSource =
@@ -467,12 +452,7 @@
     int halfDataLength = 512;
     DataSpec dataSpec = buildDataSpec(/* position= */ 0, halfDataLength);
     CacheUtil.cache(
-        dataSpec,
-        cache,
-        /* cacheKeyFactory= */ null,
-        upstream,
-        /* progressListener= */ null,
-        /* isCanceled= */ null);
+        cache, dataSpec, upstream, /* progressListener= */ null, /* isCanceled= */ null);
 
     // Create blocking CacheDataSource.
     CacheDataSource cacheDataSource =
diff --git a/tree/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java b/tree/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java
index 65c9c4d..d0a4da4 100644
--- a/tree/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java
+++ b/tree/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java
@@ -201,12 +201,7 @@
 
     CachingCounters counters = new CachingCounters();
     CacheUtil.cache(
-        new DataSpec(Uri.parse("test_data")),
-        cache,
-        /* cacheKeyFactory= */ null,
-        dataSource,
-        counters,
-        /* isCanceled= */ null);
+        cache, new DataSpec(Uri.parse("test_data")), dataSource, counters, /* isCanceled= */ null);
 
     counters.assertValues(0, 100, 100);
     assertCachedData(cache, fakeDataSet);
@@ -220,19 +215,12 @@
     Uri testUri = Uri.parse("test_data");
     DataSpec dataSpec = new DataSpec(testUri, /* position= */ 10, /* length= */ 20);
     CachingCounters counters = new CachingCounters();
-    CacheUtil.cache(
-        dataSpec, cache, /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null);
+    CacheUtil.cache(cache, dataSpec, dataSource, counters, /* isCanceled= */ null);
 
     counters.assertValues(0, 20, 20);
     counters.reset();
 
-    CacheUtil.cache(
-        new DataSpec(testUri),
-        cache,
-        /* cacheKeyFactory= */ null,
-        dataSource,
-        counters,
-        /* isCanceled= */ null);
+    CacheUtil.cache(cache, new DataSpec(testUri), dataSource, counters, /* isCanceled= */ null);
 
     counters.assertValues(20, 80, 100);
     assertCachedData(cache, fakeDataSet);
@@ -247,8 +235,7 @@
 
     DataSpec dataSpec = new DataSpec(Uri.parse("test_data"));
     CachingCounters counters = new CachingCounters();
-    CacheUtil.cache(
-        dataSpec, cache, /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null);
+    CacheUtil.cache(cache, dataSpec, dataSource, counters, /* isCanceled= */ null);
 
     counters.assertValues(0, 100, 100);
     assertCachedData(cache, fakeDataSet);
@@ -264,19 +251,12 @@
     Uri testUri = Uri.parse("test_data");
     DataSpec dataSpec = new DataSpec(testUri, /* position= */ 10, /* length= */ 20);
     CachingCounters counters = new CachingCounters();
-    CacheUtil.cache(
-        dataSpec, cache, /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null);
+    CacheUtil.cache(cache, dataSpec, dataSource, counters, /* isCanceled= */ null);
 
     counters.assertValues(0, 20, 20);
     counters.reset();
 
-    CacheUtil.cache(
-        new DataSpec(testUri),
-        cache,
-        /* cacheKeyFactory= */ null,
-        dataSource,
-        counters,
-        /* isCanceled= */ null);
+    CacheUtil.cache(cache, new DataSpec(testUri), dataSource, counters, /* isCanceled= */ null);
 
     counters.assertValues(20, 80, 100);
     assertCachedData(cache, fakeDataSet);
@@ -290,8 +270,7 @@
     Uri testUri = Uri.parse("test_data");
     DataSpec dataSpec = new DataSpec(testUri, /* position= */ 0, /* length= */ 1000);
     CachingCounters counters = new CachingCounters();
-    CacheUtil.cache(
-        dataSpec, cache, /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null);
+    CacheUtil.cache(cache, dataSpec, dataSource, counters, /* isCanceled= */ null);
 
     counters.assertValues(0, 100, 1000);
     assertCachedData(cache, fakeDataSet);
@@ -307,16 +286,12 @@
 
     try {
       CacheUtil.cache(
-          dataSpec,
-          cache,
-          /* cacheKeyFactory= */ null,
           new CacheDataSource(cache, dataSource),
-          new byte[CacheUtil.DEFAULT_BUFFER_SIZE_BYTES],
-          /* priorityTaskManager= */ null,
-          /* priority= */ 0,
+          dataSpec,
           /* progressListener= */ null,
           /* isCanceled= */ null,
-          /* enableEOFException= */ true);
+          /* enableEOFException= */ true,
+          /* temporaryBuffer= */ new byte[CacheUtil.DEFAULT_BUFFER_SIZE_BYTES]);
       fail();
     } catch (EOFException e) {
       // Do nothing.
@@ -338,12 +313,7 @@
     FakeDataSource dataSource = new FakeDataSource(fakeDataSet);
 
     CacheUtil.cache(
-        new DataSpec(Uri.parse("test_data")),
-        cache,
-        /* cacheKeyFactory= */ null,
-        dataSource,
-        counters,
-        /* isCanceled= */ null);
+        cache, new DataSpec(Uri.parse("test_data")), dataSource, counters, /* isCanceled= */ null);
 
     counters.assertValues(0, 300, 300);
     assertCachedData(cache, fakeDataSet);
@@ -360,9 +330,6 @@
             .setFlags(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION)
             .build();
     CacheUtil.cache(
-        dataSpec,
-        cache,
-        /* cacheKeyFactory= */ null,
         // Set fragmentSize to 10 to make sure there are multiple spans.
         new CacheDataSource(
             cache,
@@ -371,12 +338,11 @@
             new CacheDataSink(cache, /* fragmentSize= */ 10),
             /* flags= */ 0,
             /* eventListener= */ null),
-        new byte[CacheUtil.DEFAULT_BUFFER_SIZE_BYTES],
-        /* priorityTaskManager= */ null,
-        /* priority= */ 0,
+        dataSpec,
         /* progressListener= */ null,
         /* isCanceled= */ null,
-        true);
+        /* enableEOFException= */ true,
+        /* temporaryBuffer= */ new byte[CacheUtil.DEFAULT_BUFFER_SIZE_BYTES]);
     CacheUtil.remove(dataSpec, cache, /* cacheKeyFactory= */ null);
 
     assertCacheEmpty(cache);
diff --git a/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java b/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java
index 2f2cc26..e1a441f 100644
--- a/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java
+++ b/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java
@@ -16,6 +16,7 @@
 package com.google.android.exoplayer2.source.dash;
 
 import android.util.Pair;
+import android.util.SparseArray;
 import android.util.SparseIntArray;
 import androidx.annotation.IntDef;
 import androidx.annotation.Nullable;
@@ -516,51 +517,94 @@
     return Pair.create(new TrackGroupArray(trackGroups), trackGroupInfos);
   }
 
+  /**
+   * Groups adaptation sets. Two adaptations sets belong to the same group if either:
+   *
+   * <ul>
+   *   <li>One is a trick-play adaptation set and uses a {@code
+   *       http://dashif.org/guidelines/trickmode} essential or supplemental property to indicate
+   *       that the other is the main adaptation set to which it corresponds.
+   *   <li>The two adaptation sets are marked as safe for switching using {@code
+   *       urn:mpeg:dash:adaptation-set-switching:2016} supplemental properties.
+   * </ul>
+   *
+   * @param adaptationSets The adaptation sets to merge.
+   * @return An array of groups, where each group is an array of adaptation set indices.
+   */
   private static int[][] getGroupedAdaptationSetIndices(List<AdaptationSet> adaptationSets) {
     int adaptationSetCount = adaptationSets.size();
-    SparseIntArray idToIndexMap = new SparseIntArray(adaptationSetCount);
+    SparseIntArray adaptationSetIdToIndex = new SparseIntArray(adaptationSetCount);
+    List<List<Integer>> adaptationSetGroupedIndices = new ArrayList<>(adaptationSetCount);
+    SparseArray<List<Integer>> adaptationSetIndexToGroupedIndices =
+        new SparseArray<>(adaptationSetCount);
+
+    // Initially make each adaptation set belong to its own group. Also build the
+    // adaptationSetIdToIndex map.
     for (int i = 0; i < adaptationSetCount; i++) {
-      idToIndexMap.put(adaptationSets.get(i).id, i);
+      adaptationSetIdToIndex.put(adaptationSets.get(i).id, i);
+      List<Integer> initialGroup = new ArrayList<>();
+      initialGroup.add(i);
+      adaptationSetGroupedIndices.add(initialGroup);
+      adaptationSetIndexToGroupedIndices.put(i, initialGroup);
     }
 
-    int[][] groupedAdaptationSetIndices = new int[adaptationSetCount][];
-    boolean[] adaptationSetUsedFlags = new boolean[adaptationSetCount];
-
-    int groupCount = 0;
+    // Merge adaptation set groups.
     for (int i = 0; i < adaptationSetCount; i++) {
-      if (adaptationSetUsedFlags[i]) {
-        // This adaptation set has already been included in a group.
-        continue;
-      }
-      adaptationSetUsedFlags[i] = true;
+      int mergedGroupIndex = i;
+      AdaptationSet adaptationSet = adaptationSets.get(i);
+
+      // Trick-play adaptation sets are merged with their corresponding main adaptation sets.
       @Nullable
-      Descriptor adaptationSetSwitchingProperty =
-          findAdaptationSetSwitchingProperty(adaptationSets.get(i).supplementalProperties);
-      if (adaptationSetSwitchingProperty == null) {
-        groupedAdaptationSetIndices[groupCount++] = new int[] {i};
-      } else {
-        String[] extraAdaptationSetIds = Util.split(adaptationSetSwitchingProperty.value, ",");
-        int[] adaptationSetIndices = new int[1 + extraAdaptationSetIds.length];
-        adaptationSetIndices[0] = i;
-        int outputIndex = 1;
-        for (String adaptationSetId : extraAdaptationSetIds) {
-          int extraIndex =
-              idToIndexMap.get(Integer.parseInt(adaptationSetId), /* valueIfKeyNotFound= */ -1);
-          if (extraIndex != -1) {
-            adaptationSetUsedFlags[extraIndex] = true;
-            adaptationSetIndices[outputIndex] = extraIndex;
-            outputIndex++;
+      Descriptor trickPlayProperty = findTrickPlayProperty(adaptationSet.essentialProperties);
+      if (trickPlayProperty == null) {
+        // Trick-play can also be specified using a supplemental property.
+        trickPlayProperty = findTrickPlayProperty(adaptationSet.supplementalProperties);
+      }
+      if (trickPlayProperty != null) {
+        int mainAdaptationSetId = Integer.parseInt(trickPlayProperty.value);
+        int mainAdaptationSetIndex =
+            adaptationSetIdToIndex.get(mainAdaptationSetId, /* valueIfKeyNotFound= */ -1);
+        if (mainAdaptationSetIndex != -1) {
+          mergedGroupIndex = mainAdaptationSetIndex;
+        }
+      }
+
+      // Adaptation sets that are safe for switching are merged, using the smallest index for the
+      // merged group.
+      if (mergedGroupIndex == i) {
+        @Nullable
+        Descriptor adaptationSetSwitchingProperty =
+            findAdaptationSetSwitchingProperty(adaptationSet.supplementalProperties);
+        if (adaptationSetSwitchingProperty != null) {
+          String[] otherAdaptationSetIds = Util.split(adaptationSetSwitchingProperty.value, ",");
+          for (String adaptationSetId : otherAdaptationSetIds) {
+            int otherAdaptationSetId =
+                adaptationSetIdToIndex.get(
+                    Integer.parseInt(adaptationSetId), /* valueIfKeyNotFound= */ -1);
+            if (otherAdaptationSetId != -1) {
+              mergedGroupIndex = Math.min(mergedGroupIndex, otherAdaptationSetId);
+            }
           }
         }
-        if (outputIndex < adaptationSetIndices.length) {
-          adaptationSetIndices = Arrays.copyOf(adaptationSetIndices, outputIndex);
-        }
-        groupedAdaptationSetIndices[groupCount++] = adaptationSetIndices;
+      }
+
+      // Merge the groups if necessary.
+      if (mergedGroupIndex != i) {
+        List<Integer> thisGroup = adaptationSetIndexToGroupedIndices.get(i);
+        List<Integer> mergedGroup = adaptationSetIndexToGroupedIndices.get(mergedGroupIndex);
+        mergedGroup.addAll(thisGroup);
+        adaptationSetIndexToGroupedIndices.put(i, mergedGroup);
+        adaptationSetGroupedIndices.remove(thisGroup);
       }
     }
 
-    return groupCount < adaptationSetCount
-        ? Arrays.copyOf(groupedAdaptationSetIndices, groupCount) : groupedAdaptationSetIndices;
+    int[][] groupedAdaptationSetIndices = new int[adaptationSetGroupedIndices.size()][];
+    for (int i = 0; i < groupedAdaptationSetIndices.length; i++) {
+      groupedAdaptationSetIndices[i] = Util.toArray(adaptationSetGroupedIndices.get(i));
+      // Restore the original adaptation set order within each group.
+      Arrays.sort(groupedAdaptationSetIndices[i]);
+    }
+    return groupedAdaptationSetIndices;
   }
 
   /**
@@ -747,9 +791,19 @@
 
   @Nullable
   private static Descriptor findAdaptationSetSwitchingProperty(List<Descriptor> descriptors) {
+    return findDescriptor(descriptors, "urn:mpeg:dash:adaptation-set-switching:2016");
+  }
+
+  @Nullable
+  private static Descriptor findTrickPlayProperty(List<Descriptor> descriptors) {
+    return findDescriptor(descriptors, "http://dashif.org/guidelines/trickmode");
+  }
+
+  @Nullable
+  private static Descriptor findDescriptor(List<Descriptor> descriptors, String schemeIdUri) {
     for (int i = 0; i < descriptors.size(); i++) {
       Descriptor descriptor = descriptors.get(i);
-      if ("urn:mpeg:dash:adaptation-set-switching:2016".equals(descriptor.schemeIdUri)) {
+      if (schemeIdUri.equals(descriptor.schemeIdUri)) {
         return descriptor;
       }
     }
diff --git a/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java
index 919997e..53c9f1c 100644
--- a/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java
+++ b/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java
@@ -33,6 +33,7 @@
 import com.google.android.exoplayer2.source.BaseMediaSource;
 import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory;
 import com.google.android.exoplayer2.source.DefaultCompositeSequenceableLoaderFactory;
+import com.google.android.exoplayer2.source.LoadEventInfo;
 import com.google.android.exoplayer2.source.MediaPeriod;
 import com.google.android.exoplayer2.source.MediaSource;
 import com.google.android.exoplayer2.source.MediaSourceEventListener;
@@ -754,13 +755,14 @@
   /* package */ void onManifestLoadCompleted(ParsingLoadable<DashManifest> loadable,
       long elapsedRealtimeMs, long loadDurationMs) {
     manifestEventDispatcher.loadCompleted(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
-        loadable.type,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.bytesLoaded());
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            loadable.bytesLoaded()),
+        loadable.type);
     DashManifest newManifest = loadable.getResult();
 
     int oldPeriodCount = manifest == null ? 0 : manifest.getPeriodCount();
@@ -856,13 +858,14 @@
             ? Loader.DONT_RETRY_FATAL
             : Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs);
     manifestEventDispatcher.loadError(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            loadable.bytesLoaded()),
         loadable.type,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.bytesLoaded(),
         error,
         !loadErrorAction.isRetry());
     return loadErrorAction;
@@ -871,13 +874,14 @@
   /* package */ void onUtcTimestampLoadCompleted(ParsingLoadable<Long> loadable,
       long elapsedRealtimeMs, long loadDurationMs) {
     manifestEventDispatcher.loadCompleted(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
-        loadable.type,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.bytesLoaded());
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            loadable.bytesLoaded()),
+        loadable.type);
     onUtcTimestampResolved(loadable.getResult() - elapsedRealtimeMs);
   }
 
@@ -887,15 +891,16 @@
       long loadDurationMs,
       IOException error) {
     manifestEventDispatcher.loadError(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            loadable.bytesLoaded()),
         loadable.type,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.bytesLoaded(),
         error,
-        true);
+        /* wasCanceled= */ true);
     onUtcTimestampResolutionError(error);
     return Loader.DONT_RETRY;
   }
@@ -903,13 +908,14 @@
   /* package */ void onLoadCanceled(ParsingLoadable<?> loadable, long elapsedRealtimeMs,
       long loadDurationMs) {
     manifestEventDispatcher.loadCanceled(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
-        loadable.type,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.bytesLoaded());
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            loadable.bytesLoaded()),
+        loadable.type);
   }
 
   // Internal methods.
@@ -1122,7 +1128,8 @@
   private <T> void startLoading(ParsingLoadable<T> loadable,
       Loader.Callback<ParsingLoadable<T>> callback, int minRetryCount) {
     long elapsedRealtimeMs = loader.startLoading(loadable, callback, minRetryCount);
-    manifestEventDispatcher.loadStarted(loadable.dataSpec, loadable.type, elapsedRealtimeMs);
+    manifestEventDispatcher.loadStarted(
+        new LoadEventInfo(loadable.dataSpec, elapsedRealtimeMs), loadable.type);
   }
 
   private static final class PeriodSeekInfo {
diff --git a/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java b/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java
index 504b2f4..7888841 100644
--- a/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java
+++ b/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java
@@ -288,6 +288,7 @@
       this.sampleQueue =
           new SampleQueue(
               allocator,
+              /* playbackLooper= */ handler.getLooper(),
               DrmSessionManager.getDummyDrmSessionManager(),
               new MediaSourceEventDispatcher());
       formatHolder = new FormatHolder();
@@ -300,13 +301,14 @@
     }
 
     @Override
-    public int sampleData(DataReader input, int length, boolean allowEndOfInput)
+    public int sampleData(
+        DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart)
         throws IOException {
       return sampleQueue.sampleData(input, length, allowEndOfInput);
     }
 
     @Override
-    public void sampleData(ParsableByteArray data, int length) {
+    public void sampleData(ParsableByteArray data, int length, @SampleDataPart int sampleDataPart) {
       sampleQueue.sampleData(data, length);
     }
 
diff --git a/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java b/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java
index 7f76e65..8629c91 100644
--- a/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java
+++ b/tree/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java
@@ -20,7 +20,6 @@
 import com.google.android.exoplayer2.C;
 import com.google.android.exoplayer2.extractor.ChunkIndex;
 import com.google.android.exoplayer2.offline.DownloadException;
-import com.google.android.exoplayer2.offline.DownloaderConstructorHelper;
 import com.google.android.exoplayer2.offline.SegmentDownloader;
 import com.google.android.exoplayer2.offline.StreamKey;
 import com.google.android.exoplayer2.source.dash.DashSegmentIndex;
@@ -35,9 +34,11 @@
 import com.google.android.exoplayer2.upstream.DataSource;
 import com.google.android.exoplayer2.upstream.DataSpec;
 import com.google.android.exoplayer2.upstream.ParsingLoadable;
+import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.Executor;
 
 /**
  * A downloader for DASH streams.
@@ -46,19 +47,20 @@
  *
  * <pre>{@code
  * SimpleCache cache = new SimpleCache(downloadFolder, new NoOpCacheEvictor(), databaseProvider);
- * DefaultHttpDataSourceFactory factory = new DefaultHttpDataSourceFactory("ExoPlayer", null);
- * DownloaderConstructorHelper constructorHelper =
- *     new DownloaderConstructorHelper(cache, factory);
+ * CacheDataSource.Factory cacheDataSourceFactory =
+ *     new CacheDataSource.Factory()
+ *         .setCache(cache)
+ *         .setUpstreamDataSourceFactory(new DefaultHttpDataSourceFactory(userAgent));
  * // Create a downloader for the first representation of the first adaptation set of the first
  * // period.
  * DashDownloader dashDownloader =
  *     new DashDownloader(
- *         manifestUrl, Collections.singletonList(new StreamKey(0, 0, 0)), constructorHelper);
+ *         manifestUrl, Collections.singletonList(new StreamKey(0, 0, 0)), cacheDataSourceFactory);
  * // Perform the download.
  * dashDownloader.download(progressListener);
- * // Access downloaded data using CacheDataSource
- * CacheDataSource cacheDataSource =
- *     new CacheDataSource(cache, factory.createDataSource(), CacheDataSource.FLAG_BLOCK_ON_CACHE);
+ * // Use the downloaded data for playback.
+ * DashMediaSource mediaSource =
+ *     new DashMediaSource.Factory(cacheDataSourceFactory).createMediaSource(mediaItem);
  * }</pre>
  */
 public final class DashDownloader extends SegmentDownloader<DashManifest> {
@@ -67,11 +69,30 @@
    * @param manifestUri The {@link Uri} of the manifest to be downloaded.
    * @param streamKeys Keys defining which representations in the manifest should be selected for
    *     download. If empty, all representations are downloaded.
-   * @param constructorHelper A {@link DownloaderConstructorHelper} instance.
+   * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the
+   *     download will be written.
    */
   public DashDownloader(
-      Uri manifestUri, List<StreamKey> streamKeys, DownloaderConstructorHelper constructorHelper) {
-    super(manifestUri, streamKeys, constructorHelper);
+      Uri manifestUri, List<StreamKey> streamKeys, CacheDataSource.Factory cacheDataSourceFactory) {
+    this(manifestUri, streamKeys, cacheDataSourceFactory, Runnable::run);
+  }
+
+  /**
+   * @param manifestUri The {@link Uri} of the manifest to be downloaded.
+   * @param streamKeys Keys defining which representations in the manifest should be selected for
+   *     download. If empty, all representations are downloaded.
+   * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the
+   *     download will be written.
+   * @param executor An {@link Executor} used to make requests for the media being downloaded.
+   *     Providing an {@link Executor} that uses multiple threads will speed up the download by
+   *     allowing parts of it to be executed in parallel.
+   */
+  public DashDownloader(
+      Uri manifestUri,
+      List<StreamKey> streamKeys,
+      CacheDataSource.Factory cacheDataSourceFactory,
+      Executor executor) {
+    super(manifestUri, streamKeys, cacheDataSourceFactory, executor);
   }
 
   @Override
diff --git a/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java b/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java
index e9e5f30..5a5318c 100644
--- a/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java
+++ b/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java
@@ -26,6 +26,8 @@
 import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory;
 import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId;
 import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher;
+import com.google.android.exoplayer2.source.TrackGroup;
+import com.google.android.exoplayer2.source.TrackGroupArray;
 import com.google.android.exoplayer2.source.dash.PlayerEmsgHandler.PlayerEmsgCallback;
 import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet;
 import com.google.android.exoplayer2.source.dash.manifest.DashManifest;
@@ -35,7 +37,6 @@
 import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.SingleSegmentBase;
 import com.google.android.exoplayer2.source.dash.manifest.UtcTimingElement;
 import com.google.android.exoplayer2.testutil.MediaPeriodAsserts;
-import com.google.android.exoplayer2.testutil.MediaPeriodAsserts.FilterableManifestMediaPeriodFactory;
 import com.google.android.exoplayer2.upstream.Allocator;
 import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy;
 import com.google.android.exoplayer2.upstream.LoaderErrorThrower;
@@ -43,6 +44,7 @@
 import com.google.android.exoplayer2.util.MimeTypes;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.annotation.LooperMode;
@@ -53,7 +55,7 @@
 public final class DashMediaPeriodTest {
 
   @Test
-  public void getSteamKeys_isCompatibleWithDashManifestFilter() {
+  public void getStreamKeys_isCompatibleWithDashManifestFilter() {
     // Test manifest which covers various edge cases:
     //  - Multiple periods.
     //  - Single and multiple representations per adaptation set.
@@ -61,83 +63,220 @@
     //  - Embedded track groups.
     // All cases are deliberately combined in one test to catch potential indexing problems which
     // only occur in combination.
-    DashManifest testManifest =
+    DashManifest manifest =
         createDashManifest(
             createPeriod(
                 createAdaptationSet(
                     /* id= */ 0,
-                    /* trackType= */ C.TRACK_TYPE_VIDEO,
+                    C.TRACK_TYPE_VIDEO,
                     /* descriptor= */ null,
                     createVideoRepresentation(/* bitrate= */ 1000000))),
             createPeriod(
                 createAdaptationSet(
                     /* id= */ 100,
-                    /* trackType= */ C.TRACK_TYPE_VIDEO,
-                    /* descriptor= */ createSwitchDescriptor(/* ids...= */ 103, 104),
+                    C.TRACK_TYPE_VIDEO,
+                    createSwitchDescriptor(/* ids...= */ 103, 104),
                     createVideoRepresentationWithInbandEventStream(/* bitrate= */ 200000),
                     createVideoRepresentationWithInbandEventStream(/* bitrate= */ 400000),
                     createVideoRepresentationWithInbandEventStream(/* bitrate= */ 600000)),
                 createAdaptationSet(
                     /* id= */ 101,
-                    /* trackType= */ C.TRACK_TYPE_AUDIO,
-                    /* descriptor= */ createSwitchDescriptor(/* ids...= */ 102),
+                    C.TRACK_TYPE_AUDIO,
+                    createSwitchDescriptor(/* ids...= */ 102),
                     createAudioRepresentation(/* bitrate= */ 48000),
                     createAudioRepresentation(/* bitrate= */ 96000)),
                 createAdaptationSet(
                     /* id= */ 102,
-                    /* trackType= */ C.TRACK_TYPE_AUDIO,
-                    /* descriptor= */ createSwitchDescriptor(/* ids...= */ 101),
+                    C.TRACK_TYPE_AUDIO,
+                    createSwitchDescriptor(/* ids...= */ 101),
                     createAudioRepresentation(/* bitrate= */ 256000)),
                 createAdaptationSet(
                     /* id= */ 103,
-                    /* trackType= */ C.TRACK_TYPE_VIDEO,
-                    /* descriptor= */ createSwitchDescriptor(/* ids...= */ 100, 104),
+                    C.TRACK_TYPE_VIDEO,
+                    createSwitchDescriptor(/* ids...= */ 100, 104),
                     createVideoRepresentationWithInbandEventStream(/* bitrate= */ 800000),
                     createVideoRepresentationWithInbandEventStream(/* bitrate= */ 1000000)),
                 createAdaptationSet(
                     /* id= */ 104,
-                    /* trackType= */ C.TRACK_TYPE_VIDEO,
-                    /* descriptor= */ createSwitchDescriptor(/* ids...= */ 100, 103),
+                    C.TRACK_TYPE_VIDEO,
+                    createSwitchDescriptor(/* ids...= */ 100, 103),
                     createVideoRepresentationWithInbandEventStream(/* bitrate= */ 2000000)),
                 createAdaptationSet(
                     /* id= */ 105,
-                    /* trackType= */ C.TRACK_TYPE_TEXT,
+                    C.TRACK_TYPE_TEXT,
                     /* descriptor= */ null,
                     createTextRepresentation(/* language= */ "eng")),
                 createAdaptationSet(
                     /* id= */ 105,
-                    /* trackType= */ C.TRACK_TYPE_TEXT,
+                    C.TRACK_TYPE_TEXT,
                     /* descriptor= */ null,
                     createTextRepresentation(/* language= */ "ger"))));
-    FilterableManifestMediaPeriodFactory<DashManifest> mediaPeriodFactory =
-        (manifest, periodIndex) ->
-            new DashMediaPeriod(
-                /* id= */ periodIndex,
-                manifest,
-                periodIndex,
-                mock(DashChunkSource.Factory.class),
-                mock(TransferListener.class),
-                DrmSessionManager.getDummyDrmSessionManager(),
-                mock(LoadErrorHandlingPolicy.class),
-                new EventDispatcher()
-                    .withParameters(
-                        /* windowIndex= */ 0,
-                        /* mediaPeriodId= */ new MediaPeriodId(/* periodUid= */ new Object()),
-                        /* mediaTimeOffsetMs= */ 0),
-                /* elapsedRealtimeOffsetMs= */ 0,
-                mock(LoaderErrorThrower.class),
-                mock(Allocator.class),
-                mock(CompositeSequenceableLoaderFactory.class),
-                mock(PlayerEmsgCallback.class));
 
     // Ignore embedded metadata as we don't want to select primary group just to get embedded track.
     MediaPeriodAsserts.assertGetStreamKeysAndManifestFilterIntegration(
-        mediaPeriodFactory,
-        testManifest,
+        DashMediaPeriodTest::createDashMediaPeriod,
+        manifest,
         /* periodIndex= */ 1,
         /* ignoredMimeType= */ "application/x-emsg");
   }
 
+  @Test
+  public void adaptationSetSwitchingProperty_mergesTrackGroups() {
+    DashManifest manifest =
+        createDashManifest(
+            createPeriod(
+                createAdaptationSet(
+                    /* id= */ 0,
+                    C.TRACK_TYPE_VIDEO,
+                    createSwitchDescriptor(/* ids...= */ 1, 2),
+                    createVideoRepresentation(/* bitrate= */ 0),
+                    createVideoRepresentation(/* bitrate= */ 1)),
+                createAdaptationSet(
+                    /* id= */ 3,
+                    C.TRACK_TYPE_VIDEO,
+                    /* descriptor= */ null,
+                    createVideoRepresentation(/* bitrate= */ 300)),
+                createAdaptationSet(
+                    /* id= */ 2,
+                    C.TRACK_TYPE_VIDEO,
+                    createSwitchDescriptor(/* ids...= */ 0, 1),
+                    createVideoRepresentation(/* bitrate= */ 200),
+                    createVideoRepresentation(/* bitrate= */ 201)),
+                createAdaptationSet(
+                    /* id= */ 1,
+                    C.TRACK_TYPE_VIDEO,
+                    createSwitchDescriptor(/* ids...= */ 0, 2),
+                    createVideoRepresentation(/* bitrate= */ 100))));
+    DashMediaPeriod dashMediaPeriod = createDashMediaPeriod(manifest, 0);
+    List<AdaptationSet> adaptationSets = manifest.getPeriod(0).adaptationSets;
+
+    // We expect the three adaptation sets with the switch descriptor to be merged, retaining the
+    // representations in their original order.
+    TrackGroupArray expectedTrackGroups =
+        new TrackGroupArray(
+            new TrackGroup(
+                adaptationSets.get(0).representations.get(0).format,
+                adaptationSets.get(0).representations.get(1).format,
+                adaptationSets.get(2).representations.get(0).format,
+                adaptationSets.get(2).representations.get(1).format,
+                adaptationSets.get(3).representations.get(0).format),
+            new TrackGroup(adaptationSets.get(1).representations.get(0).format));
+
+    MediaPeriodAsserts.assertTrackGroups(dashMediaPeriod, expectedTrackGroups);
+  }
+
+  @Test
+  public void trickPlayProperty_mergesTrackGroups() {
+    DashManifest manifest =
+        createDashManifest(
+            createPeriod(
+                createAdaptationSet(
+                    /* id= */ 0,
+                    C.TRACK_TYPE_VIDEO,
+                    createTrickPlayDescriptor(/* mainAdaptationSetId= */ 1),
+                    createVideoRepresentation(/* bitrate= */ 0),
+                    createVideoRepresentation(/* bitrate= */ 1)),
+                createAdaptationSet(
+                    /* id= */ 1,
+                    C.TRACK_TYPE_VIDEO,
+                    /* descriptor= */ null,
+                    createVideoRepresentation(/* bitrate= */ 100)),
+                createAdaptationSet(
+                    /* id= */ 2,
+                    C.TRACK_TYPE_VIDEO,
+                    /* descriptor= */ null,
+                    createVideoRepresentation(/* bitrate= */ 200),
+                    createVideoRepresentation(/* bitrate= */ 201)),
+                createAdaptationSet(
+                    /* id= */ 3,
+                    C.TRACK_TYPE_VIDEO,
+                    createTrickPlayDescriptor(/* mainAdaptationSetId= */ 2),
+                    createVideoRepresentation(/* bitrate= */ 300))));
+    DashMediaPeriod dashMediaPeriod = createDashMediaPeriod(manifest, 0);
+    List<AdaptationSet> adaptationSets = manifest.getPeriod(0).adaptationSets;
+
+    // We expect the trick play adaptation sets to be merged with the ones to which they refer,
+    // retaining representations in their original order.
+    TrackGroupArray expectedTrackGroups =
+        new TrackGroupArray(
+            new TrackGroup(
+                adaptationSets.get(0).representations.get(0).format,
+                adaptationSets.get(0).representations.get(1).format,
+                adaptationSets.get(1).representations.get(0).format),
+            new TrackGroup(
+                adaptationSets.get(2).representations.get(0).format,
+                adaptationSets.get(2).representations.get(1).format,
+                adaptationSets.get(3).representations.get(0).format));
+
+    MediaPeriodAsserts.assertTrackGroups(dashMediaPeriod, expectedTrackGroups);
+  }
+
+  @Test
+  public void adaptationSetSwitchingProperty_andTrickPlayProperty_mergesTrackGroups() {
+    DashManifest manifest =
+        createDashManifest(
+            createPeriod(
+                createAdaptationSet(
+                    /* id= */ 0,
+                    C.TRACK_TYPE_VIDEO,
+                    createTrickPlayDescriptor(/* mainAdaptationSetId= */ 1),
+                    createVideoRepresentation(/* bitrate= */ 0),
+                    createVideoRepresentation(/* bitrate= */ 1)),
+                createAdaptationSet(
+                    /* id= */ 1,
+                    C.TRACK_TYPE_VIDEO,
+                    createSwitchDescriptor(/* ids...= */ 2),
+                    createVideoRepresentation(/* bitrate= */ 100)),
+                createAdaptationSet(
+                    /* id= */ 2,
+                    C.TRACK_TYPE_VIDEO,
+                    createSwitchDescriptor(/* ids...= */ 1),
+                    createVideoRepresentation(/* bitrate= */ 200),
+                    createVideoRepresentation(/* bitrate= */ 201)),
+                createAdaptationSet(
+                    /* id= */ 3,
+                    C.TRACK_TYPE_VIDEO,
+                    createTrickPlayDescriptor(/* mainAdaptationSetId= */ 2),
+                    createVideoRepresentation(/* bitrate= */ 300))));
+    DashMediaPeriod dashMediaPeriod = createDashMediaPeriod(manifest, 0);
+    List<AdaptationSet> adaptationSets = manifest.getPeriod(0).adaptationSets;
+
+    // We expect all adaptation sets to be merged into one group, retaining representations in their
+    // original order.
+    TrackGroupArray expectedTrackGroups =
+        new TrackGroupArray(
+            new TrackGroup(
+                adaptationSets.get(0).representations.get(0).format,
+                adaptationSets.get(0).representations.get(1).format,
+                adaptationSets.get(1).representations.get(0).format,
+                adaptationSets.get(2).representations.get(0).format,
+                adaptationSets.get(2).representations.get(1).format,
+                adaptationSets.get(3).representations.get(0).format));
+
+    MediaPeriodAsserts.assertTrackGroups(dashMediaPeriod, expectedTrackGroups);
+  }
+
+  private static DashMediaPeriod createDashMediaPeriod(DashManifest manifest, int periodIndex) {
+    return new DashMediaPeriod(
+        /* id= */ periodIndex,
+        manifest,
+        periodIndex,
+        mock(DashChunkSource.Factory.class),
+        mock(TransferListener.class),
+        DrmSessionManager.getDummyDrmSessionManager(),
+        mock(LoadErrorHandlingPolicy.class),
+        new EventDispatcher()
+            .withParameters(
+                /* windowIndex= */ 0,
+                /* mediaPeriodId= */ new MediaPeriodId(/* periodUid= */ new Object()),
+                /* mediaTimeOffsetMs= */ 0),
+        /* elapsedRealtimeOffsetMs= */ 0,
+        mock(LoaderErrorThrower.class),
+        mock(Allocator.class),
+        mock(CompositeSequenceableLoaderFactory.class),
+        mock(PlayerEmsgCallback.class));
+  }
+
   private static DashManifest createDashManifest(Period... periods) {
     return new DashManifest(
         /* availabilityStartTimeMs= */ 0,
@@ -228,6 +367,13 @@
         /* id= */ null);
   }
 
+  private static Descriptor createTrickPlayDescriptor(int mainAdaptationSetId) {
+    return new Descriptor(
+        /* schemeIdUri= */ "http://dashif.org/guidelines/trickmode",
+        /* value= */ Integer.toString(mainAdaptationSetId),
+        /* id= */ null);
+  }
+
   private static Descriptor getInbandEventDescriptor() {
     return new Descriptor(
         /* schemeIdUri= */ "inBandSchemeIdUri", /* value= */ "inBandValue", /* id= */ "inBandId");
diff --git a/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java b/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java
index fc99e20..49e111b 100644
--- a/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java
+++ b/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java
@@ -32,17 +32,16 @@
 import com.google.android.exoplayer2.offline.DownloadException;
 import com.google.android.exoplayer2.offline.DownloadRequest;
 import com.google.android.exoplayer2.offline.Downloader;
-import com.google.android.exoplayer2.offline.DownloaderConstructorHelper;
 import com.google.android.exoplayer2.offline.DownloaderFactory;
 import com.google.android.exoplayer2.offline.StreamKey;
 import com.google.android.exoplayer2.testutil.CacheAsserts.RequestSet;
 import com.google.android.exoplayer2.testutil.FakeDataSet;
 import com.google.android.exoplayer2.testutil.FakeDataSource;
-import com.google.android.exoplayer2.testutil.FakeDataSource.Factory;
 import com.google.android.exoplayer2.testutil.TestUtil;
 import com.google.android.exoplayer2.upstream.DataSpec;
 import com.google.android.exoplayer2.upstream.DummyDataSource;
 import com.google.android.exoplayer2.upstream.cache.Cache;
+import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
 import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor;
 import com.google.android.exoplayer2.upstream.cache.SimpleCache;
 import com.google.android.exoplayer2.util.Util;
@@ -81,9 +80,11 @@
 
   @Test
   public void createWithDefaultDownloaderFactory() {
-    DownloaderConstructorHelper constructorHelper =
-        new DownloaderConstructorHelper(Mockito.mock(Cache.class), DummyDataSource.FACTORY);
-    DownloaderFactory factory = new DefaultDownloaderFactory(constructorHelper);
+    CacheDataSource.Factory cacheDataSourceFactory =
+        new CacheDataSource.Factory()
+            .setCache(Mockito.mock(Cache.class))
+            .setUpstreamDataSourceFactory(DummyDataSource.FACTORY);
+    DownloaderFactory factory = new DefaultDownloaderFactory(cacheDataSourceFactory);
 
     Downloader downloader =
         factory.createDownloader(
@@ -184,7 +185,7 @@
             .setRandomData("text_segment_2", 2)
             .setRandomData("text_segment_3", 3);
     FakeDataSource fakeDataSource = new FakeDataSource(fakeDataSet);
-    Factory factory = mock(Factory.class);
+    FakeDataSource.Factory factory = mock(FakeDataSource.Factory.class);
     when(factory.createDataSource()).thenReturn(fakeDataSource);
 
     DashDownloader dashDownloader =
@@ -216,7 +217,7 @@
             .setRandomData("period_2_segment_2", 2)
             .setRandomData("period_2_segment_3", 3);
     FakeDataSource fakeDataSource = new FakeDataSource(fakeDataSet);
-    Factory factory = mock(Factory.class);
+    FakeDataSource.Factory factory = mock(FakeDataSource.Factory.class);
     when(factory.createDataSource()).thenReturn(fakeDataSource);
 
     DashDownloader dashDownloader =
@@ -327,12 +328,16 @@
   }
 
   private DashDownloader getDashDownloader(FakeDataSet fakeDataSet, StreamKey... keys) {
-    return getDashDownloader(new Factory().setFakeDataSet(fakeDataSet), keys);
+    return getDashDownloader(new FakeDataSource.Factory().setFakeDataSet(fakeDataSet), keys);
   }
 
-  private DashDownloader getDashDownloader(Factory factory, StreamKey... keys) {
-    return new DashDownloader(
-        TEST_MPD_URI, keysList(keys), new DownloaderConstructorHelper(cache, factory));
+  private DashDownloader getDashDownloader(
+      FakeDataSource.Factory upstreamDataSourceFactory, StreamKey... keys) {
+    CacheDataSource.Factory cacheDataSourceFactory =
+        new CacheDataSource.Factory()
+            .setCache(cache)
+            .setUpstreamDataSourceFactory(upstreamDataSourceFactory);
+    return new DashDownloader(TEST_MPD_URI, keysList(keys), cacheDataSourceFactory);
   }
 
   private static ArrayList<StreamKey> keysList(StreamKey... keys) {
diff --git a/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java b/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java
index 89426f7..164299f 100644
--- a/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java
+++ b/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java
@@ -29,7 +29,6 @@
 import com.google.android.exoplayer2.offline.DefaultDownloaderFactory;
 import com.google.android.exoplayer2.offline.DownloadManager;
 import com.google.android.exoplayer2.offline.DownloadRequest;
-import com.google.android.exoplayer2.offline.DownloaderConstructorHelper;
 import com.google.android.exoplayer2.offline.StreamKey;
 import com.google.android.exoplayer2.scheduler.Requirements;
 import com.google.android.exoplayer2.testutil.CacheAsserts.RequestSet;
@@ -40,6 +39,7 @@
 import com.google.android.exoplayer2.testutil.TestDownloadManagerListener;
 import com.google.android.exoplayer2.testutil.TestUtil;
 import com.google.android.exoplayer2.upstream.DataSource.Factory;
+import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
 import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor;
 import com.google.android.exoplayer2.upstream.cache.SimpleCache;
 import com.google.android.exoplayer2.util.Util;
@@ -253,12 +253,14 @@
     runOnMainThread(
         () -> {
           Factory fakeDataSourceFactory = new FakeDataSource.Factory().setFakeDataSet(fakeDataSet);
+          DefaultDownloaderFactory downloaderFactory =
+              new DefaultDownloaderFactory(
+                  new CacheDataSource.Factory()
+                      .setCache(cache)
+                      .setUpstreamDataSourceFactory(fakeDataSourceFactory));
           downloadManager =
               new DownloadManager(
-                  ApplicationProvider.getApplicationContext(),
-                  downloadIndex,
-                  new DefaultDownloaderFactory(
-                      new DownloaderConstructorHelper(cache, fakeDataSourceFactory)));
+                  ApplicationProvider.getApplicationContext(), downloadIndex, downloaderFactory);
           downloadManager.setRequirements(new Requirements(0));
 
           downloadManagerListener =
diff --git a/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java b/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java
index 0bf5089..42b0a2e 100644
--- a/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java
+++ b/tree/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java
@@ -33,7 +33,6 @@
 import com.google.android.exoplayer2.offline.DownloadManager;
 import com.google.android.exoplayer2.offline.DownloadRequest;
 import com.google.android.exoplayer2.offline.DownloadService;
-import com.google.android.exoplayer2.offline.DownloaderConstructorHelper;
 import com.google.android.exoplayer2.offline.StreamKey;
 import com.google.android.exoplayer2.scheduler.Scheduler;
 import com.google.android.exoplayer2.testutil.DummyMainThread;
@@ -42,6 +41,7 @@
 import com.google.android.exoplayer2.testutil.TestDownloadManagerListener;
 import com.google.android.exoplayer2.testutil.TestUtil;
 import com.google.android.exoplayer2.upstream.DataSource;
+import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
 import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor;
 import com.google.android.exoplayer2.upstream.cache.SimpleCache;
 import com.google.android.exoplayer2.util.ConditionVariable;
@@ -113,12 +113,14 @@
         () -> {
           DefaultDownloadIndex downloadIndex =
               new DefaultDownloadIndex(TestUtil.getInMemoryDatabaseProvider());
+          DefaultDownloaderFactory downloaderFactory =
+              new DefaultDownloaderFactory(
+                  new CacheDataSource.Factory()
+                      .setCache(cache)
+                      .setUpstreamDataSourceFactory(fakeDataSourceFactory));
           final DownloadManager dashDownloadManager =
               new DownloadManager(
-                  ApplicationProvider.getApplicationContext(),
-                  downloadIndex,
-                  new DefaultDownloaderFactory(
-                      new DownloaderConstructorHelper(cache, fakeDataSourceFactory)));
+                  ApplicationProvider.getApplicationContext(), downloadIndex, downloaderFactory);
           downloadManagerListener =
               new TestDownloadManagerListener(dashDownloadManager, dummyMainThread);
           dashDownloadManager.resumeDownloads();
diff --git a/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyTrackOutput.java b/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyTrackOutput.java
index 7ef308e..4700bbb 100644
--- a/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyTrackOutput.java
+++ b/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyTrackOutput.java
@@ -43,7 +43,9 @@
   }
 
   @Override
-  public int sampleData(DataReader input, int length, boolean allowEndOfInput) throws IOException {
+  public int sampleData(
+      DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart)
+      throws IOException {
     int bytesToSkipByReading = Math.min(readBuffer.length, length);
     int bytesSkipped = input.read(readBuffer, /* offset= */ 0, bytesToSkipByReading);
     if (bytesSkipped == C.RESULT_END_OF_INPUT) {
@@ -56,7 +58,7 @@
   }
 
   @Override
-  public void sampleData(ParsableByteArray data, int length) {
+  public void sampleData(ParsableByteArray data, int length, @SampleDataPart int sampleDataPart) {
     data.skipBytes(length);
   }
 
diff --git a/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/TrackOutput.java b/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/TrackOutput.java
index 3e95fab..f1a9ac7 100644
--- a/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/TrackOutput.java
+++ b/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/TrackOutput.java
@@ -15,6 +15,7 @@
  */
 package com.google.android.exoplayer2.extractor;
 
+import androidx.annotation.IntDef;
 import androidx.annotation.Nullable;
 import com.google.android.exoplayer2.C;
 import com.google.android.exoplayer2.Format;
@@ -22,6 +23,9 @@
 import com.google.android.exoplayer2.util.ParsableByteArray;
 import java.io.EOFException;
 import java.io.IOException;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
 import java.util.Arrays;
 
 /**
@@ -94,6 +98,41 @@
 
   }
 
+  /** Defines the part of the sample data to which a call to {@link #sampleData} corresponds. */
+  @Documented
+  @Retention(RetentionPolicy.SOURCE)
+  @IntDef({SAMPLE_DATA_PART_MAIN, SAMPLE_DATA_PART_ENCRYPTION, SAMPLE_DATA_PART_SUPPLEMENTAL})
+  @interface SampleDataPart {}
+
+  /** Main media sample data. */
+  int SAMPLE_DATA_PART_MAIN = 0;
+  /**
+   * Sample encryption data.
+   *
+   * <p>The format for encryption information is:
+   *
+   * <ul>
+   *   <li>(1 byte) {@code encryption_signal_byte}: Most significant bit signals whether the
+   *       encryption data contains subsample encryption data. The remaining bits contain {@code
+   *       initialization_vector_size}.
+   *   <li>({@code initialization_vector_size} bytes) Initialization vector.
+   *   <li>If subsample encryption data is present, as per {@code encryption_signal_byte}, the
+   *       encryption data also contains:
+   *       <ul>
+   *         <li>(2 bytes) {@code subsample_encryption_data_length}.
+   *         <li>({@code subsample_encryption_data_length} bytes) Subsample encryption data
+   *             (repeated {@code subsample_encryption_data_length / 6} times:
+   *             <ul>
+   *               <li>(3 bytes) Size of a clear section in sample.
+   *               <li>(3 bytes) Size of an encryption section in sample.
+   *             </ul>
+   *       </ul>
+   * </ul>
+   */
+  int SAMPLE_DATA_PART_ENCRYPTION = 1;
+  /** Sample supplemental data. */
+  int SAMPLE_DATA_PART_SUPPLEMENTAL = 2;
+
   /**
    * Called when the {@link Format} of the track has been extracted from the stream.
    *
@@ -102,6 +141,22 @@
   void format(Format format);
 
   /**
+   * Equivalent to {@link #sampleData(DataReader, int, boolean, int) sampleData(input, length,
+   * allowEndOfInput, SAMPLE_DATA_PART_MAIN)}.
+   */
+  default int sampleData(DataReader input, int length, boolean allowEndOfInput) throws IOException {
+    return sampleData(input, length, allowEndOfInput, SAMPLE_DATA_PART_MAIN);
+  }
+
+  /**
+   * Equivalent to {@link #sampleData(ParsableByteArray, int, int)} sampleData(data, length,
+   * SAMPLE_DATA_PART_MAIN)}.
+   */
+  default void sampleData(ParsableByteArray data, int length) {
+    sampleData(data, length, SAMPLE_DATA_PART_MAIN);
+  }
+
+  /**
    * Called to write sample data to the output.
    *
    * @param input A {@link DataReader} from which to read the sample data.
@@ -109,18 +164,22 @@
    * @param allowEndOfInput True if encountering the end of the input having read no data is
    *     allowed, and should result in {@link C#RESULT_END_OF_INPUT} being returned. False if it
    *     should be considered an error, causing an {@link EOFException} to be thrown.
+   * @param sampleDataPart The part of the sample data to which this call corresponds.
    * @return The number of bytes appended.
    * @throws IOException If an error occurred reading from the input.
    */
-  int sampleData(DataReader input, int length, boolean allowEndOfInput) throws IOException;
+  int sampleData(
+      DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart)
+      throws IOException;
 
   /**
    * Called to write sample data to the output.
    *
    * @param data A {@link ParsableByteArray} from which to read the sample data.
    * @param length The number of bytes to read, starting from {@code data.getPosition()}.
+   * @param sampleDataPart The part of the sample data to which this call corresponds.
    */
-  void sampleData(ParsableByteArray data, int length);
+  void sampleData(ParsableByteArray data, int length, @SampleDataPart int sampleDataPart);
 
   /**
    * Called when metadata associated with a sample has been extracted from the stream.
diff --git a/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java b/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java
index 6e66049..4d24c4f 100644
--- a/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java
+++ b/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java
@@ -54,8 +54,10 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.UUID;
 import org.checkerframework.checker.nullness.compatqual.NullableType;
 import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
@@ -319,6 +321,18 @@
    */
   private static final UUID WAVE_SUBFORMAT_PCM = new UUID(0x0100000000001000L, 0x800000AA00389B71L);
 
+  /** Some HTC devices signal rotation in track names. */
+  private static final Map<String, Integer> TRACK_NAME_TO_ROTATION_DEGREES;
+
+  static {
+    Map<String, Integer> trackNameToRotationDegrees = new HashMap<>();
+    trackNameToRotationDegrees.put("htc_video_rotA-000", 0);
+    trackNameToRotationDegrees.put("htc_video_rotA-090", 90);
+    trackNameToRotationDegrees.put("htc_video_rotA-180", 180);
+    trackNameToRotationDegrees.put("htc_video_rotA-270", 270);
+    TRACK_NAME_TO_ROTATION_DEGREES = Collections.unmodifiableMap(trackNameToRotationDegrees);
+  }
+
   private final EbmlReader reader;
   private final VarintReader varintReader;
   private final SparseArray<Track> tracks;
@@ -1267,7 +1281,8 @@
         } else {
           // Append supplemental data.
           int blockAdditionalSize = blockAdditionalData.limit();
-          track.output.sampleData(blockAdditionalData, blockAdditionalSize);
+          track.output.sampleData(
+              blockAdditionalData, blockAdditionalSize, TrackOutput.SAMPLE_DATA_PART_SUPPLEMENTAL);
           size += blockAdditionalSize;
         }
       }
@@ -1336,11 +1351,14 @@
             // Write the signal byte, containing the IV size and the subsample encryption flag.
             scratch.data[0] = (byte) (ENCRYPTION_IV_SIZE | (hasSubsampleEncryption ? 0x80 : 0x00));
             scratch.setPosition(0);
-            output.sampleData(scratch, 1);
+            output.sampleData(scratch, 1, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION);
             sampleBytesWritten++;
             // Write the IV.
             encryptionInitializationVector.setPosition(0);
-            output.sampleData(encryptionInitializationVector, ENCRYPTION_IV_SIZE);
+            output.sampleData(
+                encryptionInitializationVector,
+                ENCRYPTION_IV_SIZE,
+                TrackOutput.SAMPLE_DATA_PART_ENCRYPTION);
             sampleBytesWritten += ENCRYPTION_IV_SIZE;
           }
           if (hasSubsampleEncryption) {
@@ -1388,7 +1406,10 @@
               encryptionSubsampleDataBuffer.putInt(0);
             }
             encryptionSubsampleData.reset(encryptionSubsampleDataBuffer.array(), subsampleDataSize);
-            output.sampleData(encryptionSubsampleData, subsampleDataSize);
+            output.sampleData(
+                encryptionSubsampleData,
+                subsampleDataSize,
+                TrackOutput.SAMPLE_DATA_PART_ENCRYPTION);
             sampleBytesWritten += subsampleDataSize;
           }
         }
@@ -1407,7 +1428,7 @@
         scratch.data[1] = (byte) ((size >> 16) & 0xFF);
         scratch.data[2] = (byte) ((size >> 8) & 0xFF);
         scratch.data[3] = (byte) (size & 0xFF);
-        output.sampleData(scratch, 4);
+        output.sampleData(scratch, 4, TrackOutput.SAMPLE_DATA_PART_SUPPLEMENTAL);
         sampleBytesWritten += 4;
       }
 
@@ -2088,15 +2109,9 @@
           colorInfo = new ColorInfo(colorSpace, colorRange, colorTransfer, hdrStaticInfo);
         }
         int rotationDegrees = Format.NO_VALUE;
-        // Some HTC devices signal rotation in track names.
-        if ("htc_video_rotA-000".equals(name)) {
-          rotationDegrees = 0;
-        } else if ("htc_video_rotA-090".equals(name)) {
-          rotationDegrees = 90;
-        } else if ("htc_video_rotA-180".equals(name)) {
-          rotationDegrees = 180;
-        } else if ("htc_video_rotA-270".equals(name)) {
-          rotationDegrees = 270;
+
+        if (TRACK_NAME_TO_ROTATION_DEGREES.containsKey(name)) {
+          rotationDegrees = TRACK_NAME_TO_ROTATION_DEGREES.get(name);
         }
         if (projectionType == C.PROJECTION_RECTANGULAR
             && Float.compare(projectionPoseYaw, 0f) == 0
@@ -2136,6 +2151,10 @@
         throw new ParserException("Unexpected MIME type.");
       }
 
+      if (!TRACK_NAME_TO_ROTATION_DEGREES.containsKey(name)) {
+        formatBuilder.setLabel(name);
+      }
+
       Format format =
           formatBuilder
               .setId(trackId)
diff --git a/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java b/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java
index 359ccc1..d20da44 100644
--- a/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java
+++ b/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java
@@ -965,20 +965,20 @@
 
     // Offset to the entire video timeline. In the presence of B-frames this is usually used to
     // ensure that the first frame's presentation timestamp is zero.
-    long edtsOffset = 0;
+    long edtsOffsetUs = 0;
 
     // Currently we only support a single edit that moves the entire media timeline (indicated by
     // duration == 0). Other uses of edit lists are uncommon and unsupported.
     if (track.editListDurations != null && track.editListDurations.length == 1
         && track.editListDurations[0] == 0) {
-      edtsOffset =
+      edtsOffsetUs =
           Util.scaleLargeTimestamp(
-              track.editListMediaTimes[0], C.MILLIS_PER_SECOND, track.timescale);
+              track.editListMediaTimes[0], C.MICROS_PER_SECOND, track.timescale);
     }
 
     int[] sampleSizeTable = fragment.sampleSizeTable;
-    int[] sampleCompositionTimeOffsetTable = fragment.sampleCompositionTimeOffsetTable;
-    long[] sampleDecodingTimeTable = fragment.sampleDecodingTimeTable;
+    int[] sampleCompositionTimeOffsetUsTable = fragment.sampleCompositionTimeOffsetUsTable;
+    long[] sampleDecodingTimeUsTable = fragment.sampleDecodingTimeUsTable;
     boolean[] sampleIsSyncFrameTable = fragment.sampleIsSyncFrameTable;
 
     boolean workaroundEveryVideoFrameIsSyncFrame = track.type == C.TRACK_TYPE_VIDEO
@@ -1002,13 +1002,13 @@
         // here, because unsigned integers will still be parsed correctly (unless their top bit is
         // set, which is never true in practice because sample offsets are always small).
         int sampleOffset = trun.readInt();
-        sampleCompositionTimeOffsetTable[i] =
-            (int) ((sampleOffset * C.MILLIS_PER_SECOND) / timescale);
+        sampleCompositionTimeOffsetUsTable[i] =
+            (int) ((sampleOffset * C.MICROS_PER_SECOND) / timescale);
       } else {
-        sampleCompositionTimeOffsetTable[i] = 0;
+        sampleCompositionTimeOffsetUsTable[i] = 0;
       }
-      sampleDecodingTimeTable[i] =
-          Util.scaleLargeTimestamp(cumulativeTime, C.MILLIS_PER_SECOND, timescale) - edtsOffset;
+      sampleDecodingTimeUsTable[i] =
+          Util.scaleLargeTimestamp(cumulativeTime, C.MICROS_PER_SECOND, timescale) - edtsOffsetUs;
       sampleSizeTable[i] = sampleSize;
       sampleIsSyncFrameTable[i] = ((sampleFlags >> 16) & 0x1) == 0
           && (!workaroundEveryVideoFrameIsSyncFrame || i == 0);
@@ -1297,7 +1297,7 @@
     Track track = currentTrackBundle.track;
     TrackOutput output = currentTrackBundle.output;
     int sampleIndex = currentTrackBundle.currentSampleIndex;
-    long sampleTimeUs = fragment.getSamplePresentationTime(sampleIndex) * 1000L;
+    long sampleTimeUs = fragment.getSamplePresentationTimeUs(sampleIndex);
     if (timestampAdjuster != null) {
       sampleTimeUs = timestampAdjuster.adjustSampleTimestamp(sampleTimeUs);
     }
@@ -1545,10 +1545,9 @@
      * @param timeUs The seek time, in microseconds.
      */
     public void seek(long timeUs) {
-      long timeMs = C.usToMs(timeUs);
       int searchIndex = currentSampleIndex;
       while (searchIndex < fragment.sampleCount
-          && fragment.getSamplePresentationTime(searchIndex) < timeMs) {
+          && fragment.getSamplePresentationTimeUs(searchIndex) < timeUs) {
         if (fragment.sampleIsSyncFrameTable[searchIndex]) {
           firstSampleToOutputIndex = searchIndex;
         }
@@ -1611,9 +1610,10 @@
       encryptionSignalByte.data[0] =
           (byte) (vectorSize | (writeSubsampleEncryptionData ? 0x80 : 0));
       encryptionSignalByte.setPosition(0);
-      output.sampleData(encryptionSignalByte, 1);
+      output.sampleData(encryptionSignalByte, 1, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION);
       // Write the vector.
-      output.sampleData(initializationVectorData, vectorSize);
+      output.sampleData(
+          initializationVectorData, vectorSize, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION);
 
       if (!writeSubsampleEncryptionData) {
         return 1 + vectorSize;
@@ -1635,7 +1635,10 @@
         scratch.data[5] = (byte) ((sampleSize >> 16) & 0xFF);
         scratch.data[6] = (byte) ((sampleSize >> 8) & 0xFF);
         scratch.data[7] = (byte) (sampleSize & 0xFF);
-        output.sampleData(scratch, SINGLE_SUBSAMPLE_ENCRYPTION_DATA_LENGTH);
+        output.sampleData(
+            scratch,
+            SINGLE_SUBSAMPLE_ENCRYPTION_DATA_LENGTH,
+            TrackOutput.SAMPLE_DATA_PART_ENCRYPTION);
         return 1 + vectorSize + SINGLE_SUBSAMPLE_ENCRYPTION_DATA_LENGTH;
       }
 
@@ -1658,7 +1661,8 @@
         subsampleEncryptionData = scratch;
       }
 
-      output.sampleData(subsampleEncryptionData, subsampleDataLength);
+      output.sampleData(
+          subsampleEncryptionData, subsampleDataLength, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION);
       return 1 + vectorSize + subsampleDataLength;
     }
 
diff --git a/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java b/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java
index 456cd50..b214114 100644
--- a/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java
+++ b/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java
@@ -60,14 +60,10 @@
    * The size of each sample in the fragment.
    */
   public int[] sampleSizeTable;
-  /**
-   * The composition time offset of each sample in the fragment.
-   */
-  public int[] sampleCompositionTimeOffsetTable;
-  /**
-   * The decoding time of each sample in the fragment.
-   */
-  public long[] sampleDecodingTimeTable;
+  /** The composition time offset of each sample in the fragment, in microseconds. */
+  public int[] sampleCompositionTimeOffsetUsTable;
+  /** The decoding time of each sample in the fragment, in microseconds. */
+  public long[] sampleDecodingTimeUsTable;
   /**
    * Indicates which samples are sync frames.
    */
@@ -101,8 +97,8 @@
     trunDataPosition = new long[0];
     trunLength = new int[0];
     sampleSizeTable = new int[0];
-    sampleCompositionTimeOffsetTable = new int[0];
-    sampleDecodingTimeTable = new long[0];
+    sampleCompositionTimeOffsetUsTable = new int[0];
+    sampleDecodingTimeUsTable = new long[0];
     sampleIsSyncFrameTable = new boolean[0];
     sampleHasSubsampleEncryptionTable = new boolean[0];
     sampleEncryptionData = new ParsableByteArray();
@@ -143,8 +139,8 @@
       // likely. The choice of 25% is relatively arbitrary.
       int tableSize = (sampleCount * 125) / 100;
       sampleSizeTable = new int[tableSize];
-      sampleCompositionTimeOffsetTable = new int[tableSize];
-      sampleDecodingTimeTable = new long[tableSize];
+      sampleCompositionTimeOffsetUsTable = new int[tableSize];
+      sampleDecodingTimeUsTable = new long[tableSize];
       sampleIsSyncFrameTable = new boolean[tableSize];
       sampleHasSubsampleEncryptionTable = new boolean[tableSize];
     }
@@ -186,8 +182,14 @@
     sampleEncryptionDataNeedsFill = false;
   }
 
-  public long getSamplePresentationTime(int index) {
-    return sampleDecodingTimeTable[index] + sampleCompositionTimeOffsetTable[index];
+  /**
+   * Returns the sample presentation timestamp in microseconds.
+   *
+   * @param index The sample index.
+   * @return The presentation timestamps of this sample in microseconds.
+   */
+  public long getSamplePresentationTimeUs(int index) {
+    return sampleDecodingTimeUsTable[index] + sampleCompositionTimeOffsetUsTable[index];
   }
 
   /** Returns whether the sample at the given index has a subsample encryption table. */
diff --git a/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java b/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java
index dcf64d9..c356b1c 100644
--- a/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java
+++ b/tree/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java
@@ -47,6 +47,7 @@
   private static final int VPS_NUT = 32;
   private static final int SPS_NUT = 33;
   private static final int PPS_NUT = 34;
+  private static final int AUD_NUT = 35;
   private static final int PREFIX_SEI_NUT = 39;
   private static final int SUFFIX_SEI_NUT = 40;
 
@@ -65,7 +66,7 @@
   private final NalUnitTargetBuffer sps;
   private final NalUnitTargetBuffer pps;
   private final NalUnitTargetBuffer prefixSei;
-  private final NalUnitTargetBuffer suffixSei; // TODO: Are both needed?
+  private final NalUnitTargetBuffer suffixSei;
   private long totalBytesWritten;
 
   // Per packet state that gets reset at the start of each packet.
@@ -424,17 +425,17 @@
     private final TrackOutput output;
 
     // Per NAL unit state. A sample consists of one or more NAL units.
-    private long nalUnitStartPosition;
+    private long nalUnitPosition;
     private boolean nalUnitHasKeyframeData;
     private int nalUnitBytesRead;
     private long nalUnitTimeUs;
     private boolean lookingForFirstSliceFlag;
     private boolean isFirstSlice;
-    private boolean isFirstParameterSet;
+    private boolean isFirstPrefixNalUnit;
 
     // Per sample state that gets reset at the start of each sample.
     private boolean readingSample;
-    private boolean writingParameterSets;
+    private boolean readingPrefix;
     private long samplePosition;
     private long sampleTimeUs;
     private boolean sampleIsKeyframe;
@@ -446,35 +447,33 @@
     public void reset() {
       lookingForFirstSliceFlag = false;
       isFirstSlice = false;
-      isFirstParameterSet = false;
+      isFirstPrefixNalUnit = false;
       readingSample = false;
-      writingParameterSets = false;
+      readingPrefix = false;
     }
 
     public void startNalUnit(
         long position, int offset, int nalUnitType, long pesTimeUs, boolean hasOutputFormat) {
       isFirstSlice = false;
-      isFirstParameterSet = false;
+      isFirstPrefixNalUnit = false;
       nalUnitTimeUs = pesTimeUs;
       nalUnitBytesRead = 0;
-      nalUnitStartPosition = position;
+      nalUnitPosition = position;
 
-      if (nalUnitType >= VPS_NUT) {
-        if (!writingParameterSets && readingSample) {
-          // This is a non-VCL NAL unit, so flush the previous sample.
+      if (!isVclBodyNalUnit(nalUnitType)) {
+        if (readingSample && !readingPrefix) {
           if (hasOutputFormat) {
             outputSample(offset);
           }
           readingSample = false;
         }
-        if (nalUnitType <= PPS_NUT) {
-          // This sample will have parameter sets at the start.
-          isFirstParameterSet = !writingParameterSets;
-          writingParameterSets = true;
+        if (isPrefixNalUnit(nalUnitType)) {
+          isFirstPrefixNalUnit = !readingPrefix;
+          readingPrefix = true;
         }
       }
 
-      // Look for the flag if this NAL unit contains a slice_segment_layer_rbsp.
+      // Look for the first slice flag if this NAL unit contains a slice_segment_layer_rbsp.
       nalUnitHasKeyframeData = (nalUnitType >= BLA_W_LP && nalUnitType <= CRA_NUT);
       lookingForFirstSliceFlag = nalUnitHasKeyframeData || nalUnitType <= RASL_R;
     }
@@ -492,30 +491,38 @@
     }
 
     public void endNalUnit(long position, int offset, boolean hasOutputFormat) {
-      if (writingParameterSets && isFirstSlice) {
+      if (readingPrefix && isFirstSlice) {
         // This sample has parameter sets. Reset the key-frame flag based on the first slice.
         sampleIsKeyframe = nalUnitHasKeyframeData;
-        writingParameterSets = false;
-      } else if (isFirstParameterSet || isFirstSlice) {
+        readingPrefix = false;
+      } else if (isFirstPrefixNalUnit || isFirstSlice) {
         // This NAL unit is at the start of a new sample (access unit).
         if (hasOutputFormat && readingSample) {
           // Output the sample ending before this NAL unit.
-          int nalUnitLength = (int) (position - nalUnitStartPosition);
+          int nalUnitLength = (int) (position - nalUnitPosition);
           outputSample(offset + nalUnitLength);
         }
-        samplePosition = nalUnitStartPosition;
+        samplePosition = nalUnitPosition;
         sampleTimeUs = nalUnitTimeUs;
-        readingSample = true;
         sampleIsKeyframe = nalUnitHasKeyframeData;
+        readingSample = true;
       }
     }
 
     private void outputSample(int offset) {
       @C.BufferFlags int flags = sampleIsKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0;
-      int size = (int) (nalUnitStartPosition - samplePosition);
+      int size = (int) (nalUnitPosition - samplePosition);
       output.sampleMetadata(sampleTimeUs, flags, size, offset, null);
     }
 
-  }
+    /** Returns whether a NAL unit type is one that occurs before any VCL NAL units in a sample. */
+    private static boolean isPrefixNalUnit(int nalUnitType) {
+      return (VPS_NUT <= nalUnitType && nalUnitType <= AUD_NUT) || nalUnitType == PREFIX_SEI_NUT;
+    }
 
+    /** Returns whether a NAL unit type is one that occurs in the VLC body of a sample. */
+    private static boolean isVclBodyNalUnit(int nalUnitType) {
+      return nalUnitType < VPS_NUT || nalUnitType == SUFFIX_SEI_NUT;
+    }
+  }
 }
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorNonParameterizedTest.java
similarity index 85%
rename from tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorTest.java
rename to tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorNonParameterizedTest.java
index 03b6fcb..65b8122 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorNonParameterizedTest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2018 The Android Open Source Project
+ * Copyright (C) 2020 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -12,6 +12,7 @@
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  * See the License for the specific language governing permissions and
  * limitations under the License.
+ *
  */
 package com.google.android.exoplayer2.extractor.amr;
 
@@ -35,9 +36,14 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
-/** Unit test for {@link AmrExtractor}. */
+/**
+ * Tests for {@link AmrExtractor} that test specific behaviours and don't need to be parameterized.
+ *
+ * <p>For parameterized tests using {@link ExtractorAsserts} see {@link
+ * AmrExtractorParameterizedTest}.
+ */
 @RunWith(AndroidJUnit4.class)
-public final class AmrExtractorTest {
+public final class AmrExtractorNonParameterizedTest {
 
   private static final Random RANDOM = new Random(1234);
 
@@ -169,30 +175,6 @@
     }
   }
 
-  @Test
-  public void extractingNarrowBandSamples() throws Exception {
-    ExtractorAsserts.assertBehavior(
-        createAmrExtractorFactory(/* withSeeking= */ false), "amr/sample_nb.amr");
-  }
-
-  @Test
-  public void extractingWideBandSamples() throws Exception {
-    ExtractorAsserts.assertBehavior(
-        createAmrExtractorFactory(/* withSeeking= */ false), "amr/sample_wb.amr");
-  }
-
-  @Test
-  public void extractingNarrowBandSamples_withSeeking() throws Exception {
-    ExtractorAsserts.assertBehavior(
-        createAmrExtractorFactory(/* withSeeking= */ true), "amr/sample_nb_cbr.amr");
-  }
-
-  @Test
-  public void extractingWideBandSamples_withSeeking() throws Exception {
-    ExtractorAsserts.assertBehavior(
-        createAmrExtractorFactory(/* withSeeking= */ true), "amr/sample_wb_cbr.amr");
-  }
-
   private byte[] newWideBandAmrFrameWithType(int frameType) {
     byte frameHeader = (byte) ((frameType << 3) & (0b01111100));
     int frameContentInBytes = frameSizeBytesByTypeWb(frameType) - 1;
@@ -237,14 +219,4 @@
   private static FakeExtractorInput fakeExtractorInputWithData(byte[] data) {
     return new FakeExtractorInput.Builder().setData(data).build();
   }
-
-  private static ExtractorAsserts.ExtractorFactory createAmrExtractorFactory(boolean withSeeking) {
-    return () -> {
-      if (!withSeeking) {
-        return new AmrExtractor();
-      } else {
-        return new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING);
-      }
-    };
-  }
 }
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorParameterizedTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorParameterizedTest.java
new file mode 100644
index 0000000..833567c
--- /dev/null
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorParameterizedTest.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2018 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.extractor.amr;
+
+import com.google.android.exoplayer2.testutil.ExtractorAsserts;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
+
+/**
+ * Unit tests for {@link AmrExtractor} that use parameterization to test a range of behaviours.
+ *
+ * <p>For non-parameterized tests see {@link AmrExtractorSeekTest} and {@link
+ * AmrExtractorNonParameterizedTest}.
+ */
+@RunWith(ParameterizedRobolectricTestRunner.class)
+public final class AmrExtractorParameterizedTest {
+
+  @Parameters(name = "{0}")
+  public static List<Object[]> params() {
+    return ExtractorAsserts.configs();
+  }
+
+  @Parameter(0)
+  public ExtractorAsserts.Config assertionConfig;
+
+  @Test
+  public void extractingNarrowBandSamples() throws Exception {
+    ExtractorAsserts.assertBehavior(
+        createAmrExtractorFactory(/* withSeeking= */ false), "amr/sample_nb.amr", assertionConfig);
+  }
+
+  @Test
+  public void extractingWideBandSamples() throws Exception {
+    ExtractorAsserts.assertBehavior(
+        createAmrExtractorFactory(/* withSeeking= */ false), "amr/sample_wb.amr", assertionConfig);
+  }
+
+  @Test
+  public void extractingNarrowBandSamples_withSeeking() throws Exception {
+    ExtractorAsserts.assertBehavior(
+        createAmrExtractorFactory(/* withSeeking= */ true),
+        "amr/sample_nb_cbr.amr",
+        assertionConfig);
+  }
+
+  @Test
+  public void extractingWideBandSamples_withSeeking() throws Exception {
+    ExtractorAsserts.assertBehavior(
+        createAmrExtractorFactory(/* withSeeking= */ true),
+        "amr/sample_wb_cbr.amr",
+        assertionConfig);
+  }
+
+
+  private static ExtractorAsserts.ExtractorFactory createAmrExtractorFactory(boolean withSeeking) {
+    return () -> {
+      if (!withSeeking) {
+        return new AmrExtractor();
+      } else {
+        return new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING);
+      }
+    };
+  }
+}
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java
index 850321e..42e9f93 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java
@@ -33,7 +33,7 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
-/** Unit test for {@link AmrExtractor}. */
+/** Unit tests for {@link AmrExtractor} seeking behaviour. */
 @RunWith(AndroidJUnit4.class)
 public final class AmrExtractorSeekTest {
 
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java
index fab950a..a620ee2 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java
@@ -15,22 +15,32 @@
  */
 package com.google.android.exoplayer2.extractor.flac;
 
-import androidx.test.core.app.ApplicationProvider;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.testutil.ExtractorAsserts;
+import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
 
 /** Unit tests for {@link FlacExtractor}. */
-@RunWith(AndroidJUnit4.class)
+@RunWith(ParameterizedRobolectricTestRunner.class)
 public class FlacExtractorTest {
 
+  @Parameters(name = "{0}")
+  public static List<Object[]> params() {
+    return ExtractorAsserts.configs();
+  }
+
+  @Parameter(0)
+  public ExtractorAsserts.Config assertionConfig;
+
   @Test
   public void sample() throws Exception {
     ExtractorAsserts.assertBehavior(
         FlacExtractor::new,
         /* file= */ "flac/bear.flac",
-        ApplicationProvider.getApplicationContext(),
+        assertionConfig,
         /* dumpFilesPrefix= */ "flac/bear_flac");
   }
 
@@ -39,7 +49,7 @@
     ExtractorAsserts.assertBehavior(
         FlacExtractor::new,
         /* file= */ "flac/bear_with_id3.flac",
-        ApplicationProvider.getApplicationContext(),
+        assertionConfig,
         /* dumpFilesPrefix= */ "flac/bear_with_id3_enabled_flac");
   }
 
@@ -48,7 +58,7 @@
     ExtractorAsserts.assertBehavior(
         () -> new FlacExtractor(FlacExtractor.FLAG_DISABLE_ID3_METADATA),
         /* file= */ "flac/bear_with_id3.flac",
-        ApplicationProvider.getApplicationContext(),
+        assertionConfig,
         /* dumpFilesPrefix= */ "flac/bear_with_id3_disabled_flac");
   }
 
@@ -57,7 +67,7 @@
     ExtractorAsserts.assertBehavior(
         FlacExtractor::new,
         /* file= */ "flac/bear_no_seek_table_no_num_samples.flac",
-        ApplicationProvider.getApplicationContext(),
+        assertionConfig,
         /* dumpFilesPrefix= */ "flac/bear_no_seek_table_no_num_samples_flac");
   }
 
@@ -66,7 +76,7 @@
     ExtractorAsserts.assertBehavior(
         FlacExtractor::new,
         /* file= */ "flac/bear_with_vorbis_comments.flac",
-        ApplicationProvider.getApplicationContext(),
+        assertionConfig,
         /* dumpFilesPrefix= */ "flac/bear_with_vorbis_comments_flac");
   }
 
@@ -75,7 +85,7 @@
     ExtractorAsserts.assertBehavior(
         FlacExtractor::new,
         /* file= */ "flac/bear_with_picture.flac",
-        ApplicationProvider.getApplicationContext(),
+        assertionConfig,
         /* dumpFilesPrefix= */ "flac/bear_with_picture_flac");
   }
 
@@ -84,7 +94,7 @@
     ExtractorAsserts.assertBehavior(
         FlacExtractor::new,
         /* file= */ "flac/bear_one_metadata_block.flac",
-        ApplicationProvider.getApplicationContext(),
+        assertionConfig,
         /* dumpFilesPrefix= */ "flac/bear_one_metadata_block_flac");
   }
 
@@ -93,7 +103,7 @@
     ExtractorAsserts.assertBehavior(
         FlacExtractor::new,
         /* file= */ "flac/bear_no_min_max_frame_size.flac",
-        ApplicationProvider.getApplicationContext(),
+        assertionConfig,
         /* dumpFilesPrefix= */ "flac/bear_no_min_max_frame_size_flac");
   }
 
@@ -102,7 +112,7 @@
     ExtractorAsserts.assertBehavior(
         FlacExtractor::new,
         /* file= */ "flac/bear_no_num_samples.flac",
-        ApplicationProvider.getApplicationContext(),
+        assertionConfig,
         /* dumpFilesPrefix= */ "flac/bear_no_num_samples_flac");
   }
 
@@ -111,7 +121,7 @@
     ExtractorAsserts.assertBehavior(
         FlacExtractor::new,
         /* file= */ "flac/bear_uncommon_sample_rate.flac",
-        ApplicationProvider.getApplicationContext(),
+        assertionConfig,
         /* dumpFilesPrefix= */ "flac/bear_uncommon_sample_rate_flac");
   }
 }
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java
index 52b6a04..6481658 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java
@@ -15,17 +15,28 @@
  */
 package com.google.android.exoplayer2.extractor.flv;
 
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.testutil.ExtractorAsserts;
+import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
 
 /** Unit test for {@link FlvExtractor}. */
-@RunWith(AndroidJUnit4.class)
+@RunWith(ParameterizedRobolectricTestRunner.class)
 public final class FlvExtractorTest {
 
+  @Parameters(name = "{0}")
+  public static List<Object[]> params() {
+    return ExtractorAsserts.configs();
+  }
+
+  @Parameter(0)
+  public ExtractorAsserts.Config assertionConfig;
+
   @Test
   public void sample() throws Exception {
-    ExtractorAsserts.assertBehavior(FlvExtractor::new, "flv/sample.flv");
+    ExtractorAsserts.assertBehavior(FlvExtractor::new, "flv/sample.flv", assertionConfig);
   }
 }
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractorTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractorTest.java
index 761815c..70c0ec4 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractorTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractorTest.java
@@ -15,33 +15,57 @@
  */
 package com.google.android.exoplayer2.extractor.mkv;
 
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.testutil.ExtractorAsserts;
+import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
 
 /** Tests for {@link MatroskaExtractor}. */
-@RunWith(AndroidJUnit4.class)
+@RunWith(ParameterizedRobolectricTestRunner.class)
 public final class MatroskaExtractorTest {
 
+  @Parameters(name = "{0}")
+  public static List<Object[]> params() {
+    return ExtractorAsserts.configs();
+  }
+
+  @Parameter(0)
+  public ExtractorAsserts.Config assertionConfig;
+
   @Test
   public void mkvSample() throws Exception {
-    ExtractorAsserts.assertBehavior(MatroskaExtractor::new, "mkv/sample.mkv");
+    ExtractorAsserts.assertBehavior(MatroskaExtractor::new, "mkv/sample.mkv", assertionConfig);
+  }
+
+  @Test
+  public void mkvSample_withSubripSubtitles() throws Exception {
+    ExtractorAsserts.assertBehavior(
+        MatroskaExtractor::new, "mkv/sample_with_srt.mkv", assertionConfig);
+  }
+
+  @Test
+  public void mkvSample_withHtcRotationInfoInTrackName() throws Exception {
+    ExtractorAsserts.assertBehavior(
+        MatroskaExtractor::new, "mkv/sample_with_htc_rotation_track_name.mkv", assertionConfig);
   }
 
   @Test
   public void mkvFullBlocksSample() throws Exception {
-    ExtractorAsserts.assertBehavior(MatroskaExtractor::new, "mkv/full_blocks.mkv");
+    ExtractorAsserts.assertBehavior(MatroskaExtractor::new, "mkv/full_blocks.mkv", assertionConfig);
   }
 
   @Test
   public void webmSubsampleEncryption() throws Exception {
     ExtractorAsserts.assertBehavior(
-        MatroskaExtractor::new, "mkv/subsample_encrypted_noaltref.webm");
+        MatroskaExtractor::new, "mkv/subsample_encrypted_noaltref.webm", assertionConfig);
   }
 
   @Test
   public void webmSubsampleEncryptionWithAltrefFrames() throws Exception {
-    ExtractorAsserts.assertBehavior(MatroskaExtractor::new, "mkv/subsample_encrypted_altref.webm");
+    ExtractorAsserts.assertBehavior(
+        MatroskaExtractor::new, "mkv/subsample_encrypted_altref.webm", assertionConfig);
   }
 }
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/Mp3ExtractorTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/Mp3ExtractorTest.java
index 5622137..611c8f7 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/Mp3ExtractorTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/Mp3ExtractorTest.java
@@ -15,37 +15,49 @@
  */
 package com.google.android.exoplayer2.extractor.mp3;
 
-import androidx.test.core.app.ApplicationProvider;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.testutil.ExtractorAsserts;
+import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
 
 /** Unit test for {@link Mp3Extractor}. */
-@RunWith(AndroidJUnit4.class)
+@RunWith(ParameterizedRobolectricTestRunner.class)
 public final class Mp3ExtractorTest {
 
+  @Parameters(name = "{0}")
+  public static List<Object[]> params() {
+    return ExtractorAsserts.configs();
+  }
+
+  @Parameter(0)
+  public ExtractorAsserts.Config assertionConfig;
+
   @Test
   public void mp3SampleWithXingHeader() throws Exception {
-    ExtractorAsserts.assertBehavior(Mp3Extractor::new, "mp3/bear-vbr-xing-header.mp3");
+    ExtractorAsserts.assertBehavior(
+        Mp3Extractor::new, "mp3/bear-vbr-xing-header.mp3", assertionConfig);
   }
 
   @Test
   public void mp3SampleWithCbrSeeker() throws Exception {
     ExtractorAsserts.assertBehavior(
-        Mp3Extractor::new, "mp3/bear-cbr-variable-frame-size-no-seek-table.mp3");
+        Mp3Extractor::new, "mp3/bear-cbr-variable-frame-size-no-seek-table.mp3", assertionConfig);
   }
 
   @Test
   public void mp3SampleWithIndexSeeker() throws Exception {
     ExtractorAsserts.assertBehavior(
         () -> new Mp3Extractor(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING),
-        "mp3/bear-vbr-no-seek-table.mp3");
+        "mp3/bear-vbr-no-seek-table.mp3",
+        assertionConfig);
   }
 
   @Test
   public void trimmedMp3Sample() throws Exception {
-    ExtractorAsserts.assertBehavior(Mp3Extractor::new, "mp3/play-trimmed.mp3");
+    ExtractorAsserts.assertBehavior(Mp3Extractor::new, "mp3/play-trimmed.mp3", assertionConfig);
   }
 
   @Test
@@ -53,7 +65,7 @@
     ExtractorAsserts.assertBehavior(
         Mp3Extractor::new,
         /* file= */ "mp3/bear-id3.mp3",
-        ApplicationProvider.getApplicationContext(),
+        assertionConfig,
         /* dumpFilesPrefix= */ "mp3/bear-id3-enabled");
   }
 
@@ -62,7 +74,7 @@
     ExtractorAsserts.assertBehavior(
         () -> new Mp3Extractor(Mp3Extractor.FLAG_DISABLE_ID3_METADATA),
         /* file= */ "mp3/bear-id3.mp3",
-        ApplicationProvider.getApplicationContext(),
+        assertionConfig,
         /* dumpFilesPrefix= */ "mp3/bear-id3-disabled");
   }
 }
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4ExtractorTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4ExtractorTest.java
index 86f8e84..d983caa 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4ExtractorTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4ExtractorTest.java
@@ -15,7 +15,6 @@
  */
 package com.google.android.exoplayer2.extractor.mp4;
 
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.Format;
 import com.google.android.exoplayer2.testutil.ExtractorAsserts;
 import com.google.android.exoplayer2.testutil.ExtractorAsserts.ExtractorFactory;
@@ -25,21 +24,34 @@
 import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
 
 /** Unit test for {@link FragmentedMp4Extractor}. */
-@RunWith(AndroidJUnit4.class)
+@RunWith(ParameterizedRobolectricTestRunner.class)
 public final class FragmentedMp4ExtractorTest {
 
+  @Parameters(name = "{0}")
+  public static List<Object[]> params() {
+    return ExtractorAsserts.configs();
+  }
+
+  @Parameter(0)
+  public ExtractorAsserts.Config assertionConfig;
+
   @Test
   public void sample() throws Exception {
     ExtractorAsserts.assertBehavior(
-        getExtractorFactory(ImmutableList.of()), "mp4/sample_fragmented.mp4");
+        getExtractorFactory(ImmutableList.of()), "mp4/sample_fragmented.mp4", assertionConfig);
   }
 
   @Test
   public void sampleSeekable() throws Exception {
     ExtractorAsserts.assertBehavior(
-        getExtractorFactory(ImmutableList.of()), "mp4/sample_fragmented_seekable.mp4");
+        getExtractorFactory(ImmutableList.of()),
+        "mp4/sample_fragmented_seekable.mp4",
+        assertionConfig);
   }
 
   @Test
@@ -49,37 +61,40 @@
         getExtractorFactory(
             Collections.singletonList(
                 new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_CEA608).build()));
-    ExtractorAsserts.assertBehavior(extractorFactory, "mp4/sample_fragmented_sei.mp4");
+    ExtractorAsserts.assertBehavior(
+        extractorFactory, "mp4/sample_fragmented_sei.mp4", assertionConfig);
   }
 
   @Test
   public void sampleWithAc3Track() throws Exception {
     ExtractorAsserts.assertBehavior(
-        getExtractorFactory(ImmutableList.of()), "mp4/sample_ac3_fragmented.mp4");
+        getExtractorFactory(ImmutableList.of()), "mp4/sample_ac3_fragmented.mp4", assertionConfig);
   }
 
   @Test
   public void sampleWithAc4Track() throws Exception {
     ExtractorAsserts.assertBehavior(
-        getExtractorFactory(ImmutableList.of()), "mp4/sample_ac4_fragmented.mp4");
+        getExtractorFactory(ImmutableList.of()), "mp4/sample_ac4_fragmented.mp4", assertionConfig);
   }
 
   @Test
   public void sampleWithProtectedAc4Track() throws Exception {
     ExtractorAsserts.assertBehavior(
-        getExtractorFactory(ImmutableList.of()), "mp4/sample_ac4_protected.mp4");
+        getExtractorFactory(ImmutableList.of()), "mp4/sample_ac4_protected.mp4", assertionConfig);
   }
 
   @Test
   public void sampleWithEac3Track() throws Exception {
     ExtractorAsserts.assertBehavior(
-        getExtractorFactory(ImmutableList.of()), "mp4/sample_eac3_fragmented.mp4");
+        getExtractorFactory(ImmutableList.of()), "mp4/sample_eac3_fragmented.mp4", assertionConfig);
   }
 
   @Test
   public void sampleWithEac3jocTrack() throws Exception {
     ExtractorAsserts.assertBehavior(
-        getExtractorFactory(ImmutableList.of()), "mp4/sample_eac3joc_fragmented.mp4");
+        getExtractorFactory(ImmutableList.of()),
+        "mp4/sample_eac3joc_fragmented.mp4",
+        assertionConfig);
   }
 
   private static ExtractorFactory getExtractorFactory(final List<Format> closedCaptionFormats) {
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java
index 5c1e8e1..d2b54e7 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java
@@ -15,23 +15,35 @@
  */
 package com.google.android.exoplayer2.extractor.mp4;
 
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.testutil.ExtractorAsserts;
+import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
 
 /** Tests for {@link Mp4Extractor}. */
-@RunWith(AndroidJUnit4.class)
+@RunWith(ParameterizedRobolectricTestRunner.class)
 public final class Mp4ExtractorTest {
 
+  @Parameters(name = "{0}")
+  public static List<Object[]> params() {
+    return ExtractorAsserts.configs();
+  }
+
+  @Parameter(0)
+  public ExtractorAsserts.Config assertionConfig;
+
   @Test
   public void mp4Sample() throws Exception {
-    ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample.mp4");
+    ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample.mp4", assertionConfig);
   }
 
   @Test
   public void mp4SampleWithSlowMotionMetadata() throws Exception {
-    ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_android_slow_motion.mp4");
+    ExtractorAsserts.assertBehavior(
+        Mp4Extractor::new, "mp4/sample_android_slow_motion.mp4", assertionConfig);
   }
 
   /**
@@ -40,26 +52,27 @@
    */
   @Test
   public void mp4SampleWithMdatTooLong() throws Exception {
-    ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_mdat_too_long.mp4");
+    ExtractorAsserts.assertBehavior(
+        Mp4Extractor::new, "mp4/sample_mdat_too_long.mp4", assertionConfig);
   }
 
   @Test
   public void mp4SampleWithAc3Track() throws Exception {
-    ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_ac3.mp4");
+    ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_ac3.mp4", assertionConfig);
   }
 
   @Test
   public void mp4SampleWithAc4Track() throws Exception {
-    ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_ac4.mp4");
+    ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_ac4.mp4", assertionConfig);
   }
 
   @Test
   public void mp4SampleWithEac3Track() throws Exception {
-    ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_eac3.mp4");
+    ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_eac3.mp4", assertionConfig);
   }
 
   @Test
   public void mp4SampleWithEac3jocTrack() throws Exception {
-    ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_eac3joc.mp4");
+    ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_eac3joc.mp4", assertionConfig);
   }
 }
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorNonParameterizedTest.java
similarity index 75%
rename from tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorTest.java
rename to tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorNonParameterizedTest.java
index bffaa58..bf2a350 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorNonParameterizedTest.java
@@ -20,37 +20,19 @@
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.testutil.ExtractorAsserts;
-import com.google.android.exoplayer2.testutil.ExtractorAsserts.ExtractorFactory;
 import com.google.android.exoplayer2.testutil.FakeExtractorInput;
 import java.io.IOException;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
-/** Unit test for {@link OggExtractor}. */
+/**
+ * Tests for {@link OggExtractor} that test specific behaviours and don't need to be parameterized.
+ *
+ * <p>For parameterized tests using {@link ExtractorAsserts} see {@link
+ * OggExtractorParameterizedTest}.
+ */
 @RunWith(AndroidJUnit4.class)
-public final class OggExtractorTest {
-
-  private static final ExtractorFactory OGG_EXTRACTOR_FACTORY = OggExtractor::new;
-
-  @Test
-  public void opus() throws Exception {
-    ExtractorAsserts.assertBehavior(OGG_EXTRACTOR_FACTORY, "ogg/bear.opus");
-  }
-
-  @Test
-  public void flac() throws Exception {
-    ExtractorAsserts.assertBehavior(OGG_EXTRACTOR_FACTORY, "ogg/bear_flac.ogg");
-  }
-
-  @Test
-  public void flacNoSeektable() throws Exception {
-    ExtractorAsserts.assertBehavior(OGG_EXTRACTOR_FACTORY, "ogg/bear_flac_noseektable.ogg");
-  }
-
-  @Test
-  public void vorbis() throws Exception {
-    ExtractorAsserts.assertBehavior(OGG_EXTRACTOR_FACTORY, "ogg/bear_vorbis.ogg");
-  }
+public final class OggExtractorNonParameterizedTest {
 
   @Test
   public void sniffVorbis() throws Exception {
@@ -97,6 +79,6 @@
             .setSimulateUnknownLength(true)
             .setSimulatePartialReads(true)
             .build();
-    ExtractorAsserts.assertSniff(OGG_EXTRACTOR_FACTORY.create(), input, expectedResult);
+    ExtractorAsserts.assertSniff(new OggExtractor(), input, expectedResult);
   }
 }
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java
new file mode 100644
index 0000000..2235539
--- /dev/null
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.extractor.ogg;
+
+import com.google.android.exoplayer2.testutil.ExtractorAsserts;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
+
+/**
+ * Unit tests for {@link OggExtractor} that use parameterization to test a range of behaviours.
+ *
+ * <p>For non-parameterized tests see {@link OggExtractorNonParameterizedTest}.
+ */
+@RunWith(ParameterizedRobolectricTestRunner.class)
+public final class OggExtractorParameterizedTest {
+
+  @Parameters(name = "{0}")
+  public static List<Object[]> params() {
+    return ExtractorAsserts.configs();
+  }
+
+  @Parameter(0)
+  public ExtractorAsserts.Config assertionConfig;
+
+  @Test
+  public void opus() throws Exception {
+    ExtractorAsserts.assertBehavior(OggExtractor::new, "ogg/bear.opus", assertionConfig);
+  }
+
+  @Test
+  public void flac() throws Exception {
+    ExtractorAsserts.assertBehavior(OggExtractor::new, "ogg/bear_flac.ogg", assertionConfig);
+  }
+
+  @Test
+  public void flacNoSeektable() throws Exception {
+    ExtractorAsserts.assertBehavior(
+        OggExtractor::new, "ogg/bear_flac_noseektable.ogg", assertionConfig);
+  }
+
+  @Test
+  public void vorbis() throws Exception {
+    ExtractorAsserts.assertBehavior(OggExtractor::new, "ogg/bear_vorbis.ogg", assertionConfig);
+  }
+}
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java
index ca7e30a..1cae51a 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java
@@ -15,17 +15,26 @@
  */
 package com.google.android.exoplayer2.extractor.rawcc;
 
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.Format;
 import com.google.android.exoplayer2.testutil.ExtractorAsserts;
 import com.google.android.exoplayer2.util.MimeTypes;
+import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
 
 /** Tests for {@link RawCcExtractor}. */
-@RunWith(AndroidJUnit4.class)
+@RunWith(ParameterizedRobolectricTestRunner.class)
 public final class RawCcExtractorTest {
 
+  @ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
+  public static List<Object[]> params() {
+    return ExtractorAsserts.configs();
+  }
+
+  @ParameterizedRobolectricTestRunner.Parameter(0)
+  public ExtractorAsserts.Config assertionConfig;
+
   @Test
   public void rawCcSample() throws Exception {
     Format format =
@@ -34,6 +43,7 @@
             .setCodecs("cea608")
             .setAccessibilityChannel(1)
             .build();
-    ExtractorAsserts.assertBehavior(() -> new RawCcExtractor(format), "rawcc/sample.rawcc");
+    ExtractorAsserts.assertBehavior(
+        () -> new RawCcExtractor(format), "rawcc/sample.rawcc", assertionConfig);
   }
 }
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac3ExtractorTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac3ExtractorTest.java
index df216c7..8e46999 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac3ExtractorTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac3ExtractorTest.java
@@ -15,27 +15,38 @@
  */
 package com.google.android.exoplayer2.extractor.ts;
 
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.testutil.ExtractorAsserts;
+import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
 
 /** Unit test for {@link Ac3Extractor}. */
-@RunWith(AndroidJUnit4.class)
+@RunWith(ParameterizedRobolectricTestRunner.class)
 public final class Ac3ExtractorTest {
 
+  @Parameters(name = "{0}")
+  public static List<Object[]> params() {
+    return ExtractorAsserts.configs();
+  }
+
+  @Parameter(0)
+  public ExtractorAsserts.Config assertionConfig;
+
   @Test
   public void ac3Sample() throws Exception {
-    ExtractorAsserts.assertBehavior(Ac3Extractor::new, "ts/sample.ac3");
+    ExtractorAsserts.assertBehavior(Ac3Extractor::new, "ts/sample.ac3", assertionConfig);
   }
 
   @Test
   public void eAc3Sample() throws Exception {
-    ExtractorAsserts.assertBehavior(Ac3Extractor::new, "ts/sample.eac3");
+    ExtractorAsserts.assertBehavior(Ac3Extractor::new, "ts/sample.eac3", assertionConfig);
   }
 
   @Test
   public void eAc3jocSample() throws Exception {
-    ExtractorAsserts.assertBehavior(Ac3Extractor::new, "ts/sample_eac3joc.ec3");
+    ExtractorAsserts.assertBehavior(Ac3Extractor::new, "ts/sample_eac3joc.ec3", assertionConfig);
   }
 }
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac4ExtractorTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac4ExtractorTest.java
index 8ddd6f5..36c8dd8 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac4ExtractorTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac4ExtractorTest.java
@@ -15,17 +15,28 @@
  */
 package com.google.android.exoplayer2.extractor.ts;
 
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.testutil.ExtractorAsserts;
+import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
 
 /** Unit test for {@link Ac4Extractor}. */
-@RunWith(AndroidJUnit4.class)
+@RunWith(ParameterizedRobolectricTestRunner.class)
 public final class Ac4ExtractorTest {
 
+  @Parameters(name = "{0}")
+  public static List<Object[]> params() {
+    return ExtractorAsserts.configs();
+  }
+
+  @Parameter(0)
+  public ExtractorAsserts.Config assertionConfig;
+
   @Test
   public void ac4Sample() throws Exception {
-    ExtractorAsserts.assertBehavior(Ac4Extractor::new, "ts/sample.ac4");
+    ExtractorAsserts.assertBehavior(Ac4Extractor::new, "ts/sample.ac4", assertionConfig);
   }
 }
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorTest.java
index a06d228..0928f8b 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorTest.java
@@ -15,30 +15,42 @@
  */
 package com.google.android.exoplayer2.extractor.ts;
 
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.testutil.ExtractorAsserts;
+import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
 
 /** Unit test for {@link AdtsExtractor}. */
-@RunWith(AndroidJUnit4.class)
+@RunWith(ParameterizedRobolectricTestRunner.class)
 public final class AdtsExtractorTest {
 
+  @Parameters(name = "{0}")
+  public static List<Object[]> params() {
+    return ExtractorAsserts.configs();
+  }
+
+  @Parameter(0)
+  public ExtractorAsserts.Config assertionConfig;
+
   @Test
   public void sample() throws Exception {
-    ExtractorAsserts.assertBehavior(AdtsExtractor::new, "ts/sample.adts");
+    ExtractorAsserts.assertBehavior(AdtsExtractor::new, "ts/sample.adts", assertionConfig);
   }
 
   @Test
   public void sample_with_id3() throws Exception {
-    ExtractorAsserts.assertBehavior(AdtsExtractor::new, "ts/sample_with_id3.adts");
+    ExtractorAsserts.assertBehavior(AdtsExtractor::new, "ts/sample_with_id3.adts", assertionConfig);
   }
 
   @Test
   public void sample_withSeeking() throws Exception {
     ExtractorAsserts.assertBehavior(
         () -> new AdtsExtractor(/* flags= */ AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING),
-        "ts/sample_cbs.adts");
+        "ts/sample_cbs.adts",
+        assertionConfig);
   }
 
   // https://github.com/google/ExoPlayer/issues/6700
@@ -46,6 +58,7 @@
   public void sample_withSeekingAndTruncatedFile() throws Exception {
     ExtractorAsserts.assertBehavior(
         () -> new AdtsExtractor(/* flags= */ AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING),
-        "ts/sample_cbs_truncated.adts");
+        "ts/sample_cbs_truncated.adts",
+        assertionConfig);
   }
 }
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorTest.java
index c9e9dce..de3fbf2 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorTest.java
@@ -15,22 +15,34 @@
  */
 package com.google.android.exoplayer2.extractor.ts;
 
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.testutil.ExtractorAsserts;
+import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
 
 /** Unit test for {@link PsExtractor}. */
-@RunWith(AndroidJUnit4.class)
+@RunWith(ParameterizedRobolectricTestRunner.class)
 public final class PsExtractorTest {
 
+  @Parameters(name = "{0}")
+  public static List<Object[]> params() {
+    return ExtractorAsserts.configs();
+  }
+
+  @Parameter(0)
+  public ExtractorAsserts.Config assertionConfig;
+
   @Test
   public void sampleWithH262AndMpegAudio() throws Exception {
-    ExtractorAsserts.assertBehavior(PsExtractor::new, "ts/sample_h262_mpeg_audio.ps");
+    ExtractorAsserts.assertBehavior(
+        PsExtractor::new, "ts/sample_h262_mpeg_audio.ps", assertionConfig);
   }
 
   @Test
   public void sampleWithAc3() throws Exception {
-    ExtractorAsserts.assertBehavior(PsExtractor::new, "ts/sample_ac3.ps");
+    ExtractorAsserts.assertBehavior(PsExtractor::new, "ts/sample_ac3.ps", assertionConfig);
   }
 }
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java
index d040c22..68ba0d4 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java
@@ -15,12 +15,12 @@
  */
 package com.google.android.exoplayer2.extractor.ts;
 
+import static com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory.FLAG_DETECT_ACCESS_UNITS;
 import static com.google.common.truth.Truth.assertThat;
 
 import android.util.SparseArray;
 import androidx.annotation.Nullable;
 import androidx.test.core.app.ApplicationProvider;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.C;
 import com.google.android.exoplayer2.Format;
 import com.google.android.exoplayer2.extractor.Extractor;
@@ -36,27 +36,57 @@
 import com.google.android.exoplayer2.testutil.TestUtil;
 import com.google.android.exoplayer2.util.ParsableByteArray;
 import com.google.android.exoplayer2.util.TimestampAdjuster;
+import java.util.List;
 import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
 
 /** Unit test for {@link TsExtractor}. */
-@RunWith(AndroidJUnit4.class)
+@RunWith(ParameterizedRobolectricTestRunner.class)
 public final class TsExtractorTest {
 
+  @Parameters(name = "{0}")
+  public static List<Object[]> params() {
+    return ExtractorAsserts.configs();
+  }
+
+  @Parameter(0)
+  public ExtractorAsserts.Config assertionConfig;
+
   @Test
   public void sampleWithH262AndMpegAudio() throws Exception {
-    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_h262_mpeg_audio.ts");
+    ExtractorAsserts.assertBehavior(
+        TsExtractor::new, "ts/sample_h262_mpeg_audio.ts", assertionConfig);
   }
 
   @Test
   public void sampleWithH264AndMpegAudio() throws Exception {
-    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_h264_mpeg_audio.ts");
+    ExtractorAsserts.assertBehavior(
+        TsExtractor::new, "ts/sample_h264_mpeg_audio.ts", assertionConfig);
+  }
+
+  @Test
+  public void sampleWithH264NoAccessUnitDelimiters() throws Exception {
+    ExtractorAsserts.assertBehavior(
+        () -> new TsExtractor(FLAG_DETECT_ACCESS_UNITS),
+        "ts/sample_h264_no_access_unit_delimiters.ts",
+        assertionConfig);
+  }
+
+  @Test
+  public void sampleWithH264AndDtsAudio() throws Exception {
+    ExtractorAsserts.assertBehavior(
+        () -> new TsExtractor(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS),
+        "ts/sample_h264_dts_audio.ts",
+        assertionConfig);
   }
 
   @Test
   public void sampleWithH265() throws Exception {
-    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_h265.ts");
+    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_h265.ts", assertionConfig);
   }
 
   @Test
@@ -64,7 +94,7 @@
   // TODO(internal: b/153539929) Re-enable when ExtractorAsserts is less strict around repeated
   // formats and seeking.
   public void sampleWithScte35() throws Exception {
-    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_scte35.ts");
+    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_scte35.ts", assertionConfig);
   }
 
   @Test
@@ -72,38 +102,37 @@
   // TODO(internal: b/153539929) Re-enable when ExtractorAsserts is less strict around repeated
   // formats and seeking.
   public void sampleWithAit() throws Exception {
-    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_ait.ts");
+    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_ait.ts", assertionConfig);
   }
 
   @Test
   public void sampleWithAc3() throws Exception {
-    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_ac3.ts");
+    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_ac3.ts", assertionConfig);
   }
 
   @Test
   public void sampleWithAc4() throws Exception {
-    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_ac4.ts");
+    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_ac4.ts", assertionConfig);
   }
 
   @Test
   public void sampleWithEac3() throws Exception {
-    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_eac3.ts");
+    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_eac3.ts", assertionConfig);
   }
 
   @Test
   public void sampleWithEac3joc() throws Exception {
-    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_eac3joc.ts");
+    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_eac3joc.ts", assertionConfig);
   }
 
   @Test
   public void sampleWithLatm() throws Exception {
-    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_latm.ts");
+    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_latm.ts", assertionConfig);
   }
 
   @Test
   public void streamWithJunkData() throws Exception {
-    ExtractorAsserts.assertBehavior(
-        TsExtractor::new, "ts/sample_with_junk", ApplicationProvider.getApplicationContext());
+    ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_with_junk", assertionConfig);
   }
 
   @Test
diff --git a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/wav/WavExtractorTest.java b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/wav/WavExtractorTest.java
index 9287c68..cbcc847 100644
--- a/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/wav/WavExtractorTest.java
+++ b/tree/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/wav/WavExtractorTest.java
@@ -15,19 +15,27 @@
  */
 package com.google.android.exoplayer2.extractor.wav;
 
-import androidx.test.core.app.ApplicationProvider;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.android.exoplayer2.testutil.ExtractorAsserts;
+import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
 
 /** Unit test for {@link WavExtractor}. */
-@RunWith(AndroidJUnit4.class)
+@RunWith(ParameterizedRobolectricTestRunner.class)
 public final class WavExtractorTest {
 
+  @ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
+  public static List<Object[]> params() {
+    return ExtractorAsserts.configs();
+  }
+
+  @ParameterizedRobolectricTestRunner.Parameter(0)
+  public ExtractorAsserts.Config assertionConfig;
+
   @Test
   public void sample() throws Exception {
-    ExtractorAsserts.assertBehavior(WavExtractor::new, "wav/sample.wav");
+    ExtractorAsserts.assertBehavior(WavExtractor::new, "wav/sample.wav", assertionConfig);
   }
 
   @Test
@@ -35,12 +43,12 @@
     ExtractorAsserts.assertBehavior(
         WavExtractor::new,
         "wav/sample_with_trailing_bytes.wav",
-        ApplicationProvider.getApplicationContext(),
+        assertionConfig,
         /* dumpFilesPrefix= */ "wav/sample.wav");
   }
 
   @Test
   public void sample_imaAdpcm() throws Exception {
-    ExtractorAsserts.assertBehavior(WavExtractor::new, "wav/sample_ima_adpcm.wav");
+    ExtractorAsserts.assertBehavior(WavExtractor::new, "wav/sample_ima_adpcm.wav", assertionConfig);
   }
 }
diff --git a/tree/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/tree/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java
index 41dc652..20d8ed3 100644
--- a/tree/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java
+++ b/tree/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java
@@ -17,6 +17,7 @@
 
 import android.net.Uri;
 import android.os.Handler;
+import android.os.Looper;
 import android.util.SparseIntArray;
 import androidx.annotation.Nullable;
 import com.google.android.exoplayer2.C;
@@ -36,6 +37,7 @@
 import com.google.android.exoplayer2.metadata.emsg.EventMessage;
 import com.google.android.exoplayer2.metadata.emsg.EventMessageDecoder;
 import com.google.android.exoplayer2.metadata.id3.PrivFrame;
+import com.google.android.exoplayer2.source.LoadEventInfo;
 import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher;
 import com.google.android.exoplayer2.source.SampleQueue;
 import com.google.android.exoplayer2.source.SampleQueue.UpstreamFormatChangedListener;
@@ -674,15 +676,14 @@
         loader.startLoading(
             loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(loadable.type));
     eventDispatcher.loadStarted(
-        loadable.dataSpec,
+        new LoadEventInfo(loadable.dataSpec, elapsedRealtimeMs),
         loadable.type,
         trackType,
         loadable.trackFormat,
         loadable.trackSelectionReason,
         loadable.trackSelectionData,
         loadable.startTimeUs,
-        loadable.endTimeUs,
-        elapsedRealtimeMs);
+        loadable.endTimeUs);
     return true;
   }
 
@@ -702,19 +703,20 @@
   public void onLoadCompleted(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs) {
     chunkSource.onChunkLoadCompleted(loadable);
     eventDispatcher.loadCompleted(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            loadable.bytesLoaded()),
         loadable.type,
         trackType,
         loadable.trackFormat,
         loadable.trackSelectionReason,
         loadable.trackSelectionData,
         loadable.startTimeUs,
-        loadable.endTimeUs,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.bytesLoaded());
+        loadable.endTimeUs);
     if (!prepared) {
       continueLoading(lastSeekPositionUs);
     } else {
@@ -726,19 +728,20 @@
   public void onLoadCanceled(
       Chunk loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released) {
     eventDispatcher.loadCanceled(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            loadable.bytesLoaded()),
         loadable.type,
         trackType,
         loadable.trackFormat,
         loadable.trackSelectionReason,
         loadable.trackSelectionData,
         loadable.startTimeUs,
-        loadable.endTimeUs,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.bytesLoaded());
+        loadable.endTimeUs);
     if (!released) {
       resetSampleQueues();
       if (enabledTrackGroupCount > 0) {
@@ -786,9 +789,13 @@
     }
 
     eventDispatcher.loadError(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            bytesLoaded),
         loadable.type,
         trackType,
         loadable.trackFormat,
@@ -796,9 +803,6 @@
         loadable.trackSelectionData,
         loadable.startTimeUs,
         loadable.endTimeUs,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        bytesLoaded,
         error,
         /* wasCanceled= */ !loadErrorAction.isRetry());
 
@@ -907,7 +911,12 @@
 
     boolean isAudioVideo = type == C.TRACK_TYPE_AUDIO || type == C.TRACK_TYPE_VIDEO;
     HlsSampleQueue sampleQueue =
-        new HlsSampleQueue(allocator, drmSessionManager, eventDispatcher, overridingDrmInitData);
+        new HlsSampleQueue(
+            allocator,
+            /* playbackLooper= */ handler.getLooper(),
+            drmSessionManager,
+            eventDispatcher,
+            overridingDrmInitData);
     if (isAudioVideo) {
       sampleQueue.setDrmInitData(drmInitData);
     }
@@ -1377,10 +1386,11 @@
 
     private HlsSampleQueue(
         Allocator allocator,
+        Looper playbackLooper,
         DrmSessionManager drmSessionManager,
         MediaSourceEventDispatcher eventDispatcher,
         Map<String, DrmInitData> overridingDrmInitData) {
-      super(allocator, drmSessionManager, eventDispatcher);
+      super(allocator, playbackLooper, drmSessionManager, eventDispatcher);
       this.overridingDrmInitData = overridingDrmInitData;
     }
 
@@ -1521,7 +1531,8 @@
     }
 
     @Override
-    public int sampleData(DataReader input, int length, boolean allowEndOfInput)
+    public int sampleData(
+        DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart)
         throws IOException {
       ensureBufferCapacity(bufferPosition + length);
       int numBytesRead = input.read(buffer, bufferPosition, length);
@@ -1537,7 +1548,8 @@
     }
 
     @Override
-    public void sampleData(ParsableByteArray buffer, int length) {
+    public void sampleData(
+        ParsableByteArray buffer, int length, @SampleDataPart int sampleDataPart) {
       ensureBufferCapacity(bufferPosition + length);
       buffer.readBytes(this.buffer, bufferPosition, length);
       bufferPosition += length;
diff --git a/tree/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java b/tree/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java
index d172aa2..cd8651d 100644
--- a/tree/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java
+++ b/tree/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java
@@ -17,7 +17,6 @@
 
 import android.net.Uri;
 import com.google.android.exoplayer2.C;
-import com.google.android.exoplayer2.offline.DownloaderConstructorHelper;
 import com.google.android.exoplayer2.offline.SegmentDownloader;
 import com.google.android.exoplayer2.offline.StreamKey;
 import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist;
@@ -27,11 +26,13 @@
 import com.google.android.exoplayer2.upstream.DataSource;
 import com.google.android.exoplayer2.upstream.DataSpec;
 import com.google.android.exoplayer2.upstream.ParsingLoadable;
+import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
 import com.google.android.exoplayer2.util.UriUtil;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
+import java.util.concurrent.Executor;
 
 /**
  * A downloader for HLS streams.
@@ -40,20 +41,20 @@
  *
  * <pre>{@code
  * SimpleCache cache = new SimpleCache(downloadFolder, new NoOpCacheEvictor(), databaseProvider);
- * DefaultHttpDataSourceFactory factory = new DefaultHttpDataSourceFactory("ExoPlayer", null);
- * DownloaderConstructorHelper constructorHelper =
- *     new DownloaderConstructorHelper(cache, factory);
+ * CacheDataSource.Factory cacheDataSourceFactory =
+ *     new CacheDataSource.Factory()
+ *         .setCache(cache)
+ *         .setUpstreamDataSourceFactory(new DefaultHttpDataSourceFactory(userAgent));
  * // Create a downloader for the first variant in a master playlist.
  * HlsDownloader hlsDownloader =
  *     new HlsDownloader(
  *         playlistUri,
- *         Collections.singletonList(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, 0)),
- *         constructorHelper);
+ *         Collections.singletonList(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, 0));
  * // Perform the download.
  * hlsDownloader.download(progressListener);
- * // Access downloaded data using CacheDataSource
- * CacheDataSource cacheDataSource =
- *     new CacheDataSource(cache, factory.createDataSource(), CacheDataSource.FLAG_BLOCK_ON_CACHE);
+ * // Use the downloaded data for playback.
+ * HlsMediaSource mediaSource =
+ *     new HlsMediaSource.Factory(cacheDataSourceFactory).createMediaSource(mediaItem);
  * }</pre>
  */
 public final class HlsDownloader extends SegmentDownloader<HlsPlaylist> {
@@ -62,11 +63,30 @@
    * @param playlistUri The {@link Uri} of the playlist to be downloaded.
    * @param streamKeys Keys defining which renditions in the playlist should be selected for
    *     download. If empty, all renditions are downloaded.
-   * @param constructorHelper A {@link DownloaderConstructorHelper} instance.
+   * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the
+   *     download will be written.
    */
   public HlsDownloader(
-      Uri playlistUri, List<StreamKey> streamKeys, DownloaderConstructorHelper constructorHelper) {
-    super(playlistUri, streamKeys, constructorHelper);
+      Uri playlistUri, List<StreamKey> streamKeys, CacheDataSource.Factory cacheDataSourceFactory) {
+    this(playlistUri, streamKeys, cacheDataSourceFactory, Runnable::run);
+  }
+
+  /**
+   * @param playlistUri The {@link Uri} of the playlist to be downloaded.
+   * @param streamKeys Keys defining which renditions in the playlist should be selected for
+   *     download. If empty, all renditions are downloaded.
+   * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the
+   *     download will be written.
+   * @param executor An {@link Executor} used to make requests for the media being downloaded.
+   *     Providing an {@link Executor} that uses multiple threads will speed up the download by
+   *     allowing parts of it to be executed in parallel.
+   */
+  public HlsDownloader(
+      Uri playlistUri,
+      List<StreamKey> streamKeys,
+      CacheDataSource.Factory cacheDataSourceFactory,
+      Executor executor) {
+    super(playlistUri, streamKeys, cacheDataSourceFactory, executor);
   }
 
   @Override
diff --git a/tree/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java b/tree/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java
index e624027..9178c8a 100644
--- a/tree/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java
+++ b/tree/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java
@@ -21,6 +21,7 @@
 import androidx.annotation.Nullable;
 import com.google.android.exoplayer2.C;
 import com.google.android.exoplayer2.ParserException;
+import com.google.android.exoplayer2.source.LoadEventInfo;
 import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher;
 import com.google.android.exoplayer2.source.hls.HlsDataSourceFactory;
 import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant;
@@ -135,9 +136,8 @@
             this,
             loadErrorHandlingPolicy.getMinimumLoadableRetryCount(masterPlaylistLoadable.type));
     eventDispatcher.loadStarted(
-        masterPlaylistLoadable.dataSpec,
-        masterPlaylistLoadable.type,
-        elapsedRealtime);
+        new LoadEventInfo(masterPlaylistLoadable.dataSpec, elapsedRealtime),
+        masterPlaylistLoadable.type);
   }
 
   @Override
@@ -242,13 +242,14 @@
       primaryBundle.loadPlaylist();
     }
     eventDispatcher.loadCompleted(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
-        C.DATA_TYPE_MANIFEST,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.bytesLoaded());
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            loadable.bytesLoaded()),
+        C.DATA_TYPE_MANIFEST);
   }
 
   @Override
@@ -258,13 +259,14 @@
       long loadDurationMs,
       boolean released) {
     eventDispatcher.loadCanceled(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
-        C.DATA_TYPE_MANIFEST,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.bytesLoaded());
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            loadable.bytesLoaded()),
+        C.DATA_TYPE_MANIFEST);
   }
 
   @Override
@@ -279,13 +281,14 @@
             loadable.type, loadDurationMs, error, errorCount);
     boolean isFatal = retryDelayMs == C.TIME_UNSET;
     eventDispatcher.loadError(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            loadable.bytesLoaded()),
         C.DATA_TYPE_MANIFEST,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.bytesLoaded(),
         error,
         isFatal);
     return isFatal
@@ -521,13 +524,14 @@
       if (result instanceof HlsMediaPlaylist) {
         processLoadedPlaylist((HlsMediaPlaylist) result, loadDurationMs);
         eventDispatcher.loadCompleted(
-            loadable.dataSpec,
-            loadable.getUri(),
-            loadable.getResponseHeaders(),
-            C.DATA_TYPE_MANIFEST,
-            elapsedRealtimeMs,
-            loadDurationMs,
-            loadable.bytesLoaded());
+            new LoadEventInfo(
+                loadable.dataSpec,
+                loadable.getUri(),
+                loadable.getResponseHeaders(),
+                elapsedRealtimeMs,
+                loadDurationMs,
+                loadable.bytesLoaded()),
+            C.DATA_TYPE_MANIFEST);
       } else {
         playlistError = new ParserException("Loaded playlist has unexpected type.");
       }
@@ -540,13 +544,14 @@
         long loadDurationMs,
         boolean released) {
       eventDispatcher.loadCanceled(
-          loadable.dataSpec,
-          loadable.getUri(),
-          loadable.getResponseHeaders(),
-          C.DATA_TYPE_MANIFEST,
-          elapsedRealtimeMs,
-          loadDurationMs,
-          loadable.bytesLoaded());
+          new LoadEventInfo(
+              loadable.dataSpec,
+              loadable.getUri(),
+              loadable.getResponseHeaders(),
+              elapsedRealtimeMs,
+              loadDurationMs,
+              loadable.bytesLoaded()),
+          C.DATA_TYPE_MANIFEST);
     }
 
     @Override
@@ -582,13 +587,14 @@
       }
 
       eventDispatcher.loadError(
-          loadable.dataSpec,
-          loadable.getUri(),
-          loadable.getResponseHeaders(),
+          new LoadEventInfo(
+              loadable.dataSpec,
+              loadable.getUri(),
+              loadable.getResponseHeaders(),
+              elapsedRealtimeMs,
+              loadDurationMs,
+              loadable.bytesLoaded()),
           C.DATA_TYPE_MANIFEST,
-          elapsedRealtimeMs,
-          loadDurationMs,
-          loadable.bytesLoaded(),
           error,
           /* wasCanceled= */ !loadErrorAction.isRetry());
 
@@ -612,9 +618,8 @@
               this,
               loadErrorHandlingPolicy.getMinimumLoadableRetryCount(mediaPlaylistLoadable.type));
       eventDispatcher.loadStarted(
-          mediaPlaylistLoadable.dataSpec,
-          mediaPlaylistLoadable.type,
-          elapsedRealtime);
+          new LoadEventInfo(mediaPlaylistLoadable.dataSpec, elapsedRealtime),
+          mediaPlaylistLoadable.type);
     }
 
     private void processLoadedPlaylist(HlsMediaPlaylist loadedPlaylist, long loadDurationMs) {
diff --git a/tree/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java b/tree/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java
index a8473b8..0dcae17 100644
--- a/tree/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java
+++ b/tree/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java
@@ -40,15 +40,15 @@
 import com.google.android.exoplayer2.offline.DefaultDownloaderFactory;
 import com.google.android.exoplayer2.offline.DownloadRequest;
 import com.google.android.exoplayer2.offline.Downloader;
-import com.google.android.exoplayer2.offline.DownloaderConstructorHelper;
 import com.google.android.exoplayer2.offline.DownloaderFactory;
 import com.google.android.exoplayer2.offline.StreamKey;
 import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist;
 import com.google.android.exoplayer2.testutil.CacheAsserts.RequestSet;
 import com.google.android.exoplayer2.testutil.FakeDataSet;
-import com.google.android.exoplayer2.testutil.FakeDataSource.Factory;
+import com.google.android.exoplayer2.testutil.FakeDataSource;
 import com.google.android.exoplayer2.upstream.DummyDataSource;
 import com.google.android.exoplayer2.upstream.cache.Cache;
+import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
 import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor;
 import com.google.android.exoplayer2.upstream.cache.SimpleCache;
 import com.google.android.exoplayer2.util.Util;
@@ -97,9 +97,11 @@
 
   @Test
   public void createWithDefaultDownloaderFactory() {
-    DownloaderConstructorHelper constructorHelper =
-        new DownloaderConstructorHelper(Mockito.mock(Cache.class), DummyDataSource.FACTORY);
-    DownloaderFactory factory = new DefaultDownloaderFactory(constructorHelper);
+    CacheDataSource.Factory cacheDataSourceFactory =
+        new CacheDataSource.Factory()
+            .setCache(Mockito.mock(Cache.class))
+            .setUpstreamDataSourceFactory(DummyDataSource.FACTORY);
+    DownloaderFactory factory = new DefaultDownloaderFactory(cacheDataSourceFactory);
 
     Downloader downloader =
         factory.createDownloader(
@@ -213,9 +215,11 @@
   }
 
   private HlsDownloader getHlsDownloader(String mediaPlaylistUri, List<StreamKey> keys) {
-    Factory factory = new Factory().setFakeDataSet(fakeDataSet);
-    return new HlsDownloader(
-        Uri.parse(mediaPlaylistUri), keys, new DownloaderConstructorHelper(cache, factory));
+    CacheDataSource.Factory cacheDataSourceFactory =
+        new CacheDataSource.Factory()
+            .setCache(cache)
+            .setUpstreamDataSourceFactory(new FakeDataSource.Factory().setFakeDataSet(fakeDataSet));
+    return new HlsDownloader(Uri.parse(mediaPlaylistUri), keys, cacheDataSourceFactory);
   }
 
   private static ArrayList<StreamKey> getKeys(int... variantIndices) {
diff --git a/tree/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java b/tree/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java
index ac3085c..418fb17 100644
--- a/tree/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java
+++ b/tree/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java
@@ -30,6 +30,7 @@
 import com.google.android.exoplayer2.source.BaseMediaSource;
 import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory;
 import com.google.android.exoplayer2.source.DefaultCompositeSequenceableLoaderFactory;
+import com.google.android.exoplayer2.source.LoadEventInfo;
 import com.google.android.exoplayer2.source.MediaPeriod;
 import com.google.android.exoplayer2.source.MediaSource;
 import com.google.android.exoplayer2.source.MediaSourceEventListener;
@@ -621,13 +622,14 @@
   public void onLoadCompleted(
       ParsingLoadable<SsManifest> loadable, long elapsedRealtimeMs, long loadDurationMs) {
     manifestEventDispatcher.loadCompleted(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
-        loadable.type,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.bytesLoaded());
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            loadable.bytesLoaded()),
+        loadable.type);
     manifest = loadable.getResult();
     manifestLoadStartTimestamp = elapsedRealtimeMs - loadDurationMs;
     processManifest();
@@ -641,13 +643,14 @@
       long loadDurationMs,
       boolean released) {
     manifestEventDispatcher.loadCanceled(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
-        loadable.type,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.bytesLoaded());
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            loadable.bytesLoaded()),
+        loadable.type);
   }
 
   @Override
@@ -665,13 +668,14 @@
             ? Loader.DONT_RETRY_FATAL
             : Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs);
     manifestEventDispatcher.loadError(
-        loadable.dataSpec,
-        loadable.getUri(),
-        loadable.getResponseHeaders(),
+        new LoadEventInfo(
+            loadable.dataSpec,
+            loadable.getUri(),
+            loadable.getResponseHeaders(),
+            elapsedRealtimeMs,
+            loadDurationMs,
+            loadable.bytesLoaded()),
         loadable.type,
-        elapsedRealtimeMs,
-        loadDurationMs,
-        loadable.bytesLoaded(),
         error,
         !loadErrorAction.isRetry());
     return loadErrorAction;
@@ -767,7 +771,8 @@
     long elapsedRealtimeMs =
         manifestLoader.startLoading(
             loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(loadable.type));
-    manifestEventDispatcher.loadStarted(loadable.dataSpec, loadable.type, elapsedRealtimeMs);
+    manifestEventDispatcher.loadStarted(
+        new LoadEventInfo(loadable.dataSpec, elapsedRealtimeMs), loadable.type);
   }
 
 }
diff --git a/tree/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java b/tree/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java
index 1331fe4..dc141a2 100644
--- a/tree/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java
+++ b/tree/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java
@@ -17,7 +17,6 @@
 
 import android.net.Uri;
 import com.google.android.exoplayer2.C;
-import com.google.android.exoplayer2.offline.DownloaderConstructorHelper;
 import com.google.android.exoplayer2.offline.SegmentDownloader;
 import com.google.android.exoplayer2.offline.StreamKey;
 import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest;
@@ -27,9 +26,11 @@
 import com.google.android.exoplayer2.upstream.DataSource;
 import com.google.android.exoplayer2.upstream.DataSpec;
 import com.google.android.exoplayer2.upstream.ParsingLoadable;
+import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.Executor;
 
 /**
  * A downloader for SmoothStreaming streams.
@@ -38,20 +39,21 @@
  *
  * <pre>{@code
  * SimpleCache cache = new SimpleCache(downloadFolder, new NoOpCacheEvictor(), databaseProvider);
- * DefaultHttpDataSourceFactory factory = new DefaultHttpDataSourceFactory("ExoPlayer", null);
- * DownloaderConstructorHelper constructorHelper =
- *     new DownloaderConstructorHelper(cache, factory);
+ * CacheDataSource.Factory cacheDataSourceFactory =
+ *     new CacheDataSource.Factory()
+ *         .setCache(cache)
+ *         .setUpstreamDataSourceFactory(new DefaultHttpDataSourceFactory(userAgent));
  * // Create a downloader for the first track of the first stream element.
  * SsDownloader ssDownloader =
  *     new SsDownloader(
  *         manifestUrl,
  *         Collections.singletonList(new StreamKey(0, 0)),
- *         constructorHelper);
+ *         cacheDataSourceFactory);
  * // Perform the download.
  * ssDownloader.download(progressListener);
- * // Access downloaded data using CacheDataSource
- * CacheDataSource cacheDataSource =
- *     new CacheDataSource(cache, factory.createDataSource(), CacheDataSource.FLAG_BLOCK_ON_CACHE);
+ * // Use the downloaded data for playback.
+ * SsMediaSource mediaSource =
+ *     new SsMediaSource.Factory(cacheDataSourceFactory).createMediaSource(mediaItem);
  * }</pre>
  */
 public final class SsDownloader extends SegmentDownloader<SsManifest> {
@@ -60,11 +62,30 @@
    * @param manifestUri The {@link Uri} of the manifest to be downloaded.
    * @param streamKeys Keys defining which streams in the manifest should be selected for download.
    *     If empty, all streams are downloaded.
-   * @param constructorHelper A {@link DownloaderConstructorHelper} instance.
+   * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the
+   *     download will be written.
    */
   public SsDownloader(
-      Uri manifestUri, List<StreamKey> streamKeys, DownloaderConstructorHelper constructorHelper) {
-    super(SsUtil.fixManifestUri(manifestUri), streamKeys, constructorHelper);
+      Uri manifestUri, List<StreamKey> streamKeys, CacheDataSource.Factory cacheDataSourceFactory) {
+    this(manifestUri, streamKeys, cacheDataSourceFactory, Runnable::run);
+  }
+
+  /**
+   * @param manifestUri The {@link Uri} of the manifest to be downloaded.
+   * @param streamKeys Keys defining which streams in the manifest should be selected for download.
+   *     If empty, all streams are downloaded.
+   * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the
+   *     download will be written.
+   * @param executor An {@link Executor} used to make requests for the media being downloaded.
+   *     Providing an {@link Executor} that uses multiple threads will speed up the download by
+   *     allowing parts of it to be executed in parallel.
+   */
+  public SsDownloader(
+      Uri manifestUri,
+      List<StreamKey> streamKeys,
+      CacheDataSource.Factory cacheDataSourceFactory,
+      Executor executor) {
+    super(SsUtil.fixManifestUri(manifestUri), streamKeys, cacheDataSourceFactory, executor);
   }
 
   @Override
diff --git a/tree/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java b/tree/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java
index 5560a72..1bbe0b1 100644
--- a/tree/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java
+++ b/tree/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java
@@ -22,11 +22,11 @@
 import com.google.android.exoplayer2.offline.DefaultDownloaderFactory;
 import com.google.android.exoplayer2.offline.DownloadRequest;
 import com.google.android.exoplayer2.offline.Downloader;
-import com.google.android.exoplayer2.offline.DownloaderConstructorHelper;
 import com.google.android.exoplayer2.offline.DownloaderFactory;
 import com.google.android.exoplayer2.offline.StreamKey;
 import com.google.android.exoplayer2.upstream.DummyDataSource;
 import com.google.android.exoplayer2.upstream.cache.Cache;
+import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
 import java.util.Collections;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -38,9 +38,11 @@
 
   @Test
   public void createWithDefaultDownloaderFactory() throws Exception {
-    DownloaderConstructorHelper constructorHelper =
-        new DownloaderConstructorHelper(Mockito.mock(Cache.class), DummyDataSource.FACTORY);
-    DownloaderFactory factory = new DefaultDownloaderFactory(constructorHelper);
+    CacheDataSource.Factory cacheDataSourceFactory =
+        new CacheDataSource.Factory()
+            .setCache(Mockito.mock(Cache.class))
+            .setUpstreamDataSourceFactory(DummyDataSource.FACTORY);
+    DownloaderFactory factory = new DefaultDownloaderFactory(cacheDataSourceFactory);
 
     Downloader downloader =
         factory.createDownloader(
diff --git a/tree/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java b/tree/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java
index 841d1b6..f0093a2 100644
--- a/tree/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java
+++ b/tree/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java
@@ -65,7 +65,8 @@
   private final float spacingAdd;
 
   private final TextPaint textPaint;
-  private final Paint paint;
+  private final Paint windowPaint;
+  private final Paint bitmapPaint;
 
   // Previous input variables.
   @Nullable private CharSequence cueText;
@@ -124,9 +125,13 @@
     textPaint.setAntiAlias(true);
     textPaint.setSubpixelText(true);
 
-    paint = new Paint();
-    paint.setAntiAlias(true);
-    paint.setStyle(Style.FILL);
+    windowPaint = new Paint();
+    windowPaint.setAntiAlias(true);
+    windowPaint.setStyle(Style.FILL);
+
+    bitmapPaint = new Paint();
+    bitmapPaint.setAntiAlias(true);
+    bitmapPaint.setFilterBitmap(true);
   }
 
   /**
@@ -442,9 +447,13 @@
     canvas.translate(textLeft, textTop);
 
     if (Color.alpha(windowColor) > 0) {
-      paint.setColor(windowColor);
+      windowPaint.setColor(windowColor);
       canvas.drawRect(
-          -textPaddingX, 0, textLayout.getWidth() + textPaddingX, textLayout.getHeight(), paint);
+          -textPaddingX,
+          0,
+          textLayout.getWidth() + textPaddingX,
+          textLayout.getHeight(),
+          windowPaint);
     }
 
     if (edgeType == CaptionStyleCompat.EDGE_TYPE_OUTLINE) {
@@ -478,7 +487,7 @@
 
   @RequiresNonNull({"cueBitmap", "bitmapRect"})
   private void drawBitmapLayout(Canvas canvas) {
-    canvas.drawBitmap(cueBitmap, /* src= */ null, bitmapRect, /* paint= */ null);
+    canvas.drawBitmap(cueBitmap, /* src= */ null, bitmapRect, bitmapPaint);
   }
 
   /**
diff --git a/tree/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java b/tree/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java
index c2e14ab..5e92dfd 100644
--- a/tree/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java
+++ b/tree/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java
@@ -20,7 +20,6 @@
 import android.net.Uri;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.rule.ActivityTestRule;
-import com.google.android.exoplayer2.offline.DownloaderConstructorHelper;
 import com.google.android.exoplayer2.offline.StreamKey;
 import com.google.android.exoplayer2.source.dash.DashUtil;
 import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet;
@@ -28,10 +27,9 @@
 import com.google.android.exoplayer2.source.dash.manifest.Representation;
 import com.google.android.exoplayer2.source.dash.offline.DashDownloader;
 import com.google.android.exoplayer2.testutil.HostActivity;
+import com.google.android.exoplayer2.upstream.DataSource;
 import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
-import com.google.android.exoplayer2.upstream.DummyDataSource;
 import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
-import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory;
 import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor;
 import com.google.android.exoplayer2.upstream.cache.SimpleCache;
 import com.google.android.exoplayer2.util.Util;
@@ -57,8 +55,8 @@
   private DashTestRunner testRunner;
   private File tempFolder;
   private SimpleCache cache;
-  private DefaultHttpDataSourceFactory httpDataSourceFactory;
-  private CacheDataSourceFactory offlineDataSourceFactory;
+  private DataSource.Factory httpDataSourceFactory;
+  private DataSource.Factory offlineDataSourceFactory;
 
   @Before
   public void setUp() throws Exception {
@@ -72,9 +70,7 @@
     tempFolder = Util.createTempDirectory(testRule.getActivity(), "ExoPlayerTest");
     cache = new SimpleCache(tempFolder, new NoOpCacheEvictor());
     httpDataSourceFactory = new DefaultHttpDataSourceFactory("ExoPlayer", null);
-    offlineDataSourceFactory =
-        new CacheDataSourceFactory(
-            cache, DummyDataSource.FACTORY, CacheDataSource.FLAG_BLOCK_ON_CACHE);
+    offlineDataSourceFactory = new CacheDataSource.Factory().setCache(cache);
   }
 
   @After
@@ -120,9 +116,11 @@
         }
       }
     }
-    DownloaderConstructorHelper constructorHelper =
-        new DownloaderConstructorHelper(cache, httpDataSourceFactory);
-    return new DashDownloader(MANIFEST_URI, keys, constructorHelper);
+    CacheDataSource.Factory cacheDataSourceFactory =
+        new CacheDataSource.Factory()
+            .setCache(cache)
+            .setUpstreamDataSourceFactory(httpDataSourceFactory);
+    return new DashDownloader(MANIFEST_URI, keys, cacheDataSourceFactory);
   }
 
 }
diff --git a/tree/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java b/tree/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java
index befdc49..40ec1ed 100644
--- a/tree/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java
+++ b/tree/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java
@@ -17,7 +17,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static org.junit.Assert.fail;
+import static org.junit.Assert.assertThrows;
 
 import android.media.MediaDrm.MediaDrmStateException;
 import android.net.Uri;
@@ -111,29 +111,47 @@
 
   @Test
   public void widevineOfflineReleasedLicenseV22() throws Throwable {
-    if (Util.SDK_INT < 22) {
+    if (Util.SDK_INT < 22 || Util.SDK_INT > 28) {
       return; // Pass.
     }
     downloadLicense();
     releaseLicense(); // keySetId no longer valid.
 
-    try {
-      testRunner.run();
-      fail("Playback should fail because the license has been released.");
-    } catch (Throwable e) {
-      // Get the root cause
-      while (true) {
-        Throwable cause = e.getCause();
-        if (cause == null || cause == e) {
-          break;
-        }
-        e = cause;
-      }
-      // It should be a MediaDrmStateException instance
-      if (!(e instanceof MediaDrmStateException)) {
-        throw e;
-      }
+    Throwable error =
+        assertThrows(
+            "Playback should fail because the license has been released.",
+            Throwable.class,
+            () -> testRunner.run());
+
+    // Get the root cause
+    Throwable cause = error.getCause();
+    while (cause != null && cause != error) {
+      error = cause;
+      cause = error.getCause();
     }
+    assertThat(error).isInstanceOf(MediaDrmStateException.class);
+  }
+
+  @Test
+  public void widevineOfflineReleasedLicenseV29() throws Throwable {
+    if (Util.SDK_INT < 29) {
+      return; // Pass.
+    }
+    downloadLicense();
+    releaseLicense(); // keySetId no longer valid.
+
+    Throwable error =
+        assertThrows(
+            "Playback should fail because the license has been released.",
+            Throwable.class,
+            () -> testRunner.run());
+    // Get the root cause
+    Throwable cause = error.getCause();
+    while (cause != null && cause != error) {
+      error = cause;
+      cause = error.getCause();
+    }
+    assertThat(error).isInstanceOf(IllegalArgumentException.class);
   }
 
   @Test
diff --git a/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv b/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv
new file mode 100644
index 0000000..02574ff
--- /dev/null
+++ b/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv
Binary files differ
diff --git a/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv.0.dump b/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv.0.dump
new file mode 100644
index 0000000..258efdc
--- /dev/null
+++ b/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv.0.dump
@@ -0,0 +1,269 @@
+seekMap:
+  isSeekable = true
+  duration = 1071000
+  getPosition(0) = [[timeUs=0, position=994]]
+  getPosition(1) = [[timeUs=0, position=994]]
+  getPosition(535500) = [[timeUs=0, position=994]]
+  getPosition(1071000) = [[timeUs=0, position=994]]
+numberOfTracks = 2
+track 1:
+  total output bytes = 89502
+  sample count = 30
+  format 0:
+    id = 1
+    sampleMimeType = video/avc
+    width = 1080
+    height = 720
+    rotationDegrees = 90
+    selectionFlags = 1
+    language = und
+    initializationData:
+      data = length 30, hash F6F3D010
+      data = length 10, hash 7A0D0F2B
+  sample 0:
+    time = 0
+    flags = 1
+    data = length 36477, hash F0F36CFE
+  sample 1:
+    time = 67000
+    flags = 0
+    data = length 5341, hash 40B85E2
+  sample 2:
+    time = 33000
+    flags = 0
+    data = length 596, hash 357B4D92
+  sample 3:
+    time = 200000
+    flags = 0
+    data = length 7704, hash A39EDA06
+  sample 4:
+    time = 133000
+    flags = 0
+    data = length 989, hash 2813C72D
+  sample 5:
+    time = 100000
+    flags = 0
+    data = length 721, hash C50D1C73
+  sample 6:
+    time = 167000
+    flags = 0
+    data = length 519, hash 65FE1911
+  sample 7:
+    time = 333000
+    flags = 0
+    data = length 6160, hash E1CAC0EC
+  sample 8:
+    time = 267000
+    flags = 0
+    data = length 953, hash 7160C661
+  sample 9:
+    time = 233000
+    flags = 0
+    data = length 620, hash 7A7AE07C
+  sample 10:
+    time = 300000
+    flags = 0
+    data = length 405, hash 5CC7F4E7
+  sample 11:
+    time = 433000
+    flags = 0
+    data = length 4852, hash 9DB6979D
+  sample 12:
+    time = 400000
+    flags = 0
+    data = length 547, hash E31A6979
+  sample 13:
+    time = 367000
+    flags = 0
+    data = length 570, hash FEC40D00
+  sample 14:
+    time = 567000
+    flags = 0
+    data = length 5525, hash 7C478F7E
+  sample 15:
+    time = 500000
+    flags = 0
+    data = length 1082, hash DA07059A
+  sample 16:
+    time = 467000
+    flags = 0
+    data = length 807, hash 93478E6B
+  sample 17:
+    time = 533000
+    flags = 0
+    data = length 744, hash 9A8E6026
+  sample 18:
+    time = 700000
+    flags = 0
+    data = length 4732, hash C73B23C0
+  sample 19:
+    time = 633000
+    flags = 0
+    data = length 1004, hash 8A19A228
+  sample 20:
+    time = 600000
+    flags = 0
+    data = length 794, hash 8126022C
+  sample 21:
+    time = 667000
+    flags = 0
+    data = length 645, hash F08300E5
+  sample 22:
+    time = 833000
+    flags = 0
+    data = length 2684, hash 727FE378
+  sample 23:
+    time = 767000
+    flags = 0
+    data = length 787, hash 419A7821
+  sample 24:
+    time = 733000
+    flags = 0
+    data = length 649, hash 5C159346
+  sample 25:
+    time = 800000
+    flags = 0
+    data = length 509, hash F912D655
+  sample 26:
+    time = 967000
+    flags = 0
+    data = length 1226, hash 29815C21
+  sample 27:
+    time = 900000
+    flags = 0
+    data = length 898, hash D997AD0A
+  sample 28:
+    time = 867000
+    flags = 0
+    data = length 476, hash A0423645
+  sample 29:
+    time = 933000
+    flags = 0
+    data = length 486, hash DDF32CBB
+track 2:
+  total output bytes = 12120
+  sample count = 29
+  format 0:
+    id = 2
+    sampleMimeType = audio/ac3
+    channelCount = 1
+    sampleRate = 44100
+    selectionFlags = 1
+    language = und
+  sample 0:
+    time = 62000
+    flags = 1
+    data = length 416, hash 211F2286
+  sample 1:
+    time = 97000
+    flags = 1
+    data = length 418, hash 77425A86
+  sample 2:
+    time = 131000
+    flags = 1
+    data = length 418, hash A0FE5CA1
+  sample 3:
+    time = 166000
+    flags = 1
+    data = length 418, hash 2309B066
+  sample 4:
+    time = 201000
+    flags = 1
+    data = length 418, hash 928A653B
+  sample 5:
+    time = 236000
+    flags = 1
+    data = length 418, hash 3422F0CB
+  sample 6:
+    time = 270000
+    flags = 1
+    data = length 418, hash EFF43D5B
+  sample 7:
+    time = 306000
+    flags = 1
+    data = length 418, hash FC8093C7
+  sample 8:
+    time = 341000
+    flags = 1
+    data = length 418, hash CCC08A16
+  sample 9:
+    time = 376000
+    flags = 1
+    data = length 418, hash 2A6EE863
+  sample 10:
+    time = 410000
+    flags = 1
+    data = length 418, hash D69A9251
+  sample 11:
+    time = 445000
+    flags = 1
+    data = length 418, hash BCFB758D
+  sample 12:
+    time = 480000
+    flags = 1
+    data = length 418, hash 11B66799
+  sample 13:
+    time = 514000
+    flags = 1
+    data = length 418, hash C824D392
+  sample 14:
+    time = 550000
+    flags = 1
+    data = length 418, hash C167D872
+  sample 15:
+    time = 585000
+    flags = 1
+    data = length 418, hash 4221C855
+  sample 16:
+    time = 620000
+    flags = 1
+    data = length 418, hash 4D4FF934
+  sample 17:
+    time = 654000
+    flags = 1
+    data = length 418, hash 984AA025
+  sample 18:
+    time = 690000
+    flags = 1
+    data = length 418, hash BB788B46
+  sample 19:
+    time = 724000
+    flags = 1
+    data = length 418, hash 9EFBFD97
+  sample 20:
+    time = 759000
+    flags = 1
+    data = length 418, hash DF1A460C
+  sample 21:
+    time = 793000
+    flags = 1
+    data = length 418, hash 2BDB56A
+  sample 22:
+    time = 829000
+    flags = 1
+    data = length 418, hash CA230060
+  sample 23:
+    time = 864000
+    flags = 1
+    data = length 418, hash D2F19F41
+  sample 24:
+    time = 898000
+    flags = 1
+    data = length 418, hash AF392D79
+  sample 25:
+    time = 932000
+    flags = 1
+    data = length 418, hash C5D7F2A3
+  sample 26:
+    time = 968000
+    flags = 1
+    data = length 418, hash 733A35AE
+  sample 27:
+    time = 1002000
+    flags = 1
+    data = length 418, hash DE46E5D3
+  sample 28:
+    time = 1037000
+    flags = 1
+    data = length 418, hash 56AB8D37
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv.1.dump b/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv.1.dump
new file mode 100644
index 0000000..258efdc
--- /dev/null
+++ b/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv.1.dump
@@ -0,0 +1,269 @@
+seekMap:
+  isSeekable = true
+  duration = 1071000
+  getPosition(0) = [[timeUs=0, position=994]]
+  getPosition(1) = [[timeUs=0, position=994]]
+  getPosition(535500) = [[timeUs=0, position=994]]
+  getPosition(1071000) = [[timeUs=0, position=994]]
+numberOfTracks = 2
+track 1:
+  total output bytes = 89502
+  sample count = 30
+  format 0:
+    id = 1
+    sampleMimeType = video/avc
+    width = 1080
+    height = 720
+    rotationDegrees = 90
+    selectionFlags = 1
+    language = und
+    initializationData:
+      data = length 30, hash F6F3D010
+      data = length 10, hash 7A0D0F2B
+  sample 0:
+    time = 0
+    flags = 1
+    data = length 36477, hash F0F36CFE
+  sample 1:
+    time = 67000
+    flags = 0
+    data = length 5341, hash 40B85E2
+  sample 2:
+    time = 33000
+    flags = 0
+    data = length 596, hash 357B4D92
+  sample 3:
+    time = 200000
+    flags = 0
+    data = length 7704, hash A39EDA06
+  sample 4:
+    time = 133000
+    flags = 0
+    data = length 989, hash 2813C72D
+  sample 5:
+    time = 100000
+    flags = 0
+    data = length 721, hash C50D1C73
+  sample 6:
+    time = 167000
+    flags = 0
+    data = length 519, hash 65FE1911
+  sample 7:
+    time = 333000
+    flags = 0
+    data = length 6160, hash E1CAC0EC
+  sample 8:
+    time = 267000
+    flags = 0
+    data = length 953, hash 7160C661
+  sample 9:
+    time = 233000
+    flags = 0
+    data = length 620, hash 7A7AE07C
+  sample 10:
+    time = 300000
+    flags = 0
+    data = length 405, hash 5CC7F4E7
+  sample 11:
+    time = 433000
+    flags = 0
+    data = length 4852, hash 9DB6979D
+  sample 12:
+    time = 400000
+    flags = 0
+    data = length 547, hash E31A6979
+  sample 13:
+    time = 367000
+    flags = 0
+    data = length 570, hash FEC40D00
+  sample 14:
+    time = 567000
+    flags = 0
+    data = length 5525, hash 7C478F7E
+  sample 15:
+    time = 500000
+    flags = 0
+    data = length 1082, hash DA07059A
+  sample 16:
+    time = 467000
+    flags = 0
+    data = length 807, hash 93478E6B
+  sample 17:
+    time = 533000
+    flags = 0
+    data = length 744, hash 9A8E6026
+  sample 18:
+    time = 700000
+    flags = 0
+    data = length 4732, hash C73B23C0
+  sample 19:
+    time = 633000
+    flags = 0
+    data = length 1004, hash 8A19A228
+  sample 20:
+    time = 600000
+    flags = 0
+    data = length 794, hash 8126022C
+  sample 21:
+    time = 667000
+    flags = 0
+    data = length 645, hash F08300E5
+  sample 22:
+    time = 833000
+    flags = 0
+    data = length 2684, hash 727FE378
+  sample 23:
+    time = 767000
+    flags = 0
+    data = length 787, hash 419A7821
+  sample 24:
+    time = 733000
+    flags = 0
+    data = length 649, hash 5C159346
+  sample 25:
+    time = 800000
+    flags = 0
+    data = length 509, hash F912D655
+  sample 26:
+    time = 967000
+    flags = 0
+    data = length 1226, hash 29815C21
+  sample 27:
+    time = 900000
+    flags = 0
+    data = length 898, hash D997AD0A
+  sample 28:
+    time = 867000
+    flags = 0
+    data = length 476, hash A0423645
+  sample 29:
+    time = 933000
+    flags = 0
+    data = length 486, hash DDF32CBB
+track 2:
+  total output bytes = 12120
+  sample count = 29
+  format 0:
+    id = 2
+    sampleMimeType = audio/ac3
+    channelCount = 1
+    sampleRate = 44100
+    selectionFlags = 1
+    language = und
+  sample 0:
+    time = 62000
+    flags = 1
+    data = length 416, hash 211F2286
+  sample 1:
+    time = 97000
+    flags = 1
+    data = length 418, hash 77425A86
+  sample 2:
+    time = 131000
+    flags = 1
+    data = length 418, hash A0FE5CA1
+  sample 3:
+    time = 166000
+    flags = 1
+    data = length 418, hash 2309B066
+  sample 4:
+    time = 201000
+    flags = 1
+    data = length 418, hash 928A653B
+  sample 5:
+    time = 236000
+    flags = 1
+    data = length 418, hash 3422F0CB
+  sample 6:
+    time = 270000
+    flags = 1
+    data = length 418, hash EFF43D5B
+  sample 7:
+    time = 306000
+    flags = 1
+    data = length 418, hash FC8093C7
+  sample 8:
+    time = 341000
+    flags = 1
+    data = length 418, hash CCC08A16
+  sample 9:
+    time = 376000
+    flags = 1
+    data = length 418, hash 2A6EE863
+  sample 10:
+    time = 410000
+    flags = 1
+    data = length 418, hash D69A9251
+  sample 11:
+    time = 445000
+    flags = 1
+    data = length 418, hash BCFB758D
+  sample 12:
+    time = 480000
+    flags = 1
+    data = length 418, hash 11B66799
+  sample 13:
+    time = 514000
+    flags = 1
+    data = length 418, hash C824D392
+  sample 14:
+    time = 550000
+    flags = 1
+    data = length 418, hash C167D872
+  sample 15:
+    time = 585000
+    flags = 1
+    data = length 418, hash 4221C855
+  sample 16:
+    time = 620000
+    flags = 1
+    data = length 418, hash 4D4FF934
+  sample 17:
+    time = 654000
+    flags = 1
+    data = length 418, hash 984AA025
+  sample 18:
+    time = 690000
+    flags = 1
+    data = length 418, hash BB788B46
+  sample 19:
+    time = 724000
+    flags = 1
+    data = length 418, hash 9EFBFD97
+  sample 20:
+    time = 759000
+    flags = 1
+    data = length 418, hash DF1A460C
+  sample 21:
+    time = 793000
+    flags = 1
+    data = length 418, hash 2BDB56A
+  sample 22:
+    time = 829000
+    flags = 1
+    data = length 418, hash CA230060
+  sample 23:
+    time = 864000
+    flags = 1
+    data = length 418, hash D2F19F41
+  sample 24:
+    time = 898000
+    flags = 1
+    data = length 418, hash AF392D79
+  sample 25:
+    time = 932000
+    flags = 1
+    data = length 418, hash C5D7F2A3
+  sample 26:
+    time = 968000
+    flags = 1
+    data = length 418, hash 733A35AE
+  sample 27:
+    time = 1002000
+    flags = 1
+    data = length 418, hash DE46E5D3
+  sample 28:
+    time = 1037000
+    flags = 1
+    data = length 418, hash 56AB8D37
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv.2.dump b/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv.2.dump
new file mode 100644
index 0000000..258efdc
--- /dev/null
+++ b/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv.2.dump
@@ -0,0 +1,269 @@
+seekMap:
+  isSeekable = true
+  duration = 1071000
+  getPosition(0) = [[timeUs=0, position=994]]
+  getPosition(1) = [[timeUs=0, position=994]]
+  getPosition(535500) = [[timeUs=0, position=994]]
+  getPosition(1071000) = [[timeUs=0, position=994]]
+numberOfTracks = 2
+track 1:
+  total output bytes = 89502
+  sample count = 30
+  format 0:
+    id = 1
+    sampleMimeType = video/avc
+    width = 1080
+    height = 720
+    rotationDegrees = 90
+    selectionFlags = 1
+    language = und
+    initializationData:
+      data = length 30, hash F6F3D010
+      data = length 10, hash 7A0D0F2B
+  sample 0:
+    time = 0
+    flags = 1
+    data = length 36477, hash F0F36CFE
+  sample 1:
+    time = 67000
+    flags = 0
+    data = length 5341, hash 40B85E2
+  sample 2:
+    time = 33000
+    flags = 0
+    data = length 596, hash 357B4D92
+  sample 3:
+    time = 200000
+    flags = 0
+    data = length 7704, hash A39EDA06
+  sample 4:
+    time = 133000
+    flags = 0
+    data = length 989, hash 2813C72D
+  sample 5:
+    time = 100000
+    flags = 0
+    data = length 721, hash C50D1C73
+  sample 6:
+    time = 167000
+    flags = 0
+    data = length 519, hash 65FE1911
+  sample 7:
+    time = 333000
+    flags = 0
+    data = length 6160, hash E1CAC0EC
+  sample 8:
+    time = 267000
+    flags = 0
+    data = length 953, hash 7160C661
+  sample 9:
+    time = 233000
+    flags = 0
+    data = length 620, hash 7A7AE07C
+  sample 10:
+    time = 300000
+    flags = 0
+    data = length 405, hash 5CC7F4E7
+  sample 11:
+    time = 433000
+    flags = 0
+    data = length 4852, hash 9DB6979D
+  sample 12:
+    time = 400000
+    flags = 0
+    data = length 547, hash E31A6979
+  sample 13:
+    time = 367000
+    flags = 0
+    data = length 570, hash FEC40D00
+  sample 14:
+    time = 567000
+    flags = 0
+    data = length 5525, hash 7C478F7E
+  sample 15:
+    time = 500000
+    flags = 0
+    data = length 1082, hash DA07059A
+  sample 16:
+    time = 467000
+    flags = 0
+    data = length 807, hash 93478E6B
+  sample 17:
+    time = 533000
+    flags = 0
+    data = length 744, hash 9A8E6026
+  sample 18:
+    time = 700000
+    flags = 0
+    data = length 4732, hash C73B23C0
+  sample 19:
+    time = 633000
+    flags = 0
+    data = length 1004, hash 8A19A228
+  sample 20:
+    time = 600000
+    flags = 0
+    data = length 794, hash 8126022C
+  sample 21:
+    time = 667000
+    flags = 0
+    data = length 645, hash F08300E5
+  sample 22:
+    time = 833000
+    flags = 0
+    data = length 2684, hash 727FE378
+  sample 23:
+    time = 767000
+    flags = 0
+    data = length 787, hash 419A7821
+  sample 24:
+    time = 733000
+    flags = 0
+    data = length 649, hash 5C159346
+  sample 25:
+    time = 800000
+    flags = 0
+    data = length 509, hash F912D655
+  sample 26:
+    time = 967000
+    flags = 0
+    data = length 1226, hash 29815C21
+  sample 27:
+    time = 900000
+    flags = 0
+    data = length 898, hash D997AD0A
+  sample 28:
+    time = 867000
+    flags = 0
+    data = length 476, hash A0423645
+  sample 29:
+    time = 933000
+    flags = 0
+    data = length 486, hash DDF32CBB
+track 2:
+  total output bytes = 12120
+  sample count = 29
+  format 0:
+    id = 2
+    sampleMimeType = audio/ac3
+    channelCount = 1
+    sampleRate = 44100
+    selectionFlags = 1
+    language = und
+  sample 0:
+    time = 62000
+    flags = 1
+    data = length 416, hash 211F2286
+  sample 1:
+    time = 97000
+    flags = 1
+    data = length 418, hash 77425A86
+  sample 2:
+    time = 131000
+    flags = 1
+    data = length 418, hash A0FE5CA1
+  sample 3:
+    time = 166000
+    flags = 1
+    data = length 418, hash 2309B066
+  sample 4:
+    time = 201000
+    flags = 1
+    data = length 418, hash 928A653B
+  sample 5:
+    time = 236000
+    flags = 1
+    data = length 418, hash 3422F0CB
+  sample 6:
+    time = 270000
+    flags = 1
+    data = length 418, hash EFF43D5B
+  sample 7:
+    time = 306000
+    flags = 1
+    data = length 418, hash FC8093C7
+  sample 8:
+    time = 341000
+    flags = 1
+    data = length 418, hash CCC08A16
+  sample 9:
+    time = 376000
+    flags = 1
+    data = length 418, hash 2A6EE863
+  sample 10:
+    time = 410000
+    flags = 1
+    data = length 418, hash D69A9251
+  sample 11:
+    time = 445000
+    flags = 1
+    data = length 418, hash BCFB758D
+  sample 12:
+    time = 480000
+    flags = 1
+    data = length 418, hash 11B66799
+  sample 13:
+    time = 514000
+    flags = 1
+    data = length 418, hash C824D392
+  sample 14:
+    time = 550000
+    flags = 1
+    data = length 418, hash C167D872
+  sample 15:
+    time = 585000
+    flags = 1
+    data = length 418, hash 4221C855
+  sample 16:
+    time = 620000
+    flags = 1
+    data = length 418, hash 4D4FF934
+  sample 17:
+    time = 654000
+    flags = 1
+    data = length 418, hash 984AA025
+  sample 18:
+    time = 690000
+    flags = 1
+    data = length 418, hash BB788B46
+  sample 19:
+    time = 724000
+    flags = 1
+    data = length 418, hash 9EFBFD97
+  sample 20:
+    time = 759000
+    flags = 1
+    data = length 418, hash DF1A460C
+  sample 21:
+    time = 793000
+    flags = 1
+    data = length 418, hash 2BDB56A
+  sample 22:
+    time = 829000
+    flags = 1
+    data = length 418, hash CA230060
+  sample 23:
+    time = 864000
+    flags = 1
+    data = length 418, hash D2F19F41
+  sample 24:
+    time = 898000
+    flags = 1
+    data = length 418, hash AF392D79
+  sample 25:
+    time = 932000
+    flags = 1
+    data = length 418, hash C5D7F2A3
+  sample 26:
+    time = 968000
+    flags = 1
+    data = length 418, hash 733A35AE
+  sample 27:
+    time = 1002000
+    flags = 1
+    data = length 418, hash DE46E5D3
+  sample 28:
+    time = 1037000
+    flags = 1
+    data = length 418, hash 56AB8D37
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv.3.dump b/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv.3.dump
new file mode 100644
index 0000000..258efdc
--- /dev/null
+++ b/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv.3.dump
@@ -0,0 +1,269 @@
+seekMap:
+  isSeekable = true
+  duration = 1071000
+  getPosition(0) = [[timeUs=0, position=994]]
+  getPosition(1) = [[timeUs=0, position=994]]
+  getPosition(535500) = [[timeUs=0, position=994]]
+  getPosition(1071000) = [[timeUs=0, position=994]]
+numberOfTracks = 2
+track 1:
+  total output bytes = 89502
+  sample count = 30
+  format 0:
+    id = 1
+    sampleMimeType = video/avc
+    width = 1080
+    height = 720
+    rotationDegrees = 90
+    selectionFlags = 1
+    language = und
+    initializationData:
+      data = length 30, hash F6F3D010
+      data = length 10, hash 7A0D0F2B
+  sample 0:
+    time = 0
+    flags = 1
+    data = length 36477, hash F0F36CFE
+  sample 1:
+    time = 67000
+    flags = 0
+    data = length 5341, hash 40B85E2
+  sample 2:
+    time = 33000
+    flags = 0
+    data = length 596, hash 357B4D92
+  sample 3:
+    time = 200000
+    flags = 0
+    data = length 7704, hash A39EDA06
+  sample 4:
+    time = 133000
+    flags = 0
+    data = length 989, hash 2813C72D
+  sample 5:
+    time = 100000
+    flags = 0
+    data = length 721, hash C50D1C73
+  sample 6:
+    time = 167000
+    flags = 0
+    data = length 519, hash 65FE1911
+  sample 7:
+    time = 333000
+    flags = 0
+    data = length 6160, hash E1CAC0EC
+  sample 8:
+    time = 267000
+    flags = 0
+    data = length 953, hash 7160C661
+  sample 9:
+    time = 233000
+    flags = 0
+    data = length 620, hash 7A7AE07C
+  sample 10:
+    time = 300000
+    flags = 0
+    data = length 405, hash 5CC7F4E7
+  sample 11:
+    time = 433000
+    flags = 0
+    data = length 4852, hash 9DB6979D
+  sample 12:
+    time = 400000
+    flags = 0
+    data = length 547, hash E31A6979
+  sample 13:
+    time = 367000
+    flags = 0
+    data = length 570, hash FEC40D00
+  sample 14:
+    time = 567000
+    flags = 0
+    data = length 5525, hash 7C478F7E
+  sample 15:
+    time = 500000
+    flags = 0
+    data = length 1082, hash DA07059A
+  sample 16:
+    time = 467000
+    flags = 0
+    data = length 807, hash 93478E6B
+  sample 17:
+    time = 533000
+    flags = 0
+    data = length 744, hash 9A8E6026
+  sample 18:
+    time = 700000
+    flags = 0
+    data = length 4732, hash C73B23C0
+  sample 19:
+    time = 633000
+    flags = 0
+    data = length 1004, hash 8A19A228
+  sample 20:
+    time = 600000
+    flags = 0
+    data = length 794, hash 8126022C
+  sample 21:
+    time = 667000
+    flags = 0
+    data = length 645, hash F08300E5
+  sample 22:
+    time = 833000
+    flags = 0
+    data = length 2684, hash 727FE378
+  sample 23:
+    time = 767000
+    flags = 0
+    data = length 787, hash 419A7821
+  sample 24:
+    time = 733000
+    flags = 0
+    data = length 649, hash 5C159346
+  sample 25:
+    time = 800000
+    flags = 0
+    data = length 509, hash F912D655
+  sample 26:
+    time = 967000
+    flags = 0
+    data = length 1226, hash 29815C21
+  sample 27:
+    time = 900000
+    flags = 0
+    data = length 898, hash D997AD0A
+  sample 28:
+    time = 867000
+    flags = 0
+    data = length 476, hash A0423645
+  sample 29:
+    time = 933000
+    flags = 0
+    data = length 486, hash DDF32CBB
+track 2:
+  total output bytes = 12120
+  sample count = 29
+  format 0:
+    id = 2
+    sampleMimeType = audio/ac3
+    channelCount = 1
+    sampleRate = 44100
+    selectionFlags = 1
+    language = und
+  sample 0:
+    time = 62000
+    flags = 1
+    data = length 416, hash 211F2286
+  sample 1:
+    time = 97000
+    flags = 1
+    data = length 418, hash 77425A86
+  sample 2:
+    time = 131000
+    flags = 1
+    data = length 418, hash A0FE5CA1
+  sample 3:
+    time = 166000
+    flags = 1
+    data = length 418, hash 2309B066
+  sample 4:
+    time = 201000
+    flags = 1
+    data = length 418, hash 928A653B
+  sample 5:
+    time = 236000
+    flags = 1
+    data = length 418, hash 3422F0CB
+  sample 6:
+    time = 270000
+    flags = 1
+    data = length 418, hash EFF43D5B
+  sample 7:
+    time = 306000
+    flags = 1
+    data = length 418, hash FC8093C7
+  sample 8:
+    time = 341000
+    flags = 1
+    data = length 418, hash CCC08A16
+  sample 9:
+    time = 376000
+    flags = 1
+    data = length 418, hash 2A6EE863
+  sample 10:
+    time = 410000
+    flags = 1
+    data = length 418, hash D69A9251
+  sample 11:
+    time = 445000
+    flags = 1
+    data = length 418, hash BCFB758D
+  sample 12:
+    time = 480000
+    flags = 1
+    data = length 418, hash 11B66799
+  sample 13:
+    time = 514000
+    flags = 1
+    data = length 418, hash C824D392
+  sample 14:
+    time = 550000
+    flags = 1
+    data = length 418, hash C167D872
+  sample 15:
+    time = 585000
+    flags = 1
+    data = length 418, hash 4221C855
+  sample 16:
+    time = 620000
+    flags = 1
+    data = length 418, hash 4D4FF934
+  sample 17:
+    time = 654000
+    flags = 1
+    data = length 418, hash 984AA025
+  sample 18:
+    time = 690000
+    flags = 1
+    data = length 418, hash BB788B46
+  sample 19:
+    time = 724000
+    flags = 1
+    data = length 418, hash 9EFBFD97
+  sample 20:
+    time = 759000
+    flags = 1
+    data = length 418, hash DF1A460C
+  sample 21:
+    time = 793000
+    flags = 1
+    data = length 418, hash 2BDB56A
+  sample 22:
+    time = 829000
+    flags = 1
+    data = length 418, hash CA230060
+  sample 23:
+    time = 864000
+    flags = 1
+    data = length 418, hash D2F19F41
+  sample 24:
+    time = 898000
+    flags = 1
+    data = length 418, hash AF392D79
+  sample 25:
+    time = 932000
+    flags = 1
+    data = length 418, hash C5D7F2A3
+  sample 26:
+    time = 968000
+    flags = 1
+    data = length 418, hash 733A35AE
+  sample 27:
+    time = 1002000
+    flags = 1
+    data = length 418, hash DE46E5D3
+  sample 28:
+    time = 1037000
+    flags = 1
+    data = length 418, hash 56AB8D37
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv.unknown_length.dump b/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv.unknown_length.dump
new file mode 100644
index 0000000..258efdc
--- /dev/null
+++ b/tree/testdata/src/test/assets/mkv/sample_with_htc_rotation_track_name.mkv.unknown_length.dump
@@ -0,0 +1,269 @@
+seekMap:
+  isSeekable = true
+  duration = 1071000
+  getPosition(0) = [[timeUs=0, position=994]]
+  getPosition(1) = [[timeUs=0, position=994]]
+  getPosition(535500) = [[timeUs=0, position=994]]
+  getPosition(1071000) = [[timeUs=0, position=994]]
+numberOfTracks = 2
+track 1:
+  total output bytes = 89502
+  sample count = 30
+  format 0:
+    id = 1
+    sampleMimeType = video/avc
+    width = 1080
+    height = 720
+    rotationDegrees = 90
+    selectionFlags = 1
+    language = und
+    initializationData:
+      data = length 30, hash F6F3D010
+      data = length 10, hash 7A0D0F2B
+  sample 0:
+    time = 0
+    flags = 1
+    data = length 36477, hash F0F36CFE
+  sample 1:
+    time = 67000
+    flags = 0
+    data = length 5341, hash 40B85E2
+  sample 2:
+    time = 33000
+    flags = 0
+    data = length 596, hash 357B4D92
+  sample 3:
+    time = 200000
+    flags = 0
+    data = length 7704, hash A39EDA06
+  sample 4:
+    time = 133000
+    flags = 0
+    data = length 989, hash 2813C72D
+  sample 5:
+    time = 100000
+    flags = 0
+    data = length 721, hash C50D1C73
+  sample 6:
+    time = 167000
+    flags = 0
+    data = length 519, hash 65FE1911
+  sample 7:
+    time = 333000
+    flags = 0
+    data = length 6160, hash E1CAC0EC
+  sample 8:
+    time = 267000
+    flags = 0
+    data = length 953, hash 7160C661
+  sample 9:
+    time = 233000
+    flags = 0
+    data = length 620, hash 7A7AE07C
+  sample 10:
+    time = 300000
+    flags = 0
+    data = length 405, hash 5CC7F4E7
+  sample 11:
+    time = 433000
+    flags = 0
+    data = length 4852, hash 9DB6979D
+  sample 12:
+    time = 400000
+    flags = 0
+    data = length 547, hash E31A6979
+  sample 13:
+    time = 367000
+    flags = 0
+    data = length 570, hash FEC40D00
+  sample 14:
+    time = 567000
+    flags = 0
+    data = length 5525, hash 7C478F7E
+  sample 15:
+    time = 500000
+    flags = 0
+    data = length 1082, hash DA07059A
+  sample 16:
+    time = 467000
+    flags = 0
+    data = length 807, hash 93478E6B
+  sample 17:
+    time = 533000
+    flags = 0
+    data = length 744, hash 9A8E6026
+  sample 18:
+    time = 700000
+    flags = 0
+    data = length 4732, hash C73B23C0
+  sample 19:
+    time = 633000
+    flags = 0
+    data = length 1004, hash 8A19A228
+  sample 20:
+    time = 600000
+    flags = 0
+    data = length 794, hash 8126022C
+  sample 21:
+    time = 667000
+    flags = 0
+    data = length 645, hash F08300E5
+  sample 22:
+    time = 833000
+    flags = 0
+    data = length 2684, hash 727FE378
+  sample 23:
+    time = 767000
+    flags = 0
+    data = length 787, hash 419A7821
+  sample 24:
+    time = 733000
+    flags = 0
+    data = length 649, hash 5C159346
+  sample 25:
+    time = 800000
+    flags = 0
+    data = length 509, hash F912D655
+  sample 26:
+    time = 967000
+    flags = 0
+    data = length 1226, hash 29815C21
+  sample 27:
+    time = 900000
+    flags = 0
+    data = length 898, hash D997AD0A
+  sample 28:
+    time = 867000
+    flags = 0
+    data = length 476, hash A0423645
+  sample 29:
+    time = 933000
+    flags = 0
+    data = length 486, hash DDF32CBB
+track 2:
+  total output bytes = 12120
+  sample count = 29
+  format 0:
+    id = 2
+    sampleMimeType = audio/ac3
+    channelCount = 1
+    sampleRate = 44100
+    selectionFlags = 1
+    language = und
+  sample 0:
+    time = 62000
+    flags = 1
+    data = length 416, hash 211F2286
+  sample 1:
+    time = 97000
+    flags = 1
+    data = length 418, hash 77425A86
+  sample 2:
+    time = 131000
+    flags = 1
+    data = length 418, hash A0FE5CA1
+  sample 3:
+    time = 166000
+    flags = 1
+    data = length 418, hash 2309B066
+  sample 4:
+    time = 201000
+    flags = 1
+    data = length 418, hash 928A653B
+  sample 5:
+    time = 236000
+    flags = 1
+    data = length 418, hash 3422F0CB
+  sample 6:
+    time = 270000
+    flags = 1
+    data = length 418, hash EFF43D5B
+  sample 7:
+    time = 306000
+    flags = 1
+    data = length 418, hash FC8093C7
+  sample 8:
+    time = 341000
+    flags = 1
+    data = length 418, hash CCC08A16
+  sample 9:
+    time = 376000
+    flags = 1
+    data = length 418, hash 2A6EE863
+  sample 10:
+    time = 410000
+    flags = 1
+    data = length 418, hash D69A9251
+  sample 11:
+    time = 445000
+    flags = 1
+    data = length 418, hash BCFB758D
+  sample 12:
+    time = 480000
+    flags = 1
+    data = length 418, hash 11B66799
+  sample 13:
+    time = 514000
+    flags = 1
+    data = length 418, hash C824D392
+  sample 14:
+    time = 550000
+    flags = 1
+    data = length 418, hash C167D872
+  sample 15:
+    time = 585000
+    flags = 1
+    data = length 418, hash 4221C855
+  sample 16:
+    time = 620000
+    flags = 1
+    data = length 418, hash 4D4FF934
+  sample 17:
+    time = 654000
+    flags = 1
+    data = length 418, hash 984AA025
+  sample 18:
+    time = 690000
+    flags = 1
+    data = length 418, hash BB788B46
+  sample 19:
+    time = 724000
+    flags = 1
+    data = length 418, hash 9EFBFD97
+  sample 20:
+    time = 759000
+    flags = 1
+    data = length 418, hash DF1A460C
+  sample 21:
+    time = 793000
+    flags = 1
+    data = length 418, hash 2BDB56A
+  sample 22:
+    time = 829000
+    flags = 1
+    data = length 418, hash CA230060
+  sample 23:
+    time = 864000
+    flags = 1
+    data = length 418, hash D2F19F41
+  sample 24:
+    time = 898000
+    flags = 1
+    data = length 418, hash AF392D79
+  sample 25:
+    time = 932000
+    flags = 1
+    data = length 418, hash C5D7F2A3
+  sample 26:
+    time = 968000
+    flags = 1
+    data = length 418, hash 733A35AE
+  sample 27:
+    time = 1002000
+    flags = 1
+    data = length 418, hash DE46E5D3
+  sample 28:
+    time = 1037000
+    flags = 1
+    data = length 418, hash 56AB8D37
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv b/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv
new file mode 100644
index 0000000..bdd56d8
--- /dev/null
+++ b/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv
Binary files differ
diff --git a/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv.0.dump b/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv.0.dump
new file mode 100644
index 0000000..1b6f763
--- /dev/null
+++ b/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv.0.dump
@@ -0,0 +1,280 @@
+seekMap:
+  isSeekable = true
+  duration = 1234000
+  getPosition(0) = [[timeUs=0, position=1163]]
+  getPosition(1) = [[timeUs=0, position=1163]]
+  getPosition(617000) = [[timeUs=0, position=1163]]
+  getPosition(1234000) = [[timeUs=0, position=1163]]
+numberOfTracks = 3
+track 1:
+  total output bytes = 89502
+  sample count = 30
+  format 0:
+    id = 1
+    sampleMimeType = video/avc
+    width = 1080
+    height = 720
+    selectionFlags = 1
+    language = und
+    initializationData:
+      data = length 30, hash F6F3D010
+      data = length 10, hash 7A0D0F2B
+  sample 0:
+    time = 0
+    flags = 1
+    data = length 36477, hash F0F36CFE
+  sample 1:
+    time = 67000
+    flags = 0
+    data = length 5341, hash 40B85E2
+  sample 2:
+    time = 33000
+    flags = 0
+    data = length 596, hash 357B4D92
+  sample 3:
+    time = 200000
+    flags = 0
+    data = length 7704, hash A39EDA06
+  sample 4:
+    time = 133000
+    flags = 0
+    data = length 989, hash 2813C72D
+  sample 5:
+    time = 100000
+    flags = 0
+    data = length 721, hash C50D1C73
+  sample 6:
+    time = 167000
+    flags = 0
+    data = length 519, hash 65FE1911
+  sample 7:
+    time = 333000
+    flags = 0
+    data = length 6160, hash E1CAC0EC
+  sample 8:
+    time = 267000
+    flags = 0
+    data = length 953, hash 7160C661
+  sample 9:
+    time = 233000
+    flags = 0
+    data = length 620, hash 7A7AE07C
+  sample 10:
+    time = 300000
+    flags = 0
+    data = length 405, hash 5CC7F4E7
+  sample 11:
+    time = 433000
+    flags = 0
+    data = length 4852, hash 9DB6979D
+  sample 12:
+    time = 400000
+    flags = 0
+    data = length 547, hash E31A6979
+  sample 13:
+    time = 367000
+    flags = 0
+    data = length 570, hash FEC40D00
+  sample 14:
+    time = 567000
+    flags = 0
+    data = length 5525, hash 7C478F7E
+  sample 15:
+    time = 500000
+    flags = 0
+    data = length 1082, hash DA07059A
+  sample 16:
+    time = 467000
+    flags = 0
+    data = length 807, hash 93478E6B
+  sample 17:
+    time = 533000
+    flags = 0
+    data = length 744, hash 9A8E6026
+  sample 18:
+    time = 700000
+    flags = 0
+    data = length 4732, hash C73B23C0
+  sample 19:
+    time = 633000
+    flags = 0
+    data = length 1004, hash 8A19A228
+  sample 20:
+    time = 600000
+    flags = 0
+    data = length 794, hash 8126022C
+  sample 21:
+    time = 667000
+    flags = 0
+    data = length 645, hash F08300E5
+  sample 22:
+    time = 833000
+    flags = 0
+    data = length 2684, hash 727FE378
+  sample 23:
+    time = 767000
+    flags = 0
+    data = length 787, hash 419A7821
+  sample 24:
+    time = 733000
+    flags = 0
+    data = length 649, hash 5C159346
+  sample 25:
+    time = 800000
+    flags = 0
+    data = length 509, hash F912D655
+  sample 26:
+    time = 967000
+    flags = 0
+    data = length 1226, hash 29815C21
+  sample 27:
+    time = 900000
+    flags = 0
+    data = length 898, hash D997AD0A
+  sample 28:
+    time = 867000
+    flags = 0
+    data = length 476, hash A0423645
+  sample 29:
+    time = 933000
+    flags = 0
+    data = length 486, hash DDF32CBB
+track 2:
+  total output bytes = 12120
+  sample count = 29
+  format 0:
+    id = 2
+    sampleMimeType = audio/ac3
+    channelCount = 1
+    sampleRate = 44100
+    selectionFlags = 1
+    language = und
+  sample 0:
+    time = 62000
+    flags = 1
+    data = length 416, hash 211F2286
+  sample 1:
+    time = 97000
+    flags = 1
+    data = length 418, hash 77425A86
+  sample 2:
+    time = 131000
+    flags = 1
+    data = length 418, hash A0FE5CA1
+  sample 3:
+    time = 166000
+    flags = 1
+    data = length 418, hash 2309B066
+  sample 4:
+    time = 201000
+    flags = 1
+    data = length 418, hash 928A653B
+  sample 5:
+    time = 236000
+    flags = 1
+    data = length 418, hash 3422F0CB
+  sample 6:
+    time = 270000
+    flags = 1
+    data = length 418, hash EFF43D5B
+  sample 7:
+    time = 306000
+    flags = 1
+    data = length 418, hash FC8093C7
+  sample 8:
+    time = 341000
+    flags = 1
+    data = length 418, hash CCC08A16
+  sample 9:
+    time = 376000
+    flags = 1
+    data = length 418, hash 2A6EE863
+  sample 10:
+    time = 410000
+    flags = 1
+    data = length 418, hash D69A9251
+  sample 11:
+    time = 445000
+    flags = 1
+    data = length 418, hash BCFB758D
+  sample 12:
+    time = 480000
+    flags = 1
+    data = length 418, hash 11B66799
+  sample 13:
+    time = 514000
+    flags = 1
+    data = length 418, hash C824D392
+  sample 14:
+    time = 550000
+    flags = 1
+    data = length 418, hash C167D872
+  sample 15:
+    time = 585000
+    flags = 1
+    data = length 418, hash 4221C855
+  sample 16:
+    time = 620000
+    flags = 1
+    data = length 418, hash 4D4FF934
+  sample 17:
+    time = 654000
+    flags = 1
+    data = length 418, hash 984AA025
+  sample 18:
+    time = 690000
+    flags = 1
+    data = length 418, hash BB788B46
+  sample 19:
+    time = 724000
+    flags = 1
+    data = length 418, hash 9EFBFD97
+  sample 20:
+    time = 759000
+    flags = 1
+    data = length 418, hash DF1A460C
+  sample 21:
+    time = 793000
+    flags = 1
+    data = length 418, hash 2BDB56A
+  sample 22:
+    time = 829000
+    flags = 1
+    data = length 418, hash CA230060
+  sample 23:
+    time = 864000
+    flags = 1
+    data = length 418, hash D2F19F41
+  sample 24:
+    time = 898000
+    flags = 1
+    data = length 418, hash AF392D79
+  sample 25:
+    time = 932000
+    flags = 1
+    data = length 418, hash C5D7F2A3
+  sample 26:
+    time = 968000
+    flags = 1
+    data = length 418, hash 733A35AE
+  sample 27:
+    time = 1002000
+    flags = 1
+    data = length 418, hash DE46E5D3
+  sample 28:
+    time = 1037000
+    flags = 1
+    data = length 418, hash 56AB8D37
+track 3:
+  total output bytes = 59
+  sample count = 1
+  format 0:
+    id = 3
+    sampleMimeType = application/x-subrip
+    language = en
+    label = Subs Label
+  sample 0:
+    time = 0
+    flags = 1
+    data = length 59, hash 1AD38625
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv.1.dump b/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv.1.dump
new file mode 100644
index 0000000..1b6f763
--- /dev/null
+++ b/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv.1.dump
@@ -0,0 +1,280 @@
+seekMap:
+  isSeekable = true
+  duration = 1234000
+  getPosition(0) = [[timeUs=0, position=1163]]
+  getPosition(1) = [[timeUs=0, position=1163]]
+  getPosition(617000) = [[timeUs=0, position=1163]]
+  getPosition(1234000) = [[timeUs=0, position=1163]]
+numberOfTracks = 3
+track 1:
+  total output bytes = 89502
+  sample count = 30
+  format 0:
+    id = 1
+    sampleMimeType = video/avc
+    width = 1080
+    height = 720
+    selectionFlags = 1
+    language = und
+    initializationData:
+      data = length 30, hash F6F3D010
+      data = length 10, hash 7A0D0F2B
+  sample 0:
+    time = 0
+    flags = 1
+    data = length 36477, hash F0F36CFE
+  sample 1:
+    time = 67000
+    flags = 0
+    data = length 5341, hash 40B85E2
+  sample 2:
+    time = 33000
+    flags = 0
+    data = length 596, hash 357B4D92
+  sample 3:
+    time = 200000
+    flags = 0
+    data = length 7704, hash A39EDA06
+  sample 4:
+    time = 133000
+    flags = 0
+    data = length 989, hash 2813C72D
+  sample 5:
+    time = 100000
+    flags = 0
+    data = length 721, hash C50D1C73
+  sample 6:
+    time = 167000
+    flags = 0
+    data = length 519, hash 65FE1911
+  sample 7:
+    time = 333000
+    flags = 0
+    data = length 6160, hash E1CAC0EC
+  sample 8:
+    time = 267000
+    flags = 0
+    data = length 953, hash 7160C661
+  sample 9:
+    time = 233000
+    flags = 0
+    data = length 620, hash 7A7AE07C
+  sample 10:
+    time = 300000
+    flags = 0
+    data = length 405, hash 5CC7F4E7
+  sample 11:
+    time = 433000
+    flags = 0
+    data = length 4852, hash 9DB6979D
+  sample 12:
+    time = 400000
+    flags = 0
+    data = length 547, hash E31A6979
+  sample 13:
+    time = 367000
+    flags = 0
+    data = length 570, hash FEC40D00
+  sample 14:
+    time = 567000
+    flags = 0
+    data = length 5525, hash 7C478F7E
+  sample 15:
+    time = 500000
+    flags = 0
+    data = length 1082, hash DA07059A
+  sample 16:
+    time = 467000
+    flags = 0
+    data = length 807, hash 93478E6B
+  sample 17:
+    time = 533000
+    flags = 0
+    data = length 744, hash 9A8E6026
+  sample 18:
+    time = 700000
+    flags = 0
+    data = length 4732, hash C73B23C0
+  sample 19:
+    time = 633000
+    flags = 0
+    data = length 1004, hash 8A19A228
+  sample 20:
+    time = 600000
+    flags = 0
+    data = length 794, hash 8126022C
+  sample 21:
+    time = 667000
+    flags = 0
+    data = length 645, hash F08300E5
+  sample 22:
+    time = 833000
+    flags = 0
+    data = length 2684, hash 727FE378
+  sample 23:
+    time = 767000
+    flags = 0
+    data = length 787, hash 419A7821
+  sample 24:
+    time = 733000
+    flags = 0
+    data = length 649, hash 5C159346
+  sample 25:
+    time = 800000
+    flags = 0
+    data = length 509, hash F912D655
+  sample 26:
+    time = 967000
+    flags = 0
+    data = length 1226, hash 29815C21
+  sample 27:
+    time = 900000
+    flags = 0
+    data = length 898, hash D997AD0A
+  sample 28:
+    time = 867000
+    flags = 0
+    data = length 476, hash A0423645
+  sample 29:
+    time = 933000
+    flags = 0
+    data = length 486, hash DDF32CBB
+track 2:
+  total output bytes = 12120
+  sample count = 29
+  format 0:
+    id = 2
+    sampleMimeType = audio/ac3
+    channelCount = 1
+    sampleRate = 44100
+    selectionFlags = 1
+    language = und
+  sample 0:
+    time = 62000
+    flags = 1
+    data = length 416, hash 211F2286
+  sample 1:
+    time = 97000
+    flags = 1
+    data = length 418, hash 77425A86
+  sample 2:
+    time = 131000
+    flags = 1
+    data = length 418, hash A0FE5CA1
+  sample 3:
+    time = 166000
+    flags = 1
+    data = length 418, hash 2309B066
+  sample 4:
+    time = 201000
+    flags = 1
+    data = length 418, hash 928A653B
+  sample 5:
+    time = 236000
+    flags = 1
+    data = length 418, hash 3422F0CB
+  sample 6:
+    time = 270000
+    flags = 1
+    data = length 418, hash EFF43D5B
+  sample 7:
+    time = 306000
+    flags = 1
+    data = length 418, hash FC8093C7
+  sample 8:
+    time = 341000
+    flags = 1
+    data = length 418, hash CCC08A16
+  sample 9:
+    time = 376000
+    flags = 1
+    data = length 418, hash 2A6EE863
+  sample 10:
+    time = 410000
+    flags = 1
+    data = length 418, hash D69A9251
+  sample 11:
+    time = 445000
+    flags = 1
+    data = length 418, hash BCFB758D
+  sample 12:
+    time = 480000
+    flags = 1
+    data = length 418, hash 11B66799
+  sample 13:
+    time = 514000
+    flags = 1
+    data = length 418, hash C824D392
+  sample 14:
+    time = 550000
+    flags = 1
+    data = length 418, hash C167D872
+  sample 15:
+    time = 585000
+    flags = 1
+    data = length 418, hash 4221C855
+  sample 16:
+    time = 620000
+    flags = 1
+    data = length 418, hash 4D4FF934
+  sample 17:
+    time = 654000
+    flags = 1
+    data = length 418, hash 984AA025
+  sample 18:
+    time = 690000
+    flags = 1
+    data = length 418, hash BB788B46
+  sample 19:
+    time = 724000
+    flags = 1
+    data = length 418, hash 9EFBFD97
+  sample 20:
+    time = 759000
+    flags = 1
+    data = length 418, hash DF1A460C
+  sample 21:
+    time = 793000
+    flags = 1
+    data = length 418, hash 2BDB56A
+  sample 22:
+    time = 829000
+    flags = 1
+    data = length 418, hash CA230060
+  sample 23:
+    time = 864000
+    flags = 1
+    data = length 418, hash D2F19F41
+  sample 24:
+    time = 898000
+    flags = 1
+    data = length 418, hash AF392D79
+  sample 25:
+    time = 932000
+    flags = 1
+    data = length 418, hash C5D7F2A3
+  sample 26:
+    time = 968000
+    flags = 1
+    data = length 418, hash 733A35AE
+  sample 27:
+    time = 1002000
+    flags = 1
+    data = length 418, hash DE46E5D3
+  sample 28:
+    time = 1037000
+    flags = 1
+    data = length 418, hash 56AB8D37
+track 3:
+  total output bytes = 59
+  sample count = 1
+  format 0:
+    id = 3
+    sampleMimeType = application/x-subrip
+    language = en
+    label = Subs Label
+  sample 0:
+    time = 0
+    flags = 1
+    data = length 59, hash 1AD38625
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv.2.dump b/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv.2.dump
new file mode 100644
index 0000000..1b6f763
--- /dev/null
+++ b/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv.2.dump
@@ -0,0 +1,280 @@
+seekMap:
+  isSeekable = true
+  duration = 1234000
+  getPosition(0) = [[timeUs=0, position=1163]]
+  getPosition(1) = [[timeUs=0, position=1163]]
+  getPosition(617000) = [[timeUs=0, position=1163]]
+  getPosition(1234000) = [[timeUs=0, position=1163]]
+numberOfTracks = 3
+track 1:
+  total output bytes = 89502
+  sample count = 30
+  format 0:
+    id = 1
+    sampleMimeType = video/avc
+    width = 1080
+    height = 720
+    selectionFlags = 1
+    language = und
+    initializationData:
+      data = length 30, hash F6F3D010
+      data = length 10, hash 7A0D0F2B
+  sample 0:
+    time = 0
+    flags = 1
+    data = length 36477, hash F0F36CFE
+  sample 1:
+    time = 67000
+    flags = 0
+    data = length 5341, hash 40B85E2
+  sample 2:
+    time = 33000
+    flags = 0
+    data = length 596, hash 357B4D92
+  sample 3:
+    time = 200000
+    flags = 0
+    data = length 7704, hash A39EDA06
+  sample 4:
+    time = 133000
+    flags = 0
+    data = length 989, hash 2813C72D
+  sample 5:
+    time = 100000
+    flags = 0
+    data = length 721, hash C50D1C73
+  sample 6:
+    time = 167000
+    flags = 0
+    data = length 519, hash 65FE1911
+  sample 7:
+    time = 333000
+    flags = 0
+    data = length 6160, hash E1CAC0EC
+  sample 8:
+    time = 267000
+    flags = 0
+    data = length 953, hash 7160C661
+  sample 9:
+    time = 233000
+    flags = 0
+    data = length 620, hash 7A7AE07C
+  sample 10:
+    time = 300000
+    flags = 0
+    data = length 405, hash 5CC7F4E7
+  sample 11:
+    time = 433000
+    flags = 0
+    data = length 4852, hash 9DB6979D
+  sample 12:
+    time = 400000
+    flags = 0
+    data = length 547, hash E31A6979
+  sample 13:
+    time = 367000
+    flags = 0
+    data = length 570, hash FEC40D00
+  sample 14:
+    time = 567000
+    flags = 0
+    data = length 5525, hash 7C478F7E
+  sample 15:
+    time = 500000
+    flags = 0
+    data = length 1082, hash DA07059A
+  sample 16:
+    time = 467000
+    flags = 0
+    data = length 807, hash 93478E6B
+  sample 17:
+    time = 533000
+    flags = 0
+    data = length 744, hash 9A8E6026
+  sample 18:
+    time = 700000
+    flags = 0
+    data = length 4732, hash C73B23C0
+  sample 19:
+    time = 633000
+    flags = 0
+    data = length 1004, hash 8A19A228
+  sample 20:
+    time = 600000
+    flags = 0
+    data = length 794, hash 8126022C
+  sample 21:
+    time = 667000
+    flags = 0
+    data = length 645, hash F08300E5
+  sample 22:
+    time = 833000
+    flags = 0
+    data = length 2684, hash 727FE378
+  sample 23:
+    time = 767000
+    flags = 0
+    data = length 787, hash 419A7821
+  sample 24:
+    time = 733000
+    flags = 0
+    data = length 649, hash 5C159346
+  sample 25:
+    time = 800000
+    flags = 0
+    data = length 509, hash F912D655
+  sample 26:
+    time = 967000
+    flags = 0
+    data = length 1226, hash 29815C21
+  sample 27:
+    time = 900000
+    flags = 0
+    data = length 898, hash D997AD0A
+  sample 28:
+    time = 867000
+    flags = 0
+    data = length 476, hash A0423645
+  sample 29:
+    time = 933000
+    flags = 0
+    data = length 486, hash DDF32CBB
+track 2:
+  total output bytes = 12120
+  sample count = 29
+  format 0:
+    id = 2
+    sampleMimeType = audio/ac3
+    channelCount = 1
+    sampleRate = 44100
+    selectionFlags = 1
+    language = und
+  sample 0:
+    time = 62000
+    flags = 1
+    data = length 416, hash 211F2286
+  sample 1:
+    time = 97000
+    flags = 1
+    data = length 418, hash 77425A86
+  sample 2:
+    time = 131000
+    flags = 1
+    data = length 418, hash A0FE5CA1
+  sample 3:
+    time = 166000
+    flags = 1
+    data = length 418, hash 2309B066
+  sample 4:
+    time = 201000
+    flags = 1
+    data = length 418, hash 928A653B
+  sample 5:
+    time = 236000
+    flags = 1
+    data = length 418, hash 3422F0CB
+  sample 6:
+    time = 270000
+    flags = 1
+    data = length 418, hash EFF43D5B
+  sample 7:
+    time = 306000
+    flags = 1
+    data = length 418, hash FC8093C7
+  sample 8:
+    time = 341000
+    flags = 1
+    data = length 418, hash CCC08A16
+  sample 9:
+    time = 376000
+    flags = 1
+    data = length 418, hash 2A6EE863
+  sample 10:
+    time = 410000
+    flags = 1
+    data = length 418, hash D69A9251
+  sample 11:
+    time = 445000
+    flags = 1
+    data = length 418, hash BCFB758D
+  sample 12:
+    time = 480000
+    flags = 1
+    data = length 418, hash 11B66799
+  sample 13:
+    time = 514000
+    flags = 1
+    data = length 418, hash C824D392
+  sample 14:
+    time = 550000
+    flags = 1
+    data = length 418, hash C167D872
+  sample 15:
+    time = 585000
+    flags = 1
+    data = length 418, hash 4221C855
+  sample 16:
+    time = 620000
+    flags = 1
+    data = length 418, hash 4D4FF934
+  sample 17:
+    time = 654000
+    flags = 1
+    data = length 418, hash 984AA025
+  sample 18:
+    time = 690000
+    flags = 1
+    data = length 418, hash BB788B46
+  sample 19:
+    time = 724000
+    flags = 1
+    data = length 418, hash 9EFBFD97
+  sample 20:
+    time = 759000
+    flags = 1
+    data = length 418, hash DF1A460C
+  sample 21:
+    time = 793000
+    flags = 1
+    data = length 418, hash 2BDB56A
+  sample 22:
+    time = 829000
+    flags = 1
+    data = length 418, hash CA230060
+  sample 23:
+    time = 864000
+    flags = 1
+    data = length 418, hash D2F19F41
+  sample 24:
+    time = 898000
+    flags = 1
+    data = length 418, hash AF392D79
+  sample 25:
+    time = 932000
+    flags = 1
+    data = length 418, hash C5D7F2A3
+  sample 26:
+    time = 968000
+    flags = 1
+    data = length 418, hash 733A35AE
+  sample 27:
+    time = 1002000
+    flags = 1
+    data = length 418, hash DE46E5D3
+  sample 28:
+    time = 1037000
+    flags = 1
+    data = length 418, hash 56AB8D37
+track 3:
+  total output bytes = 59
+  sample count = 1
+  format 0:
+    id = 3
+    sampleMimeType = application/x-subrip
+    language = en
+    label = Subs Label
+  sample 0:
+    time = 0
+    flags = 1
+    data = length 59, hash 1AD38625
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv.3.dump b/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv.3.dump
new file mode 100644
index 0000000..1b6f763
--- /dev/null
+++ b/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv.3.dump
@@ -0,0 +1,280 @@
+seekMap:
+  isSeekable = true
+  duration = 1234000
+  getPosition(0) = [[timeUs=0, position=1163]]
+  getPosition(1) = [[timeUs=0, position=1163]]
+  getPosition(617000) = [[timeUs=0, position=1163]]
+  getPosition(1234000) = [[timeUs=0, position=1163]]
+numberOfTracks = 3
+track 1:
+  total output bytes = 89502
+  sample count = 30
+  format 0:
+    id = 1
+    sampleMimeType = video/avc
+    width = 1080
+    height = 720
+    selectionFlags = 1
+    language = und
+    initializationData:
+      data = length 30, hash F6F3D010
+      data = length 10, hash 7A0D0F2B
+  sample 0:
+    time = 0
+    flags = 1
+    data = length 36477, hash F0F36CFE
+  sample 1:
+    time = 67000
+    flags = 0
+    data = length 5341, hash 40B85E2
+  sample 2:
+    time = 33000
+    flags = 0
+    data = length 596, hash 357B4D92
+  sample 3:
+    time = 200000
+    flags = 0
+    data = length 7704, hash A39EDA06
+  sample 4:
+    time = 133000
+    flags = 0
+    data = length 989, hash 2813C72D
+  sample 5:
+    time = 100000
+    flags = 0
+    data = length 721, hash C50D1C73
+  sample 6:
+    time = 167000
+    flags = 0
+    data = length 519, hash 65FE1911
+  sample 7:
+    time = 333000
+    flags = 0
+    data = length 6160, hash E1CAC0EC
+  sample 8:
+    time = 267000
+    flags = 0
+    data = length 953, hash 7160C661
+  sample 9:
+    time = 233000
+    flags = 0
+    data = length 620, hash 7A7AE07C
+  sample 10:
+    time = 300000
+    flags = 0
+    data = length 405, hash 5CC7F4E7
+  sample 11:
+    time = 433000
+    flags = 0
+    data = length 4852, hash 9DB6979D
+  sample 12:
+    time = 400000
+    flags = 0
+    data = length 547, hash E31A6979
+  sample 13:
+    time = 367000
+    flags = 0
+    data = length 570, hash FEC40D00
+  sample 14:
+    time = 567000
+    flags = 0
+    data = length 5525, hash 7C478F7E
+  sample 15:
+    time = 500000
+    flags = 0
+    data = length 1082, hash DA07059A
+  sample 16:
+    time = 467000
+    flags = 0
+    data = length 807, hash 93478E6B
+  sample 17:
+    time = 533000
+    flags = 0
+    data = length 744, hash 9A8E6026
+  sample 18:
+    time = 700000
+    flags = 0
+    data = length 4732, hash C73B23C0
+  sample 19:
+    time = 633000
+    flags = 0
+    data = length 1004, hash 8A19A228
+  sample 20:
+    time = 600000
+    flags = 0
+    data = length 794, hash 8126022C
+  sample 21:
+    time = 667000
+    flags = 0
+    data = length 645, hash F08300E5
+  sample 22:
+    time = 833000
+    flags = 0
+    data = length 2684, hash 727FE378
+  sample 23:
+    time = 767000
+    flags = 0
+    data = length 787, hash 419A7821
+  sample 24:
+    time = 733000
+    flags = 0
+    data = length 649, hash 5C159346
+  sample 25:
+    time = 800000
+    flags = 0
+    data = length 509, hash F912D655
+  sample 26:
+    time = 967000
+    flags = 0
+    data = length 1226, hash 29815C21
+  sample 27:
+    time = 900000
+    flags = 0
+    data = length 898, hash D997AD0A
+  sample 28:
+    time = 867000
+    flags = 0
+    data = length 476, hash A0423645
+  sample 29:
+    time = 933000
+    flags = 0
+    data = length 486, hash DDF32CBB
+track 2:
+  total output bytes = 12120
+  sample count = 29
+  format 0:
+    id = 2
+    sampleMimeType = audio/ac3
+    channelCount = 1
+    sampleRate = 44100
+    selectionFlags = 1
+    language = und
+  sample 0:
+    time = 62000
+    flags = 1
+    data = length 416, hash 211F2286
+  sample 1:
+    time = 97000
+    flags = 1
+    data = length 418, hash 77425A86
+  sample 2:
+    time = 131000
+    flags = 1
+    data = length 418, hash A0FE5CA1
+  sample 3:
+    time = 166000
+    flags = 1
+    data = length 418, hash 2309B066
+  sample 4:
+    time = 201000
+    flags = 1
+    data = length 418, hash 928A653B
+  sample 5:
+    time = 236000
+    flags = 1
+    data = length 418, hash 3422F0CB
+  sample 6:
+    time = 270000
+    flags = 1
+    data = length 418, hash EFF43D5B
+  sample 7:
+    time = 306000
+    flags = 1
+    data = length 418, hash FC8093C7
+  sample 8:
+    time = 341000
+    flags = 1
+    data = length 418, hash CCC08A16
+  sample 9:
+    time = 376000
+    flags = 1
+    data = length 418, hash 2A6EE863
+  sample 10:
+    time = 410000
+    flags = 1
+    data = length 418, hash D69A9251
+  sample 11:
+    time = 445000
+    flags = 1
+    data = length 418, hash BCFB758D
+  sample 12:
+    time = 480000
+    flags = 1
+    data = length 418, hash 11B66799
+  sample 13:
+    time = 514000
+    flags = 1
+    data = length 418, hash C824D392
+  sample 14:
+    time = 550000
+    flags = 1
+    data = length 418, hash C167D872
+  sample 15:
+    time = 585000
+    flags = 1
+    data = length 418, hash 4221C855
+  sample 16:
+    time = 620000
+    flags = 1
+    data = length 418, hash 4D4FF934
+  sample 17:
+    time = 654000
+    flags = 1
+    data = length 418, hash 984AA025
+  sample 18:
+    time = 690000
+    flags = 1
+    data = length 418, hash BB788B46
+  sample 19:
+    time = 724000
+    flags = 1
+    data = length 418, hash 9EFBFD97
+  sample 20:
+    time = 759000
+    flags = 1
+    data = length 418, hash DF1A460C
+  sample 21:
+    time = 793000
+    flags = 1
+    data = length 418, hash 2BDB56A
+  sample 22:
+    time = 829000
+    flags = 1
+    data = length 418, hash CA230060
+  sample 23:
+    time = 864000
+    flags = 1
+    data = length 418, hash D2F19F41
+  sample 24:
+    time = 898000
+    flags = 1
+    data = length 418, hash AF392D79
+  sample 25:
+    time = 932000
+    flags = 1
+    data = length 418, hash C5D7F2A3
+  sample 26:
+    time = 968000
+    flags = 1
+    data = length 418, hash 733A35AE
+  sample 27:
+    time = 1002000
+    flags = 1
+    data = length 418, hash DE46E5D3
+  sample 28:
+    time = 1037000
+    flags = 1
+    data = length 418, hash 56AB8D37
+track 3:
+  total output bytes = 59
+  sample count = 1
+  format 0:
+    id = 3
+    sampleMimeType = application/x-subrip
+    language = en
+    label = Subs Label
+  sample 0:
+    time = 0
+    flags = 1
+    data = length 59, hash 1AD38625
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv.unknown_length.dump b/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv.unknown_length.dump
new file mode 100644
index 0000000..1b6f763
--- /dev/null
+++ b/tree/testdata/src/test/assets/mkv/sample_with_srt.mkv.unknown_length.dump
@@ -0,0 +1,280 @@
+seekMap:
+  isSeekable = true
+  duration = 1234000
+  getPosition(0) = [[timeUs=0, position=1163]]
+  getPosition(1) = [[timeUs=0, position=1163]]
+  getPosition(617000) = [[timeUs=0, position=1163]]
+  getPosition(1234000) = [[timeUs=0, position=1163]]
+numberOfTracks = 3
+track 1:
+  total output bytes = 89502
+  sample count = 30
+  format 0:
+    id = 1
+    sampleMimeType = video/avc
+    width = 1080
+    height = 720
+    selectionFlags = 1
+    language = und
+    initializationData:
+      data = length 30, hash F6F3D010
+      data = length 10, hash 7A0D0F2B
+  sample 0:
+    time = 0
+    flags = 1
+    data = length 36477, hash F0F36CFE
+  sample 1:
+    time = 67000
+    flags = 0
+    data = length 5341, hash 40B85E2
+  sample 2:
+    time = 33000
+    flags = 0
+    data = length 596, hash 357B4D92
+  sample 3:
+    time = 200000
+    flags = 0
+    data = length 7704, hash A39EDA06
+  sample 4:
+    time = 133000
+    flags = 0
+    data = length 989, hash 2813C72D
+  sample 5:
+    time = 100000
+    flags = 0
+    data = length 721, hash C50D1C73
+  sample 6:
+    time = 167000
+    flags = 0
+    data = length 519, hash 65FE1911
+  sample 7:
+    time = 333000
+    flags = 0
+    data = length 6160, hash E1CAC0EC
+  sample 8:
+    time = 267000
+    flags = 0
+    data = length 953, hash 7160C661
+  sample 9:
+    time = 233000
+    flags = 0
+    data = length 620, hash 7A7AE07C
+  sample 10:
+    time = 300000
+    flags = 0
+    data = length 405, hash 5CC7F4E7
+  sample 11:
+    time = 433000
+    flags = 0
+    data = length 4852, hash 9DB6979D
+  sample 12:
+    time = 400000
+    flags = 0
+    data = length 547, hash E31A6979
+  sample 13:
+    time = 367000
+    flags = 0
+    data = length 570, hash FEC40D00
+  sample 14:
+    time = 567000
+    flags = 0
+    data = length 5525, hash 7C478F7E
+  sample 15:
+    time = 500000
+    flags = 0
+    data = length 1082, hash DA07059A
+  sample 16:
+    time = 467000
+    flags = 0
+    data = length 807, hash 93478E6B
+  sample 17:
+    time = 533000
+    flags = 0
+    data = length 744, hash 9A8E6026
+  sample 18:
+    time = 700000
+    flags = 0
+    data = length 4732, hash C73B23C0
+  sample 19:
+    time = 633000
+    flags = 0
+    data = length 1004, hash 8A19A228
+  sample 20:
+    time = 600000
+    flags = 0
+    data = length 794, hash 8126022C
+  sample 21:
+    time = 667000
+    flags = 0
+    data = length 645, hash F08300E5
+  sample 22:
+    time = 833000
+    flags = 0
+    data = length 2684, hash 727FE378
+  sample 23:
+    time = 767000
+    flags = 0
+    data = length 787, hash 419A7821
+  sample 24:
+    time = 733000
+    flags = 0
+    data = length 649, hash 5C159346
+  sample 25:
+    time = 800000
+    flags = 0
+    data = length 509, hash F912D655
+  sample 26:
+    time = 967000
+    flags = 0
+    data = length 1226, hash 29815C21
+  sample 27:
+    time = 900000
+    flags = 0
+    data = length 898, hash D997AD0A
+  sample 28:
+    time = 867000
+    flags = 0
+    data = length 476, hash A0423645
+  sample 29:
+    time = 933000
+    flags = 0
+    data = length 486, hash DDF32CBB
+track 2:
+  total output bytes = 12120
+  sample count = 29
+  format 0:
+    id = 2
+    sampleMimeType = audio/ac3
+    channelCount = 1
+    sampleRate = 44100
+    selectionFlags = 1
+    language = und
+  sample 0:
+    time = 62000
+    flags = 1
+    data = length 416, hash 211F2286
+  sample 1:
+    time = 97000
+    flags = 1
+    data = length 418, hash 77425A86
+  sample 2:
+    time = 131000
+    flags = 1
+    data = length 418, hash A0FE5CA1
+  sample 3:
+    time = 166000
+    flags = 1
+    data = length 418, hash 2309B066
+  sample 4:
+    time = 201000
+    flags = 1
+    data = length 418, hash 928A653B
+  sample 5:
+    time = 236000
+    flags = 1
+    data = length 418, hash 3422F0CB
+  sample 6:
+    time = 270000
+    flags = 1
+    data = length 418, hash EFF43D5B
+  sample 7:
+    time = 306000
+    flags = 1
+    data = length 418, hash FC8093C7
+  sample 8:
+    time = 341000
+    flags = 1
+    data = length 418, hash CCC08A16
+  sample 9:
+    time = 376000
+    flags = 1
+    data = length 418, hash 2A6EE863
+  sample 10:
+    time = 410000
+    flags = 1
+    data = length 418, hash D69A9251
+  sample 11:
+    time = 445000
+    flags = 1
+    data = length 418, hash BCFB758D
+  sample 12:
+    time = 480000
+    flags = 1
+    data = length 418, hash 11B66799
+  sample 13:
+    time = 514000
+    flags = 1
+    data = length 418, hash C824D392
+  sample 14:
+    time = 550000
+    flags = 1
+    data = length 418, hash C167D872
+  sample 15:
+    time = 585000
+    flags = 1
+    data = length 418, hash 4221C855
+  sample 16:
+    time = 620000
+    flags = 1
+    data = length 418, hash 4D4FF934
+  sample 17:
+    time = 654000
+    flags = 1
+    data = length 418, hash 984AA025
+  sample 18:
+    time = 690000
+    flags = 1
+    data = length 418, hash BB788B46
+  sample 19:
+    time = 724000
+    flags = 1
+    data = length 418, hash 9EFBFD97
+  sample 20:
+    time = 759000
+    flags = 1
+    data = length 418, hash DF1A460C
+  sample 21:
+    time = 793000
+    flags = 1
+    data = length 418, hash 2BDB56A
+  sample 22:
+    time = 829000
+    flags = 1
+    data = length 418, hash CA230060
+  sample 23:
+    time = 864000
+    flags = 1
+    data = length 418, hash D2F19F41
+  sample 24:
+    time = 898000
+    flags = 1
+    data = length 418, hash AF392D79
+  sample 25:
+    time = 932000
+    flags = 1
+    data = length 418, hash C5D7F2A3
+  sample 26:
+    time = 968000
+    flags = 1
+    data = length 418, hash 733A35AE
+  sample 27:
+    time = 1002000
+    flags = 1
+    data = length 418, hash DE46E5D3
+  sample 28:
+    time = 1037000
+    flags = 1
+    data = length 418, hash 56AB8D37
+track 3:
+  total output bytes = 59
+  sample count = 1
+  format 0:
+    id = 3
+    sampleMimeType = application/x-subrip
+    language = en
+    label = Subs Label
+  sample 0:
+    time = 0
+    flags = 1
+    data = length 59, hash 1AD38625
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.0.dump b/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.0.dump
index b2c54a9..8868166 100644
--- a/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.0.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.0.dump
@@ -68,7 +68,7 @@
     flags = 1
     data = length 520, hash FEE56928
   sample 13:
-    time = 520000
+    time = 519999
     flags = 1
     data = length 599, hash 41F496C5
   sample 14:
diff --git a/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.1.dump b/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.1.dump
index f8d4804..e23156e 100644
--- a/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.1.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.1.dump
@@ -44,7 +44,7 @@
     flags = 1
     data = length 520, hash FEE56928
   sample 7:
-    time = 520000
+    time = 519999
     flags = 1
     data = length 599, hash 41F496C5
   sample 8:
diff --git a/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.2.dump b/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.2.dump
index 4375173..82a11a4 100644
--- a/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.2.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.2.dump
@@ -20,7 +20,7 @@
     flags = 1
     data = length 520, hash FEE56928
   sample 1:
-    time = 520000
+    time = 519999
     flags = 1
     data = length 599, hash 41F496C5
   sample 2:
diff --git a/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.unknown_length.dump b/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.unknown_length.dump
index b2c54a9..8868166 100644
--- a/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.unknown_length.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_ac4_fragmented.mp4.unknown_length.dump
@@ -68,7 +68,7 @@
     flags = 1
     data = length 520, hash FEE56928
   sample 13:
-    time = 520000
+    time = 519999
     flags = 1
     data = length 599, hash 41F496C5
   sample 14:
diff --git a/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.0.dump b/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.0.dump
index 425e0f6..0d0b831 100644
--- a/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.0.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.0.dump
@@ -95,7 +95,7 @@
     crypto mode = 1
     encryption key = length 16, hash 9FDDEA52
   sample 13:
-    time = 520000
+    time = 519999
     flags = 1073741825
     data = length 616, hash 3F657E23
     crypto mode = 1
diff --git a/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.1.dump b/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.1.dump
index 64935a8..aeffcab 100644
--- a/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.1.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.1.dump
@@ -59,7 +59,7 @@
     crypto mode = 1
     encryption key = length 16, hash 9FDDEA52
   sample 7:
-    time = 520000
+    time = 519999
     flags = 1073741825
     data = length 616, hash 3F657E23
     crypto mode = 1
diff --git a/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.2.dump b/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.2.dump
index 5fad73c..ce0badc 100644
--- a/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.2.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.2.dump
@@ -23,7 +23,7 @@
     crypto mode = 1
     encryption key = length 16, hash 9FDDEA52
   sample 1:
-    time = 520000
+    time = 519999
     flags = 1073741825
     data = length 616, hash 3F657E23
     crypto mode = 1
diff --git a/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.unknown_length.dump b/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.unknown_length.dump
index 425e0f6..0d0b831 100644
--- a/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.unknown_length.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_ac4_protected.mp4.unknown_length.dump
@@ -95,7 +95,7 @@
     crypto mode = 1
     encryption key = length 16, hash 9FDDEA52
   sample 13:
-    time = 520000
+    time = 519999
     flags = 1073741825
     data = length 616, hash 3F657E23
     crypto mode = 1
diff --git a/tree/testdata/src/test/assets/mp4/sample_fragmented.mp4.0.dump b/tree/testdata/src/test/assets/mp4/sample_fragmented.mp4.0.dump
index a736207..2a5848f 100644
--- a/tree/testdata/src/test/assets/mp4/sample_fragmented.mp4.0.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_fragmented.mp4.0.dump
@@ -15,123 +15,123 @@
       data = length 29, hash 4746B5D9
       data = length 10, hash 7A0D0F2B
   sample 0:
-    time = 66000
+    time = 66733
     flags = 1
     data = length 38070, hash B58E1AEE
   sample 1:
-    time = 199000
+    time = 200199
     flags = 0
     data = length 8340, hash 8AC449FF
   sample 2:
-    time = 132000
+    time = 133466
     flags = 0
     data = length 1295, hash C0DA5090
   sample 3:
-    time = 100000
+    time = 100100
     flags = 0
     data = length 469, hash D6E0A200
   sample 4:
-    time = 166000
+    time = 166832
     flags = 0
     data = length 564, hash E5F56C5B
   sample 5:
-    time = 332000
+    time = 333666
     flags = 0
     data = length 6075, hash 8756E49E
   sample 6:
-    time = 266000
+    time = 266933
     flags = 0
     data = length 847, hash DCC2B618
   sample 7:
-    time = 233000
+    time = 233566
     flags = 0
     data = length 455, hash B9CCE047
   sample 8:
-    time = 299000
+    time = 300299
     flags = 0
     data = length 467, hash 69806D94
   sample 9:
-    time = 466000
+    time = 467133
     flags = 0
     data = length 4549, hash 3944F501
   sample 10:
-    time = 399000
+    time = 400399
     flags = 0
     data = length 1087, hash 491BF106
   sample 11:
-    time = 367000
+    time = 367033
     flags = 0
     data = length 380, hash 5FED016A
   sample 12:
-    time = 433000
+    time = 433766
     flags = 0
     data = length 455, hash 8A0610
   sample 13:
-    time = 599000
+    time = 600599
     flags = 0
     data = length 5190, hash B9031D8
   sample 14:
-    time = 533000
+    time = 533866
     flags = 0
     data = length 1071, hash 684E7DC8
   sample 15:
-    time = 500000
+    time = 500500
     flags = 0
     data = length 653, hash 8494F326
   sample 16:
-    time = 566000
+    time = 567232
     flags = 0
     data = length 485, hash 2CCC85F4
   sample 17:
-    time = 733000
+    time = 734066
     flags = 0
     data = length 4884, hash D16B6A96
   sample 18:
-    time = 666000
+    time = 667333
     flags = 0
     data = length 997, hash 164FF210
   sample 19:
-    time = 633000
+    time = 633966
     flags = 0
     data = length 640, hash F664125B
   sample 20:
-    time = 700000
+    time = 700699
     flags = 0
     data = length 491, hash B5930C7C
   sample 21:
-    time = 866000
+    time = 867533
     flags = 0
     data = length 2989, hash 92CF4FCF
   sample 22:
-    time = 800000
+    time = 800799
     flags = 0
     data = length 838, hash 294A3451
   sample 23:
-    time = 767000
+    time = 767433
     flags = 0
     data = length 544, hash FCCE2DE6
   sample 24:
-    time = 833000
+    time = 834166
     flags = 0
     data = length 329, hash A654FFA1
   sample 25:
-    time = 1000000
+    time = 1000999
     flags = 0
     data = length 1517, hash 5F7EBF8B
   sample 26:
-    time = 933000
+    time = 934266
     flags = 0
     data = length 803, hash 7A5C4C1D
   sample 27:
-    time = 900000
+    time = 900900
     flags = 0
     data = length 415, hash B31BBC3B
   sample 28:
-    time = 967000
+    time = 967632
     flags = 0
     data = length 415, hash 850DFEA3
   sample 29:
-    time = 1033000
+    time = 1034366
     flags = 0
     data = length 619, hash AB5E56CA
 track 1:
@@ -151,183 +151,183 @@
     flags = 1
     data = length 18, hash 96519432
   sample 1:
-    time = 23000
+    time = 23219
     flags = 1
     data = length 4, hash EE9DF
   sample 2:
-    time = 46000
+    time = 46439
     flags = 1
     data = length 4, hash EEDBF
   sample 3:
-    time = 69000
+    time = 69659
     flags = 1
     data = length 157, hash E2F078F4
   sample 4:
-    time = 92000
+    time = 92879
     flags = 1
     data = length 371, hash B9471F94
   sample 5:
-    time = 116000
+    time = 116099
     flags = 1
     data = length 373, hash 2AB265CB
   sample 6:
-    time = 139000
+    time = 139319
     flags = 1
     data = length 402, hash 1295477C
   sample 7:
-    time = 162000
+    time = 162539
     flags = 1
     data = length 455, hash 2D8146C8
   sample 8:
-    time = 185000
+    time = 185759
     flags = 1
     data = length 434, hash F2C5D287
   sample 9:
-    time = 208000
+    time = 208979
     flags = 1
     data = length 450, hash 84143FCD
   sample 10:
-    time = 232000
+    time = 232199
     flags = 1
     data = length 429, hash EF769D50
   sample 11:
-    time = 255000
+    time = 255419
     flags = 1
     data = length 450, hash EC3DE692
   sample 12:
-    time = 278000
+    time = 278639
     flags = 1
     data = length 447, hash 3E519E13
   sample 13:
-    time = 301000
+    time = 301859
     flags = 1
     data = length 457, hash 1E4F23A0
   sample 14:
-    time = 325000
+    time = 325079
     flags = 1
     data = length 447, hash A439EA97
   sample 15:
-    time = 348000
+    time = 348299
     flags = 1
     data = length 456, hash 1E9034C6
   sample 16:
-    time = 371000
+    time = 371519
     flags = 1
     data = length 398, hash 99DB7345
   sample 17:
-    time = 394000
+    time = 394739
     flags = 1
     data = length 474, hash 3F05F10A
   sample 18:
-    time = 417000
+    time = 417959
     flags = 1
     data = length 416, hash C105EE09
   sample 19:
-    time = 441000
+    time = 441179
     flags = 1
     data = length 454, hash 5FDBE458
   sample 20:
-    time = 464000
+    time = 464399
     flags = 1
     data = length 438, hash 41A93AC3
   sample 21:
-    time = 487000
+    time = 487619
     flags = 1
     data = length 443, hash 10FDA652
   sample 22:
-    time = 510000
+    time = 510839
     flags = 1
     data = length 412, hash 1F791E25
   sample 23:
-    time = 534000
+    time = 534058
     flags = 1
     data = length 482, hash A6D983D
   sample 24:
-    time = 557000
+    time = 557278
     flags = 1
     data = length 386, hash BED7392F
   sample 25:
-    time = 580000
+    time = 580498
     flags = 1
     data = length 463, hash 5309F8C9
   sample 26:
-    time = 603000
+    time = 603718
     flags = 1
     data = length 394, hash 21C7321F
   sample 27:
-    time = 626000
+    time = 626938
     flags = 1
     data = length 489, hash 71B4730D
   sample 28:
-    time = 650000
+    time = 650158
     flags = 1
     data = length 403, hash D9C6DE89
   sample 29:
-    time = 673000
+    time = 673378
     flags = 1
     data = length 447, hash 9B14B73B
   sample 30:
-    time = 696000
+    time = 696598
     flags = 1
     data = length 439, hash 4760D35B
   sample 31:
-    time = 719000
+    time = 719818
     flags = 1
     data = length 463, hash 1601F88D
   sample 32:
-    time = 743000
+    time = 743038
     flags = 1
     data = length 423, hash D4AE6773
   sample 33:
-    time = 766000
+    time = 766258
     flags = 1
     data = length 497, hash A3C674D3
   sample 34:
-    time = 789000
+    time = 789478
     flags = 1
     data = length 419, hash D3734A1F
   sample 35:
-    time = 812000
+    time = 812698
     flags = 1
     data = length 474, hash DFB41F9
   sample 36:
-    time = 835000
+    time = 835918
     flags = 1
     data = length 413, hash 53E7CB9F
   sample 37:
-    time = 859000
+    time = 859138
     flags = 1
     data = length 445, hash D15B0E39
   sample 38:
-    time = 882000
+    time = 882358
     flags = 1
     data = length 453, hash 77ED81E4
   sample 39:
-    time = 905000
+    time = 905578
     flags = 1
     data = length 545, hash 3321AEB9
   sample 40:
-    time = 928000
+    time = 928798
     flags = 1
     data = length 317, hash F557D0E
   sample 41:
-    time = 952000
+    time = 952018
     flags = 1
     data = length 537, hash ED58CF7B
   sample 42:
-    time = 975000
+    time = 975238
     flags = 1
     data = length 458, hash 51CDAA10
   sample 43:
-    time = 998000
+    time = 998458
     flags = 1
     data = length 465, hash CBA1EFD7
   sample 44:
-    time = 1021000
+    time = 1021678
     flags = 1
     data = length 446, hash D6735B8A
   sample 45:
-    time = 1044000
+    time = 1044897
     flags = 1
     data = length 10, hash A453EEBE
 tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mp4/sample_fragmented.mp4.unknown_length.dump b/tree/testdata/src/test/assets/mp4/sample_fragmented.mp4.unknown_length.dump
index a736207..2a5848f 100644
--- a/tree/testdata/src/test/assets/mp4/sample_fragmented.mp4.unknown_length.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_fragmented.mp4.unknown_length.dump
@@ -15,123 +15,123 @@
       data = length 29, hash 4746B5D9
       data = length 10, hash 7A0D0F2B
   sample 0:
-    time = 66000
+    time = 66733
     flags = 1
     data = length 38070, hash B58E1AEE
   sample 1:
-    time = 199000
+    time = 200199
     flags = 0
     data = length 8340, hash 8AC449FF
   sample 2:
-    time = 132000
+    time = 133466
     flags = 0
     data = length 1295, hash C0DA5090
   sample 3:
-    time = 100000
+    time = 100100
     flags = 0
     data = length 469, hash D6E0A200
   sample 4:
-    time = 166000
+    time = 166832
     flags = 0
     data = length 564, hash E5F56C5B
   sample 5:
-    time = 332000
+    time = 333666
     flags = 0
     data = length 6075, hash 8756E49E
   sample 6:
-    time = 266000
+    time = 266933
     flags = 0
     data = length 847, hash DCC2B618
   sample 7:
-    time = 233000
+    time = 233566
     flags = 0
     data = length 455, hash B9CCE047
   sample 8:
-    time = 299000
+    time = 300299
     flags = 0
     data = length 467, hash 69806D94
   sample 9:
-    time = 466000
+    time = 467133
     flags = 0
     data = length 4549, hash 3944F501
   sample 10:
-    time = 399000
+    time = 400399
     flags = 0
     data = length 1087, hash 491BF106
   sample 11:
-    time = 367000
+    time = 367033
     flags = 0
     data = length 380, hash 5FED016A
   sample 12:
-    time = 433000
+    time = 433766
     flags = 0
     data = length 455, hash 8A0610
   sample 13:
-    time = 599000
+    time = 600599
     flags = 0
     data = length 5190, hash B9031D8
   sample 14:
-    time = 533000
+    time = 533866
     flags = 0
     data = length 1071, hash 684E7DC8
   sample 15:
-    time = 500000
+    time = 500500
     flags = 0
     data = length 653, hash 8494F326
   sample 16:
-    time = 566000
+    time = 567232
     flags = 0
     data = length 485, hash 2CCC85F4
   sample 17:
-    time = 733000
+    time = 734066
     flags = 0
     data = length 4884, hash D16B6A96
   sample 18:
-    time = 666000
+    time = 667333
     flags = 0
     data = length 997, hash 164FF210
   sample 19:
-    time = 633000
+    time = 633966
     flags = 0
     data = length 640, hash F664125B
   sample 20:
-    time = 700000
+    time = 700699
     flags = 0
     data = length 491, hash B5930C7C
   sample 21:
-    time = 866000
+    time = 867533
     flags = 0
     data = length 2989, hash 92CF4FCF
   sample 22:
-    time = 800000
+    time = 800799
     flags = 0
     data = length 838, hash 294A3451
   sample 23:
-    time = 767000
+    time = 767433
     flags = 0
     data = length 544, hash FCCE2DE6
   sample 24:
-    time = 833000
+    time = 834166
     flags = 0
     data = length 329, hash A654FFA1
   sample 25:
-    time = 1000000
+    time = 1000999
     flags = 0
     data = length 1517, hash 5F7EBF8B
   sample 26:
-    time = 933000
+    time = 934266
     flags = 0
     data = length 803, hash 7A5C4C1D
   sample 27:
-    time = 900000
+    time = 900900
     flags = 0
     data = length 415, hash B31BBC3B
   sample 28:
-    time = 967000
+    time = 967632
     flags = 0
     data = length 415, hash 850DFEA3
   sample 29:
-    time = 1033000
+    time = 1034366
     flags = 0
     data = length 619, hash AB5E56CA
 track 1:
@@ -151,183 +151,183 @@
     flags = 1
     data = length 18, hash 96519432
   sample 1:
-    time = 23000
+    time = 23219
     flags = 1
     data = length 4, hash EE9DF
   sample 2:
-    time = 46000
+    time = 46439
     flags = 1
     data = length 4, hash EEDBF
   sample 3:
-    time = 69000
+    time = 69659
     flags = 1
     data = length 157, hash E2F078F4
   sample 4:
-    time = 92000
+    time = 92879
     flags = 1
     data = length 371, hash B9471F94
   sample 5:
-    time = 116000
+    time = 116099
     flags = 1
     data = length 373, hash 2AB265CB
   sample 6:
-    time = 139000
+    time = 139319
     flags = 1
     data = length 402, hash 1295477C
   sample 7:
-    time = 162000
+    time = 162539
     flags = 1
     data = length 455, hash 2D8146C8
   sample 8:
-    time = 185000
+    time = 185759
     flags = 1
     data = length 434, hash F2C5D287
   sample 9:
-    time = 208000
+    time = 208979
     flags = 1
     data = length 450, hash 84143FCD
   sample 10:
-    time = 232000
+    time = 232199
     flags = 1
     data = length 429, hash EF769D50
   sample 11:
-    time = 255000
+    time = 255419
     flags = 1
     data = length 450, hash EC3DE692
   sample 12:
-    time = 278000
+    time = 278639
     flags = 1
     data = length 447, hash 3E519E13
   sample 13:
-    time = 301000
+    time = 301859
     flags = 1
     data = length 457, hash 1E4F23A0
   sample 14:
-    time = 325000
+    time = 325079
     flags = 1
     data = length 447, hash A439EA97
   sample 15:
-    time = 348000
+    time = 348299
     flags = 1
     data = length 456, hash 1E9034C6
   sample 16:
-    time = 371000
+    time = 371519
     flags = 1
     data = length 398, hash 99DB7345
   sample 17:
-    time = 394000
+    time = 394739
     flags = 1
     data = length 474, hash 3F05F10A
   sample 18:
-    time = 417000
+    time = 417959
     flags = 1
     data = length 416, hash C105EE09
   sample 19:
-    time = 441000
+    time = 441179
     flags = 1
     data = length 454, hash 5FDBE458
   sample 20:
-    time = 464000
+    time = 464399
     flags = 1
     data = length 438, hash 41A93AC3
   sample 21:
-    time = 487000
+    time = 487619
     flags = 1
     data = length 443, hash 10FDA652
   sample 22:
-    time = 510000
+    time = 510839
     flags = 1
     data = length 412, hash 1F791E25
   sample 23:
-    time = 534000
+    time = 534058
     flags = 1
     data = length 482, hash A6D983D
   sample 24:
-    time = 557000
+    time = 557278
     flags = 1
     data = length 386, hash BED7392F
   sample 25:
-    time = 580000
+    time = 580498
     flags = 1
     data = length 463, hash 5309F8C9
   sample 26:
-    time = 603000
+    time = 603718
     flags = 1
     data = length 394, hash 21C7321F
   sample 27:
-    time = 626000
+    time = 626938
     flags = 1
     data = length 489, hash 71B4730D
   sample 28:
-    time = 650000
+    time = 650158
     flags = 1
     data = length 403, hash D9C6DE89
   sample 29:
-    time = 673000
+    time = 673378
     flags = 1
     data = length 447, hash 9B14B73B
   sample 30:
-    time = 696000
+    time = 696598
     flags = 1
     data = length 439, hash 4760D35B
   sample 31:
-    time = 719000
+    time = 719818
     flags = 1
     data = length 463, hash 1601F88D
   sample 32:
-    time = 743000
+    time = 743038
     flags = 1
     data = length 423, hash D4AE6773
   sample 33:
-    time = 766000
+    time = 766258
     flags = 1
     data = length 497, hash A3C674D3
   sample 34:
-    time = 789000
+    time = 789478
     flags = 1
     data = length 419, hash D3734A1F
   sample 35:
-    time = 812000
+    time = 812698
     flags = 1
     data = length 474, hash DFB41F9
   sample 36:
-    time = 835000
+    time = 835918
     flags = 1
     data = length 413, hash 53E7CB9F
   sample 37:
-    time = 859000
+    time = 859138
     flags = 1
     data = length 445, hash D15B0E39
   sample 38:
-    time = 882000
+    time = 882358
     flags = 1
     data = length 453, hash 77ED81E4
   sample 39:
-    time = 905000
+    time = 905578
     flags = 1
     data = length 545, hash 3321AEB9
   sample 40:
-    time = 928000
+    time = 928798
     flags = 1
     data = length 317, hash F557D0E
   sample 41:
-    time = 952000
+    time = 952018
     flags = 1
     data = length 537, hash ED58CF7B
   sample 42:
-    time = 975000
+    time = 975238
     flags = 1
     data = length 458, hash 51CDAA10
   sample 43:
-    time = 998000
+    time = 998458
     flags = 1
     data = length 465, hash CBA1EFD7
   sample 44:
-    time = 1021000
+    time = 1021678
     flags = 1
     data = length 446, hash D6735B8A
   sample 45:
-    time = 1044000
+    time = 1044897
     flags = 1
     data = length 10, hash A453EEBE
 tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump b/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump
index c879cde..5dc1f1d 100644
--- a/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump
@@ -18,123 +18,123 @@
       data = length 29, hash 4746B5D9
       data = length 10, hash 7A0D0F2B
   sample 0:
-    time = 66000
+    time = 66733
     flags = 1
     data = length 38070, hash B58E1AEE
   sample 1:
-    time = 199000
+    time = 200199
     flags = 0
     data = length 8340, hash 8AC449FF
   sample 2:
-    time = 132000
+    time = 133466
     flags = 0
     data = length 1295, hash C0DA5090
   sample 3:
-    time = 100000
+    time = 100100
     flags = 0
     data = length 469, hash D6E0A200
   sample 4:
-    time = 166000
+    time = 166832
     flags = 0
     data = length 564, hash E5F56C5B
   sample 5:
-    time = 332000
+    time = 333666
     flags = 0
     data = length 6075, hash 8756E49E
   sample 6:
-    time = 266000
+    time = 266933
     flags = 0
     data = length 847, hash DCC2B618
   sample 7:
-    time = 233000
+    time = 233566
     flags = 0
     data = length 455, hash B9CCE047
   sample 8:
-    time = 299000
+    time = 300299
     flags = 0
     data = length 467, hash 69806D94
   sample 9:
-    time = 466000
+    time = 467133
     flags = 0
     data = length 4549, hash 3944F501
   sample 10:
-    time = 399000
+    time = 400399
     flags = 0
     data = length 1087, hash 491BF106
   sample 11:
-    time = 367000
+    time = 367033
     flags = 0
     data = length 380, hash 5FED016A
   sample 12:
-    time = 433000
+    time = 433766
     flags = 0
     data = length 455, hash 8A0610
   sample 13:
-    time = 599000
+    time = 600599
     flags = 0
     data = length 5190, hash B9031D8
   sample 14:
-    time = 533000
+    time = 533866
     flags = 0
     data = length 1071, hash 684E7DC8
   sample 15:
-    time = 500000
+    time = 500500
     flags = 0
     data = length 653, hash 8494F326
   sample 16:
-    time = 566000
+    time = 567232
     flags = 0
     data = length 485, hash 2CCC85F4
   sample 17:
-    time = 733000
+    time = 734066
     flags = 0
     data = length 4884, hash D16B6A96
   sample 18:
-    time = 666000
+    time = 667333
     flags = 0
     data = length 997, hash 164FF210
   sample 19:
-    time = 633000
+    time = 633966
     flags = 0
     data = length 640, hash F664125B
   sample 20:
-    time = 700000
+    time = 700699
     flags = 0
     data = length 491, hash B5930C7C
   sample 21:
-    time = 866000
+    time = 867533
     flags = 0
     data = length 2989, hash 92CF4FCF
   sample 22:
-    time = 800000
+    time = 800799
     flags = 0
     data = length 838, hash 294A3451
   sample 23:
-    time = 767000
+    time = 767433
     flags = 0
     data = length 544, hash FCCE2DE6
   sample 24:
-    time = 833000
+    time = 834166
     flags = 0
     data = length 329, hash A654FFA1
   sample 25:
-    time = 1000000
+    time = 1000999
     flags = 0
     data = length 1517, hash 5F7EBF8B
   sample 26:
-    time = 933000
+    time = 934266
     flags = 0
     data = length 803, hash 7A5C4C1D
   sample 27:
-    time = 900000
+    time = 900900
     flags = 0
     data = length 415, hash B31BBC3B
   sample 28:
-    time = 967000
+    time = 967632
     flags = 0
     data = length 415, hash 850DFEA3
   sample 29:
-    time = 1033000
+    time = 1034366
     flags = 0
     data = length 619, hash AB5E56CA
 track 1:
@@ -154,183 +154,183 @@
     flags = 1
     data = length 18, hash 96519432
   sample 1:
-    time = 23000
+    time = 23219
     flags = 1
     data = length 4, hash EE9DF
   sample 2:
-    time = 46000
+    time = 46439
     flags = 1
     data = length 4, hash EEDBF
   sample 3:
-    time = 69000
+    time = 69659
     flags = 1
     data = length 157, hash E2F078F4
   sample 4:
-    time = 92000
+    time = 92879
     flags = 1
     data = length 371, hash B9471F94
   sample 5:
-    time = 116000
+    time = 116099
     flags = 1
     data = length 373, hash 2AB265CB
   sample 6:
-    time = 139000
+    time = 139319
     flags = 1
     data = length 402, hash 1295477C
   sample 7:
-    time = 162000
+    time = 162539
     flags = 1
     data = length 455, hash 2D8146C8
   sample 8:
-    time = 185000
+    time = 185759
     flags = 1
     data = length 434, hash F2C5D287
   sample 9:
-    time = 208000
+    time = 208979
     flags = 1
     data = length 450, hash 84143FCD
   sample 10:
-    time = 232000
+    time = 232199
     flags = 1
     data = length 429, hash EF769D50
   sample 11:
-    time = 255000
+    time = 255419
     flags = 1
     data = length 450, hash EC3DE692
   sample 12:
-    time = 278000
+    time = 278639
     flags = 1
     data = length 447, hash 3E519E13
   sample 13:
-    time = 301000
+    time = 301859
     flags = 1
     data = length 457, hash 1E4F23A0
   sample 14:
-    time = 325000
+    time = 325079
     flags = 1
     data = length 447, hash A439EA97
   sample 15:
-    time = 348000
+    time = 348299
     flags = 1
     data = length 456, hash 1E9034C6
   sample 16:
-    time = 371000
+    time = 371519
     flags = 1
     data = length 398, hash 99DB7345
   sample 17:
-    time = 394000
+    time = 394739
     flags = 1
     data = length 474, hash 3F05F10A
   sample 18:
-    time = 417000
+    time = 417959
     flags = 1
     data = length 416, hash C105EE09
   sample 19:
-    time = 441000
+    time = 441179
     flags = 1
     data = length 454, hash 5FDBE458
   sample 20:
-    time = 464000
+    time = 464399
     flags = 1
     data = length 438, hash 41A93AC3
   sample 21:
-    time = 487000
+    time = 487619
     flags = 1
     data = length 443, hash 10FDA652
   sample 22:
-    time = 510000
+    time = 510839
     flags = 1
     data = length 412, hash 1F791E25
   sample 23:
-    time = 534000
+    time = 534058
     flags = 1
     data = length 482, hash A6D983D
   sample 24:
-    time = 557000
+    time = 557278
     flags = 1
     data = length 386, hash BED7392F
   sample 25:
-    time = 580000
+    time = 580498
     flags = 1
     data = length 463, hash 5309F8C9
   sample 26:
-    time = 603000
+    time = 603718
     flags = 1
     data = length 394, hash 21C7321F
   sample 27:
-    time = 626000
+    time = 626938
     flags = 1
     data = length 489, hash 71B4730D
   sample 28:
-    time = 650000
+    time = 650158
     flags = 1
     data = length 403, hash D9C6DE89
   sample 29:
-    time = 673000
+    time = 673378
     flags = 1
     data = length 447, hash 9B14B73B
   sample 30:
-    time = 696000
+    time = 696598
     flags = 1
     data = length 439, hash 4760D35B
   sample 31:
-    time = 719000
+    time = 719818
     flags = 1
     data = length 463, hash 1601F88D
   sample 32:
-    time = 743000
+    time = 743038
     flags = 1
     data = length 423, hash D4AE6773
   sample 33:
-    time = 766000
+    time = 766258
     flags = 1
     data = length 497, hash A3C674D3
   sample 34:
-    time = 789000
+    time = 789478
     flags = 1
     data = length 419, hash D3734A1F
   sample 35:
-    time = 812000
+    time = 812698
     flags = 1
     data = length 474, hash DFB41F9
   sample 36:
-    time = 835000
+    time = 835918
     flags = 1
     data = length 413, hash 53E7CB9F
   sample 37:
-    time = 859000
+    time = 859138
     flags = 1
     data = length 445, hash D15B0E39
   sample 38:
-    time = 882000
+    time = 882358
     flags = 1
     data = length 453, hash 77ED81E4
   sample 39:
-    time = 905000
+    time = 905578
     flags = 1
     data = length 545, hash 3321AEB9
   sample 40:
-    time = 928000
+    time = 928798
     flags = 1
     data = length 317, hash F557D0E
   sample 41:
-    time = 952000
+    time = 952018
     flags = 1
     data = length 537, hash ED58CF7B
   sample 42:
-    time = 975000
+    time = 975238
     flags = 1
     data = length 458, hash 51CDAA10
   sample 43:
-    time = 998000
+    time = 998458
     flags = 1
     data = length 465, hash CBA1EFD7
   sample 44:
-    time = 1021000
+    time = 1021678
     flags = 1
     data = length 446, hash D6735B8A
   sample 45:
-    time = 1044000
+    time = 1044897
     flags = 1
     data = length 10, hash A453EEBE
 tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump b/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump
index b264cf1..aab2beb 100644
--- a/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump
@@ -18,123 +18,123 @@
       data = length 29, hash 4746B5D9
       data = length 10, hash 7A0D0F2B
   sample 0:
-    time = 66000
+    time = 66733
     flags = 1
     data = length 38070, hash B58E1AEE
   sample 1:
-    time = 199000
+    time = 200199
     flags = 0
     data = length 8340, hash 8AC449FF
   sample 2:
-    time = 132000
+    time = 133466
     flags = 0
     data = length 1295, hash C0DA5090
   sample 3:
-    time = 100000
+    time = 100100
     flags = 0
     data = length 469, hash D6E0A200
   sample 4:
-    time = 166000
+    time = 166832
     flags = 0
     data = length 564, hash E5F56C5B
   sample 5:
-    time = 332000
+    time = 333666
     flags = 0
     data = length 6075, hash 8756E49E
   sample 6:
-    time = 266000
+    time = 266933
     flags = 0
     data = length 847, hash DCC2B618
   sample 7:
-    time = 233000
+    time = 233566
     flags = 0
     data = length 455, hash B9CCE047
   sample 8:
-    time = 299000
+    time = 300299
     flags = 0
     data = length 467, hash 69806D94
   sample 9:
-    time = 466000
+    time = 467133
     flags = 0
     data = length 4549, hash 3944F501
   sample 10:
-    time = 399000
+    time = 400399
     flags = 0
     data = length 1087, hash 491BF106
   sample 11:
-    time = 367000
+    time = 367033
     flags = 0
     data = length 380, hash 5FED016A
   sample 12:
-    time = 433000
+    time = 433766
     flags = 0
     data = length 455, hash 8A0610
   sample 13:
-    time = 599000
+    time = 600599
     flags = 0
     data = length 5190, hash B9031D8
   sample 14:
-    time = 533000
+    time = 533866
     flags = 0
     data = length 1071, hash 684E7DC8
   sample 15:
-    time = 500000
+    time = 500500
     flags = 0
     data = length 653, hash 8494F326
   sample 16:
-    time = 566000
+    time = 567232
     flags = 0
     data = length 485, hash 2CCC85F4
   sample 17:
-    time = 733000
+    time = 734066
     flags = 0
     data = length 4884, hash D16B6A96
   sample 18:
-    time = 666000
+    time = 667333
     flags = 0
     data = length 997, hash 164FF210
   sample 19:
-    time = 633000
+    time = 633966
     flags = 0
     data = length 640, hash F664125B
   sample 20:
-    time = 700000
+    time = 700699
     flags = 0
     data = length 491, hash B5930C7C
   sample 21:
-    time = 866000
+    time = 867533
     flags = 0
     data = length 2989, hash 92CF4FCF
   sample 22:
-    time = 800000
+    time = 800799
     flags = 0
     data = length 838, hash 294A3451
   sample 23:
-    time = 767000
+    time = 767433
     flags = 0
     data = length 544, hash FCCE2DE6
   sample 24:
-    time = 833000
+    time = 834166
     flags = 0
     data = length 329, hash A654FFA1
   sample 25:
-    time = 1000000
+    time = 1000999
     flags = 0
     data = length 1517, hash 5F7EBF8B
   sample 26:
-    time = 933000
+    time = 934266
     flags = 0
     data = length 803, hash 7A5C4C1D
   sample 27:
-    time = 900000
+    time = 900900
     flags = 0
     data = length 415, hash B31BBC3B
   sample 28:
-    time = 967000
+    time = 967632
     flags = 0
     data = length 415, hash 850DFEA3
   sample 29:
-    time = 1033000
+    time = 1034366
     flags = 0
     data = length 619, hash AB5E56CA
 track 1:
@@ -150,127 +150,127 @@
     initializationData:
       data = length 5, hash 2B7623A
   sample 0:
-    time = 348000
+    time = 348299
     flags = 1
     data = length 456, hash 1E9034C6
   sample 1:
-    time = 371000
+    time = 371519
     flags = 1
     data = length 398, hash 99DB7345
   sample 2:
-    time = 394000
+    time = 394739
     flags = 1
     data = length 474, hash 3F05F10A
   sample 3:
-    time = 417000
+    time = 417959
     flags = 1
     data = length 416, hash C105EE09
   sample 4:
-    time = 441000
+    time = 441179
     flags = 1
     data = length 454, hash 5FDBE458
   sample 5:
-    time = 464000
+    time = 464399
     flags = 1
     data = length 438, hash 41A93AC3
   sample 6:
-    time = 487000
+    time = 487619
     flags = 1
     data = length 443, hash 10FDA652
   sample 7:
-    time = 510000
+    time = 510839
     flags = 1
     data = length 412, hash 1F791E25
   sample 8:
-    time = 534000
+    time = 534058
     flags = 1
     data = length 482, hash A6D983D
   sample 9:
-    time = 557000
+    time = 557278
     flags = 1
     data = length 386, hash BED7392F
   sample 10:
-    time = 580000
+    time = 580498
     flags = 1
     data = length 463, hash 5309F8C9
   sample 11:
-    time = 603000
+    time = 603718
     flags = 1
     data = length 394, hash 21C7321F
   sample 12:
-    time = 626000
+    time = 626938
     flags = 1
     data = length 489, hash 71B4730D
   sample 13:
-    time = 650000
+    time = 650158
     flags = 1
     data = length 403, hash D9C6DE89
   sample 14:
-    time = 673000
+    time = 673378
     flags = 1
     data = length 447, hash 9B14B73B
   sample 15:
-    time = 696000
+    time = 696598
     flags = 1
     data = length 439, hash 4760D35B
   sample 16:
-    time = 719000
+    time = 719818
     flags = 1
     data = length 463, hash 1601F88D
   sample 17:
-    time = 743000
+    time = 743038
     flags = 1
     data = length 423, hash D4AE6773
   sample 18:
-    time = 766000
+    time = 766258
     flags = 1
     data = length 497, hash A3C674D3
   sample 19:
-    time = 789000
+    time = 789478
     flags = 1
     data = length 419, hash D3734A1F
   sample 20:
-    time = 812000
+    time = 812698
     flags = 1
     data = length 474, hash DFB41F9
   sample 21:
-    time = 835000
+    time = 835918
     flags = 1
     data = length 413, hash 53E7CB9F
   sample 22:
-    time = 859000
+    time = 859138
     flags = 1
     data = length 445, hash D15B0E39
   sample 23:
-    time = 882000
+    time = 882358
     flags = 1
     data = length 453, hash 77ED81E4
   sample 24:
-    time = 905000
+    time = 905578
     flags = 1
     data = length 545, hash 3321AEB9
   sample 25:
-    time = 928000
+    time = 928798
     flags = 1
     data = length 317, hash F557D0E
   sample 26:
-    time = 952000
+    time = 952018
     flags = 1
     data = length 537, hash ED58CF7B
   sample 27:
-    time = 975000
+    time = 975238
     flags = 1
     data = length 458, hash 51CDAA10
   sample 28:
-    time = 998000
+    time = 998458
     flags = 1
     data = length 465, hash CBA1EFD7
   sample 29:
-    time = 1021000
+    time = 1021678
     flags = 1
     data = length 446, hash D6735B8A
   sample 30:
-    time = 1044000
+    time = 1044897
     flags = 1
     data = length 10, hash A453EEBE
 tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump b/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump
index 4b10cca..c1d569c 100644
--- a/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump
@@ -18,123 +18,123 @@
       data = length 29, hash 4746B5D9
       data = length 10, hash 7A0D0F2B
   sample 0:
-    time = 66000
+    time = 66733
     flags = 1
     data = length 38070, hash B58E1AEE
   sample 1:
-    time = 199000
+    time = 200199
     flags = 0
     data = length 8340, hash 8AC449FF
   sample 2:
-    time = 132000
+    time = 133466
     flags = 0
     data = length 1295, hash C0DA5090
   sample 3:
-    time = 100000
+    time = 100100
     flags = 0
     data = length 469, hash D6E0A200
   sample 4:
-    time = 166000
+    time = 166832
     flags = 0
     data = length 564, hash E5F56C5B
   sample 5:
-    time = 332000
+    time = 333666
     flags = 0
     data = length 6075, hash 8756E49E
   sample 6:
-    time = 266000
+    time = 266933
     flags = 0
     data = length 847, hash DCC2B618
   sample 7:
-    time = 233000
+    time = 233566
     flags = 0
     data = length 455, hash B9CCE047
   sample 8:
-    time = 299000
+    time = 300299
     flags = 0
     data = length 467, hash 69806D94
   sample 9:
-    time = 466000
+    time = 467133
     flags = 0
     data = length 4549, hash 3944F501
   sample 10:
-    time = 399000
+    time = 400399
     flags = 0
     data = length 1087, hash 491BF106
   sample 11:
-    time = 367000
+    time = 367033
     flags = 0
     data = length 380, hash 5FED016A
   sample 12:
-    time = 433000
+    time = 433766
     flags = 0
     data = length 455, hash 8A0610
   sample 13:
-    time = 599000
+    time = 600599
     flags = 0
     data = length 5190, hash B9031D8
   sample 14:
-    time = 533000
+    time = 533866
     flags = 0
     data = length 1071, hash 684E7DC8
   sample 15:
-    time = 500000
+    time = 500500
     flags = 0
     data = length 653, hash 8494F326
   sample 16:
-    time = 566000
+    time = 567232
     flags = 0
     data = length 485, hash 2CCC85F4
   sample 17:
-    time = 733000
+    time = 734066
     flags = 0
     data = length 4884, hash D16B6A96
   sample 18:
-    time = 666000
+    time = 667333
     flags = 0
     data = length 997, hash 164FF210
   sample 19:
-    time = 633000
+    time = 633966
     flags = 0
     data = length 640, hash F664125B
   sample 20:
-    time = 700000
+    time = 700699
     flags = 0
     data = length 491, hash B5930C7C
   sample 21:
-    time = 866000
+    time = 867533
     flags = 0
     data = length 2989, hash 92CF4FCF
   sample 22:
-    time = 800000
+    time = 800799
     flags = 0
     data = length 838, hash 294A3451
   sample 23:
-    time = 767000
+    time = 767433
     flags = 0
     data = length 544, hash FCCE2DE6
   sample 24:
-    time = 833000
+    time = 834166
     flags = 0
     data = length 329, hash A654FFA1
   sample 25:
-    time = 1000000
+    time = 1000999
     flags = 0
     data = length 1517, hash 5F7EBF8B
   sample 26:
-    time = 933000
+    time = 934266
     flags = 0
     data = length 803, hash 7A5C4C1D
   sample 27:
-    time = 900000
+    time = 900900
     flags = 0
     data = length 415, hash B31BBC3B
   sample 28:
-    time = 967000
+    time = 967632
     flags = 0
     data = length 415, hash 850DFEA3
   sample 29:
-    time = 1033000
+    time = 1034366
     flags = 0
     data = length 619, hash AB5E56CA
 track 1:
@@ -150,67 +150,67 @@
     initializationData:
       data = length 5, hash 2B7623A
   sample 0:
-    time = 696000
+    time = 696598
     flags = 1
     data = length 439, hash 4760D35B
   sample 1:
-    time = 719000
+    time = 719818
     flags = 1
     data = length 463, hash 1601F88D
   sample 2:
-    time = 743000
+    time = 743038
     flags = 1
     data = length 423, hash D4AE6773
   sample 3:
-    time = 766000
+    time = 766258
     flags = 1
     data = length 497, hash A3C674D3
   sample 4:
-    time = 789000
+    time = 789478
     flags = 1
     data = length 419, hash D3734A1F
   sample 5:
-    time = 812000
+    time = 812698
     flags = 1
     data = length 474, hash DFB41F9
   sample 6:
-    time = 835000
+    time = 835918
     flags = 1
     data = length 413, hash 53E7CB9F
   sample 7:
-    time = 859000
+    time = 859138
     flags = 1
     data = length 445, hash D15B0E39
   sample 8:
-    time = 882000
+    time = 882358
     flags = 1
     data = length 453, hash 77ED81E4
   sample 9:
-    time = 905000
+    time = 905578
     flags = 1
     data = length 545, hash 3321AEB9
   sample 10:
-    time = 928000
+    time = 928798
     flags = 1
     data = length 317, hash F557D0E
   sample 11:
-    time = 952000
+    time = 952018
     flags = 1
     data = length 537, hash ED58CF7B
   sample 12:
-    time = 975000
+    time = 975238
     flags = 1
     data = length 458, hash 51CDAA10
   sample 13:
-    time = 998000
+    time = 998458
     flags = 1
     data = length 465, hash CBA1EFD7
   sample 14:
-    time = 1021000
+    time = 1021678
     flags = 1
     data = length 446, hash D6735B8A
   sample 15:
-    time = 1044000
+    time = 1044897
     flags = 1
     data = length 10, hash A453EEBE
 tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump b/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump
index 4477d48..dd915bc 100644
--- a/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump
@@ -18,123 +18,123 @@
       data = length 29, hash 4746B5D9
       data = length 10, hash 7A0D0F2B
   sample 0:
-    time = 66000
+    time = 66733
     flags = 1
     data = length 38070, hash B58E1AEE
   sample 1:
-    time = 199000
+    time = 200199
     flags = 0
     data = length 8340, hash 8AC449FF
   sample 2:
-    time = 132000
+    time = 133466
     flags = 0
     data = length 1295, hash C0DA5090
   sample 3:
-    time = 100000
+    time = 100100
     flags = 0
     data = length 469, hash D6E0A200
   sample 4:
-    time = 166000
+    time = 166832
     flags = 0
     data = length 564, hash E5F56C5B
   sample 5:
-    time = 332000
+    time = 333666
     flags = 0
     data = length 6075, hash 8756E49E
   sample 6:
-    time = 266000
+    time = 266933
     flags = 0
     data = length 847, hash DCC2B618
   sample 7:
-    time = 233000
+    time = 233566
     flags = 0
     data = length 455, hash B9CCE047
   sample 8:
-    time = 299000
+    time = 300299
     flags = 0
     data = length 467, hash 69806D94
   sample 9:
-    time = 466000
+    time = 467133
     flags = 0
     data = length 4549, hash 3944F501
   sample 10:
-    time = 399000
+    time = 400399
     flags = 0
     data = length 1087, hash 491BF106
   sample 11:
-    time = 367000
+    time = 367033
     flags = 0
     data = length 380, hash 5FED016A
   sample 12:
-    time = 433000
+    time = 433766
     flags = 0
     data = length 455, hash 8A0610
   sample 13:
-    time = 599000
+    time = 600599
     flags = 0
     data = length 5190, hash B9031D8
   sample 14:
-    time = 533000
+    time = 533866
     flags = 0
     data = length 1071, hash 684E7DC8
   sample 15:
-    time = 500000
+    time = 500500
     flags = 0
     data = length 653, hash 8494F326
   sample 16:
-    time = 566000
+    time = 567232
     flags = 0
     data = length 485, hash 2CCC85F4
   sample 17:
-    time = 733000
+    time = 734066
     flags = 0
     data = length 4884, hash D16B6A96
   sample 18:
-    time = 666000
+    time = 667333
     flags = 0
     data = length 997, hash 164FF210
   sample 19:
-    time = 633000
+    time = 633966
     flags = 0
     data = length 640, hash F664125B
   sample 20:
-    time = 700000
+    time = 700699
     flags = 0
     data = length 491, hash B5930C7C
   sample 21:
-    time = 866000
+    time = 867533
     flags = 0
     data = length 2989, hash 92CF4FCF
   sample 22:
-    time = 800000
+    time = 800799
     flags = 0
     data = length 838, hash 294A3451
   sample 23:
-    time = 767000
+    time = 767433
     flags = 0
     data = length 544, hash FCCE2DE6
   sample 24:
-    time = 833000
+    time = 834166
     flags = 0
     data = length 329, hash A654FFA1
   sample 25:
-    time = 1000000
+    time = 1000999
     flags = 0
     data = length 1517, hash 5F7EBF8B
   sample 26:
-    time = 933000
+    time = 934266
     flags = 0
     data = length 803, hash 7A5C4C1D
   sample 27:
-    time = 900000
+    time = 900900
     flags = 0
     data = length 415, hash B31BBC3B
   sample 28:
-    time = 967000
+    time = 967632
     flags = 0
     data = length 415, hash 850DFEA3
   sample 29:
-    time = 1033000
+    time = 1034366
     flags = 0
     data = length 619, hash AB5E56CA
 track 1:
@@ -150,7 +150,7 @@
     initializationData:
       data = length 5, hash 2B7623A
   sample 0:
-    time = 1044000
+    time = 1044897
     flags = 1
     data = length 10, hash A453EEBE
 tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.unknown_length.dump b/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.unknown_length.dump
index c879cde..5dc1f1d 100644
--- a/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.unknown_length.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_fragmented_seekable.mp4.unknown_length.dump
@@ -18,123 +18,123 @@
       data = length 29, hash 4746B5D9
       data = length 10, hash 7A0D0F2B
   sample 0:
-    time = 66000
+    time = 66733
     flags = 1
     data = length 38070, hash B58E1AEE
   sample 1:
-    time = 199000
+    time = 200199
     flags = 0
     data = length 8340, hash 8AC449FF
   sample 2:
-    time = 132000
+    time = 133466
     flags = 0
     data = length 1295, hash C0DA5090
   sample 3:
-    time = 100000
+    time = 100100
     flags = 0
     data = length 469, hash D6E0A200
   sample 4:
-    time = 166000
+    time = 166832
     flags = 0
     data = length 564, hash E5F56C5B
   sample 5:
-    time = 332000
+    time = 333666
     flags = 0
     data = length 6075, hash 8756E49E
   sample 6:
-    time = 266000
+    time = 266933
     flags = 0
     data = length 847, hash DCC2B618
   sample 7:
-    time = 233000
+    time = 233566
     flags = 0
     data = length 455, hash B9CCE047
   sample 8:
-    time = 299000
+    time = 300299
     flags = 0
     data = length 467, hash 69806D94
   sample 9:
-    time = 466000
+    time = 467133
     flags = 0
     data = length 4549, hash 3944F501
   sample 10:
-    time = 399000
+    time = 400399
     flags = 0
     data = length 1087, hash 491BF106
   sample 11:
-    time = 367000
+    time = 367033
     flags = 0
     data = length 380, hash 5FED016A
   sample 12:
-    time = 433000
+    time = 433766
     flags = 0
     data = length 455, hash 8A0610
   sample 13:
-    time = 599000
+    time = 600599
     flags = 0
     data = length 5190, hash B9031D8
   sample 14:
-    time = 533000
+    time = 533866
     flags = 0
     data = length 1071, hash 684E7DC8
   sample 15:
-    time = 500000
+    time = 500500
     flags = 0
     data = length 653, hash 8494F326
   sample 16:
-    time = 566000
+    time = 567232
     flags = 0
     data = length 485, hash 2CCC85F4
   sample 17:
-    time = 733000
+    time = 734066
     flags = 0
     data = length 4884, hash D16B6A96
   sample 18:
-    time = 666000
+    time = 667333
     flags = 0
     data = length 997, hash 164FF210
   sample 19:
-    time = 633000
+    time = 633966
     flags = 0
     data = length 640, hash F664125B
   sample 20:
-    time = 700000
+    time = 700699
     flags = 0
     data = length 491, hash B5930C7C
   sample 21:
-    time = 866000
+    time = 867533
     flags = 0
     data = length 2989, hash 92CF4FCF
   sample 22:
-    time = 800000
+    time = 800799
     flags = 0
     data = length 838, hash 294A3451
   sample 23:
-    time = 767000
+    time = 767433
     flags = 0
     data = length 544, hash FCCE2DE6
   sample 24:
-    time = 833000
+    time = 834166
     flags = 0
     data = length 329, hash A654FFA1
   sample 25:
-    time = 1000000
+    time = 1000999
     flags = 0
     data = length 1517, hash 5F7EBF8B
   sample 26:
-    time = 933000
+    time = 934266
     flags = 0
     data = length 803, hash 7A5C4C1D
   sample 27:
-    time = 900000
+    time = 900900
     flags = 0
     data = length 415, hash B31BBC3B
   sample 28:
-    time = 967000
+    time = 967632
     flags = 0
     data = length 415, hash 850DFEA3
   sample 29:
-    time = 1033000
+    time = 1034366
     flags = 0
     data = length 619, hash AB5E56CA
 track 1:
@@ -154,183 +154,183 @@
     flags = 1
     data = length 18, hash 96519432
   sample 1:
-    time = 23000
+    time = 23219
     flags = 1
     data = length 4, hash EE9DF
   sample 2:
-    time = 46000
+    time = 46439
     flags = 1
     data = length 4, hash EEDBF
   sample 3:
-    time = 69000
+    time = 69659
     flags = 1
     data = length 157, hash E2F078F4
   sample 4:
-    time = 92000
+    time = 92879
     flags = 1
     data = length 371, hash B9471F94
   sample 5:
-    time = 116000
+    time = 116099
     flags = 1
     data = length 373, hash 2AB265CB
   sample 6:
-    time = 139000
+    time = 139319
     flags = 1
     data = length 402, hash 1295477C
   sample 7:
-    time = 162000
+    time = 162539
     flags = 1
     data = length 455, hash 2D8146C8
   sample 8:
-    time = 185000
+    time = 185759
     flags = 1
     data = length 434, hash F2C5D287
   sample 9:
-    time = 208000
+    time = 208979
     flags = 1
     data = length 450, hash 84143FCD
   sample 10:
-    time = 232000
+    time = 232199
     flags = 1
     data = length 429, hash EF769D50
   sample 11:
-    time = 255000
+    time = 255419
     flags = 1
     data = length 450, hash EC3DE692
   sample 12:
-    time = 278000
+    time = 278639
     flags = 1
     data = length 447, hash 3E519E13
   sample 13:
-    time = 301000
+    time = 301859
     flags = 1
     data = length 457, hash 1E4F23A0
   sample 14:
-    time = 325000
+    time = 325079
     flags = 1
     data = length 447, hash A439EA97
   sample 15:
-    time = 348000
+    time = 348299
     flags = 1
     data = length 456, hash 1E9034C6
   sample 16:
-    time = 371000
+    time = 371519
     flags = 1
     data = length 398, hash 99DB7345
   sample 17:
-    time = 394000
+    time = 394739
     flags = 1
     data = length 474, hash 3F05F10A
   sample 18:
-    time = 417000
+    time = 417959
     flags = 1
     data = length 416, hash C105EE09
   sample 19:
-    time = 441000
+    time = 441179
     flags = 1
     data = length 454, hash 5FDBE458
   sample 20:
-    time = 464000
+    time = 464399
     flags = 1
     data = length 438, hash 41A93AC3
   sample 21:
-    time = 487000
+    time = 487619
     flags = 1
     data = length 443, hash 10FDA652
   sample 22:
-    time = 510000
+    time = 510839
     flags = 1
     data = length 412, hash 1F791E25
   sample 23:
-    time = 534000
+    time = 534058
     flags = 1
     data = length 482, hash A6D983D
   sample 24:
-    time = 557000
+    time = 557278
     flags = 1
     data = length 386, hash BED7392F
   sample 25:
-    time = 580000
+    time = 580498
     flags = 1
     data = length 463, hash 5309F8C9
   sample 26:
-    time = 603000
+    time = 603718
     flags = 1
     data = length 394, hash 21C7321F
   sample 27:
-    time = 626000
+    time = 626938
     flags = 1
     data = length 489, hash 71B4730D
   sample 28:
-    time = 650000
+    time = 650158
     flags = 1
     data = length 403, hash D9C6DE89
   sample 29:
-    time = 673000
+    time = 673378
     flags = 1
     data = length 447, hash 9B14B73B
   sample 30:
-    time = 696000
+    time = 696598
     flags = 1
     data = length 439, hash 4760D35B
   sample 31:
-    time = 719000
+    time = 719818
     flags = 1
     data = length 463, hash 1601F88D
   sample 32:
-    time = 743000
+    time = 743038
     flags = 1
     data = length 423, hash D4AE6773
   sample 33:
-    time = 766000
+    time = 766258
     flags = 1
     data = length 497, hash A3C674D3
   sample 34:
-    time = 789000
+    time = 789478
     flags = 1
     data = length 419, hash D3734A1F
   sample 35:
-    time = 812000
+    time = 812698
     flags = 1
     data = length 474, hash DFB41F9
   sample 36:
-    time = 835000
+    time = 835918
     flags = 1
     data = length 413, hash 53E7CB9F
   sample 37:
-    time = 859000
+    time = 859138
     flags = 1
     data = length 445, hash D15B0E39
   sample 38:
-    time = 882000
+    time = 882358
     flags = 1
     data = length 453, hash 77ED81E4
   sample 39:
-    time = 905000
+    time = 905578
     flags = 1
     data = length 545, hash 3321AEB9
   sample 40:
-    time = 928000
+    time = 928798
     flags = 1
     data = length 317, hash F557D0E
   sample 41:
-    time = 952000
+    time = 952018
     flags = 1
     data = length 537, hash ED58CF7B
   sample 42:
-    time = 975000
+    time = 975238
     flags = 1
     data = length 458, hash 51CDAA10
   sample 43:
-    time = 998000
+    time = 998458
     flags = 1
     data = length 465, hash CBA1EFD7
   sample 44:
-    time = 1021000
+    time = 1021678
     flags = 1
     data = length 446, hash D6735B8A
   sample 45:
-    time = 1044000
+    time = 1044897
     flags = 1
     data = length 10, hash A453EEBE
 tracksEnded = true
diff --git a/tree/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump b/tree/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump
index 95fe467..341fba4 100644
--- a/tree/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump
@@ -15,123 +15,123 @@
       data = length 29, hash 4746B5D9
       data = length 10, hash 7A0D0F2B
   sample 0:
-    time = 66000
+    time = 66733
     flags = 1
     data = length 38070, hash B58E1AEE
   sample 1:
-    time = 199000
+    time = 200199
     flags = 0
     data = length 8340, hash 8AC449FF
   sample 2:
-    time = 132000
+    time = 133466
     flags = 0
     data = length 1295, hash C0DA5090
   sample 3:
-    time = 100000
+    time = 100100
     flags = 0
     data = length 469, hash D6E0A200
   sample 4:
-    time = 166000
+    time = 166832
     flags = 0
     data = length 564, hash E5F56C5B
   sample 5:
-    time = 332000
+    time = 333666
     flags = 0
     data = length 6075, hash 8756E49E
   sample 6:
-    time = 266000
+    time = 266933
     flags = 0
     data = length 847, hash DCC2B618
   sample 7:
-    time = 233000
+    time = 233566
     flags = 0
     data = length 455, hash B9CCE047
   sample 8:
-    time = 299000
+    time = 300299
     flags = 0
     data = length 467, hash 69806D94
   sample 9:
-    time = 466000
+    time = 467133
     flags = 0
     data = length 4549, hash 3944F501
   sample 10:
-    time = 399000
+    time = 400399
     flags = 0
     data = length 1087, hash 491BF106
   sample 11:
-    time = 367000
+    time = 367033
     flags = 0
     data = length 380, hash 5FED016A
   sample 12:
-    time = 433000
+    time = 433766
     flags = 0
     data = length 455, hash 8A0610
   sample 13:
-    time = 599000
+    time = 600599
     flags = 0
     data = length 5190, hash B9031D8
   sample 14:
-    time = 533000
+    time = 533866
     flags = 0
     data = length 1071, hash 684E7DC8
   sample 15:
-    time = 500000
+    time = 500500
     flags = 0
     data = length 653, hash 8494F326
   sample 16:
-    time = 566000
+    time = 567232
     flags = 0
     data = length 485, hash 2CCC85F4
   sample 17:
-    time = 733000
+    time = 734066
     flags = 0
     data = length 4884, hash D16B6A96
   sample 18:
-    time = 666000
+    time = 667333
     flags = 0
     data = length 997, hash 164FF210
   sample 19:
-    time = 633000
+    time = 633966
     flags = 0
     data = length 640, hash F664125B
   sample 20:
-    time = 700000
+    time = 700699
     flags = 0
     data = length 491, hash B5930C7C
   sample 21:
-    time = 866000
+    time = 867533
     flags = 0
     data = length 2989, hash 92CF4FCF
   sample 22:
-    time = 800000
+    time = 800799
     flags = 0
     data = length 838, hash 294A3451
   sample 23:
-    time = 767000
+    time = 767433
     flags = 0
     data = length 544, hash FCCE2DE6
   sample 24:
-    time = 833000
+    time = 834166
     flags = 0
     data = length 329, hash A654FFA1
   sample 25:
-    time = 1000000
+    time = 1000999
     flags = 0
     data = length 1517, hash 5F7EBF8B
   sample 26:
-    time = 933000
+    time = 934266
     flags = 0
     data = length 803, hash 7A5C4C1D
   sample 27:
-    time = 900000
+    time = 900900
     flags = 0
     data = length 415, hash B31BBC3B
   sample 28:
-    time = 967000
+    time = 967632
     flags = 0
     data = length 415, hash 850DFEA3
   sample 29:
-    time = 1033000
+    time = 1034366
     flags = 0
     data = length 619, hash AB5E56CA
 track 1:
@@ -151,183 +151,183 @@
     flags = 1
     data = length 18, hash 96519432
   sample 1:
-    time = 23000
+    time = 23219
     flags = 1
     data = length 4, hash EE9DF
   sample 2:
-    time = 46000
+    time = 46439
     flags = 1
     data = length 4, hash EEDBF
   sample 3:
-    time = 69000
+    time = 69659
     flags = 1
     data = length 157, hash E2F078F4
   sample 4:
-    time = 92000
+    time = 92879
     flags = 1
     data = length 371, hash B9471F94
   sample 5:
-    time = 116000
+    time = 116099
     flags = 1
     data = length 373, hash 2AB265CB
   sample 6:
-    time = 139000
+    time = 139319
     flags = 1
     data = length 402, hash 1295477C
   sample 7:
-    time = 162000
+    time = 162539
     flags = 1
     data = length 455, hash 2D8146C8
   sample 8:
-    time = 185000
+    time = 185759
     flags = 1
     data = length 434, hash F2C5D287
   sample 9:
-    time = 208000
+    time = 208979
     flags = 1
     data = length 450, hash 84143FCD
   sample 10:
-    time = 232000
+    time = 232199
     flags = 1
     data = length 429, hash EF769D50
   sample 11:
-    time = 255000
+    time = 255419
     flags = 1
     data = length 450, hash EC3DE692
   sample 12:
-    time = 278000
+    time = 278639
     flags = 1
     data = length 447, hash 3E519E13
   sample 13:
-    time = 301000
+    time = 301859
     flags = 1
     data = length 457, hash 1E4F23A0
   sample 14:
-    time = 325000
+    time = 325079
     flags = 1
     data = length 447, hash A439EA97
   sample 15:
-    time = 348000
+    time = 348299
     flags = 1
     data = length 456, hash 1E9034C6
   sample 16:
-    time = 371000
+    time = 371519
     flags = 1
     data = length 398, hash 99DB7345
   sample 17:
-    time = 394000
+    time = 394739
     flags = 1
     data = length 474, hash 3F05F10A
   sample 18:
-    time = 417000
+    time = 417959
     flags = 1
     data = length 416, hash C105EE09
   sample 19:
-    time = 441000
+    time = 441179
     flags = 1
     data = length 454, hash 5FDBE458
   sample 20:
-    time = 464000
+    time = 464399
     flags = 1
     data = length 438, hash 41A93AC3
   sample 21:
-    time = 487000
+    time = 487619
     flags = 1
     data = length 443, hash 10FDA652
   sample 22:
-    time = 510000
+    time = 510839
     flags = 1
     data = length 412, hash 1F791E25
   sample 23:
-    time = 534000
+    time = 534058
     flags = 1
     data = length 482, hash A6D983D
   sample 24:
-    time = 557000
+    time = 557278
     flags = 1
     data = length 386, hash BED7392F
   sample 25:
-    time = 580000
+    time = 580498
     flags = 1
     data = length 463, hash 5309F8C9
   sample 26:
-    time = 603000
+    time = 603718
     flags = 1
     data = length 394, hash 21C7321F
   sample 27:
-    time = 626000
+    time = 626938
     flags = 1
     data = length 489, hash 71B4730D
   sample 28:
-    time = 650000
+    time = 650158
     flags = 1
     data = length 403, hash D9C6DE89
   sample 29:
-    time = 673000
+    time = 673378
     flags = 1
     data = length 447, hash 9B14B73B
   sample 30:
-    time = 696000
+    time = 696598
     flags = 1
     data = length 439, hash 4760D35B
   sample 31:
-    time = 719000
+    time = 719818
     flags = 1
     data = length 463, hash 1601F88D
   sample 32:
-    time = 743000
+    time = 743038
     flags = 1
     data = length 423, hash D4AE6773
   sample 33:
-    time = 766000
+    time = 766258
     flags = 1
     data = length 497, hash A3C674D3
   sample 34:
-    time = 789000
+    time = 789478
     flags = 1
     data = length 419, hash D3734A1F
   sample 35:
-    time = 812000
+    time = 812698
     flags = 1
     data = length 474, hash DFB41F9
   sample 36:
-    time = 835000
+    time = 835918
     flags = 1
     data = length 413, hash 53E7CB9F
   sample 37:
-    time = 859000
+    time = 859138
     flags = 1
     data = length 445, hash D15B0E39
   sample 38:
-    time = 882000
+    time = 882358
     flags = 1
     data = length 453, hash 77ED81E4
   sample 39:
-    time = 905000
+    time = 905578
     flags = 1
     data = length 545, hash 3321AEB9
   sample 40:
-    time = 928000
+    time = 928798
     flags = 1
     data = length 317, hash F557D0E
   sample 41:
-    time = 952000
+    time = 952018
     flags = 1
     data = length 537, hash ED58CF7B
   sample 42:
-    time = 975000
+    time = 975238
     flags = 1
     data = length 458, hash 51CDAA10
   sample 43:
-    time = 998000
+    time = 998458
     flags = 1
     data = length 465, hash CBA1EFD7
   sample 44:
-    time = 1021000
+    time = 1021678
     flags = 1
     data = length 446, hash D6735B8A
   sample 45:
-    time = 1044000
+    time = 1044897
     flags = 1
     data = length 10, hash A453EEBE
 track 3:
diff --git a/tree/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.unknown_length.dump b/tree/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.unknown_length.dump
index 95fe467..341fba4 100644
--- a/tree/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.unknown_length.dump
+++ b/tree/testdata/src/test/assets/mp4/sample_fragmented_sei.mp4.unknown_length.dump
@@ -15,123 +15,123 @@
       data = length 29, hash 4746B5D9
       data = length 10, hash 7A0D0F2B
   sample 0:
-    time = 66000
+    time = 66733
     flags = 1
     data = length 38070, hash B58E1AEE
   sample 1:
-    time = 199000
+    time = 200199
     flags = 0
     data = length 8340, hash 8AC449FF
   sample 2:
-    time = 132000
+    time = 133466
     flags = 0
     data = length 1295, hash C0DA5090
   sample 3:
-    time = 100000
+    time = 100100
     flags = 0
     data = length 469, hash D6E0A200
   sample 4:
-    time = 166000
+    time = 166832
     flags = 0
     data = length 564, hash E5F56C5B
   sample 5:
-    time = 332000
+    time = 333666
     flags = 0
     data = length 6075, hash 8756E49E
   sample 6:
-    time = 266000
+    time = 266933
     flags = 0
     data = length 847, hash DCC2B618
   sample 7:
-    time = 233000
+    time = 233566
     flags = 0
     data = length 455, hash B9CCE047
   sample 8:
-    time = 299000
+    time = 300299
     flags = 0
     data = length 467, hash 69806D94
   sample 9:
-    time = 466000
+    time = 467133
     flags = 0
     data = length 4549, hash 3944F501
   sample 10:
-    time = 399000
+    time = 400399
     flags = 0
     data = length 1087, hash 491BF106
   sample 11:
-    time = 367000
+    time = 367033
     flags = 0
     data = length 380, hash 5FED016A
   sample 12:
-    time = 433000
+    time = 433766
     flags = 0
     data = length 455, hash 8A0610
   sample 13:
-    time = 599000
+    time = 600599
     flags = 0
     data = length 5190, hash B9031D8
   sample 14:
-    time = 533000
+    time = 533866
     flags = 0
     data = length 1071, hash 684E7DC8
   sample 15:
-    time = 500000
+    time = 500500
     flags = 0
     data = length 653, hash 8494F326
   sample 16:
-    time = 566000
+    time = 567232
     flags = 0
     data = length 485, hash 2CCC85F4
   sample 17:
-    time = 733000
+    time = 734066
     flags = 0
     data = length 4884, hash D16B6A96
   sample 18:
-    time = 666000
+    time = 667333
     flags = 0
     data = length 997, hash 164FF210
   sample 19:
-    time = 633000
+    time = 633966
     flags = 0
     data = length 640, hash F664125B
   sample 20:
-    time = 700000
+    time = 700699
     flags = 0
     data = length 491, hash B5930C7C
   sample 21:
-    time = 866000
+    time = 867533
     flags = 0
     data = length 2989, hash 92CF4FCF
   sample 22:
-    time = 800000
+    time = 800799
     flags = 0
     data = length 838, hash 294A3451
   sample 23:
-    time = 767000
+    time = 767433
     flags = 0
     data = length 544, hash FCCE2DE6
   sample 24:
-    time = 833000
+    time = 834166
     flags = 0
     data = length 329, hash A654FFA1
   sample 25:
-    time = 1000000
+    time = 1000999
     flags = 0
     data = length 1517, hash 5F7EBF8B
   sample 26:
-    time = 933000
+    time = 934266
     flags = 0
     data = length 803, hash 7A5C4C1D
   sample 27:
-    time = 900000
+    time = 900900
     flags = 0
     data = length 415, hash B31BBC3B
   sample 28:
-    time = 967000
+    time = 967632
     flags = 0
     data = length 415, hash 850DFEA3
   sample 29:
-    time = 1033000
+    time = 1034366
     flags = 0
     data = length 619, hash AB5E56CA
 track 1:
@@ -151,183 +151,183 @@
     flags = 1
     data = length 18, hash 96519432
   sample 1:
-    time = 23000
+    time = 23219
     flags = 1
     data = length 4, hash EE9DF
   sample 2:
-    time = 46000
+    time = 46439
     flags = 1
     data = length 4, hash EEDBF
   sample 3:
-    time = 69000
+    time = 69659
     flags = 1
     data = length 157, hash E2F078F4
   sample 4:
-    time = 92000
+    time = 92879
     flags = 1
     data = length 371, hash B9471F94
   sample 5:
-    time = 116000
+    time = 116099
     flags = 1
     data = length 373, hash 2AB265CB
   sample 6:
-    time = 139000
+    time = 139319
     flags = 1
     data = length 402, hash 1295477C
   sample 7:
-    time = 162000
+    time = 162539
     flags = 1
     data = length 455, hash 2D8146C8
   sample 8:
-    time = 185000
+    time = 185759
     flags = 1
     data = length 434, hash F2C5D287
   sample 9:
-    time = 208000
+    time = 208979
     flags = 1
     data = length 450, hash 84143FCD
   sample 10:
-    time = 232000
+    time = 232199
     flags = 1
     data = length 429, hash EF769D50
   sample 11:
-    time = 255000
+    time = 255419
     flags = 1
     data = length 450, hash EC3DE692
   sample 12:
-    time = 278000
+    time = 278639
     flags = 1
     data = length 447, hash 3E519E13
   sample 13:
-    time = 301000
+    time = 301859
     flags = 1
     data = length 457, hash 1E4F23A0
   sample 14:
-    time = 325000
+    time = 325079
     flags = 1
     data = length 447, hash A439EA97
   sample 15:
-    time = 348000
+    time = 348299
     flags = 1
     data = length 456, hash 1E9034C6
   sample 16:
-    time = 371000
+    time = 371519
     flags = 1
     data = length 398, hash 99DB7345
   sample 17:
-    time = 394000
+    time = 394739
     flags = 1
     data = length 474, hash 3F05F10A
   sample 18:
-    time = 417000
+    time = 417959
     flags = 1
     data = length 416, hash C105EE09
   sample 19:
-    time = 441000
+    time = 441179
     flags = 1
     data = length 454, hash 5FDBE458
   sample 20:
-    time = 464000
+    time = 464399
     flags = 1
     data = length 438, hash 41A93AC3
   sample 21:
-    time = 487000
+    time = 487619
     flags = 1
     data = length 443, hash 10FDA652
   sample 22:
-    time = 510000
+    time = 510839
     flags = 1
     data = length 412, hash 1F791E25
   sample 23:
-    time = 534000
+    time = 534058
     flags = 1
     data = length 482, hash A6D983D
   sample 24:
-    time = 557000
+    time = 557278
     flags = 1
     data = length 386, hash BED7392F
   sample 25:
-    time = 580000
+    time = 580498
     flags = 1
     data = length 463, hash 5309F8C9
   sample 26:
-    time = 603000
+    time = 603718
     flags = 1
     data = length 394, hash 21C7321F
   sample 27:
-    time = 626000
+    time = 626938
     flags = 1
     data = length 489, hash 71B4730D
   sample 28:
-    time = 650000
+    time = 650158
     flags = 1
     data = length 403, hash D9C6DE89
   sample 29:
-    time = 673000
+    time = 673378
     flags = 1
     data = length 447, hash 9B14B73B
   sample 30:
-    time = 696000
+    time = 696598
     flags = 1
     data = length 439, hash 4760D35B
   sample 31:
-    time = 719000
+    time = 719818
     flags = 1
     data = length 463, hash 1601F88D
   sample 32:
-    time = 743000
+    time = 743038
     flags = 1
     data = length 423, hash D4AE6773
   sample 33:
-    time = 766000
+    time = 766258
     flags = 1
     data = length 497, hash A3C674D3
   sample 34:
-    time = 789000
+    time = 789478
     flags = 1
     data = length 419, hash D3734A1F
   sample 35:
-    time = 812000
+    time = 812698
     flags = 1
     data = length 474, hash DFB41F9
   sample 36:
-    time = 835000
+    time = 835918
     flags = 1
     data = length 413, hash 53E7CB9F
   sample 37:
-    time = 859000
+    time = 859138
     flags = 1
     data = length 445, hash D15B0E39
   sample 38:
-    time = 882000
+    time = 882358
     flags = 1
     data = length 453, hash 77ED81E4
   sample 39:
-    time = 905000
+    time = 905578
     flags = 1
     data = length 545, hash 3321AEB9
   sample 40:
-    time = 928000
+    time = 928798
     flags = 1
     data = length 317, hash F557D0E
   sample 41:
-    time = 952000
+    time = 952018
     flags = 1
     data = length 537, hash ED58CF7B
   sample 42:
-    time = 975000
+    time = 975238
     flags = 1
     data = length 458, hash 51CDAA10
   sample 43:
-    time = 998000
+    time = 998458
     flags = 1
     data = length 465, hash CBA1EFD7
   sample 44:
-    time = 1021000
+    time = 1021678
     flags = 1
     data = length 446, hash D6735B8A
   sample 45:
-    time = 1044000
+    time = 1044897
     flags = 1
     data = length 10, hash A453EEBE
 track 3:
diff --git a/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts b/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts
new file mode 100644
index 0000000..e9aafd7
--- /dev/null
+++ b/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts
Binary files differ
diff --git a/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts.0.dump b/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts.0.dump
new file mode 100644
index 0000000..86b6448
--- /dev/null
+++ b/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts.0.dump
@@ -0,0 +1,81 @@
+seekMap:
+  isSeekable = true
+  duration = 0
+  getPosition(0) = [[timeUs=0, position=0]]
+  getPosition(1) = [[timeUs=1, position=0]]
+  getPosition(0) = [[timeUs=0, position=0]]
+  getPosition(0) = [[timeUs=0, position=0]]
+numberOfTracks = 3
+track 256:
+  total output bytes = 13650
+  sample count = 2
+  format 0:
+    id = 1/256
+    sampleMimeType = video/avc
+    codecs = avc1.64001E
+    width = 640
+    height = 426
+    initializationData:
+      data = length 29, hash 4C2CAE9C
+      data = length 9, hash D971CD89
+  sample 0:
+    time = 66733
+    flags = 1
+    data = length 12394, hash A39F5311
+  sample 1:
+    time = 100100
+    flags = 0
+    data = length 813, hash 99F7B4FA
+track 257:
+  total output bytes = 18432
+  sample count = 9
+  format 0:
+    averageBitrate = 1411500
+    id = 1/257
+    sampleMimeType = audio/vnd.dts
+    channelCount = 1
+    sampleRate = 44100
+    language = und
+  sample 0:
+    time = 66733
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 1:
+    time = 78344
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 2:
+    time = 89955
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 3:
+    time = 101566
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 4:
+    time = 113177
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 5:
+    time = 124777
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 6:
+    time = 136388
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 7:
+    time = 148000
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 8:
+    time = 159611
+    flags = 1
+    data = length 2048, hash 88866F81
+track 8448:
+  total output bytes = 0
+  sample count = 0
+  format 0:
+    id = 1/8448
+    sampleMimeType = application/cea-608
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts.1.dump b/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts.1.dump
new file mode 100644
index 0000000..86b6448
--- /dev/null
+++ b/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts.1.dump
@@ -0,0 +1,81 @@
+seekMap:
+  isSeekable = true
+  duration = 0
+  getPosition(0) = [[timeUs=0, position=0]]
+  getPosition(1) = [[timeUs=1, position=0]]
+  getPosition(0) = [[timeUs=0, position=0]]
+  getPosition(0) = [[timeUs=0, position=0]]
+numberOfTracks = 3
+track 256:
+  total output bytes = 13650
+  sample count = 2
+  format 0:
+    id = 1/256
+    sampleMimeType = video/avc
+    codecs = avc1.64001E
+    width = 640
+    height = 426
+    initializationData:
+      data = length 29, hash 4C2CAE9C
+      data = length 9, hash D971CD89
+  sample 0:
+    time = 66733
+    flags = 1
+    data = length 12394, hash A39F5311
+  sample 1:
+    time = 100100
+    flags = 0
+    data = length 813, hash 99F7B4FA
+track 257:
+  total output bytes = 18432
+  sample count = 9
+  format 0:
+    averageBitrate = 1411500
+    id = 1/257
+    sampleMimeType = audio/vnd.dts
+    channelCount = 1
+    sampleRate = 44100
+    language = und
+  sample 0:
+    time = 66733
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 1:
+    time = 78344
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 2:
+    time = 89955
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 3:
+    time = 101566
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 4:
+    time = 113177
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 5:
+    time = 124777
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 6:
+    time = 136388
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 7:
+    time = 148000
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 8:
+    time = 159611
+    flags = 1
+    data = length 2048, hash 88866F81
+track 8448:
+  total output bytes = 0
+  sample count = 0
+  format 0:
+    id = 1/8448
+    sampleMimeType = application/cea-608
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts.2.dump b/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts.2.dump
new file mode 100644
index 0000000..86b6448
--- /dev/null
+++ b/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts.2.dump
@@ -0,0 +1,81 @@
+seekMap:
+  isSeekable = true
+  duration = 0
+  getPosition(0) = [[timeUs=0, position=0]]
+  getPosition(1) = [[timeUs=1, position=0]]
+  getPosition(0) = [[timeUs=0, position=0]]
+  getPosition(0) = [[timeUs=0, position=0]]
+numberOfTracks = 3
+track 256:
+  total output bytes = 13650
+  sample count = 2
+  format 0:
+    id = 1/256
+    sampleMimeType = video/avc
+    codecs = avc1.64001E
+    width = 640
+    height = 426
+    initializationData:
+      data = length 29, hash 4C2CAE9C
+      data = length 9, hash D971CD89
+  sample 0:
+    time = 66733
+    flags = 1
+    data = length 12394, hash A39F5311
+  sample 1:
+    time = 100100
+    flags = 0
+    data = length 813, hash 99F7B4FA
+track 257:
+  total output bytes = 18432
+  sample count = 9
+  format 0:
+    averageBitrate = 1411500
+    id = 1/257
+    sampleMimeType = audio/vnd.dts
+    channelCount = 1
+    sampleRate = 44100
+    language = und
+  sample 0:
+    time = 66733
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 1:
+    time = 78344
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 2:
+    time = 89955
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 3:
+    time = 101566
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 4:
+    time = 113177
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 5:
+    time = 124777
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 6:
+    time = 136388
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 7:
+    time = 148000
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 8:
+    time = 159611
+    flags = 1
+    data = length 2048, hash 88866F81
+track 8448:
+  total output bytes = 0
+  sample count = 0
+  format 0:
+    id = 1/8448
+    sampleMimeType = application/cea-608
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts.3.dump b/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts.3.dump
new file mode 100644
index 0000000..86b6448
--- /dev/null
+++ b/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts.3.dump
@@ -0,0 +1,81 @@
+seekMap:
+  isSeekable = true
+  duration = 0
+  getPosition(0) = [[timeUs=0, position=0]]
+  getPosition(1) = [[timeUs=1, position=0]]
+  getPosition(0) = [[timeUs=0, position=0]]
+  getPosition(0) = [[timeUs=0, position=0]]
+numberOfTracks = 3
+track 256:
+  total output bytes = 13650
+  sample count = 2
+  format 0:
+    id = 1/256
+    sampleMimeType = video/avc
+    codecs = avc1.64001E
+    width = 640
+    height = 426
+    initializationData:
+      data = length 29, hash 4C2CAE9C
+      data = length 9, hash D971CD89
+  sample 0:
+    time = 66733
+    flags = 1
+    data = length 12394, hash A39F5311
+  sample 1:
+    time = 100100
+    flags = 0
+    data = length 813, hash 99F7B4FA
+track 257:
+  total output bytes = 18432
+  sample count = 9
+  format 0:
+    averageBitrate = 1411500
+    id = 1/257
+    sampleMimeType = audio/vnd.dts
+    channelCount = 1
+    sampleRate = 44100
+    language = und
+  sample 0:
+    time = 66733
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 1:
+    time = 78344
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 2:
+    time = 89955
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 3:
+    time = 101566
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 4:
+    time = 113177
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 5:
+    time = 124777
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 6:
+    time = 136388
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 7:
+    time = 148000
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 8:
+    time = 159611
+    flags = 1
+    data = length 2048, hash 88866F81
+track 8448:
+  total output bytes = 0
+  sample count = 0
+  format 0:
+    id = 1/8448
+    sampleMimeType = application/cea-608
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts.unknown_length.dump b/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts.unknown_length.dump
new file mode 100644
index 0000000..0e87b90
--- /dev/null
+++ b/tree/testdata/src/test/assets/ts/sample_h264_dts_audio.ts.unknown_length.dump
@@ -0,0 +1,78 @@
+seekMap:
+  isSeekable = false
+  duration = UNSET TIME
+  getPosition(0) = [[timeUs=0, position=0]]
+numberOfTracks = 3
+track 256:
+  total output bytes = 13650
+  sample count = 2
+  format 0:
+    id = 1/256
+    sampleMimeType = video/avc
+    codecs = avc1.64001E
+    width = 640
+    height = 426
+    initializationData:
+      data = length 29, hash 4C2CAE9C
+      data = length 9, hash D971CD89
+  sample 0:
+    time = 66733
+    flags = 1
+    data = length 12394, hash A39F5311
+  sample 1:
+    time = 100100
+    flags = 0
+    data = length 813, hash 99F7B4FA
+track 257:
+  total output bytes = 18432
+  sample count = 9
+  format 0:
+    averageBitrate = 1411500
+    id = 1/257
+    sampleMimeType = audio/vnd.dts
+    channelCount = 1
+    sampleRate = 44100
+    language = und
+  sample 0:
+    time = 66733
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 1:
+    time = 78344
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 2:
+    time = 89955
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 3:
+    time = 101566
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 4:
+    time = 113177
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 5:
+    time = 124777
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 6:
+    time = 136388
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 7:
+    time = 148000
+    flags = 1
+    data = length 2048, hash 88866F81
+  sample 8:
+    time = 159611
+    flags = 1
+    data = length 2048, hash 88866F81
+track 8448:
+  total output bytes = 0
+  sample count = 0
+  format 0:
+    id = 1/8448
+    sampleMimeType = application/cea-608
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts b/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts
new file mode 100644
index 0000000..a1cc40b
--- /dev/null
+++ b/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts
Binary files differ
diff --git a/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts.0.dump b/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts.0.dump
new file mode 100644
index 0000000..f3f3d23
--- /dev/null
+++ b/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts.0.dump
@@ -0,0 +1,43 @@
+seekMap:
+  isSeekable = true
+  duration = 66733
+  getPosition(0) = [[timeUs=0, position=0]]
+  getPosition(1) = [[timeUs=1, position=0]]
+  getPosition(33366) = [[timeUs=33366, position=6420]]
+  getPosition(66733) = [[timeUs=66733, position=13028]]
+numberOfTracks = 2
+track 256:
+  total output bytes = 12451
+  sample count = 4
+  format 0:
+    id = 1/256
+    sampleMimeType = video/avc
+    codecs = avc1.64001E
+    width = 640
+    height = 426
+    initializationData:
+      data = length 29, hash 4C2CAE9C
+      data = length 9, hash D971CD89
+  sample 0:
+    time = 66733
+    flags = 0
+    data = length 734, hash AF0D9485
+  sample 1:
+    time = 66733
+    flags = 1
+    data = length 10938, hash 68420875
+  sample 2:
+    time = 133466
+    flags = 0
+    data = length 6, hash 34E6CF79
+  sample 3:
+    time = 133466
+    flags = 0
+    data = length 518, hash 546C177
+track 8448:
+  total output bytes = 0
+  sample count = 0
+  format 0:
+    id = 1/8448
+    sampleMimeType = application/cea-608
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts.1.dump b/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts.1.dump
new file mode 100644
index 0000000..94d3d8d
--- /dev/null
+++ b/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts.1.dump
@@ -0,0 +1,43 @@
+seekMap:
+  isSeekable = true
+  duration = 66733
+  getPosition(0) = [[timeUs=0, position=0]]
+  getPosition(1) = [[timeUs=1, position=0]]
+  getPosition(33366) = [[timeUs=33366, position=6420]]
+  getPosition(66733) = [[timeUs=66733, position=13028]]
+numberOfTracks = 2
+track 256:
+  total output bytes = 12451
+  sample count = 4
+  format 0:
+    id = 1/256
+    sampleMimeType = video/avc
+    codecs = avc1.64001E
+    width = 640
+    height = 426
+    initializationData:
+      data = length 29, hash 4C2CAE9C
+      data = length 9, hash D971CD89
+  sample 0:
+    time = 88977
+    flags = 0
+    data = length 734, hash AF0D9485
+  sample 1:
+    time = 88977
+    flags = 1
+    data = length 10938, hash 68420875
+  sample 2:
+    time = 155710
+    flags = 0
+    data = length 6, hash 34E6CF79
+  sample 3:
+    time = 155710
+    flags = 0
+    data = length 518, hash 546C177
+track 8448:
+  total output bytes = 0
+  sample count = 0
+  format 0:
+    id = 1/8448
+    sampleMimeType = application/cea-608
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts.2.dump b/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts.2.dump
new file mode 100644
index 0000000..e6e8332
--- /dev/null
+++ b/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts.2.dump
@@ -0,0 +1,43 @@
+seekMap:
+  isSeekable = true
+  duration = 66733
+  getPosition(0) = [[timeUs=0, position=0]]
+  getPosition(1) = [[timeUs=1, position=0]]
+  getPosition(33366) = [[timeUs=33366, position=6420]]
+  getPosition(66733) = [[timeUs=66733, position=13028]]
+numberOfTracks = 2
+track 256:
+  total output bytes = 12451
+  sample count = 4
+  format 0:
+    id = 1/256
+    sampleMimeType = video/avc
+    codecs = avc1.64001E
+    width = 640
+    height = 426
+    initializationData:
+      data = length 29, hash 4C2CAE9C
+      data = length 9, hash D971CD89
+  sample 0:
+    time = 111221
+    flags = 0
+    data = length 734, hash AF0D9485
+  sample 1:
+    time = 111221
+    flags = 1
+    data = length 10938, hash 68420875
+  sample 2:
+    time = 177954
+    flags = 0
+    data = length 6, hash 34E6CF79
+  sample 3:
+    time = 177954
+    flags = 0
+    data = length 518, hash 546C177
+track 8448:
+  total output bytes = 0
+  sample count = 0
+  format 0:
+    id = 1/8448
+    sampleMimeType = application/cea-608
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts.3.dump b/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts.3.dump
new file mode 100644
index 0000000..8710d53
--- /dev/null
+++ b/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts.3.dump
@@ -0,0 +1,27 @@
+seekMap:
+  isSeekable = true
+  duration = 66733
+  getPosition(0) = [[timeUs=0, position=0]]
+  getPosition(1) = [[timeUs=1, position=0]]
+  getPosition(33366) = [[timeUs=33366, position=6420]]
+  getPosition(66733) = [[timeUs=66733, position=13028]]
+numberOfTracks = 2
+track 256:
+  total output bytes = 255
+  sample count = 0
+  format 0:
+    id = 1/256
+    sampleMimeType = video/avc
+    codecs = avc1.64001E
+    width = 640
+    height = 426
+    initializationData:
+      data = length 29, hash 4C2CAE9C
+      data = length 9, hash D971CD89
+track 8448:
+  total output bytes = 0
+  sample count = 0
+  format 0:
+    id = 1/8448
+    sampleMimeType = application/cea-608
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts.unknown_length.dump b/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts.unknown_length.dump
new file mode 100644
index 0000000..a23c081
--- /dev/null
+++ b/tree/testdata/src/test/assets/ts/sample_h264_no_access_unit_delimiters.ts.unknown_length.dump
@@ -0,0 +1,40 @@
+seekMap:
+  isSeekable = false
+  duration = UNSET TIME
+  getPosition(0) = [[timeUs=0, position=0]]
+numberOfTracks = 2
+track 256:
+  total output bytes = 12451
+  sample count = 4
+  format 0:
+    id = 1/256
+    sampleMimeType = video/avc
+    codecs = avc1.64001E
+    width = 640
+    height = 426
+    initializationData:
+      data = length 29, hash 4C2CAE9C
+      data = length 9, hash D971CD89
+  sample 0:
+    time = 66733
+    flags = 0
+    data = length 734, hash AF0D9485
+  sample 1:
+    time = 66733
+    flags = 1
+    data = length 10938, hash 68420875
+  sample 2:
+    time = 133466
+    flags = 0
+    data = length 6, hash 34E6CF79
+  sample 3:
+    time = 133466
+    flags = 0
+    data = length 518, hash 546C177
+track 8448:
+  total output bytes = 0
+  sample count = 0
+  format 0:
+    id = 1/8448
+    sampleMimeType = application/cea-608
+tracksEnded = true
diff --git a/tree/testdata/src/test/assets/ts/sample_h265.ts.0.dump b/tree/testdata/src/test/assets/ts/sample_h265.ts.0.dump
index 1c06207..cee71dd 100644
--- a/tree/testdata/src/test/assets/ts/sample_h265.ts.0.dump
+++ b/tree/testdata/src/test/assets/ts/sample_h265.ts.0.dump
@@ -19,119 +19,119 @@
   sample 0:
     time = 66666
     flags = 1
-    data = length 2510, hash 796A98BE
+    data = length 2517, hash 85352308
   sample 1:
     time = 100000
     flags = 0
-    data = length 1219, hash 131AA4E4
+    data = length 1226, hash 11D564DA
   sample 2:
     time = 266666
     flags = 0
-    data = length 7810, hash 3F881DB9
+    data = length 7817, hash 50D15703
   sample 3:
     time = 200000
     flags = 0
-    data = length 2306, hash 9A77959C
+    data = length 2313, hash ECA5AEE6
   sample 4:
     time = 133333
     flags = 0
-    data = length 1058, hash B887F7EF
+    data = length 1065, hash 8720A939
   sample 5:
     time = 166666
     flags = 0
-    data = length 98, hash D95BF6E3
+    data = length 105, hash 3A3A582D
   sample 6:
     time = 233333
     flags = 0
-    data = length 61, hash 574C41C3
+    data = length 68, hash FC241239
   sample 7:
     time = 433333
     flags = 0
-    data = length 296, hash E92DB288
+    data = length 303, hash 41B28452
   sample 8:
     time = 366666
     flags = 0
-    data = length 137, hash 586DADD6
+    data = length 144, hash 60BBCD4C
   sample 9:
     time = 300000
     flags = 0
-    data = length 218, hash 91E82C9F
+    data = length 225, hash E0FAD7E9
   sample 10:
     time = 333333
     flags = 0
-    data = length 177, hash 4A4FEEC0
+    data = length 184, hash A3A6E036
   sample 11:
     time = 400000
     flags = 0
-    data = length 82, hash 2E2ADD8
+    data = length 89, hash 43B0E322
   sample 12:
     time = 533333
     flags = 0
-    data = length 290, hash 63CF7D90
+    data = length 297, hash 6D9FEEDA
   sample 13:
     time = 500000
     flags = 0
-    data = length 268, hash E8CBAC11
+    data = length 275, hash 27430DB
   sample 14:
     time = 466666
     flags = 0
-    data = length 178, hash C5B1613E
+    data = length 185, hash 97389E88
   sample 15:
     time = 566666
     flags = 0
-    data = length 271, hash 76652FC5
+    data = length 278, hash 5819FEBB
   sample 16:
     time = 733333
     flags = 0
-    data = length 257, hash 960B5DF4
+    data = length 264, hash 8545F36A
   sample 17:
     time = 666666
     flags = 0
-    data = length 206, hash 87358D00
+    data = length 213, hash 52C7574A
   sample 18:
     time = 600000
     flags = 0
-    data = length 130, hash 4D7A038D
+    data = length 137, hash D4F0BCD7
   sample 19:
     time = 633333
     flags = 0
-    data = length 114, hash 2B3C616E
+    data = length 121, hash BE52EEB8
   sample 20:
     time = 700000
     flags = 0
-    data = length 95, hash 37D79559
+    data = length 102, hash 6AA3C84F
   sample 21:
     time = 900000
     flags = 0
-    data = length 233, hash 80308C9E
+    data = length 240, hash 8E3CA414
   sample 22:
     time = 833333
     flags = 0
-    data = length 203, hash E70BA5F2
+    data = length 210, hash 5D050FE8
   sample 23:
     time = 766666
     flags = 0
-    data = length 95, hash BA6FA2D3
+    data = length 102, hash ED3BD5C9
   sample 24:
     time = 800000
     flags = 0
-    data = length 103, hash 51291041
+    data = length 110, hash CF65ED37
   sample 25:
     time = 866666
     flags = 0
-    data = length 111, hash EE9DCFC9
+    data = length 118, hash BA0156BF
   sample 26:
     time = 1033333
     flags = 0
-    data = length 253, hash D0CEFBA7
+    data = length 260, hash ED6ABC1D
   sample 27:
     time = 966666
     flags = 0
-    data = length 134, hash 8802EEF0
+    data = length 141, hash 9787F33A
   sample 28:
     time = 933333
     flags = 0
-    data = length 80, hash C635D9C2
+    data = length 87, hash EEC4D98C
 track 8448:
   total output bytes = 0
   sample count = 0
diff --git a/tree/testdata/src/test/assets/ts/sample_h265.ts.1.dump b/tree/testdata/src/test/assets/ts/sample_h265.ts.1.dump
index 95d8d9b..a9db2af 100644
--- a/tree/testdata/src/test/assets/ts/sample_h265.ts.1.dump
+++ b/tree/testdata/src/test/assets/ts/sample_h265.ts.1.dump
@@ -19,83 +19,83 @@
   sample 0:
     time = 300000
     flags = 0
-    data = length 218, hash 91E82C9F
+    data = length 225, hash E0FAD7E9
   sample 1:
     time = 333333
     flags = 0
-    data = length 177, hash 4A4FEEC0
+    data = length 184, hash A3A6E036
   sample 2:
     time = 400000
     flags = 0
-    data = length 82, hash 2E2ADD8
+    data = length 89, hash 43B0E322
   sample 3:
     time = 533333
     flags = 0
-    data = length 290, hash 63CF7D90
+    data = length 297, hash 6D9FEEDA
   sample 4:
     time = 500000
     flags = 0
-    data = length 268, hash E8CBAC11
+    data = length 275, hash 27430DB
   sample 5:
     time = 466666
     flags = 0
-    data = length 178, hash C5B1613E
+    data = length 185, hash 97389E88
   sample 6:
     time = 566666
     flags = 0
-    data = length 271, hash 76652FC5
+    data = length 278, hash 5819FEBB
   sample 7:
     time = 733333
     flags = 0
-    data = length 257, hash 960B5DF4
+    data = length 264, hash 8545F36A
   sample 8:
     time = 666666
     flags = 0
-    data = length 206, hash 87358D00
+    data = length 213, hash 52C7574A
   sample 9:
     time = 600000
     flags = 0
-    data = length 130, hash 4D7A038D
+    data = length 137, hash D4F0BCD7
   sample 10:
     time = 633333
     flags = 0
-    data = length 114, hash 2B3C616E
+    data = length 121, hash BE52EEB8
   sample 11:
     time = 700000
     flags = 0
-    data = length 95, hash 37D79559
+    data = length 102, hash 6AA3C84F
   sample 12:
     time = 900000
     flags = 0
-    data = length 233, hash 80308C9E
+    data = length 240, hash 8E3CA414
   sample 13:
     time = 833333
     flags = 0
-    data = length 203, hash E70BA5F2
+    data = length 210, hash 5D050FE8
   sample 14:
     time = 766666
     flags = 0
-    data = length 95, hash BA6FA2D3
+    data = length 102, hash ED3BD5C9
   sample 15:
     time = 800000
     flags = 0
-    data = length 103, hash 51291041
+    data = length 110, hash CF65ED37
   sample 16:
     time = 866666
     flags = 0
-    data = length 111, hash EE9DCFC9
+    data = length 118, hash BA0156BF
   sample 17:
     time = 1033333
     flags = 0
-    data = length 253, hash D0CEFBA7
+    data = length 260, hash ED6ABC1D
   sample 18:
     time = 966666
     flags = 0
-    data = length 134, hash 8802EEF0
+    data = length 141, hash 9787F33A
   sample 19:
     time = 933333
     flags = 0
-    data = length 80, hash C635D9C2
+    data = length 87, hash EEC4D98C
 track 8448:
   total output bytes = 0
   sample count = 0
diff --git a/tree/testdata/src/test/assets/ts/sample_h265.ts.2.dump b/tree/testdata/src/test/assets/ts/sample_h265.ts.2.dump
index 9080f5c..7f694a3 100644
--- a/tree/testdata/src/test/assets/ts/sample_h265.ts.2.dump
+++ b/tree/testdata/src/test/assets/ts/sample_h265.ts.2.dump
@@ -19,47 +19,47 @@
   sample 0:
     time = 600000
     flags = 0
-    data = length 130, hash 4D7A038D
+    data = length 137, hash D4F0BCD7
   sample 1:
     time = 633333
     flags = 0
-    data = length 114, hash 2B3C616E
+    data = length 121, hash BE52EEB8
   sample 2:
     time = 700000
     flags = 0
-    data = length 95, hash 37D79559
+    data = length 102, hash 6AA3C84F
   sample 3:
     time = 900000
     flags = 0
-    data = length 233, hash 80308C9E
+    data = length 240, hash 8E3CA414
   sample 4:
     time = 833333
     flags = 0
-    data = length 203, hash E70BA5F2
+    data = length 210, hash 5D050FE8
   sample 5:
     time = 766666
     flags = 0
-    data = length 95, hash BA6FA2D3
+    data = length 102, hash ED3BD5C9
   sample 6:
     time = 800000
     flags = 0
-    data = length 103, hash 51291041
+    data = length 110, hash CF65ED37
   sample 7:
     time = 866666
     flags = 0
-    data = length 111, hash EE9DCFC9
+    data = length 118, hash BA0156BF
   sample 8:
     time = 1033333
     flags = 0
-    data = length 253, hash D0CEFBA7
+    data = length 260, hash ED6ABC1D
   sample 9:
     time = 966666
     flags = 0
-    data = length 134, hash 8802EEF0
+    data = length 141, hash 9787F33A
   sample 10:
     time = 933333
     flags = 0
-    data = length 80, hash C635D9C2
+    data = length 87, hash EEC4D98C
 track 8448:
   total output bytes = 0
   sample count = 0
diff --git a/tree/testdata/src/test/assets/ts/sample_h265.ts.3.dump b/tree/testdata/src/test/assets/ts/sample_h265.ts.3.dump
index 5fe7bce..7926850 100644
--- a/tree/testdata/src/test/assets/ts/sample_h265.ts.3.dump
+++ b/tree/testdata/src/test/assets/ts/sample_h265.ts.3.dump
@@ -19,11 +19,11 @@
   sample 0:
     time = 966666
     flags = 0
-    data = length 134, hash 8802EEF0
+    data = length 141, hash 9787F33A
   sample 1:
     time = 933333
     flags = 0
-    data = length 80, hash C635D9C2
+    data = length 87, hash EEC4D98C
 track 8448:
   total output bytes = 0
   sample count = 0
diff --git a/tree/testdata/src/test/assets/ts/sample_h265.ts.unknown_length.dump b/tree/testdata/src/test/assets/ts/sample_h265.ts.unknown_length.dump
index 733be6f..2df0a73 100644
--- a/tree/testdata/src/test/assets/ts/sample_h265.ts.unknown_length.dump
+++ b/tree/testdata/src/test/assets/ts/sample_h265.ts.unknown_length.dump
@@ -16,119 +16,119 @@
   sample 0:
     time = 66666
     flags = 1
-    data = length 2510, hash 796A98BE
+    data = length 2517, hash 85352308
   sample 1:
     time = 100000
     flags = 0
-    data = length 1219, hash 131AA4E4
+    data = length 1226, hash 11D564DA
   sample 2:
     time = 266666
     flags = 0
-    data = length 7810, hash 3F881DB9
+    data = length 7817, hash 50D15703
   sample 3:
     time = 200000
     flags = 0
-    data = length 2306, hash 9A77959C
+    data = length 2313, hash ECA5AEE6
   sample 4:
     time = 133333
     flags = 0
-    data = length 1058, hash B887F7EF
+    data = length 1065, hash 8720A939
   sample 5:
     time = 166666
     flags = 0
-    data = length 98, hash D95BF6E3
+    data = length 105, hash 3A3A582D
   sample 6:
     time = 233333
     flags = 0
-    data = length 61, hash 574C41C3
+    data = length 68, hash FC241239
   sample 7:
     time = 433333
     flags = 0
-    data = length 296, hash E92DB288
+    data = length 303, hash 41B28452
   sample 8:
     time = 366666
     flags = 0
-    data = length 137, hash 586DADD6
+    data = length 144, hash 60BBCD4C
   sample 9:
     time = 300000
     flags = 0
-    data = length 218, hash 91E82C9F
+    data = length 225, hash E0FAD7E9
   sample 10:
     time = 333333
     flags = 0
-    data = length 177, hash 4A4FEEC0
+    data = length 184, hash A3A6E036
   sample 11:
     time = 400000
     flags = 0
-    data = length 82, hash 2E2ADD8
+    data = length 89, hash 43B0E322
   sample 12:
     time = 533333
     flags = 0
-    data = length 290, hash 63CF7D90
+    data = length 297, hash 6D9FEEDA
   sample 13:
     time = 500000
     flags = 0
-    data = length 268, hash E8CBAC11
+    data = length 275, hash 27430DB
   sample 14:
     time = 466666
     flags = 0
-    data = length 178, hash C5B1613E
+    data = length 185, hash 97389E88
   sample 15:
     time = 566666
     flags = 0
-    data = length 271, hash 76652FC5
+    data = length 278, hash 5819FEBB
   sample 16:
     time = 733333
     flags = 0
-    data = length 257, hash 960B5DF4
+    data = length 264, hash 8545F36A
   sample 17:
     time = 666666
     flags = 0
-    data = length 206, hash 87358D00
+    data = length 213, hash 52C7574A
   sample 18:
     time = 600000
     flags = 0
-    data = length 130, hash 4D7A038D
+    data = length 137, hash D4F0BCD7
   sample 19:
     time = 633333
     flags = 0
-    data = length 114, hash 2B3C616E
+    data = length 121, hash BE52EEB8
   sample 20:
     time = 700000
     flags = 0
-    data = length 95, hash 37D79559
+    data = length 102, hash 6AA3C84F
   sample 21:
     time = 900000
     flags = 0
-    data = length 233, hash 80308C9E
+    data = length 240, hash 8E3CA414
   sample 22:
     time = 833333
     flags = 0
-    data = length 203, hash E70BA5F2
+    data = length 210, hash 5D050FE8
   sample 23:
     time = 766666
     flags = 0
-    data = length 95, hash BA6FA2D3
+    data = length 102, hash ED3BD5C9
   sample 24:
     time = 800000
     flags = 0
-    data = length 103, hash 51291041
+    data = length 110, hash CF65ED37
   sample 25:
     time = 866666
     flags = 0
-    data = length 111, hash EE9DCFC9
+    data = length 118, hash BA0156BF
   sample 26:
     time = 1033333
     flags = 0
-    data = length 253, hash D0CEFBA7
+    data = length 260, hash ED6ABC1D
   sample 27:
     time = 966666
     flags = 0
-    data = length 134, hash 8802EEF0
+    data = length 141, hash 9787F33A
   sample 28:
     time = 933333
     flags = 0
-    data = length 80, hash C635D9C2
+    data = length 87, hash EEC4D98C
 track 8448:
   total output bytes = 0
   sample count = 0
diff --git a/tree/testdata/src/test/assets/webvtt/with_positioning b/tree/testdata/src/test/assets/webvtt/with_positioning
index 7db327c..c4c897a 100644
--- a/tree/testdata/src/test/assets/webvtt/with_positioning
+++ b/tree/testdata/src/test/assets/webvtt/with_positioning
@@ -2,7 +2,7 @@
 
 NOTE Position with percentage and position alignment
 
-00:00:00.000 --> 00:00:01.234 position:10%,start align:start size:35%
+00:00:00.000 --> 00:00:01.234 position:60%,end align:start size:35%
 This is the first subtitle.
 
 NOTE Wrong position provided. It should be provided as
@@ -30,3 +30,13 @@
 
 00:10.000 --> 00:11.000 align:center
 This is the sixth subtitle.
+
+NOTE In older drafts position alignment could be start,middle,end
+
+00:12.000 --> 00:13.000 position:20%,start
+This is the seventh subtitle.
+
+NOTE In the released spec position alignment can be line-left,center,line-right
+
+00:14.000 --> 00:15.000 position:70%,line-right
+This is the eighth subtitle.
diff --git a/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java b/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java
index 269a992..808cb0c 100644
--- a/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java
+++ b/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java
@@ -27,7 +27,10 @@
 import com.google.android.exoplayer2.extractor.SeekMap;
 import com.google.android.exoplayer2.testutil.FakeExtractorInput.SimulatedIOException;
 import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Util;
 import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
 
 /**
  * Assertion methods for {@link Extractor}.
@@ -35,8 +38,60 @@
 public final class ExtractorAsserts {
 
   /**
-   * A factory for {@link Extractor} instances.
+   * Returns a list of arrays containing {@link Config} objects to exercise different extractor
+   * paths.
+   *
+   * <p>This is intended to be used from tests using {@code ParameterizedRobolectricTestRunner} or
+   * {@code org.junit.runners.Parameterized}.
    */
+  public static List<Object[]> configs() {
+    return Arrays.asList(
+        new Object[] {new Config(true, false, false, false)},
+        new Object[] {new Config(true, false, false, true)},
+        new Object[] {new Config(true, false, true, false)},
+        new Object[] {new Config(true, false, true, true)},
+        new Object[] {new Config(true, true, false, false)},
+        new Object[] {new Config(true, true, false, true)},
+        new Object[] {new Config(true, true, true, false)},
+        new Object[] {new Config(true, true, true, true)},
+        new Object[] {new Config(false, false, false, false)});
+  }
+
+  /** A config of different environments to simulate and extractor behaviour to test. */
+  public static class Config {
+
+    /**
+     * Whether to sniff the data by calling {@link Extractor#sniff(ExtractorInput)} prior to
+     * consuming it.
+     */
+    public final boolean sniffFirst;
+    /** Whether to simulate IO errors. */
+    public final boolean simulateIOErrors;
+    /** Whether to simulate unknown input length. */
+    public final boolean simulateUnknownLength;
+    /** Whether to simulate partial reads. */
+    public final boolean simulatePartialReads;
+
+    private Config(
+        boolean sniffFirst,
+        boolean simulateIOErrors,
+        boolean simulateUnknownLength,
+        boolean simulatePartialReads) {
+      this.sniffFirst = sniffFirst;
+      this.simulateIOErrors = simulateIOErrors;
+      this.simulateUnknownLength = simulateUnknownLength;
+      this.simulatePartialReads = simulatePartialReads;
+    }
+
+    @Override
+    public String toString() {
+      return Util.formatInvariant(
+          "sniff=%s,ioErr=%s,unknownLen=%s,partRead=%s",
+          sniffFirst, simulateIOErrors, simulateUnknownLength, simulatePartialReads);
+    }
+  }
+
+  /** A factory for {@link Extractor} instances. */
   public interface ExtractorFactory {
     Extractor create();
   }
@@ -66,8 +121,7 @@
   }
 
   /**
-   * Asserts that an extractor behaves correctly given valid input data. Can only be used from
-   * Robolectric tests.
+   * Asserts that an extractor behaves correctly given valid input data.
    *
    * <ul>
    *   <li>Calls {@link Extractor#seek(long, long)} and {@link Extractor#release()} without calling
@@ -81,8 +135,8 @@
    * @param file The path to the input sample.
    * @throws IOException If reading from the input fails.
    */
-  public static void assertBehavior(ExtractorFactory factory, String file) throws IOException {
-    assertBehavior(factory, file, ApplicationProvider.getApplicationContext());
+  public static void assertAllBehaviors(ExtractorFactory factory, String file) throws IOException {
+    assertAllBehaviors(factory, file, file);
   }
 
   /**
@@ -98,41 +152,71 @@
    * @param factory An {@link ExtractorFactory} which creates instances of the {@link Extractor}
    *     class which is to be tested.
    * @param file The path to the input sample.
-   * @param context To be used to load the sample file.
-   * @throws IOException If reading from the input fails.
-   */
-  public static void assertBehavior(ExtractorFactory factory, String file, Context context)
-      throws IOException {
-    assertBehavior(factory, file, context, file);
-  }
-
-  /**
-   * Asserts that an extractor behaves correctly given valid input data:
-   *
-   * <ul>
-   *   <li>Calls {@link Extractor#seek(long, long)} and {@link Extractor#release()} without calling
-   *       {@link Extractor#init(ExtractorOutput)} to check these calls do not fail.
-   *   <li>Calls {@link #assertOutput(Extractor, String, byte[], Context, boolean, boolean, boolean,
-   *       boolean)} with all possible combinations of "simulate" parameters.
-   * </ul>
-   *
-   * @param factory An {@link ExtractorFactory} which creates instances of the {@link Extractor}
-   *     class which is to be tested.
-   * @param file The path to the input sample.
-   * @param context To be used to load the sample file.
    * @param dumpFilesPrefix The dump files prefix appended to the dump files path.
    * @throws IOException If reading from the input fails.
    */
+  public static void assertAllBehaviors(
+      ExtractorFactory factory, String file, String dumpFilesPrefix) throws IOException {
+    // Check behavior prior to initialization.
+    Extractor extractor = factory.create();
+    extractor.seek(0, 0);
+    extractor.release();
+    // Assert output.
+    Context context = ApplicationProvider.getApplicationContext();
+    byte[] fileData = TestUtil.getByteArray(context, file);
+    assertOutput(factory, dumpFilesPrefix, fileData, context);
+  }
+
+  /**
+   * Asserts that an extractor consumes valid input data successfully under the conditions specified
+   * by {@code config}.
+   *
+   * <p>The output of the extractor is compared against prerecorded dump files whose names are
+   * derived from the {@code file} parameter.
+   *
+   * @param factory An {@link ExtractorFactory} which creates instances of the {@link Extractor}
+   *     class which is to be tested.
+   * @param file The path to the input sample.
+   * @param config Details on the environment to simulate and behaviours to assert.
+   * @throws IOException If reading from the input fails.
+   */
+  public static void assertBehavior(ExtractorFactory factory, String file, Config config)
+      throws IOException {
+    assertBehavior(factory, file, config, file);
+  }
+
+  /**
+   * Asserts that an extractor consumes valid input data successfully successfully under the
+   * conditions specified by {@code config}.
+   *
+   * <p>The output of the extractor is compared against prerecorded dump files.
+   *
+   * @param factory An {@link ExtractorFactory} which creates instances of the {@link Extractor}
+   *     class which is to be tested.
+   * @param file The path to the input sample.
+   * @param config Details on the environment to simulate and behaviours to assert.
+   * @param dumpFilesPrefix The dump files prefix prepended to the dump files path.
+   * @throws IOException If reading from the input fails.
+   */
   public static void assertBehavior(
-      ExtractorFactory factory, String file, Context context, String dumpFilesPrefix)
+      ExtractorFactory factory, String file, Config config, String dumpFilesPrefix)
       throws IOException {
     // Check behavior prior to initialization.
     Extractor extractor = factory.create();
     extractor.seek(0, 0);
     extractor.release();
     // Assert output.
+    Context context = ApplicationProvider.getApplicationContext();
     byte[] fileData = TestUtil.getByteArray(context, file);
-    assertOutput(factory, dumpFilesPrefix, fileData, context);
+    assertOutput(
+        factory.create(),
+        dumpFilesPrefix,
+        fileData,
+        context,
+        config.sniffFirst,
+        config.simulateIOErrors,
+        config.simulateUnknownLength,
+        config.simulatePartialReads);
   }
 
   /**
@@ -148,7 +232,7 @@
    * @param context To be used to load the sample file.
    * @throws IOException If reading from the input fails.
    */
-  public static void assertOutput(
+  private static void assertOutput(
       ExtractorFactory factory, String dumpFilesPrefix, byte[] data, Context context)
       throws IOException {
     assertOutput(factory.create(), dumpFilesPrefix, data, context, true, false, false, false);
@@ -163,11 +247,11 @@
   }
 
   /**
-   * Asserts that {@code extractor} consumes {@code data} successfully and that its output for
-   * various initial seek times and for a known and unknown length matches prerecorded dump files.
+   * Asserts that an extractor consumes valid input data successfully under the specified
+   * conditions.
    *
    * @param extractor The {@link Extractor} to be tested.
-   * @param dumpFilesPrefix The dump files prefix appended to the dump files path.
+   * @param dumpFilesPrefix The dump files prefix prepended to the dump files path.
    * @param data Content of the input file.
    * @param context To be used to load the sample file.
    * @param sniffFirst Whether to sniff the data by calling {@link Extractor#sniff(ExtractorInput)}
@@ -178,7 +262,7 @@
    * @return The {@link FakeExtractorOutput} used in the test.
    * @throws IOException If reading from the input fails.
    */
-  public static FakeExtractorOutput assertOutput(
+  private static FakeExtractorOutput assertOutput(
       Extractor extractor,
       String dumpFilesPrefix,
       byte[] data,
diff --git a/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java b/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java
index 5cefbff..4486630 100644
--- a/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java
+++ b/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java
@@ -27,6 +27,7 @@
 import com.google.android.exoplayer2.extractor.SeekMap;
 import com.google.android.exoplayer2.util.Assertions;
 import java.io.File;
+import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.PrintWriter;
 import java.lang.annotation.Documented;
@@ -34,11 +35,14 @@
 import java.lang.annotation.RetentionPolicy;
 import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
 
-/**
- * A fake {@link ExtractorOutput}.
- */
+/** A fake {@link ExtractorOutput}. */
 public final class FakeExtractorOutput implements ExtractorOutput, Dumper.Dumpable {
 
+  private static final String DUMP_UPDATE_INSTRUCTIONS =
+      "To update the dump file, change FakeExtractorOutput#DUMP_FILE_ACTION to WRITE_TO_LOCAL (for"
+          + " Robolectric tests) or WRITE_TO_DEVICE (for instrumentation tests) and re-run the"
+          + " test.";
+
   /**
    * Possible actions to take with the dumps generated from this {@code FakeExtractorOutput} in
    * {@link #assertOutput(Context, String)}.
@@ -128,13 +132,15 @@
     String actual = new Dumper().add(this).toString();
 
     if (DUMP_FILE_ACTION == COMPARE_WITH_EXISTING) {
-      String expected = TestUtil.getString(context, dumpFile);
+      String expected;
+      try {
+        expected = TestUtil.getString(context, dumpFile);
+      } catch (FileNotFoundException e) {
+        throw new IOException("Dump file not found. " + DUMP_UPDATE_INSTRUCTIONS, e);
+      }
       assertWithMessage(
-              "Extractor output doesn't match golden file: %s\n"
-                  + "To update the golden, change FakeExtractorOutput#DUMP_FILE_ACTION to"
-                  + " WRITE_TO_LOCAL (for Robolectric tests) or WRITE_TO_DEVICE (for"
-                  + " instrumentation tests) and re-run the test.",
-              dumpFile)
+              "Extractor output doesn't match dump file: %s\n%s",
+              dumpFile, DUMP_UPDATE_INSTRUCTIONS)
           .that(actual)
           .isEqualTo(expected);
     } else {
diff --git a/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java b/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java
index 03a52cf..3bd0b6b 100644
--- a/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java
+++ b/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java
@@ -23,6 +23,7 @@
 import androidx.annotation.Nullable;
 import com.google.android.exoplayer2.C;
 import com.google.android.exoplayer2.SeekParameters;
+import com.google.android.exoplayer2.source.LoadEventInfo;
 import com.google.android.exoplayer2.source.MediaPeriod;
 import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher;
 import com.google.android.exoplayer2.source.SampleStream;
@@ -121,15 +122,14 @@
   @Override
   public synchronized void prepare(Callback callback, long positionUs) {
     eventDispatcher.loadStarted(
-        FAKE_DATA_SPEC,
+        new LoadEventInfo(FAKE_DATA_SPEC, SystemClock.elapsedRealtime()),
         C.DATA_TYPE_MEDIA,
         C.TRACK_TYPE_UNKNOWN,
         /* trackFormat= */ null,
         C.SELECTION_REASON_UNKNOWN,
         /* trackSelectionData= */ null,
         /* mediaStartTimeUs= */ 0,
-        /* mediaEndTimeUs = */ C.TIME_UNSET,
-        SystemClock.elapsedRealtime());
+        /* mediaEndTimeUs = */ C.TIME_UNSET);
     prepareCallback = callback;
     if (deferOnPrepared) {
       playerHandler = Util.createHandler();
@@ -273,18 +273,19 @@
     prepared = true;
     Util.castNonNull(prepareCallback).onPrepared(this);
     eventDispatcher.loadCompleted(
-        FAKE_DATA_SPEC,
-        FAKE_DATA_SPEC.uri,
-        /* responseHeaders= */ Collections.emptyMap(),
+        new LoadEventInfo(
+            FAKE_DATA_SPEC,
+            FAKE_DATA_SPEC.uri,
+            /* responseHeaders= */ Collections.emptyMap(),
+            SystemClock.elapsedRealtime(),
+            /* loadDurationMs= */ 0,
+            /* bytesLoaded= */ 100),
         C.DATA_TYPE_MEDIA,
         C.TRACK_TYPE_UNKNOWN,
         /* trackFormat= */ null,
         C.SELECTION_REASON_UNKNOWN,
         /* trackSelectionData= */ null,
         /* mediaStartTimeUs= */ 0,
-        /* mediaEndTimeUs = */ C.TIME_UNSET,
-        SystemClock.elapsedRealtime(),
-        /* loadDurationMs= */ 0,
-        /* bytesLoaded= */ 100);
+        /* mediaEndTimeUs = */ C.TIME_UNSET);
   }
 }
diff --git a/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java b/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java
index b3c6bf7..fba8a9d 100644
--- a/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java
+++ b/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java
@@ -72,7 +72,9 @@
   }
 
   @Override
-  public int sampleData(DataReader input, int length, boolean allowEndOfInput) throws IOException {
+  public int sampleData(
+      DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart)
+      throws IOException {
     byte[] newData = new byte[length];
     int bytesAppended = input.read(newData, 0, length);
     if (bytesAppended == C.RESULT_END_OF_INPUT) {
@@ -87,7 +89,7 @@
   }
 
   @Override
-  public void sampleData(ParsableByteArray data, int length) {
+  public void sampleData(ParsableByteArray data, int length, @SampleDataPart int sampleDataPart) {
     byte[] newData = new byte[length];
     data.readBytes(newData, 0, length);
     sampleData = TestUtil.joinByteArrays(sampleData, newData);
@@ -294,6 +296,7 @@
       addIfNonDefault(dumper, "subsampleOffsetUs", format -> format.subsampleOffsetUs);
       addIfNonDefault(dumper, "selectionFlags", format -> format.selectionFlags);
       addIfNonDefault(dumper, "language", format -> format.language);
+      addIfNonDefault(dumper, "label", format -> format.label);
       if (format.drmInitData != null) {
         dumper.add("drmInitData", format.drmInitData.hashCode());
       }
diff --git a/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java b/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java
index 27d6b08..1972801 100644
--- a/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java
+++ b/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java
@@ -55,6 +55,17 @@
   private MediaPeriodAsserts() {}
 
   /**
+   * Prepares the {@link MediaPeriod} and asserts that it provides the specified track groups.
+   *
+   * @param mediaPeriod The {@link MediaPeriod} to test.
+   * @param expectedGroups The expected track groups.
+   */
+  public static void assertTrackGroups(MediaPeriod mediaPeriod, TrackGroupArray expectedGroups) {
+    TrackGroupArray actualGroups = prepareAndGetTrackGroups(mediaPeriod);
+    assertThat(actualGroups).isEqualTo(expectedGroups);
+  }
+
+  /**
    * Asserts that the values returns by {@link MediaPeriod#getStreamKeys(List)} are compatible with
    * a {@link FilterableManifest} using these stream keys.
    *
@@ -85,7 +96,7 @@
           int periodIndex,
           @Nullable String ignoredMimeType) {
     MediaPeriod mediaPeriod = mediaPeriodFactory.createMediaPeriod(manifest, periodIndex);
-    TrackGroupArray trackGroupArray = getTrackGroups(mediaPeriod);
+    TrackGroupArray trackGroupArray = prepareAndGetTrackGroups(mediaPeriod);
 
     // Create test vector of query test selections:
     //  - One selection with one track per group, two tracks or all tracks.
@@ -146,7 +157,7 @@
       // The filtered manifest should only have one period left.
       MediaPeriod filteredMediaPeriod =
           mediaPeriodFactory.createMediaPeriod(filteredManifest, /* periodIndex= */ 0);
-      TrackGroupArray filteredTrackGroupArray = getTrackGroups(filteredMediaPeriod);
+      TrackGroupArray filteredTrackGroupArray = prepareAndGetTrackGroups(filteredMediaPeriod);
       for (TrackSelection trackSelection : testSelection) {
         if (ignoredMimeType != null
             && ignoredMimeType.equals(trackSelection.getFormat(0).sampleMimeType)) {
@@ -186,7 +197,7 @@
     return true;
   }
 
-  private static TrackGroupArray getTrackGroups(MediaPeriod mediaPeriod) {
+  private static TrackGroupArray prepareAndGetTrackGroups(MediaPeriod mediaPeriod) {
     AtomicReference<TrackGroupArray> trackGroupArray = new AtomicReference<>();
     DummyMainThread dummyMainThread = new DummyMainThread();
     ConditionVariable preparedCondition = new ConditionVariable();
diff --git a/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java b/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java
index ad36635..9d431b7 100644
--- a/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java
+++ b/tree/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java
@@ -23,6 +23,7 @@
 import androidx.annotation.Nullable;
 import com.google.android.exoplayer2.DefaultLoadControl;
 import com.google.android.exoplayer2.ExoPlaybackException;
+import com.google.android.exoplayer2.ExoPlayer;
 import com.google.android.exoplayer2.LoadControl;
 import com.google.android.exoplayer2.Player;
 import com.google.android.exoplayer2.Renderer;
@@ -36,6 +37,7 @@
 import com.google.android.exoplayer2.util.Assertions;
 import com.google.android.exoplayer2.util.Clock;
 import com.google.android.exoplayer2.util.Supplier;
+import com.google.android.exoplayer2.util.Util;
 import com.google.android.exoplayer2.video.VideoListener;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
@@ -78,6 +80,7 @@
     @Nullable private Renderer[] renderers;
     @Nullable private RenderersFactory renderersFactory;
     private boolean useLazyPreparation;
+    private boolean throwWhenStuckBuffering;
     private @MonotonicNonNull Looper looper;
 
     public Builder(Context context) {
@@ -248,6 +251,19 @@
     }
 
     /**
+     * Sets whether the player should throw when it detects it's stuck buffering.
+     *
+     * <p>This method is experimental, and will be renamed or removed in a future release.
+     *
+     * @param throwWhenStuckBuffering Whether to throw when the player detects it's stuck buffering.
+     * @return This builder.
+     */
+    public Builder experimental_setThrowWhenStuckBuffering(boolean throwWhenStuckBuffering) {
+      this.throwWhenStuckBuffering = throwWhenStuckBuffering;
+      return this;
+    }
+
+    /**
      * Builds an {@link SimpleExoPlayer} using the provided values or their defaults.
      *
      * @return The built {@link ExoPlayerTestRunner}.
@@ -281,6 +297,7 @@
           .setClock(clock)
           .setUseLazyPreparation(useLazyPreparation)
           .setLooper(looper)
+          .experimental_setThrowWhenStuckBuffering(throwWhenStuckBuffering)
           .build();
     }
   }
@@ -291,8 +308,7 @@
    * Run tasks of the main {@link Looper} until the {@code player}'s state reaches the {@code
    * expectedState}.
    */
-  public static void runUntilPlaybackState(
-      SimpleExoPlayer player, @Player.State int expectedState) {
+  public static void runUntilPlaybackState(Player player, @Player.State int expectedState) {
     verifyMainTestThread(player);
     if (player.getPlaybackState() == expectedState) {
       return;
@@ -318,7 +334,7 @@
    * Player.EventListener#onPlaybackSpeedChanged} callback with that matches {@code
    * expectedPlayWhenReady}.
    */
-  public static void runUntilPlayWhenReady(SimpleExoPlayer player, boolean expectedPlayWhenReady) {
+  public static void runUntilPlayWhenReady(Player player, boolean expectedPlayWhenReady) {
     verifyMainTestThread(player);
     if (player.getPlayWhenReady() == expectedPlayWhenReady) {
       return;
@@ -343,13 +359,13 @@
    * Run tasks of the main {@link Looper} until the {@code player} calls the {@link
    * Player.EventListener#onTimelineChanged} callback.
    *
-   * @param player The {@link SimpleExoPlayer}.
+   * @param player The {@link Player}.
    * @param expectedTimeline A specific {@link Timeline} to wait for, or null if any timeline is
    *     accepted.
    * @return The received {@link Timeline}.
    */
   public static Timeline runUntilTimelineChanged(
-      SimpleExoPlayer player, @Nullable Timeline expectedTimeline) {
+      Player player, @Nullable Timeline expectedTimeline) {
     verifyMainTestThread(player);
 
     if (expectedTimeline != null && expectedTimeline.equals(player.getCurrentTimeline())) {
@@ -378,7 +394,7 @@
    * Player.DiscontinuityReason}.
    */
   public static void runUntilPositionDiscontinuity(
-      SimpleExoPlayer player, @Player.DiscontinuityReason int expectedReason) {
+      Player player, @Player.DiscontinuityReason int expectedReason) {
     AtomicBoolean receivedCallback = new AtomicBoolean(false);
     Player.EventListener listener =
         new Player.EventListener() {
@@ -398,10 +414,10 @@
    * Run tasks of the main {@link Looper} until the {@code player} calls the {@link
    * Player.EventListener#onPlayerError} callback.
    *
-   * @param player The {@link SimpleExoPlayer}.
+   * @param player The {@link Player}.
    * @return The raised error.
    */
-  public static ExoPlaybackException runUntilError(SimpleExoPlayer player) {
+  public static ExoPlaybackException runUntilError(Player player) {
     verifyMainTestThread(player);
     AtomicReference<ExoPlaybackException> receivedError = new AtomicReference<>();
     Player.EventListener listener =
@@ -436,6 +452,23 @@
     runUntil(() -> receivedCallback.get());
   }
 
+  /**
+   * Runs tasks of the main {@link Looper} until the {@code player} handled all previously issued
+   * commands completely on the internal playback thread.
+   */
+  public static void runUntilPendingCommandsAreFullyHandled(ExoPlayer player) {
+    verifyMainTestThread(player);
+    // Send message to player that will arrive after all other pending commands. Thus, the message
+    // execution on the app thread will also happen after all other pending command
+    // acknowledgements have arrived back on the app thread.
+    AtomicBoolean receivedMessageCallback = new AtomicBoolean(false);
+    player
+        .createMessage((type, data) -> receivedMessageCallback.set(true))
+        .setHandler(Util.createHandler())
+        .send();
+    runUntil(() -> receivedMessageCallback.get());
+  }
+
   /** Run tasks of the main {@link Looper} until the {@code condition} returns {@code true}. */
   public static void runUntil(Supplier<Boolean> condition) {
     verifyMainTestThread();
@@ -451,7 +484,7 @@
     }
   }
 
-  private static void verifyMainTestThread(SimpleExoPlayer player) {
+  private static void verifyMainTestThread(Player player) {
     if (Looper.myLooper() != Looper.getMainLooper()
         || player.getApplicationLooper() != Looper.getMainLooper()) {
       throw new IllegalStateException();