From 94f774b82d1659ae5ef07b6c0a0789d310d2e779 Mon Sep 17 00:00:00 2001
From: TiA4f8R <74829229+TiA4f8R@users.noreply.github.com>
Date: Sat, 15 Jan 2022 13:50:35 +0100
Subject: [PATCH] Use a custom HlsPlaylistTracker, based on
 DefaultHlsPlaylistTracker to allow more stucking on HLS livestreams

ExoPlayer's default behavior is to use a multiplication of target segment by a coefficient (3,5).
This coefficient (and this behavior) cannot be customized without using a custom HlsPlaylistTracker right now.

New behavior is to wait 15 seconds before throwing a PlaylistStuckException.
This should improve a lot HLS live streaming on (very) low-latency livestreams with buffering issues, especially on YouTube with their HLS manifests.
---
 .../player/helper/PlayerDataSource.java       |   7 +-
 .../playback/CustomHlsPlaylistTracker.java    | 782 ++++++++++++++++++
 2 files changed, 787 insertions(+), 2 deletions(-)
 create mode 100644 app/src/main/java/org/schabi/newpipe/player/playback/CustomHlsPlaylistTracker.java

diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java
index 708b72ff2..1fce25e78 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java
@@ -16,6 +16,8 @@ import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
 import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy;
 import com.google.android.exoplayer2.upstream.TransferListener;
 
+import org.schabi.newpipe.player.playback.CustomHlsPlaylistTracker;
+
 public class PlayerDataSource {
     private static final int MANIFEST_MINIMUM_RETRY = 5;
     private static final int EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE;
@@ -44,8 +46,9 @@ public class PlayerDataSource {
     public HlsMediaSource.Factory getLiveHlsMediaSourceFactory() {
         return new HlsMediaSource.Factory(cachelessDataSourceFactory)
                 .setAllowChunklessPreparation(true)
-                .setLoadErrorHandlingPolicy(
-                        new DefaultLoadErrorHandlingPolicy(MANIFEST_MINIMUM_RETRY));
+                .setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy(
+                        MANIFEST_MINIMUM_RETRY))
+                .setPlaylistTrackerFactory(CustomHlsPlaylistTracker.FACTORY);
     }
 
     public DashMediaSource.Factory getLiveDashMediaSourceFactory() {
diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/CustomHlsPlaylistTracker.java b/app/src/main/java/org/schabi/newpipe/player/playback/CustomHlsPlaylistTracker.java
new file mode 100644
index 000000000..28f6b01fe
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/playback/CustomHlsPlaylistTracker.java
@@ -0,0 +1,782 @@
+/*
+ * Original source code (DefaultHlsPlaylistTracker): Copyright (C) 2016 The Android Open Source
+ * Project
+ *
+ * Original source code licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use the original source code of 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 org.schabi.newpipe.player.playback;
+
+import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
+import static com.google.android.exoplayer2.util.Util.castNonNull;
+import static java.lang.Math.max;
+
+import android.net.Uri;
+import android.os.Handler;
+import android.os.SystemClock;
+
+import androidx.annotation.NonNull;
+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.MediaLoadData;
+import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher;
+import com.google.android.exoplayer2.source.hls.HlsDataSourceFactory;
+import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTracker;
+import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist;
+import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant;
+import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist;
+import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Part;
+import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.RenditionReport;
+import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment;
+import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist;
+import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser;
+import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParserFactory;
+import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker;
+import com.google.android.exoplayer2.upstream.DataSource;
+import com.google.android.exoplayer2.upstream.HttpDataSource;
+import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy;
+import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.LoadErrorInfo;
+import com.google.android.exoplayer2.upstream.Loader;
+import com.google.android.exoplayer2.upstream.Loader.LoadErrorAction;
+import com.google.android.exoplayer2.upstream.ParsingLoadable;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Util;
+import com.google.common.collect.Iterables;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+
+/**
+ * NewPipe's implementation for {@link HlsPlaylistTracker}, based on
+ * {@link DefaultHlsPlaylistTracker}.
+ *
+ * <p>
+ * It redefines the way of how
+ * {@link PlaylistStuckException PlaylistStuckExceptions} are thrown: instead of
+ * using a multiplication between the target duration of segments and
+ * {@link DefaultHlsPlaylistTracker#DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT}, it uses a
+ * constant value (see {@link #MAXIMUM_PLAYLIST_STUCK_DURATION_MS}), in order to reduce the number
+ * of this exception thrown, especially on (very) low-latency livestreams.
+ * </p>
+ */
+public final class CustomHlsPlaylistTracker implements HlsPlaylistTracker,
+        Loader.Callback<ParsingLoadable<HlsPlaylist>> {
+
+    /**
+     * Factory for {@link CustomHlsPlaylistTracker} instances.
+     */
+    public static final Factory FACTORY = CustomHlsPlaylistTracker::new;
+
+    /**
+     * The maximum duration before a {@link PlaylistStuckException} is thrown, in milliseconds.
+     */
+    private static final double MAXIMUM_PLAYLIST_STUCK_DURATION_MS = 15000;
+
+    private final HlsDataSourceFactory dataSourceFactory;
+    private final HlsPlaylistParserFactory playlistParserFactory;
+    private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
+    private final HashMap<Uri, MediaPlaylistBundle> playlistBundles;
+    private final List<PlaylistEventListener> listeners;
+
+    @Nullable
+    private EventDispatcher eventDispatcher;
+    @Nullable
+    private Loader initialPlaylistLoader;
+    @Nullable
+    private Handler playlistRefreshHandler;
+    @Nullable
+    private PrimaryPlaylistListener primaryPlaylistListener;
+    @Nullable
+    private HlsMasterPlaylist masterPlaylist;
+    @Nullable
+    private Uri primaryMediaPlaylistUrl;
+    @Nullable
+    private HlsMediaPlaylist primaryMediaPlaylistSnapshot;
+    private boolean isLive;
+    private long initialStartTimeUs;
+
+    /**
+     * Creates an instance.
+     *
+     * @param dataSourceFactory       A factory for {@link DataSource} instances.
+     * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}.
+     * @param playlistParserFactory   An {@link HlsPlaylistParserFactory}.
+     */
+    public CustomHlsPlaylistTracker(final HlsDataSourceFactory dataSourceFactory,
+                                    final LoadErrorHandlingPolicy loadErrorHandlingPolicy,
+                                    final HlsPlaylistParserFactory playlistParserFactory) {
+        this.dataSourceFactory = dataSourceFactory;
+        this.playlistParserFactory = playlistParserFactory;
+        this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
+        listeners = new ArrayList<>();
+        playlistBundles = new HashMap<>();
+        initialStartTimeUs = C.TIME_UNSET;
+    }
+
+    // HlsPlaylistTracker implementation.
+
+    @Override
+    public void start(@NonNull final Uri initialPlaylistUri,
+                      @NonNull final EventDispatcher eventDispatcherObject,
+                      @NonNull final PrimaryPlaylistListener primaryPlaylistListenerObject) {
+        this.playlistRefreshHandler = Util.createHandlerForCurrentLooper();
+        this.eventDispatcher = eventDispatcherObject;
+        this.primaryPlaylistListener = primaryPlaylistListenerObject;
+        final ParsingLoadable<HlsPlaylist> masterPlaylistLoadable = new ParsingLoadable<>(
+                dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST),
+                initialPlaylistUri,
+                C.DATA_TYPE_MANIFEST,
+                playlistParserFactory.createPlaylistParser());
+        Assertions.checkState(initialPlaylistLoader == null);
+        initialPlaylistLoader = new Loader("CustomHlsPlaylistTracker:MasterPlaylist");
+        final long elapsedRealtime = initialPlaylistLoader.startLoading(masterPlaylistLoadable,
+                this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(
+                        masterPlaylistLoadable.type));
+        eventDispatcherObject.loadStarted(new LoadEventInfo(masterPlaylistLoadable.loadTaskId,
+                        masterPlaylistLoadable.dataSpec, elapsedRealtime),
+                masterPlaylistLoadable.type);
+    }
+
+    @Override
+    public void stop() {
+        primaryMediaPlaylistUrl = null;
+        primaryMediaPlaylistSnapshot = null;
+        masterPlaylist = null;
+        initialStartTimeUs = C.TIME_UNSET;
+        initialPlaylistLoader.release();
+        initialPlaylistLoader = null;
+        for (final MediaPlaylistBundle bundle : playlistBundles.values()) {
+            bundle.release();
+        }
+        playlistRefreshHandler.removeCallbacksAndMessages(null);
+        playlistRefreshHandler = null;
+        playlistBundles.clear();
+    }
+
+    @Override
+    public void addListener(@NonNull final PlaylistEventListener listener) {
+        checkNotNull(listener);
+        listeners.add(listener);
+    }
+
+    @Override
+    public void removeListener(@NonNull final PlaylistEventListener listener) {
+        listeners.remove(listener);
+    }
+
+    @Override
+    @Nullable
+    public HlsMasterPlaylist getMasterPlaylist() {
+        return masterPlaylist;
+    }
+
+    @Override
+    @Nullable
+    public HlsMediaPlaylist getPlaylistSnapshot(@NonNull final Uri url,
+                                                final boolean isForPlayback) {
+        final HlsMediaPlaylist snapshot = playlistBundles.get(url).getPlaylistSnapshot();
+        if (snapshot != null && isForPlayback) {
+            maybeSetPrimaryUrl(url);
+        }
+        return snapshot;
+    }
+
+    @Override
+    public long getInitialStartTimeUs() {
+        return initialStartTimeUs;
+    }
+
+    @Override
+    public boolean isSnapshotValid(@NonNull final Uri url) {
+        return playlistBundles.get(url).isSnapshotValid();
+    }
+
+    @Override
+    public void maybeThrowPrimaryPlaylistRefreshError() throws IOException {
+        if (initialPlaylistLoader != null) {
+            initialPlaylistLoader.maybeThrowError();
+        }
+        if (primaryMediaPlaylistUrl != null) {
+            maybeThrowPlaylistRefreshError(primaryMediaPlaylistUrl);
+        }
+    }
+
+    @Override
+    public void maybeThrowPlaylistRefreshError(@NonNull final Uri url) throws IOException {
+        playlistBundles.get(url).maybeThrowPlaylistRefreshError();
+    }
+
+    @Override
+    public void refreshPlaylist(@NonNull final Uri url) {
+        playlistBundles.get(url).loadPlaylist();
+    }
+
+    @Override
+    public boolean isLive() {
+        return isLive;
+    }
+
+    // Loader.Callback implementation.
+
+    @Override
+    public void onLoadCompleted(@NonNull final ParsingLoadable<HlsPlaylist> loadable,
+                                final long elapsedRealtimeMs,
+                                final long loadDurationMs) {
+        final HlsPlaylist result = loadable.getResult();
+        final HlsMasterPlaylist newMasterPlaylist;
+        final boolean isMediaPlaylist = result instanceof HlsMediaPlaylist;
+        if (isMediaPlaylist) {
+            newMasterPlaylist = HlsMasterPlaylist.createSingleVariantMasterPlaylist(
+                    result.baseUri);
+        } else { // result instanceof HlsMasterPlaylist
+            newMasterPlaylist = (HlsMasterPlaylist) result;
+        }
+        this.masterPlaylist = newMasterPlaylist;
+        primaryMediaPlaylistUrl = newMasterPlaylist.variants.get(0).url;
+        createBundles(newMasterPlaylist.mediaPlaylistUrls);
+        final LoadEventInfo loadEventInfo = new LoadEventInfo(loadable.loadTaskId,
+                loadable.dataSpec, loadable.getUri(), loadable.getResponseHeaders(),
+                elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded());
+        final MediaPlaylistBundle primaryBundle = playlistBundles.get(primaryMediaPlaylistUrl);
+        if (isMediaPlaylist) {
+            // We don't need to load the playlist again. We can use the same result.
+            primaryBundle.processLoadedPlaylist((HlsMediaPlaylist) result, loadEventInfo);
+        } else {
+            primaryBundle.loadPlaylist();
+        }
+        loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
+        eventDispatcher.loadCompleted(loadEventInfo, C.DATA_TYPE_MANIFEST);
+    }
+
+    @Override
+    public void onLoadCanceled(@NonNull final ParsingLoadable<HlsPlaylist> loadable,
+                               final long elapsedRealtimeMs,
+                               final long loadDurationMs,
+                               final boolean released) {
+        final LoadEventInfo loadEventInfo = new LoadEventInfo(loadable.loadTaskId,
+                loadable.dataSpec, loadable.getUri(), loadable.getResponseHeaders(),
+                elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded());
+        loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
+        eventDispatcher.loadCanceled(loadEventInfo, C.DATA_TYPE_MANIFEST);
+    }
+
+    @Override
+    public LoadErrorAction onLoadError(@NonNull final ParsingLoadable<HlsPlaylist> loadable,
+                                       final long elapsedRealtimeMs,
+                                       final long loadDurationMs,
+                                       final IOException error,
+                                       final int errorCount) {
+        final LoadEventInfo loadEventInfo = new LoadEventInfo(loadable.loadTaskId,
+                loadable.dataSpec, loadable.getUri(), loadable.getResponseHeaders(),
+                elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded());
+        final MediaLoadData mediaLoadData = new MediaLoadData(loadable.type);
+        final long retryDelayMs = loadErrorHandlingPolicy.getRetryDelayMsFor(new LoadErrorInfo(
+                loadEventInfo, mediaLoadData, error, errorCount));
+        final boolean isFatal = retryDelayMs == C.TIME_UNSET;
+        eventDispatcher.loadError(loadEventInfo, loadable.type, error, isFatal);
+        if (isFatal) {
+            loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
+        }
+        return isFatal ? Loader.DONT_RETRY_FATAL : Loader.createRetryAction(false, retryDelayMs);
+    }
+
+    // Internal methods.
+
+    private boolean maybeSelectNewPrimaryUrl() {
+        final List<Variant> variants = masterPlaylist.variants;
+        final int variantsSize = variants.size();
+        final long currentTimeMs = SystemClock.elapsedRealtime();
+        for (int i = 0; i < variantsSize; i++) {
+            final MediaPlaylistBundle bundle = checkNotNull(playlistBundles.get(
+                    variants.get(i).url));
+            if (currentTimeMs > bundle.excludeUntilMs) {
+                primaryMediaPlaylistUrl = bundle.playlistUrl;
+                bundle.loadPlaylistInternal(getRequestUriForPrimaryChange(
+                        primaryMediaPlaylistUrl));
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private void maybeSetPrimaryUrl(@NonNull final Uri url) {
+        if (url.equals(primaryMediaPlaylistUrl) || !isVariantUrl(url)
+                || (primaryMediaPlaylistSnapshot != null
+                && primaryMediaPlaylistSnapshot.hasEndTag)) {
+            // Ignore if the primary media playlist URL is unchanged, if the media playlist is not
+            // referenced directly by a variant, or it the last primary snapshot contains an end
+            // tag.
+            return;
+        }
+        primaryMediaPlaylistUrl = url;
+        final MediaPlaylistBundle newPrimaryBundle = playlistBundles.get(primaryMediaPlaylistUrl);
+        final HlsMediaPlaylist newPrimarySnapshot = newPrimaryBundle.playlistSnapshot;
+        if (newPrimarySnapshot != null && newPrimarySnapshot.hasEndTag) {
+            primaryMediaPlaylistSnapshot = newPrimarySnapshot;
+            primaryPlaylistListener.onPrimaryPlaylistRefreshed(newPrimarySnapshot);
+        } else {
+            // The snapshot for the new primary media playlist URL may be stale. Defer updating the
+            // primary snapshot until after we've refreshed it.
+            newPrimaryBundle.loadPlaylistInternal(getRequestUriForPrimaryChange(url));
+        }
+    }
+
+    private Uri getRequestUriForPrimaryChange(@NonNull final Uri newPrimaryPlaylistUri) {
+        if (primaryMediaPlaylistSnapshot != null
+                && primaryMediaPlaylistSnapshot.serverControl.canBlockReload) {
+            final RenditionReport renditionReport = primaryMediaPlaylistSnapshot.renditionReports
+                    .get(newPrimaryPlaylistUri);
+            if (renditionReport != null) {
+                final Uri.Builder uriBuilder = newPrimaryPlaylistUri.buildUpon();
+                uriBuilder.appendQueryParameter(MediaPlaylistBundle.BLOCK_MSN_PARAM,
+                        String.valueOf(renditionReport.lastMediaSequence));
+                if (renditionReport.lastPartIndex != C.INDEX_UNSET) {
+                    uriBuilder.appendQueryParameter(MediaPlaylistBundle.BLOCK_PART_PARAM,
+                            String.valueOf(renditionReport.lastPartIndex));
+                }
+                return uriBuilder.build();
+            }
+        }
+        return newPrimaryPlaylistUri;
+    }
+
+    /**
+     * @return whether any of the variants in the master playlist have the specified playlist URL.
+     * @param playlistUrl the playlist URL to test
+     */
+    private boolean isVariantUrl(final Uri playlistUrl) {
+        final List<Variant> variants = masterPlaylist.variants;
+        final int variantsSize = variants.size();
+        for (int i = 0; i < variantsSize; i++) {
+            if (playlistUrl.equals(variants.get(i).url)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private void createBundles(@NonNull final List<Uri> urls) {
+        final int listSize = urls.size();
+        for (int i = 0; i < listSize; i++) {
+            final Uri url = urls.get(i);
+            final MediaPlaylistBundle bundle = new MediaPlaylistBundle(url);
+            playlistBundles.put(url, bundle);
+        }
+    }
+
+    /**
+     * Called by the bundles when a snapshot changes.
+     *
+     * @param url         The url of the playlist.
+     * @param newSnapshot The new snapshot.
+     */
+    private void onPlaylistUpdated(@NonNull final Uri url, final HlsMediaPlaylist newSnapshot) {
+        if (url.equals(primaryMediaPlaylistUrl)) {
+            if (primaryMediaPlaylistSnapshot == null) {
+                // This is the first primary URL snapshot.
+                isLive = !newSnapshot.hasEndTag;
+                initialStartTimeUs = newSnapshot.startTimeUs;
+            }
+            primaryMediaPlaylistSnapshot = newSnapshot;
+            primaryPlaylistListener.onPrimaryPlaylistRefreshed(newSnapshot);
+        }
+        final int listenersSize = listeners.size();
+        for (int i = 0; i < listenersSize; i++) {
+            listeners.get(i).onPlaylistChanged();
+        }
+    }
+
+    private boolean notifyPlaylistError(final Uri playlistUrl, final long exclusionDurationMs) {
+        final int listenersSize = listeners.size();
+        boolean anyExclusionFailed = false;
+        for (int i = 0; i < listenersSize; i++) {
+            anyExclusionFailed |= !listeners.get(i).onPlaylistError(playlistUrl,
+                    exclusionDurationMs);
+        }
+        return anyExclusionFailed;
+    }
+
+    private HlsMediaPlaylist getLatestPlaylistSnapshot(
+            @Nullable final HlsMediaPlaylist oldPlaylist,
+            @NonNull final HlsMediaPlaylist loadedPlaylist) {
+        if (!loadedPlaylist.isNewerThan(oldPlaylist)) {
+            if (loadedPlaylist.hasEndTag) {
+                // If the loaded playlist has an end tag but is not newer than the old playlist
+                // then we have an inconsistent state. This is typically caused by the server
+                // incorrectly resetting the media sequence when appending the end tag. We resolve
+                // this case as best we can by returning the old playlist with the end tag
+                // appended.
+                return oldPlaylist.copyWithEndTag();
+            } else {
+                return oldPlaylist;
+            }
+        }
+        final long startTimeUs = getLoadedPlaylistStartTimeUs(oldPlaylist, loadedPlaylist);
+        final int discontinuitySequence = getLoadedPlaylistDiscontinuitySequence(oldPlaylist,
+                loadedPlaylist);
+        return loadedPlaylist.copyWith(startTimeUs, discontinuitySequence);
+    }
+
+    private long getLoadedPlaylistStartTimeUs(@Nullable final HlsMediaPlaylist oldPlaylist,
+                                              @NonNull final HlsMediaPlaylist loadedPlaylist) {
+        if (loadedPlaylist.hasProgramDateTime) {
+            return loadedPlaylist.startTimeUs;
+        }
+        final long primarySnapshotStartTimeUs = primaryMediaPlaylistSnapshot != null
+                ? primaryMediaPlaylistSnapshot.startTimeUs : 0;
+        if (oldPlaylist == null) {
+            return primarySnapshotStartTimeUs;
+        }
+        final Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist,
+                loadedPlaylist);
+        if (firstOldOverlappingSegment != null) {
+            return oldPlaylist.startTimeUs + firstOldOverlappingSegment.relativeStartTimeUs;
+        } else if (oldPlaylist.segments.size() == loadedPlaylist.mediaSequence
+                - oldPlaylist.mediaSequence) {
+            return oldPlaylist.getEndTimeUs();
+        } else {
+            // No segments overlap, we assume the new playlist start coincides with the primary
+            // playlist.
+            return primarySnapshotStartTimeUs;
+        }
+    }
+
+    private int getLoadedPlaylistDiscontinuitySequence(
+            @Nullable final HlsMediaPlaylist oldPlaylist,
+            @NonNull final HlsMediaPlaylist loadedPlaylist) {
+        if (loadedPlaylist.hasDiscontinuitySequence) {
+            return loadedPlaylist.discontinuitySequence;
+        }
+        // TODO: Improve cross-playlist discontinuity adjustment.
+        final int primaryUrlDiscontinuitySequence = primaryMediaPlaylistSnapshot != null
+                ? primaryMediaPlaylistSnapshot.discontinuitySequence : 0;
+        if (oldPlaylist == null) {
+            return primaryUrlDiscontinuitySequence;
+        }
+        final Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist,
+                loadedPlaylist);
+        if (firstOldOverlappingSegment != null) {
+            return oldPlaylist.discontinuitySequence
+                    + firstOldOverlappingSegment.relativeDiscontinuitySequence
+                    - loadedPlaylist.segments.get(0).relativeDiscontinuitySequence;
+        }
+        return primaryUrlDiscontinuitySequence;
+    }
+
+    @Nullable
+    private static Segment getFirstOldOverlappingSegment(
+            @NonNull final HlsMediaPlaylist oldPlaylist,
+            @NonNull final HlsMediaPlaylist loadedPlaylist) {
+        final int mediaSequenceOffset = (int) (loadedPlaylist.mediaSequence
+                - oldPlaylist.mediaSequence);
+        final List<Segment> oldSegments = oldPlaylist.segments;
+        return mediaSequenceOffset < oldSegments.size() ? oldSegments.get(mediaSequenceOffset)
+                : null;
+    }
+
+    /**
+     * Hold all information related to a specific Media Playlist.
+     */
+    private final class MediaPlaylistBundle
+            implements Loader.Callback<ParsingLoadable<HlsPlaylist>> {
+
+        private static final String BLOCK_MSN_PARAM = "_HLS_msn";
+        private static final String BLOCK_PART_PARAM = "_HLS_part";
+        private static final String SKIP_PARAM = "_HLS_skip";
+
+        private final Uri playlistUrl;
+        private final Loader mediaPlaylistLoader;
+        private final DataSource mediaPlaylistDataSource;
+
+        @Nullable
+        private HlsMediaPlaylist playlistSnapshot;
+        private long lastSnapshotLoadMs;
+        private long lastSnapshotChangeMs;
+        private long earliestNextLoadTimeMs;
+        private long excludeUntilMs;
+        private boolean loadPending;
+        @Nullable
+        private IOException playlistError;
+
+        MediaPlaylistBundle(final Uri playlistUrl) {
+            this.playlistUrl = playlistUrl;
+            mediaPlaylistLoader = new Loader("CustomHlsPlaylistTracker:MediaPlaylist");
+            mediaPlaylistDataSource = dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST);
+        }
+
+        @Nullable
+        public HlsMediaPlaylist getPlaylistSnapshot() {
+            return playlistSnapshot;
+        }
+
+        public boolean isSnapshotValid() {
+            if (playlistSnapshot == null) {
+                return false;
+            }
+            final long currentTimeMs = SystemClock.elapsedRealtime();
+            final long snapshotValidityDurationMs = max(30000, C.usToMs(
+                    playlistSnapshot.durationUs));
+            return playlistSnapshot.hasEndTag
+                    || playlistSnapshot.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_EVENT
+                    || playlistSnapshot.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_VOD
+                    || lastSnapshotLoadMs + snapshotValidityDurationMs > currentTimeMs;
+        }
+
+        public void loadPlaylist() {
+            loadPlaylistInternal(playlistUrl);
+        }
+
+        public void maybeThrowPlaylistRefreshError() throws IOException {
+            mediaPlaylistLoader.maybeThrowError();
+            if (playlistError != null) {
+                throw playlistError;
+            }
+        }
+
+        public void release() {
+            mediaPlaylistLoader.release();
+        }
+
+        // Loader.Callback implementation.
+
+        @Override
+        public void onLoadCompleted(@NonNull final ParsingLoadable<HlsPlaylist> loadable,
+                                    final long elapsedRealtimeMs,
+                                    final long loadDurationMs) {
+            final HlsPlaylist result = loadable.getResult();
+            final LoadEventInfo loadEventInfo = new LoadEventInfo(loadable.loadTaskId,
+                    loadable.dataSpec, loadable.getUri(), loadable.getResponseHeaders(),
+                    elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded());
+            if (result instanceof HlsMediaPlaylist) {
+                processLoadedPlaylist((HlsMediaPlaylist) result, loadEventInfo);
+                eventDispatcher.loadCompleted(loadEventInfo, C.DATA_TYPE_MANIFEST);
+            } else {
+                playlistError = new ParserException("Loaded playlist has unexpected type.");
+                eventDispatcher.loadError(
+                        loadEventInfo, C.DATA_TYPE_MANIFEST, playlistError, true);
+            }
+            loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
+        }
+
+        @Override
+        public void onLoadCanceled(@NonNull final ParsingLoadable<HlsPlaylist> loadable,
+                                   final long elapsedRealtimeMs,
+                                   final long loadDurationMs,
+                                   final boolean released) {
+            final LoadEventInfo loadEventInfo = new LoadEventInfo(loadable.loadTaskId,
+                    loadable.dataSpec, loadable.getUri(), loadable.getResponseHeaders(),
+                    elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded());
+            loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
+            eventDispatcher.loadCanceled(loadEventInfo, C.DATA_TYPE_MANIFEST);
+        }
+
+        @Override
+        public LoadErrorAction onLoadError(@NonNull final ParsingLoadable<HlsPlaylist> loadable,
+                                           final long elapsedRealtimeMs,
+                                           final long loadDurationMs,
+                                           final IOException error,
+                                           final int errorCount) {
+            final LoadEventInfo loadEventInfo = new LoadEventInfo(loadable.loadTaskId,
+                    loadable.dataSpec, loadable.getUri(), loadable.getResponseHeaders(),
+                    elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded());
+            final boolean isBlockingRequest = loadable.getUri().getQueryParameter(BLOCK_MSN_PARAM)
+                    != null;
+            final boolean deltaUpdateFailed = error instanceof HlsPlaylistParser
+                    .DeltaUpdateException;
+            if (isBlockingRequest || deltaUpdateFailed) {
+                int responseCode = Integer.MAX_VALUE;
+                if (error instanceof HttpDataSource.InvalidResponseCodeException) {
+                    responseCode = ((HttpDataSource.InvalidResponseCodeException) error)
+                            .responseCode;
+                }
+                if (deltaUpdateFailed || responseCode == 400 || responseCode == 503) {
+                    // Intercept failed delta updates and blocking requests producing a Bad Request
+                    // (400) and Service Unavailable (503). In such cases, force a full,
+                    // non-blocking request (see RFC 8216, section 6.2.5.2 and 6.3.7).
+                    earliestNextLoadTimeMs = SystemClock.elapsedRealtime();
+                    loadPlaylist();
+                    castNonNull(eventDispatcher).loadError(loadEventInfo, loadable.type, error,
+                            true);
+                    return Loader.DONT_RETRY;
+                }
+            }
+            final MediaLoadData mediaLoadData = new MediaLoadData(loadable.type);
+            final LoadErrorInfo loadErrorInfo = new LoadErrorInfo(loadEventInfo, mediaLoadData,
+                    error, errorCount);
+            final LoadErrorAction loadErrorAction;
+            final long exclusionDurationMs = loadErrorHandlingPolicy.getBlacklistDurationMsFor(
+                    loadErrorInfo);
+            final boolean shouldExclude = exclusionDurationMs != C.TIME_UNSET;
+
+            boolean exclusionFailed = notifyPlaylistError(playlistUrl, exclusionDurationMs)
+                    || !shouldExclude;
+            if (shouldExclude) {
+                exclusionFailed |= excludePlaylist(exclusionDurationMs);
+            }
+
+            if (exclusionFailed) {
+                final long retryDelay = loadErrorHandlingPolicy.getRetryDelayMsFor(loadErrorInfo);
+                loadErrorAction = retryDelay != C.TIME_UNSET
+                        ? Loader.createRetryAction(false, retryDelay)
+                        : Loader.DONT_RETRY_FATAL;
+            } else {
+                loadErrorAction = Loader.DONT_RETRY;
+            }
+
+            final boolean wasCanceled = !loadErrorAction.isRetry();
+            eventDispatcher.loadError(loadEventInfo, loadable.type, error, wasCanceled);
+            if (wasCanceled) {
+                loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
+            }
+            return loadErrorAction;
+        }
+
+        // Internal methods.
+
+        private void loadPlaylistInternal(@NonNull final Uri playlistRequestUri) {
+            excludeUntilMs = 0;
+            if (loadPending || mediaPlaylistLoader.isLoading()
+                    || mediaPlaylistLoader.hasFatalError()) {
+                // Load already pending, in progress, or a fatal error has been encountered. Do
+                // nothing.
+                return;
+            }
+            final long currentTimeMs = SystemClock.elapsedRealtime();
+            if (currentTimeMs < earliestNextLoadTimeMs) {
+                loadPending = true;
+                playlistRefreshHandler.postDelayed(
+                        () -> {
+                            loadPending = false;
+                            loadPlaylistImmediately(playlistRequestUri);
+                        },
+                        earliestNextLoadTimeMs - currentTimeMs);
+            } else {
+                loadPlaylistImmediately(playlistRequestUri);
+            }
+        }
+
+        private void loadPlaylistImmediately(@NonNull final Uri playlistRequestUri) {
+            final ParsingLoadable.Parser<HlsPlaylist> mediaPlaylistParser = playlistParserFactory
+                    .createPlaylistParser(masterPlaylist, playlistSnapshot);
+            final ParsingLoadable<HlsPlaylist> mediaPlaylistLoadable = new ParsingLoadable<>(
+                    mediaPlaylistDataSource, playlistRequestUri, C.DATA_TYPE_MANIFEST,
+                    mediaPlaylistParser);
+            final long elapsedRealtime = mediaPlaylistLoader.startLoading(mediaPlaylistLoadable,
+                    this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(
+                            mediaPlaylistLoadable.type));
+            eventDispatcher.loadStarted(new LoadEventInfo(mediaPlaylistLoadable.loadTaskId,
+                            mediaPlaylistLoadable.dataSpec, elapsedRealtime),
+                    mediaPlaylistLoadable.type);
+        }
+
+        private void processLoadedPlaylist(final HlsMediaPlaylist loadedPlaylist,
+                                           final LoadEventInfo loadEventInfo) {
+            final HlsMediaPlaylist oldPlaylist = playlistSnapshot;
+            final long currentTimeMs = SystemClock.elapsedRealtime();
+            lastSnapshotLoadMs = currentTimeMs;
+            playlistSnapshot = getLatestPlaylistSnapshot(oldPlaylist, loadedPlaylist);
+            if (playlistSnapshot != oldPlaylist) {
+                playlistError = null;
+                lastSnapshotChangeMs = currentTimeMs;
+                onPlaylistUpdated(playlistUrl, playlistSnapshot);
+            } else if (!playlistSnapshot.hasEndTag) {
+                if (loadedPlaylist.mediaSequence + loadedPlaylist.segments.size()
+                        < playlistSnapshot.mediaSequence) {
+                    // TODO: Allow customization of playlist resets handling.
+                    // The media sequence jumped backwards. The server has probably reset. We do
+                    // not try excluding in this case.
+                    playlistError = new PlaylistResetException(playlistUrl);
+                    notifyPlaylistError(playlistUrl, C.TIME_UNSET);
+                } else if (currentTimeMs - lastSnapshotChangeMs
+                        > MAXIMUM_PLAYLIST_STUCK_DURATION_MS) {
+                    // TODO: Allow customization of stuck playlists handling.
+                    playlistError = new PlaylistStuckException(playlistUrl);
+                    final LoadErrorInfo loadErrorInfo = new LoadErrorInfo(loadEventInfo,
+                            new MediaLoadData(C.DATA_TYPE_MANIFEST),
+                            playlistError, 1);
+                    final long exclusionDurationMs = loadErrorHandlingPolicy
+                            .getBlacklistDurationMsFor(loadErrorInfo);
+                    notifyPlaylistError(playlistUrl, exclusionDurationMs);
+                    if (exclusionDurationMs != C.TIME_UNSET) {
+                        excludePlaylist(exclusionDurationMs);
+                    }
+                }
+            }
+            long durationUntilNextLoadUs = 0L;
+            if (!playlistSnapshot.serverControl.canBlockReload) {
+                // If blocking requests are not supported, do not allow the playlist to load again
+                // within the target duration if we obtained a new snapshot, or half the target
+                // duration otherwise.
+                durationUntilNextLoadUs = playlistSnapshot != oldPlaylist
+                        ? playlistSnapshot.targetDurationUs
+                        : (playlistSnapshot.targetDurationUs / 2);
+            }
+            earliestNextLoadTimeMs = currentTimeMs + C.usToMs(durationUntilNextLoadUs);
+            // Schedule a load if this is the primary playlist or a playlist of a low-latency
+            // stream and it doesn't have an end tag. Else the next load will be scheduled when
+            // refreshPlaylist is called, or when this playlist becomes the primary.
+            final boolean scheduleLoad = playlistSnapshot.partTargetDurationUs != C.TIME_UNSET
+                    || playlistUrl.equals(primaryMediaPlaylistUrl);
+            if (scheduleLoad && !playlistSnapshot.hasEndTag) {
+                loadPlaylistInternal(getMediaPlaylistUriForReload());
+            }
+        }
+
+        private Uri getMediaPlaylistUriForReload() {
+            if (playlistSnapshot == null
+                    || (playlistSnapshot.serverControl.skipUntilUs == C.TIME_UNSET
+                    && !playlistSnapshot.serverControl.canBlockReload)) {
+                return playlistUrl;
+            }
+            final Uri.Builder uriBuilder = playlistUrl.buildUpon();
+            if (playlistSnapshot.serverControl.canBlockReload) {
+                final long targetMediaSequence = playlistSnapshot.mediaSequence
+                        + playlistSnapshot.segments.size();
+                uriBuilder.appendQueryParameter(BLOCK_MSN_PARAM, String.valueOf(
+                        targetMediaSequence));
+                if (playlistSnapshot.partTargetDurationUs != C.TIME_UNSET) {
+                    final List<Part> trailingParts = playlistSnapshot.trailingParts;
+                    int targetPartIndex = trailingParts.size();
+                    if (!trailingParts.isEmpty() && Iterables.getLast(trailingParts).isPreload) {
+                        // Ignore the preload part.
+                        targetPartIndex--;
+                    }
+                    uriBuilder.appendQueryParameter(BLOCK_PART_PARAM, String.valueOf(
+                            targetPartIndex));
+                }
+            }
+            if (playlistSnapshot.serverControl.skipUntilUs != C.TIME_UNSET) {
+                uriBuilder.appendQueryParameter(SKIP_PARAM,
+                        playlistSnapshot.serverControl.canSkipDateRanges ? "v2" : "YES");
+            }
+            return uriBuilder.build();
+        }
+
+        /**
+         * Exclude the playlist.
+         *
+         * @param exclusionDurationMs The number of milliseconds for which the playlist should be
+         *                            excluded.
+         * @return Whether the playlist is the primary, despite being excluded.
+         */
+        private boolean excludePlaylist(final long exclusionDurationMs) {
+            excludeUntilMs = SystemClock.elapsedRealtime() + exclusionDurationMs;
+            return playlistUrl.equals(primaryMediaPlaylistUrl) && !maybeSelectNewPrimaryUrl();
+        }
+    }
+}