public final class

DefaultHlsPlaylistTracker

extends java.lang.Object

implements HlsPlaylistTracker, Loader.Callback<ParsingLoadable>

 java.lang.Object

↳androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistTracker

Gradle dependencies

compile group: 'androidx.media3', name: 'media3-exoplayer-hls', version: '1.0.0-alpha03'

  • groupId: androidx.media3
  • artifactId: media3-exoplayer-hls
  • version: 1.0.0-alpha03

Artifact androidx.media3:media3-exoplayer-hls:1.0.0-alpha03 it located at Google repository (https://maven.google.com/)

Overview

Default implementation for HlsPlaylistTracker.

Summary

Fields
public static final doubleDEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT

Default coefficient applied on the target duration of a playlist to determine the amount of time after which an unchanging playlist is considered stuck.

public static final HlsPlaylistTracker.FactoryFACTORY

Factory for DefaultHlsPlaylistTracker instances.

Constructors
publicDefaultHlsPlaylistTracker(HlsDataSourceFactory dataSourceFactory, LoadErrorHandlingPolicy loadErrorHandlingPolicy, HlsPlaylistParserFactory playlistParserFactory)

Creates an instance.

publicDefaultHlsPlaylistTracker(HlsDataSourceFactory dataSourceFactory, LoadErrorHandlingPolicy loadErrorHandlingPolicy, HlsPlaylistParserFactory playlistParserFactory, double playlistStuckTargetDurationCoefficient)

Creates an instance.

Methods
public voidaddListener(HlsPlaylistTracker.PlaylistEventListener listener)

public booleanexcludeMediaPlaylist(Uri playlistUrl, long exclusionDurationMs)

public longgetInitialStartTimeUs()

public HlsMultivariantPlaylistgetMultivariantPlaylist()

public HlsMediaPlaylistgetPlaylistSnapshot(Uri url, boolean isForPlayback)

public booleanisLive()

public booleanisSnapshotValid(Uri url)

public voidmaybeThrowPlaylistRefreshError(Uri url)

public voidmaybeThrowPrimaryPlaylistRefreshError()

public voidonLoadCanceled(ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released)

public voidonLoadCompleted(ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs)

public Loader.LoadErrorActiononLoadError(ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs, java.io.IOException error, int errorCount)

public voidrefreshPlaylist(Uri url)

public voidremoveListener(HlsPlaylistTracker.PlaylistEventListener listener)

public voidstart(Uri initialPlaylistUri, MediaSourceEventListener.EventDispatcher eventDispatcher, HlsPlaylistTracker.PrimaryPlaylistListener primaryPlaylistListener)

public voidstop()

from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Fields

public static final HlsPlaylistTracker.Factory FACTORY

Factory for DefaultHlsPlaylistTracker instances.

public static final double DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT

Default coefficient applied on the target duration of a playlist to determine the amount of time after which an unchanging playlist is considered stuck.

Constructors

public DefaultHlsPlaylistTracker(HlsDataSourceFactory dataSourceFactory, LoadErrorHandlingPolicy loadErrorHandlingPolicy, HlsPlaylistParserFactory playlistParserFactory)

Creates an instance.

Parameters:

dataSourceFactory: A factory for DataSource instances.
loadErrorHandlingPolicy: The LoadErrorHandlingPolicy.
playlistParserFactory: An HlsPlaylistParserFactory.

public DefaultHlsPlaylistTracker(HlsDataSourceFactory dataSourceFactory, LoadErrorHandlingPolicy loadErrorHandlingPolicy, HlsPlaylistParserFactory playlistParserFactory, double playlistStuckTargetDurationCoefficient)

Creates an instance.

Parameters:

dataSourceFactory: A factory for DataSource instances.
loadErrorHandlingPolicy: The LoadErrorHandlingPolicy.
playlistParserFactory: An HlsPlaylistParserFactory.
playlistStuckTargetDurationCoefficient: A coefficient to apply to the target duration of media playlists in order to determine that a non-changing playlist is stuck. Once a playlist is deemed stuck, a is thrown via DefaultHlsPlaylistTracker.maybeThrowPlaylistRefreshError(Uri).

Methods

public void start(Uri initialPlaylistUri, MediaSourceEventListener.EventDispatcher eventDispatcher, HlsPlaylistTracker.PrimaryPlaylistListener primaryPlaylistListener)

public void stop()

public void addListener(HlsPlaylistTracker.PlaylistEventListener listener)

public void removeListener(HlsPlaylistTracker.PlaylistEventListener listener)

public HlsMultivariantPlaylist getMultivariantPlaylist()

public HlsMediaPlaylist getPlaylistSnapshot(Uri url, boolean isForPlayback)

public long getInitialStartTimeUs()

public boolean isSnapshotValid(Uri url)

public void maybeThrowPrimaryPlaylistRefreshError()

public void maybeThrowPlaylistRefreshError(Uri url)

public void refreshPlaylist(Uri url)

public boolean isLive()

public boolean excludeMediaPlaylist(Uri playlistUrl, long exclusionDurationMs)

public void onLoadCompleted(ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs)

public void onLoadCanceled(ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released)

public Loader.LoadErrorAction onLoadError(ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs, java.io.IOException error, int errorCount)

Source

/*
 * 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 androidx.media3.exoplayer.hls.playlist;

import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Util.castNonNull;
import static java.lang.Math.max;

import android.net.Uri;
import android.os.Handler;
import android.os.SystemClock;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.HttpDataSource;
import androidx.media3.exoplayer.hls.HlsDataSourceFactory;
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Part;
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.RenditionReport;
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Segment;
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist.Variant;
import androidx.media3.exoplayer.source.LoadEventInfo;
import androidx.media3.exoplayer.source.MediaLoadData;
import androidx.media3.exoplayer.source.MediaSourceEventListener.EventDispatcher;
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy;
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.LoadErrorInfo;
import androidx.media3.exoplayer.upstream.Loader;
import androidx.media3.exoplayer.upstream.Loader.LoadErrorAction;
import androidx.media3.exoplayer.upstream.ParsingLoadable;
import com.google.common.collect.Iterables;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

/** Default implementation for {@link HlsPlaylistTracker}. */
@UnstableApi
public final class DefaultHlsPlaylistTracker
    implements HlsPlaylistTracker, Loader.Callback<ParsingLoadable<HlsPlaylist>> {

  /** Factory for {@link DefaultHlsPlaylistTracker} instances. */
  public static final Factory FACTORY = DefaultHlsPlaylistTracker::new;

  /**
   * Default coefficient applied on the target duration of a playlist to determine the amount of
   * time after which an unchanging playlist is considered stuck.
   */
  public static final double DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT = 3.5;

  private final HlsDataSourceFactory dataSourceFactory;
  private final HlsPlaylistParserFactory playlistParserFactory;
  private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
  private final HashMap<Uri, MediaPlaylistBundle> playlistBundles;
  private final CopyOnWriteArrayList<PlaylistEventListener> listeners;
  private final double playlistStuckTargetDurationCoefficient;

  @Nullable private EventDispatcher eventDispatcher;
  @Nullable private Loader initialPlaylistLoader;
  @Nullable private Handler playlistRefreshHandler;
  @Nullable private PrimaryPlaylistListener primaryPlaylistListener;
  @Nullable private HlsMultivariantPlaylist multivariantPlaylist;
  @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 DefaultHlsPlaylistTracker(
      HlsDataSourceFactory dataSourceFactory,
      LoadErrorHandlingPolicy loadErrorHandlingPolicy,
      HlsPlaylistParserFactory playlistParserFactory) {
    this(
        dataSourceFactory,
        loadErrorHandlingPolicy,
        playlistParserFactory,
        DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT);
  }

  /**
   * Creates an instance.
   *
   * @param dataSourceFactory A factory for {@link DataSource} instances.
   * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}.
   * @param playlistParserFactory An {@link HlsPlaylistParserFactory}.
   * @param playlistStuckTargetDurationCoefficient A coefficient to apply to the target duration of
   *     media playlists in order to determine that a non-changing playlist is stuck. Once a
   *     playlist is deemed stuck, a {@link PlaylistStuckException} is thrown via {@link
   *     #maybeThrowPlaylistRefreshError(Uri)}.
   */
  public DefaultHlsPlaylistTracker(
      HlsDataSourceFactory dataSourceFactory,
      LoadErrorHandlingPolicy loadErrorHandlingPolicy,
      HlsPlaylistParserFactory playlistParserFactory,
      double playlistStuckTargetDurationCoefficient) {
    this.dataSourceFactory = dataSourceFactory;
    this.playlistParserFactory = playlistParserFactory;
    this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
    this.playlistStuckTargetDurationCoefficient = playlistStuckTargetDurationCoefficient;
    listeners = new CopyOnWriteArrayList<>();
    playlistBundles = new HashMap<>();
    initialStartTimeUs = C.TIME_UNSET;
  }

  // HlsPlaylistTracker implementation.

  @Override
  public void start(
      Uri initialPlaylistUri,
      EventDispatcher eventDispatcher,
      PrimaryPlaylistListener primaryPlaylistListener) {
    this.playlistRefreshHandler = Util.createHandlerForCurrentLooper();
    this.eventDispatcher = eventDispatcher;
    this.primaryPlaylistListener = primaryPlaylistListener;
    ParsingLoadable<HlsPlaylist> multivariantPlaylistLoadable =
        new ParsingLoadable<>(
            dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST),
            initialPlaylistUri,
            C.DATA_TYPE_MANIFEST,
            playlistParserFactory.createPlaylistParser());
    Assertions.checkState(initialPlaylistLoader == null);
    initialPlaylistLoader = new Loader("DefaultHlsPlaylistTracker:MultivariantPlaylist");
    long elapsedRealtime =
        initialPlaylistLoader.startLoading(
            multivariantPlaylistLoadable,
            this,
            loadErrorHandlingPolicy.getMinimumLoadableRetryCount(
                multivariantPlaylistLoadable.type));
    eventDispatcher.loadStarted(
        new LoadEventInfo(
            multivariantPlaylistLoadable.loadTaskId,
            multivariantPlaylistLoadable.dataSpec,
            elapsedRealtime),
        multivariantPlaylistLoadable.type);
  }

  @Override
  public void stop() {
    primaryMediaPlaylistUrl = null;
    primaryMediaPlaylistSnapshot = null;
    multivariantPlaylist = null;
    initialStartTimeUs = C.TIME_UNSET;
    initialPlaylistLoader.release();
    initialPlaylistLoader = null;
    for (MediaPlaylistBundle bundle : playlistBundles.values()) {
      bundle.release();
    }
    playlistRefreshHandler.removeCallbacksAndMessages(null);
    playlistRefreshHandler = null;
    playlistBundles.clear();
  }

  @Override
  public void addListener(PlaylistEventListener listener) {
    checkNotNull(listener);
    listeners.add(listener);
  }

  @Override
  public void removeListener(PlaylistEventListener listener) {
    listeners.remove(listener);
  }

  @Override
  @Nullable
  public HlsMultivariantPlaylist getMultivariantPlaylist() {
    return multivariantPlaylist;
  }

  @Override
  @Nullable
  public HlsMediaPlaylist getPlaylistSnapshot(Uri url, boolean isForPlayback) {
    @Nullable HlsMediaPlaylist snapshot = playlistBundles.get(url).getPlaylistSnapshot();
    if (snapshot != null && isForPlayback) {
      maybeSetPrimaryUrl(url);
    }
    return snapshot;
  }

  @Override
  public long getInitialStartTimeUs() {
    return initialStartTimeUs;
  }

  @Override
  public boolean isSnapshotValid(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(Uri url) throws IOException {
    playlistBundles.get(url).maybeThrowPlaylistRefreshError();
  }

  @Override
  public void refreshPlaylist(Uri url) {
    playlistBundles.get(url).loadPlaylist();
  }

  @Override
  public boolean isLive() {
    return isLive;
  }

  @Override
  public boolean excludeMediaPlaylist(Uri playlistUrl, long exclusionDurationMs) {
    @Nullable MediaPlaylistBundle bundle = playlistBundles.get(playlistUrl);
    if (bundle != null) {
      return !bundle.excludePlaylist(exclusionDurationMs);
    }
    return false;
  }

  // Loader.Callback implementation.

  @Override
  public void onLoadCompleted(
      ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs) {
    HlsPlaylist result = loadable.getResult();
    HlsMultivariantPlaylist multivariantPlaylist;
    boolean isMediaPlaylist = result instanceof HlsMediaPlaylist;
    if (isMediaPlaylist) {
      multivariantPlaylist =
          HlsMultivariantPlaylist.createSingleVariantMultivariantPlaylist(result.baseUri);
    } else /* result instanceof HlsMultivariantPlaylist */ {
      multivariantPlaylist = (HlsMultivariantPlaylist) result;
    }
    this.multivariantPlaylist = multivariantPlaylist;
    primaryMediaPlaylistUrl = multivariantPlaylist.variants.get(0).url;
    // Add a temporary playlist listener for loading the first primary playlist.
    listeners.add(new FirstPrimaryMediaPlaylistListener());
    createBundles(multivariantPlaylist.mediaPlaylistUrls);
    LoadEventInfo loadEventInfo =
        new LoadEventInfo(
            loadable.loadTaskId,
            loadable.dataSpec,
            loadable.getUri(),
            loadable.getResponseHeaders(),
            elapsedRealtimeMs,
            loadDurationMs,
            loadable.bytesLoaded());
    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(
      ParsingLoadable<HlsPlaylist> loadable,
      long elapsedRealtimeMs,
      long loadDurationMs,
      boolean released) {
    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(
      ParsingLoadable<HlsPlaylist> loadable,
      long elapsedRealtimeMs,
      long loadDurationMs,
      IOException error,
      int errorCount) {
    LoadEventInfo loadEventInfo =
        new LoadEventInfo(
            loadable.loadTaskId,
            loadable.dataSpec,
            loadable.getUri(),
            loadable.getResponseHeaders(),
            elapsedRealtimeMs,
            loadDurationMs,
            loadable.bytesLoaded());
    MediaLoadData mediaLoadData = new MediaLoadData(loadable.type);
    long retryDelayMs =
        loadErrorHandlingPolicy.getRetryDelayMsFor(
            new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount));
    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(/* resetErrorCount= */ false, retryDelayMs);
  }

  // Internal methods.

  private boolean maybeSelectNewPrimaryUrl() {
    List<Variant> variants = multivariantPlaylist.variants;
    int variantsSize = variants.size();
    long currentTimeMs = SystemClock.elapsedRealtime();
    for (int i = 0; i < variantsSize; i++) {
      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(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;
    MediaPlaylistBundle newPrimaryBundle = playlistBundles.get(primaryMediaPlaylistUrl);
    @Nullable 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(Uri newPrimaryPlaylistUri) {
    if (primaryMediaPlaylistSnapshot != null
        && primaryMediaPlaylistSnapshot.serverControl.canBlockReload) {
      @Nullable
      RenditionReport renditionReport =
          primaryMediaPlaylistSnapshot.renditionReports.get(newPrimaryPlaylistUri);
      if (renditionReport != null) {
        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;
  }

  /**
   * Returns whether any of the variants in the multivariant playlist have the specified playlist
   * URL.
   */
  private boolean isVariantUrl(Uri playlistUrl) {
    List<Variant> variants = multivariantPlaylist.variants;
    for (int i = 0; i < variants.size(); i++) {
      if (playlistUrl.equals(variants.get(i).url)) {
        return true;
      }
    }
    return false;
  }

  private void createBundles(List<Uri> urls) {
    int listSize = urls.size();
    for (int i = 0; i < listSize; i++) {
      Uri url = urls.get(i);
      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(Uri url, 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);
    }
    for (PlaylistEventListener listener : listeners) {
      listener.onPlaylistChanged();
    }
  }

  private boolean notifyPlaylistError(
      Uri playlistUrl, LoadErrorInfo loadErrorInfo, boolean forceRetry) {
    boolean anyExclusionFailed = false;
    for (PlaylistEventListener listener : listeners) {
      anyExclusionFailed |= !listener.onPlaylistError(playlistUrl, loadErrorInfo, forceRetry);
    }
    return anyExclusionFailed;
  }

  private HlsMediaPlaylist getLatestPlaylistSnapshot(
      @Nullable HlsMediaPlaylist oldPlaylist, 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;
      }
    }
    long startTimeUs = getLoadedPlaylistStartTimeUs(oldPlaylist, loadedPlaylist);
    int discontinuitySequence = getLoadedPlaylistDiscontinuitySequence(oldPlaylist, loadedPlaylist);
    return loadedPlaylist.copyWith(startTimeUs, discontinuitySequence);
  }

  private long getLoadedPlaylistStartTimeUs(
      @Nullable HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) {
    if (loadedPlaylist.hasProgramDateTime) {
      return loadedPlaylist.startTimeUs;
    }
    long primarySnapshotStartTimeUs =
        primaryMediaPlaylistSnapshot != null ? primaryMediaPlaylistSnapshot.startTimeUs : 0;
    if (oldPlaylist == null) {
      return primarySnapshotStartTimeUs;
    }
    int oldPlaylistSize = oldPlaylist.segments.size();
    Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist, loadedPlaylist);
    if (firstOldOverlappingSegment != null) {
      return oldPlaylist.startTimeUs + firstOldOverlappingSegment.relativeStartTimeUs;
    } else if (oldPlaylistSize == 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 HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) {
    if (loadedPlaylist.hasDiscontinuitySequence) {
      return loadedPlaylist.discontinuitySequence;
    }
    // TODO: Improve cross-playlist discontinuity adjustment.
    int primaryUrlDiscontinuitySequence =
        primaryMediaPlaylistSnapshot != null
            ? primaryMediaPlaylistSnapshot.discontinuitySequence
            : 0;
    if (oldPlaylist == null) {
      return primaryUrlDiscontinuitySequence;
    }
    Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist, loadedPlaylist);
    if (firstOldOverlappingSegment != null) {
      return oldPlaylist.discontinuitySequence
          + firstOldOverlappingSegment.relativeDiscontinuitySequence
          - loadedPlaylist.segments.get(0).relativeDiscontinuitySequence;
    }
    return primaryUrlDiscontinuitySequence;
  }

  private static Segment getFirstOldOverlappingSegment(
      HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) {
    int mediaSequenceOffset = (int) (loadedPlaylist.mediaSequence - oldPlaylist.mediaSequence);
    List<Segment> oldSegments = oldPlaylist.segments;
    return mediaSequenceOffset < oldSegments.size() ? oldSegments.get(mediaSequenceOffset) : null;
  }

  /** Holds 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;

    public MediaPlaylistBundle(Uri playlistUrl) {
      this.playlistUrl = playlistUrl;
      mediaPlaylistLoader = new Loader("DefaultHlsPlaylistTracker:MediaPlaylist");
      mediaPlaylistDataSource = dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST);
    }

    @Nullable
    public HlsMediaPlaylist getPlaylistSnapshot() {
      return playlistSnapshot;
    }

    public boolean isSnapshotValid() {
      if (playlistSnapshot == null) {
        return false;
      }
      long currentTimeMs = SystemClock.elapsedRealtime();
      long snapshotValidityDurationMs = max(30000, Util.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(
        ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs) {
      @Nullable HlsPlaylist result = loadable.getResult();
      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 =
            ParserException.createForMalformedManifest(
                "Loaded playlist has unexpected type.", /* cause= */ null);
        eventDispatcher.loadError(
            loadEventInfo, C.DATA_TYPE_MANIFEST, playlistError, /* wasCanceled= */ true);
      }
      loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
    }

    @Override
    public void onLoadCanceled(
        ParsingLoadable<HlsPlaylist> loadable,
        long elapsedRealtimeMs,
        long loadDurationMs,
        boolean released) {
      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(
        ParsingLoadable<HlsPlaylist> loadable,
        long elapsedRealtimeMs,
        long loadDurationMs,
        IOException error,
        int errorCount) {
      LoadEventInfo loadEventInfo =
          new LoadEventInfo(
              loadable.loadTaskId,
              loadable.dataSpec,
              loadable.getUri(),
              loadable.getResponseHeaders(),
              elapsedRealtimeMs,
              loadDurationMs,
              loadable.bytesLoaded());
      boolean isBlockingRequest = loadable.getUri().getQueryParameter(BLOCK_MSN_PARAM) != null;
      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, /* wasCanceled= */ true);
          return Loader.DONT_RETRY;
        }
      }
      MediaLoadData mediaLoadData = new MediaLoadData(loadable.type);
      LoadErrorInfo loadErrorInfo =
          new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount);
      boolean exclusionFailed =
          notifyPlaylistError(playlistUrl, loadErrorInfo, /* forceRetry= */ false);
      LoadErrorAction loadErrorAction;
      if (exclusionFailed) {
        long retryDelay = loadErrorHandlingPolicy.getRetryDelayMsFor(loadErrorInfo);
        loadErrorAction =
            retryDelay != C.TIME_UNSET
                ? Loader.createRetryAction(false, retryDelay)
                : Loader.DONT_RETRY_FATAL;
      } else {
        loadErrorAction = Loader.DONT_RETRY;
      }

      boolean wasCanceled = !loadErrorAction.isRetry();
      eventDispatcher.loadError(loadEventInfo, loadable.type, error, wasCanceled);
      if (wasCanceled) {
        loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId);
      }
      return loadErrorAction;
    }

    // Internal methods.

    private void loadPlaylistInternal(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;
      }
      long currentTimeMs = SystemClock.elapsedRealtime();
      if (currentTimeMs < earliestNextLoadTimeMs) {
        loadPending = true;
        playlistRefreshHandler.postDelayed(
            () -> {
              loadPending = false;
              loadPlaylistImmediately(playlistRequestUri);
            },
            earliestNextLoadTimeMs - currentTimeMs);
      } else {
        loadPlaylistImmediately(playlistRequestUri);
      }
    }

    private void loadPlaylistImmediately(Uri playlistRequestUri) {
      ParsingLoadable.Parser<HlsPlaylist> mediaPlaylistParser =
          playlistParserFactory.createPlaylistParser(multivariantPlaylist, playlistSnapshot);
      ParsingLoadable<HlsPlaylist> mediaPlaylistLoadable =
          new ParsingLoadable<>(
              mediaPlaylistDataSource,
              playlistRequestUri,
              C.DATA_TYPE_MANIFEST,
              mediaPlaylistParser);
      long elapsedRealtime =
          mediaPlaylistLoader.startLoading(
              mediaPlaylistLoadable,
              /* callback= */ this,
              loadErrorHandlingPolicy.getMinimumLoadableRetryCount(mediaPlaylistLoadable.type));
      eventDispatcher.loadStarted(
          new LoadEventInfo(
              mediaPlaylistLoadable.loadTaskId, mediaPlaylistLoadable.dataSpec, elapsedRealtime),
          mediaPlaylistLoadable.type);
    }

    private void processLoadedPlaylist(
        HlsMediaPlaylist loadedPlaylist, LoadEventInfo loadEventInfo) {
      @Nullable HlsMediaPlaylist oldPlaylist = playlistSnapshot;
      long currentTimeMs = SystemClock.elapsedRealtime();
      lastSnapshotLoadMs = currentTimeMs;
      playlistSnapshot = getLatestPlaylistSnapshot(oldPlaylist, loadedPlaylist);
      if (playlistSnapshot != oldPlaylist) {
        playlistError = null;
        lastSnapshotChangeMs = currentTimeMs;
        onPlaylistUpdated(playlistUrl, playlistSnapshot);
      } else if (!playlistSnapshot.hasEndTag) {
        boolean forceRetry = false;
        @Nullable IOException playlistError = null;
        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.
          forceRetry = true;
          playlistError = new PlaylistResetException(playlistUrl);
        } else if (currentTimeMs - lastSnapshotChangeMs
            > Util.usToMs(playlistSnapshot.targetDurationUs)
                * playlistStuckTargetDurationCoefficient) {
          // TODO: Allow customization of stuck playlists handling.
          playlistError = new PlaylistStuckException(playlistUrl);
        }
        if (playlistError != null) {
          this.playlistError = playlistError;
          notifyPlaylistError(
              playlistUrl,
              new LoadErrorInfo(
                  loadEventInfo,
                  new MediaLoadData(C.DATA_TYPE_MANIFEST),
                  playlistError,
                  /* errorCount= */ 1),
              forceRetry);
        }
      }
      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 + Util.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.
      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;
      }
      Uri.Builder uriBuilder = playlistUrl.buildUpon();
      if (playlistSnapshot.serverControl.canBlockReload) {
        long targetMediaSequence =
            playlistSnapshot.mediaSequence + playlistSnapshot.segments.size();
        uriBuilder.appendQueryParameter(BLOCK_MSN_PARAM, String.valueOf(targetMediaSequence));
        if (playlistSnapshot.partTargetDurationUs != C.TIME_UNSET) {
          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();
    }

    /**
     * Excludes 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(long exclusionDurationMs) {
      excludeUntilMs = SystemClock.elapsedRealtime() + exclusionDurationMs;
      return playlistUrl.equals(primaryMediaPlaylistUrl) && !maybeSelectNewPrimaryUrl();
    }
  }

  /**
   * Takes care of handling load errors of the first media playlist and applies exclusion according
   * to the {@link LoadErrorHandlingPolicy} before the first media period has been created and
   * prepared.
   */
  private class FirstPrimaryMediaPlaylistListener implements PlaylistEventListener {

    @Override
    public void onPlaylistChanged() {
      // Remove the temporary playlist listener that is waiting for the first playlist only.
      listeners.remove(this);
    }

    @Override
    public boolean onPlaylistError(Uri url, LoadErrorInfo loadErrorInfo, boolean forceRetry) {
      if (primaryMediaPlaylistSnapshot == null) {
        long nowMs = SystemClock.elapsedRealtime();
        int variantExclusionCounter = 0;
        List<Variant> variants = castNonNull(multivariantPlaylist).variants;
        for (int i = 0; i < variants.size(); i++) {
          @Nullable
          MediaPlaylistBundle mediaPlaylistBundle = playlistBundles.get(variants.get(i).url);
          if (mediaPlaylistBundle != null && nowMs < mediaPlaylistBundle.excludeUntilMs) {
            variantExclusionCounter++;
          }
        }
        LoadErrorHandlingPolicy.FallbackOptions fallbackOptions =
            new LoadErrorHandlingPolicy.FallbackOptions(
                /* numberOfLocations= */ 1,
                /* numberOfExcludedLocations= */ 0,
                /* numberOfTracks= */ multivariantPlaylist.variants.size(),
                /* numberOfExcludedTracks= */ variantExclusionCounter);
        @Nullable
        LoadErrorHandlingPolicy.FallbackSelection fallbackSelection =
            loadErrorHandlingPolicy.getFallbackSelectionFor(fallbackOptions, loadErrorInfo);
        if (fallbackSelection != null
            && fallbackSelection.type == LoadErrorHandlingPolicy.FALLBACK_TYPE_TRACK) {
          @Nullable MediaPlaylistBundle mediaPlaylistBundle = playlistBundles.get(url);
          if (mediaPlaylistBundle != null) {
            mediaPlaylistBundle.excludePlaylist(fallbackSelection.exclusionDurationMs);
          }
        }
      }
      return false;
    }
  }
}