public final class

HlsPlaylistParser

extends java.lang.Object

implements ParsingLoadable.Parser<HlsPlaylist>

 java.lang.Object

↳androidx.media3.exoplayer.hls.playlist.HlsPlaylistParser

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

HLS playlists parsing logic.

Summary

Constructors
publicHlsPlaylistParser()

Creates an instance where media playlists are parsed without inheriting attributes from a multivariant playlist.

publicHlsPlaylistParser(HlsMultivariantPlaylist multivariantPlaylist, HlsMediaPlaylist previousMediaPlaylist)

Creates an instance where parsed media playlists inherit attributes from the given master playlist.

Methods
public HlsPlaylistparse(Uri uri, java.io.InputStream inputStream)

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

Constructors

public HlsPlaylistParser()

Creates an instance where media playlists are parsed without inheriting attributes from a multivariant playlist.

public HlsPlaylistParser(HlsMultivariantPlaylist multivariantPlaylist, HlsMediaPlaylist previousMediaPlaylist)

Creates an instance where parsed media playlists inherit attributes from the given master playlist.

Parameters:

multivariantPlaylist: The multivariant playlist from which media playlists will inherit attributes.
previousMediaPlaylist: The previous media playlist from which the new media playlist may inherit skipped segments.

Methods

public HlsPlaylist parse(Uri uri, java.io.InputStream inputStream)

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.Assertions.checkState;
import static androidx.media3.common.util.Util.castNonNull;

import android.net.Uri;
import android.text.TextUtils;
import android.util.Base64;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.DrmInitData;
import androidx.media3.common.DrmInitData.SchemeData;
import androidx.media3.common.Format;
import androidx.media3.common.Metadata;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.UriUtil;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.hls.HlsTrackMetadataEntry;
import androidx.media3.exoplayer.hls.HlsTrackMetadataEntry.VariantInfo;
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.Rendition;
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist.Variant;
import androidx.media3.exoplayer.upstream.ParsingLoadable;
import androidx.media3.extractor.mp4.PsshAtomUtil;
import com.google.common.collect.Iterables;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Queue;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf;
import org.checkerframework.checker.nullness.qual.PolyNull;

/** HLS playlists parsing logic. */
@UnstableApi
public final class HlsPlaylistParser implements ParsingLoadable.Parser<HlsPlaylist> {

  /** Exception thrown when merging a delta update fails. */
  public static final class DeltaUpdateException extends IOException {}

  private static final String LOG_TAG = "HlsPlaylistParser";

  private static final String PLAYLIST_HEADER = "#EXTM3U";

  private static final String TAG_PREFIX = "#EXT";

  private static final String TAG_VERSION = "#EXT-X-VERSION";
  private static final String TAG_PLAYLIST_TYPE = "#EXT-X-PLAYLIST-TYPE";
  private static final String TAG_DEFINE = "#EXT-X-DEFINE";
  private static final String TAG_SERVER_CONTROL = "#EXT-X-SERVER-CONTROL";
  private static final String TAG_STREAM_INF = "#EXT-X-STREAM-INF";
  private static final String TAG_PART_INF = "#EXT-X-PART-INF";
  private static final String TAG_PART = "#EXT-X-PART";
  private static final String TAG_I_FRAME_STREAM_INF = "#EXT-X-I-FRAME-STREAM-INF";
  private static final String TAG_IFRAME = "#EXT-X-I-FRAMES-ONLY";
  private static final String TAG_MEDIA = "#EXT-X-MEDIA";
  private static final String TAG_TARGET_DURATION = "#EXT-X-TARGETDURATION";
  private static final String TAG_DISCONTINUITY = "#EXT-X-DISCONTINUITY";
  private static final String TAG_DISCONTINUITY_SEQUENCE = "#EXT-X-DISCONTINUITY-SEQUENCE";
  private static final String TAG_PROGRAM_DATE_TIME = "#EXT-X-PROGRAM-DATE-TIME";
  private static final String TAG_INIT_SEGMENT = "#EXT-X-MAP";
  private static final String TAG_INDEPENDENT_SEGMENTS = "#EXT-X-INDEPENDENT-SEGMENTS";
  private static final String TAG_MEDIA_DURATION = "#EXTINF";
  private static final String TAG_MEDIA_SEQUENCE = "#EXT-X-MEDIA-SEQUENCE";
  private static final String TAG_START = "#EXT-X-START";
  private static final String TAG_ENDLIST = "#EXT-X-ENDLIST";
  private static final String TAG_KEY = "#EXT-X-KEY";
  private static final String TAG_SESSION_KEY = "#EXT-X-SESSION-KEY";
  private static final String TAG_BYTERANGE = "#EXT-X-BYTERANGE";
  private static final String TAG_GAP = "#EXT-X-GAP";
  private static final String TAG_SKIP = "#EXT-X-SKIP";
  private static final String TAG_PRELOAD_HINT = "#EXT-X-PRELOAD-HINT";
  private static final String TAG_RENDITION_REPORT = "#EXT-X-RENDITION-REPORT";

  private static final String TYPE_AUDIO = "AUDIO";
  private static final String TYPE_VIDEO = "VIDEO";
  private static final String TYPE_SUBTITLES = "SUBTITLES";
  private static final String TYPE_CLOSED_CAPTIONS = "CLOSED-CAPTIONS";
  private static final String TYPE_PART = "PART";
  private static final String TYPE_MAP = "MAP";

  private static final String METHOD_NONE = "NONE";
  private static final String METHOD_AES_128 = "AES-128";
  private static final String METHOD_SAMPLE_AES = "SAMPLE-AES";
  // Replaced by METHOD_SAMPLE_AES_CTR. Keep for backward compatibility.
  private static final String METHOD_SAMPLE_AES_CENC = "SAMPLE-AES-CENC";
  private static final String METHOD_SAMPLE_AES_CTR = "SAMPLE-AES-CTR";
  private static final String KEYFORMAT_PLAYREADY = "com.microsoft.playready";
  private static final String KEYFORMAT_IDENTITY = "identity";
  private static final String KEYFORMAT_WIDEVINE_PSSH_BINARY =
      "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed";
  private static final String KEYFORMAT_WIDEVINE_PSSH_JSON = "com.widevine";

  private static final String BOOLEAN_TRUE = "YES";
  private static final String BOOLEAN_FALSE = "NO";

  private static final String ATTR_CLOSED_CAPTIONS_NONE = "CLOSED-CAPTIONS=NONE";

  private static final Pattern REGEX_AVERAGE_BANDWIDTH =
      Pattern.compile("AVERAGE-BANDWIDTH=(\\d+)\\b");
  private static final Pattern REGEX_VIDEO = Pattern.compile("VIDEO=\"(.+?)\"");
  private static final Pattern REGEX_AUDIO = Pattern.compile("AUDIO=\"(.+?)\"");
  private static final Pattern REGEX_SUBTITLES = Pattern.compile("SUBTITLES=\"(.+?)\"");
  private static final Pattern REGEX_CLOSED_CAPTIONS = Pattern.compile("CLOSED-CAPTIONS=\"(.+?)\"");
  private static final Pattern REGEX_BANDWIDTH = Pattern.compile("[^-]BANDWIDTH=(\\d+)\\b");
  private static final Pattern REGEX_CHANNELS = Pattern.compile("CHANNELS=\"(.+?)\"");
  private static final Pattern REGEX_CODECS = Pattern.compile("CODECS=\"(.+?)\"");
  private static final Pattern REGEX_RESOLUTION = Pattern.compile("RESOLUTION=(\\d+x\\d+)");
  private static final Pattern REGEX_FRAME_RATE = Pattern.compile("FRAME-RATE=([\\d\\.]+)\\b");
  private static final Pattern REGEX_TARGET_DURATION =
      Pattern.compile(TAG_TARGET_DURATION + ":(\\d+)\\b");
  private static final Pattern REGEX_ATTR_DURATION = Pattern.compile("DURATION=([\\d\\.]+)\\b");
  private static final Pattern REGEX_PART_TARGET_DURATION =
      Pattern.compile("PART-TARGET=([\\d\\.]+)\\b");
  private static final Pattern REGEX_VERSION = Pattern.compile(TAG_VERSION + ":(\\d+)\\b");
  private static final Pattern REGEX_PLAYLIST_TYPE =
      Pattern.compile(TAG_PLAYLIST_TYPE + ":(.+)\\b");
  private static final Pattern REGEX_CAN_SKIP_UNTIL =
      Pattern.compile("CAN-SKIP-UNTIL=([\\d\\.]+)\\b");
  private static final Pattern REGEX_CAN_SKIP_DATE_RANGES =
      compileBooleanAttrPattern("CAN-SKIP-DATERANGES");
  private static final Pattern REGEX_SKIPPED_SEGMENTS =
      Pattern.compile("SKIPPED-SEGMENTS=(\\d+)\\b");
  private static final Pattern REGEX_HOLD_BACK = Pattern.compile("[:|,]HOLD-BACK=([\\d\\.]+)\\b");
  private static final Pattern REGEX_PART_HOLD_BACK =
      Pattern.compile("PART-HOLD-BACK=([\\d\\.]+)\\b");
  private static final Pattern REGEX_CAN_BLOCK_RELOAD =
      compileBooleanAttrPattern("CAN-BLOCK-RELOAD");
  private static final Pattern REGEX_MEDIA_SEQUENCE =
      Pattern.compile(TAG_MEDIA_SEQUENCE + ":(\\d+)\\b");
  private static final Pattern REGEX_MEDIA_DURATION =
      Pattern.compile(TAG_MEDIA_DURATION + ":([\\d\\.]+)\\b");
  private static final Pattern REGEX_MEDIA_TITLE =
      Pattern.compile(TAG_MEDIA_DURATION + ":[\\d\\.]+\\b,(.+)");
  private static final Pattern REGEX_LAST_MSN = Pattern.compile("LAST-MSN" + "=(\\d+)\\b");
  private static final Pattern REGEX_LAST_PART = Pattern.compile("LAST-PART" + "=(\\d+)\\b");
  private static final Pattern REGEX_TIME_OFFSET = Pattern.compile("TIME-OFFSET=(-?[\\d\\.]+)\\b");
  private static final Pattern REGEX_BYTERANGE =
      Pattern.compile(TAG_BYTERANGE + ":(\\d+(?:@\\d+)?)\\b");
  private static final Pattern REGEX_ATTR_BYTERANGE =
      Pattern.compile("BYTERANGE=\"(\\d+(?:@\\d+)?)\\b\"");
  private static final Pattern REGEX_BYTERANGE_START = Pattern.compile("BYTERANGE-START=(\\d+)\\b");
  private static final Pattern REGEX_BYTERANGE_LENGTH =
      Pattern.compile("BYTERANGE-LENGTH=(\\d+)\\b");
  private static final Pattern REGEX_METHOD =
      Pattern.compile(
          "METHOD=("
              + METHOD_NONE
              + "|"
              + METHOD_AES_128
              + "|"
              + METHOD_SAMPLE_AES
              + "|"
              + METHOD_SAMPLE_AES_CENC
              + "|"
              + METHOD_SAMPLE_AES_CTR
              + ")"
              + "\\s*(?:,|$)");
  private static final Pattern REGEX_KEYFORMAT = Pattern.compile("KEYFORMAT=\"(.+?)\"");
  private static final Pattern REGEX_KEYFORMATVERSIONS =
      Pattern.compile("KEYFORMATVERSIONS=\"(.+?)\"");
  private static final Pattern REGEX_URI = Pattern.compile("URI=\"(.+?)\"");
  private static final Pattern REGEX_IV = Pattern.compile("IV=([^,.*]+)");
  private static final Pattern REGEX_TYPE =
      Pattern.compile(
          "TYPE=("
              + TYPE_AUDIO
              + "|"
              + TYPE_VIDEO
              + "|"
              + TYPE_SUBTITLES
              + "|"
              + TYPE_CLOSED_CAPTIONS
              + ")");
  private static final Pattern REGEX_PRELOAD_HINT_TYPE =
      Pattern.compile("TYPE=(" + TYPE_PART + "|" + TYPE_MAP + ")");
  private static final Pattern REGEX_LANGUAGE = Pattern.compile("LANGUAGE=\"(.+?)\"");
  private static final Pattern REGEX_NAME = Pattern.compile("NAME=\"(.+?)\"");
  private static final Pattern REGEX_GROUP_ID = Pattern.compile("GROUP-ID=\"(.+?)\"");
  private static final Pattern REGEX_CHARACTERISTICS = Pattern.compile("CHARACTERISTICS=\"(.+?)\"");
  private static final Pattern REGEX_INSTREAM_ID =
      Pattern.compile("INSTREAM-ID=\"((?:CC|SERVICE)\\d+)\"");
  private static final Pattern REGEX_AUTOSELECT = compileBooleanAttrPattern("AUTOSELECT");
  private static final Pattern REGEX_DEFAULT = compileBooleanAttrPattern("DEFAULT");
  private static final Pattern REGEX_FORCED = compileBooleanAttrPattern("FORCED");
  private static final Pattern REGEX_INDEPENDENT = compileBooleanAttrPattern("INDEPENDENT");
  private static final Pattern REGEX_GAP = compileBooleanAttrPattern("GAP");
  private static final Pattern REGEX_PRECISE = compileBooleanAttrPattern("PRECISE");
  private static final Pattern REGEX_VALUE = Pattern.compile("VALUE=\"(.+?)\"");
  private static final Pattern REGEX_IMPORT = Pattern.compile("IMPORT=\"(.+?)\"");
  private static final Pattern REGEX_VARIABLE_REFERENCE =
      Pattern.compile("\\{\\$([a-zA-Z0-9\\-_]+)\\}");

  private final HlsMultivariantPlaylist multivariantPlaylist;
  @Nullable private final HlsMediaPlaylist previousMediaPlaylist;

  /**
   * Creates an instance where media playlists are parsed without inheriting attributes from a
   * multivariant playlist.
   */
  public HlsPlaylistParser() {
    this(HlsMultivariantPlaylist.EMPTY, /* previousMediaPlaylist= */ null);
  }

  /**
   * Creates an instance where parsed media playlists inherit attributes from the given master
   * playlist.
   *
   * @param multivariantPlaylist The multivariant playlist from which media playlists will inherit
   *     attributes.
   * @param previousMediaPlaylist The previous media playlist from which the new media playlist may
   *     inherit skipped segments.
   */
  public HlsPlaylistParser(
      HlsMultivariantPlaylist multivariantPlaylist,
      @Nullable HlsMediaPlaylist previousMediaPlaylist) {
    this.multivariantPlaylist = multivariantPlaylist;
    this.previousMediaPlaylist = previousMediaPlaylist;
  }

  @Override
  public HlsPlaylist parse(Uri uri, InputStream inputStream) throws IOException {
    BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
    Queue<String> extraLines = new ArrayDeque<>();
    String line;
    try {
      if (!checkPlaylistHeader(reader)) {
        throw ParserException.createForMalformedManifest(
            /* message= */ "Input does not start with the #EXTM3U header.", /* cause= */ null);
      }
      while ((line = reader.readLine()) != null) {
        line = line.trim();
        if (line.isEmpty()) {
          // Do nothing.
        } else if (line.startsWith(TAG_STREAM_INF)) {
          extraLines.add(line);
          return parseMultivariantPlaylist(new LineIterator(extraLines, reader), uri.toString());
        } else if (line.startsWith(TAG_TARGET_DURATION)
            || line.startsWith(TAG_MEDIA_SEQUENCE)
            || line.startsWith(TAG_MEDIA_DURATION)
            || line.startsWith(TAG_KEY)
            || line.startsWith(TAG_BYTERANGE)
            || line.equals(TAG_DISCONTINUITY)
            || line.equals(TAG_DISCONTINUITY_SEQUENCE)
            || line.equals(TAG_ENDLIST)) {
          extraLines.add(line);
          return parseMediaPlaylist(
              multivariantPlaylist,
              previousMediaPlaylist,
              new LineIterator(extraLines, reader),
              uri.toString());
        } else {
          extraLines.add(line);
        }
      }
    } finally {
      Util.closeQuietly(reader);
    }
    throw ParserException.createForMalformedManifest(
        "Failed to parse the playlist, could not identify any tags.", /* cause= */ null);
  }

  private static boolean checkPlaylistHeader(BufferedReader reader) throws IOException {
    int last = reader.read();
    if (last == 0xEF) {
      if (reader.read() != 0xBB || reader.read() != 0xBF) {
        return false;
      }
      // The playlist contains a Byte Order Mark, which gets discarded.
      last = reader.read();
    }
    last = skipIgnorableWhitespace(reader, true, last);
    int playlistHeaderLength = PLAYLIST_HEADER.length();
    for (int i = 0; i < playlistHeaderLength; i++) {
      if (last != PLAYLIST_HEADER.charAt(i)) {
        return false;
      }
      last = reader.read();
    }
    last = skipIgnorableWhitespace(reader, false, last);
    return Util.isLinebreak(last);
  }

  private static int skipIgnorableWhitespace(BufferedReader reader, boolean skipLinebreaks, int c)
      throws IOException {
    while (c != -1 && Character.isWhitespace(c) && (skipLinebreaks || !Util.isLinebreak(c))) {
      c = reader.read();
    }
    return c;
  }

  private static HlsMultivariantPlaylist parseMultivariantPlaylist(
      LineIterator iterator, String baseUri) throws IOException {
    HashMap<Uri, ArrayList<VariantInfo>> urlToVariantInfos = new HashMap<>();
    HashMap<String, String> variableDefinitions = new HashMap<>();
    ArrayList<Variant> variants = new ArrayList<>();
    ArrayList<Rendition> videos = new ArrayList<>();
    ArrayList<Rendition> audios = new ArrayList<>();
    ArrayList<Rendition> subtitles = new ArrayList<>();
    ArrayList<Rendition> closedCaptions = new ArrayList<>();
    ArrayList<String> mediaTags = new ArrayList<>();
    ArrayList<DrmInitData> sessionKeyDrmInitData = new ArrayList<>();
    ArrayList<String> tags = new ArrayList<>();
    Format muxedAudioFormat = null;
    List<Format> muxedCaptionFormats = null;
    boolean noClosedCaptions = false;
    boolean hasIndependentSegmentsTag = false;

    String line;
    while (iterator.hasNext()) {
      line = iterator.next();

      if (line.startsWith(TAG_PREFIX)) {
        // We expose all tags through the playlist.
        tags.add(line);
      }
      boolean isIFrameOnlyVariant = line.startsWith(TAG_I_FRAME_STREAM_INF);

      if (line.startsWith(TAG_DEFINE)) {
        variableDefinitions.put(
            /* key= */ parseStringAttr(line, REGEX_NAME, variableDefinitions),
            /* value= */ parseStringAttr(line, REGEX_VALUE, variableDefinitions));
      } else if (line.equals(TAG_INDEPENDENT_SEGMENTS)) {
        hasIndependentSegmentsTag = true;
      } else if (line.startsWith(TAG_MEDIA)) {
        // Media tags are parsed at the end to include codec information from #EXT-X-STREAM-INF
        // tags.
        mediaTags.add(line);
      } else if (line.startsWith(TAG_SESSION_KEY)) {
        String keyFormat =
            parseOptionalStringAttr(line, REGEX_KEYFORMAT, KEYFORMAT_IDENTITY, variableDefinitions);
        SchemeData schemeData = parseDrmSchemeData(line, keyFormat, variableDefinitions);
        if (schemeData != null) {
          String method = parseStringAttr(line, REGEX_METHOD, variableDefinitions);
          String scheme = parseEncryptionScheme(method);
          sessionKeyDrmInitData.add(new DrmInitData(scheme, schemeData));
        }
      } else if (line.startsWith(TAG_STREAM_INF) || isIFrameOnlyVariant) {
        noClosedCaptions |= line.contains(ATTR_CLOSED_CAPTIONS_NONE);
        int roleFlags = isIFrameOnlyVariant ? C.ROLE_FLAG_TRICK_PLAY : 0;
        int peakBitrate = parseIntAttr(line, REGEX_BANDWIDTH);
        int averageBitrate = parseOptionalIntAttr(line, REGEX_AVERAGE_BANDWIDTH, -1);
        String codecs = parseOptionalStringAttr(line, REGEX_CODECS, variableDefinitions);
        String resolutionString =
            parseOptionalStringAttr(line, REGEX_RESOLUTION, variableDefinitions);
        int width;
        int height;
        if (resolutionString != null) {
          String[] widthAndHeight = Util.split(resolutionString, "x");
          width = Integer.parseInt(widthAndHeight[0]);
          height = Integer.parseInt(widthAndHeight[1]);
          if (width <= 0 || height <= 0) {
            // Resolution string is invalid.
            width = Format.NO_VALUE;
            height = Format.NO_VALUE;
          }
        } else {
          width = Format.NO_VALUE;
          height = Format.NO_VALUE;
        }
        float frameRate = Format.NO_VALUE;
        String frameRateString =
            parseOptionalStringAttr(line, REGEX_FRAME_RATE, variableDefinitions);
        if (frameRateString != null) {
          frameRate = Float.parseFloat(frameRateString);
        }
        String videoGroupId = parseOptionalStringAttr(line, REGEX_VIDEO, variableDefinitions);
        String audioGroupId = parseOptionalStringAttr(line, REGEX_AUDIO, variableDefinitions);
        String subtitlesGroupId =
            parseOptionalStringAttr(line, REGEX_SUBTITLES, variableDefinitions);
        String closedCaptionsGroupId =
            parseOptionalStringAttr(line, REGEX_CLOSED_CAPTIONS, variableDefinitions);
        Uri uri;
        if (isIFrameOnlyVariant) {
          uri =
              UriUtil.resolveToUri(baseUri, parseStringAttr(line, REGEX_URI, variableDefinitions));
        } else if (!iterator.hasNext()) {
          throw ParserException.createForMalformedManifest(
              "#EXT-X-STREAM-INF must be followed by another line", /* cause= */ null);
        } else {
          // The following line contains #EXT-X-STREAM-INF's URI.
          line = replaceVariableReferences(iterator.next(), variableDefinitions);
          uri = UriUtil.resolveToUri(baseUri, line);
        }

        Format format =
            new Format.Builder()
                .setId(variants.size())
                .setContainerMimeType(MimeTypes.APPLICATION_M3U8)
                .setCodecs(codecs)
                .setAverageBitrate(averageBitrate)
                .setPeakBitrate(peakBitrate)
                .setWidth(width)
                .setHeight(height)
                .setFrameRate(frameRate)
                .setRoleFlags(roleFlags)
                .build();
        Variant variant =
            new Variant(
                uri, format, videoGroupId, audioGroupId, subtitlesGroupId, closedCaptionsGroupId);
        variants.add(variant);
        @Nullable ArrayList<VariantInfo> variantInfosForUrl = urlToVariantInfos.get(uri);
        if (variantInfosForUrl == null) {
          variantInfosForUrl = new ArrayList<>();
          urlToVariantInfos.put(uri, variantInfosForUrl);
        }
        variantInfosForUrl.add(
            new VariantInfo(
                averageBitrate,
                peakBitrate,
                videoGroupId,
                audioGroupId,
                subtitlesGroupId,
                closedCaptionsGroupId));
      }
    }

    // TODO: Don't deduplicate variants by URL.
    ArrayList<Variant> deduplicatedVariants = new ArrayList<>();
    HashSet<Uri> urlsInDeduplicatedVariants = new HashSet<>();
    for (int i = 0; i < variants.size(); i++) {
      Variant variant = variants.get(i);
      if (urlsInDeduplicatedVariants.add(variant.url)) {
        Assertions.checkState(variant.format.metadata == null);
        HlsTrackMetadataEntry hlsMetadataEntry =
            new HlsTrackMetadataEntry(
                /* groupId= */ null,
                /* name= */ null,
                checkNotNull(urlToVariantInfos.get(variant.url)));
        Metadata metadata = new Metadata(hlsMetadataEntry);
        Format format = variant.format.buildUpon().setMetadata(metadata).build();
        deduplicatedVariants.add(variant.copyWithFormat(format));
      }
    }

    for (int i = 0; i < mediaTags.size(); i++) {
      line = mediaTags.get(i);
      String groupId = parseStringAttr(line, REGEX_GROUP_ID, variableDefinitions);
      String name = parseStringAttr(line, REGEX_NAME, variableDefinitions);
      Format.Builder formatBuilder =
          new Format.Builder()
              .setId(groupId + ":" + name)
              .setLabel(name)
              .setContainerMimeType(MimeTypes.APPLICATION_M3U8)
              .setSelectionFlags(parseSelectionFlags(line))
              .setRoleFlags(parseRoleFlags(line, variableDefinitions))
              .setLanguage(parseOptionalStringAttr(line, REGEX_LANGUAGE, variableDefinitions));

      @Nullable String referenceUri = parseOptionalStringAttr(line, REGEX_URI, variableDefinitions);
      @Nullable Uri uri = referenceUri == null ? null : UriUtil.resolveToUri(baseUri, referenceUri);
      Metadata metadata =
          new Metadata(new HlsTrackMetadataEntry(groupId, name, Collections.emptyList()));
      switch (parseStringAttr(line, REGEX_TYPE, variableDefinitions)) {
        case TYPE_VIDEO:
          @Nullable Variant variant = getVariantWithVideoGroup(variants, groupId);
          if (variant != null) {
            Format variantFormat = variant.format;
            @Nullable
            String codecs = Util.getCodecsOfType(variantFormat.codecs, C.TRACK_TYPE_VIDEO);
            formatBuilder
                .setCodecs(codecs)
                .setSampleMimeType(MimeTypes.getMediaMimeType(codecs))
                .setWidth(variantFormat.width)
                .setHeight(variantFormat.height)
                .setFrameRate(variantFormat.frameRate);
          }
          if (uri == null) {
            // TODO: Remove this case and add a Rendition with a null uri to videos.
          } else {
            formatBuilder.setMetadata(metadata);
            videos.add(new Rendition(uri, formatBuilder.build(), groupId, name));
          }
          break;
        case TYPE_AUDIO:
          @Nullable String sampleMimeType = null;
          variant = getVariantWithAudioGroup(variants, groupId);
          if (variant != null) {
            @Nullable
            String codecs = Util.getCodecsOfType(variant.format.codecs, C.TRACK_TYPE_AUDIO);
            formatBuilder.setCodecs(codecs);
            sampleMimeType = MimeTypes.getMediaMimeType(codecs);
          }
          @Nullable
          String channelsString =
              parseOptionalStringAttr(line, REGEX_CHANNELS, variableDefinitions);
          if (channelsString != null) {
            int channelCount = Integer.parseInt(Util.splitAtFirst(channelsString, "/")[0]);
            formatBuilder.setChannelCount(channelCount);
            if (MimeTypes.AUDIO_E_AC3.equals(sampleMimeType) && channelsString.endsWith("/JOC")) {
              sampleMimeType = MimeTypes.AUDIO_E_AC3_JOC;
              formatBuilder.setCodecs(MimeTypes.CODEC_E_AC3_JOC);
            }
          }
          formatBuilder.setSampleMimeType(sampleMimeType);
          if (uri != null) {
            formatBuilder.setMetadata(metadata);
            audios.add(new Rendition(uri, formatBuilder.build(), groupId, name));
          } else if (variant != null) {
            // TODO: Remove muxedAudioFormat and add a Rendition with a null uri to audios.
            muxedAudioFormat = formatBuilder.build();
          }
          break;
        case TYPE_SUBTITLES:
          sampleMimeType = null;
          variant = getVariantWithSubtitleGroup(variants, groupId);
          if (variant != null) {
            @Nullable
            String codecs = Util.getCodecsOfType(variant.format.codecs, C.TRACK_TYPE_TEXT);
            formatBuilder.setCodecs(codecs);
            sampleMimeType = MimeTypes.getMediaMimeType(codecs);
          }
          if (sampleMimeType == null) {
            sampleMimeType = MimeTypes.TEXT_VTT;
          }
          formatBuilder.setSampleMimeType(sampleMimeType).setMetadata(metadata);
          if (uri != null) {
            subtitles.add(new Rendition(uri, formatBuilder.build(), groupId, name));
          } else {
            Log.w(LOG_TAG, "EXT-X-MEDIA tag with missing mandatory URI attribute: skipping");
          }
          break;
        case TYPE_CLOSED_CAPTIONS:
          String instreamId = parseStringAttr(line, REGEX_INSTREAM_ID, variableDefinitions);
          int accessibilityChannel;
          if (instreamId.startsWith("CC")) {
            sampleMimeType = MimeTypes.APPLICATION_CEA608;
            accessibilityChannel = Integer.parseInt(instreamId.substring(2));
          } else /* starts with SERVICE */ {
            sampleMimeType = MimeTypes.APPLICATION_CEA708;
            accessibilityChannel = Integer.parseInt(instreamId.substring(7));
          }
          if (muxedCaptionFormats == null) {
            muxedCaptionFormats = new ArrayList<>();
          }
          formatBuilder
              .setSampleMimeType(sampleMimeType)
              .setAccessibilityChannel(accessibilityChannel);
          muxedCaptionFormats.add(formatBuilder.build());
          // TODO: Remove muxedCaptionFormats and add a Rendition with a null uri to closedCaptions.
          break;
        default:
          // Do nothing.
          break;
      }
    }

    if (noClosedCaptions) {
      muxedCaptionFormats = Collections.emptyList();
    }

    return new HlsMultivariantPlaylist(
        baseUri,
        tags,
        deduplicatedVariants,
        videos,
        audios,
        subtitles,
        closedCaptions,
        muxedAudioFormat,
        muxedCaptionFormats,
        hasIndependentSegmentsTag,
        variableDefinitions,
        sessionKeyDrmInitData);
  }

  @Nullable
  private static Variant getVariantWithAudioGroup(ArrayList<Variant> variants, String groupId) {
    for (int i = 0; i < variants.size(); i++) {
      Variant variant = variants.get(i);
      if (groupId.equals(variant.audioGroupId)) {
        return variant;
      }
    }
    return null;
  }

  @Nullable
  private static Variant getVariantWithVideoGroup(ArrayList<Variant> variants, String groupId) {
    for (int i = 0; i < variants.size(); i++) {
      Variant variant = variants.get(i);
      if (groupId.equals(variant.videoGroupId)) {
        return variant;
      }
    }
    return null;
  }

  @Nullable
  private static Variant getVariantWithSubtitleGroup(ArrayList<Variant> variants, String groupId) {
    for (int i = 0; i < variants.size(); i++) {
      Variant variant = variants.get(i);
      if (groupId.equals(variant.subtitleGroupId)) {
        return variant;
      }
    }
    return null;
  }

  private static HlsMediaPlaylist parseMediaPlaylist(
      HlsMultivariantPlaylist multivariantPlaylist,
      @Nullable HlsMediaPlaylist previousMediaPlaylist,
      LineIterator iterator,
      String baseUri)
      throws IOException {
    @HlsMediaPlaylist.PlaylistType int playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_UNKNOWN;
    long startOffsetUs = C.TIME_UNSET;
    long mediaSequence = 0;
    int version = 1; // Default version == 1.
    long targetDurationUs = C.TIME_UNSET;
    long partTargetDurationUs = C.TIME_UNSET;
    boolean hasIndependentSegmentsTag = multivariantPlaylist.hasIndependentSegments;
    boolean hasEndTag = false;
    @Nullable Segment initializationSegment = null;
    HashMap<String, String> variableDefinitions = new HashMap<>();
    HashMap<String, Segment> urlToInferredInitSegment = new HashMap<>();
    List<Segment> segments = new ArrayList<>();
    List<Part> trailingParts = new ArrayList<>();
    @Nullable Part preloadPart = null;
    List<RenditionReport> renditionReports = new ArrayList<>();
    List<String> tags = new ArrayList<>();

    long segmentDurationUs = 0;
    String segmentTitle = "";
    boolean hasDiscontinuitySequence = false;
    int playlistDiscontinuitySequence = 0;
    int relativeDiscontinuitySequence = 0;
    long playlistStartTimeUs = 0;
    long segmentStartTimeUs = 0;
    boolean preciseStart = false;
    long segmentByteRangeOffset = 0;
    long segmentByteRangeLength = C.LENGTH_UNSET;
    long partStartTimeUs = 0;
    long partByteRangeOffset = 0;
    boolean isIFrameOnly = false;
    long segmentMediaSequence = 0;
    boolean hasGapTag = false;
    HlsMediaPlaylist.ServerControl serverControl =
        new HlsMediaPlaylist.ServerControl(
            /* skipUntilUs= */ C.TIME_UNSET,
            /* canSkipDateRanges= */ false,
            /* holdBackUs= */ C.TIME_UNSET,
            /* partHoldBackUs= */ C.TIME_UNSET,
            /* canBlockReload= */ false);

    @Nullable DrmInitData playlistProtectionSchemes = null;
    @Nullable String fullSegmentEncryptionKeyUri = null;
    @Nullable String fullSegmentEncryptionIV = null;
    TreeMap<String, SchemeData> currentSchemeDatas = new TreeMap<>();
    @Nullable String encryptionScheme = null;
    @Nullable DrmInitData cachedDrmInitData = null;

    String line;
    while (iterator.hasNext()) {
      line = iterator.next();

      if (line.startsWith(TAG_PREFIX)) {
        // We expose all tags through the playlist.
        tags.add(line);
      }

      if (line.startsWith(TAG_PLAYLIST_TYPE)) {
        String playlistTypeString = parseStringAttr(line, REGEX_PLAYLIST_TYPE, variableDefinitions);
        if ("VOD".equals(playlistTypeString)) {
          playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_VOD;
        } else if ("EVENT".equals(playlistTypeString)) {
          playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_EVENT;
        }
      } else if (line.equals(TAG_IFRAME)) {
        isIFrameOnly = true;
      } else if (line.startsWith(TAG_START)) {
        startOffsetUs = (long) (parseDoubleAttr(line, REGEX_TIME_OFFSET) * C.MICROS_PER_SECOND);
        preciseStart =
            parseOptionalBooleanAttribute(line, REGEX_PRECISE, /* defaultValue= */ false);
      } else if (line.startsWith(TAG_SERVER_CONTROL)) {
        serverControl = parseServerControl(line);
      } else if (line.startsWith(TAG_PART_INF)) {
        double partTargetDurationSeconds = parseDoubleAttr(line, REGEX_PART_TARGET_DURATION);
        partTargetDurationUs = (long) (partTargetDurationSeconds * C.MICROS_PER_SECOND);
      } else if (line.startsWith(TAG_INIT_SEGMENT)) {
        String uri = parseStringAttr(line, REGEX_URI, variableDefinitions);
        String byteRange = parseOptionalStringAttr(line, REGEX_ATTR_BYTERANGE, variableDefinitions);
        if (byteRange != null) {
          String[] splitByteRange = Util.split(byteRange, "@");
          segmentByteRangeLength = Long.parseLong(splitByteRange[0]);
          if (splitByteRange.length > 1) {
            segmentByteRangeOffset = Long.parseLong(splitByteRange[1]);
          }
        }
        if (segmentByteRangeLength == C.LENGTH_UNSET) {
          // The segment has no byte range defined.
          segmentByteRangeOffset = 0;
        }
        if (fullSegmentEncryptionKeyUri != null && fullSegmentEncryptionIV == null) {
          // See RFC 8216, Section 4.3.2.5.
          throw ParserException.createForMalformedManifest(
              "The encryption IV attribute must be present when an initialization segment is"
                  + " encrypted with METHOD=AES-128.",
              /* cause= */ null);
        }
        initializationSegment =
            new Segment(
                uri,
                segmentByteRangeOffset,
                segmentByteRangeLength,
                fullSegmentEncryptionKeyUri,
                fullSegmentEncryptionIV);
        if (segmentByteRangeLength != C.LENGTH_UNSET) {
          segmentByteRangeOffset += segmentByteRangeLength;
        }
        segmentByteRangeLength = C.LENGTH_UNSET;
      } else if (line.startsWith(TAG_TARGET_DURATION)) {
        targetDurationUs = parseIntAttr(line, REGEX_TARGET_DURATION) * C.MICROS_PER_SECOND;
      } else if (line.startsWith(TAG_MEDIA_SEQUENCE)) {
        mediaSequence = parseLongAttr(line, REGEX_MEDIA_SEQUENCE);
        segmentMediaSequence = mediaSequence;
      } else if (line.startsWith(TAG_VERSION)) {
        version = parseIntAttr(line, REGEX_VERSION);
      } else if (line.startsWith(TAG_DEFINE)) {
        String importName = parseOptionalStringAttr(line, REGEX_IMPORT, variableDefinitions);
        if (importName != null) {
          String value = multivariantPlaylist.variableDefinitions.get(importName);
          if (value != null) {
            variableDefinitions.put(importName, value);
          } else {
            // The multivariant playlist does not declare the imported variable. Ignore.
          }
        } else {
          variableDefinitions.put(
              parseStringAttr(line, REGEX_NAME, variableDefinitions),
              parseStringAttr(line, REGEX_VALUE, variableDefinitions));
        }
      } else if (line.startsWith(TAG_MEDIA_DURATION)) {
        segmentDurationUs = parseTimeSecondsToUs(line, REGEX_MEDIA_DURATION);
        segmentTitle = parseOptionalStringAttr(line, REGEX_MEDIA_TITLE, "", variableDefinitions);
      } else if (line.startsWith(TAG_SKIP)) {
        int skippedSegmentCount = parseIntAttr(line, REGEX_SKIPPED_SEGMENTS);
        checkState(previousMediaPlaylist != null && segments.isEmpty());
        int startIndex = (int) (mediaSequence - castNonNull(previousMediaPlaylist).mediaSequence);
        int endIndex = startIndex + skippedSegmentCount;
        if (startIndex < 0 || endIndex > previousMediaPlaylist.segments.size()) {
          // Throw to force a reload if not all segments are available in the previous playlist.
          throw new DeltaUpdateException();
        }
        for (int i = startIndex; i < endIndex; i++) {
          Segment segment = previousMediaPlaylist.segments.get(i);
          if (mediaSequence != previousMediaPlaylist.mediaSequence) {
            // If the media sequences of the playlists are not the same, we need to recreate the
            // object with the updated relative start time and the relative discontinuity
            // sequence. With identical playlist media sequences these values do not change.
            int newRelativeDiscontinuitySequence =
                previousMediaPlaylist.discontinuitySequence
                    - playlistDiscontinuitySequence
                    + segment.relativeDiscontinuitySequence;
            segment = segment.copyWith(segmentStartTimeUs, newRelativeDiscontinuitySequence);
          }
          segments.add(segment);
          segmentStartTimeUs += segment.durationUs;
          partStartTimeUs = segmentStartTimeUs;
          if (segment.byteRangeLength != C.LENGTH_UNSET) {
            segmentByteRangeOffset = segment.byteRangeOffset + segment.byteRangeLength;
          }
          relativeDiscontinuitySequence = segment.relativeDiscontinuitySequence;
          initializationSegment = segment.initializationSegment;
          cachedDrmInitData = segment.drmInitData;
          fullSegmentEncryptionKeyUri = segment.fullSegmentEncryptionKeyUri;
          if (segment.encryptionIV == null
              || !segment.encryptionIV.equals(Long.toHexString(segmentMediaSequence))) {
            fullSegmentEncryptionIV = segment.encryptionIV;
          }
          segmentMediaSequence++;
        }
      } else if (line.startsWith(TAG_KEY)) {
        String method = parseStringAttr(line, REGEX_METHOD, variableDefinitions);
        String keyFormat =
            parseOptionalStringAttr(line, REGEX_KEYFORMAT, KEYFORMAT_IDENTITY, variableDefinitions);
        fullSegmentEncryptionKeyUri = null;
        fullSegmentEncryptionIV = null;
        if (METHOD_NONE.equals(method)) {
          currentSchemeDatas.clear();
          cachedDrmInitData = null;
        } else /* !METHOD_NONE.equals(method) */ {
          fullSegmentEncryptionIV = parseOptionalStringAttr(line, REGEX_IV, variableDefinitions);
          if (KEYFORMAT_IDENTITY.equals(keyFormat)) {
            if (METHOD_AES_128.equals(method)) {
              // The segment is fully encrypted using an identity key.
              fullSegmentEncryptionKeyUri = parseStringAttr(line, REGEX_URI, variableDefinitions);
            } else {
              // Do nothing. Samples are encrypted using an identity key, but this is not supported.
              // Hopefully, a traditional DRM alternative is also provided.
            }
          } else {
            if (encryptionScheme == null) {
              encryptionScheme = parseEncryptionScheme(method);
            }
            SchemeData schemeData = parseDrmSchemeData(line, keyFormat, variableDefinitions);
            if (schemeData != null) {
              cachedDrmInitData = null;
              currentSchemeDatas.put(keyFormat, schemeData);
            }
          }
        }
      } else if (line.startsWith(TAG_BYTERANGE)) {
        String byteRange = parseStringAttr(line, REGEX_BYTERANGE, variableDefinitions);
        String[] splitByteRange = Util.split(byteRange, "@");
        segmentByteRangeLength = Long.parseLong(splitByteRange[0]);
        if (splitByteRange.length > 1) {
          segmentByteRangeOffset = Long.parseLong(splitByteRange[1]);
        }
      } else if (line.startsWith(TAG_DISCONTINUITY_SEQUENCE)) {
        hasDiscontinuitySequence = true;
        playlistDiscontinuitySequence = Integer.parseInt(line.substring(line.indexOf(':') + 1));
      } else if (line.equals(TAG_DISCONTINUITY)) {
        relativeDiscontinuitySequence++;
      } else if (line.startsWith(TAG_PROGRAM_DATE_TIME)) {
        if (playlistStartTimeUs == 0) {
          long programDatetimeUs =
              Util.msToUs(Util.parseXsDateTime(line.substring(line.indexOf(':') + 1)));
          playlistStartTimeUs = programDatetimeUs - segmentStartTimeUs;
        }
      } else if (line.equals(TAG_GAP)) {
        hasGapTag = true;
      } else if (line.equals(TAG_INDEPENDENT_SEGMENTS)) {
        hasIndependentSegmentsTag = true;
      } else if (line.equals(TAG_ENDLIST)) {
        hasEndTag = true;
      } else if (line.startsWith(TAG_RENDITION_REPORT)) {
        long lastMediaSequence = parseOptionalLongAttr(line, REGEX_LAST_MSN, C.INDEX_UNSET);
        int lastPartIndex = parseOptionalIntAttr(line, REGEX_LAST_PART, C.INDEX_UNSET);
        String uri = parseStringAttr(line, REGEX_URI, variableDefinitions);
        Uri playlistUri = Uri.parse(UriUtil.resolve(baseUri, uri));
        renditionReports.add(new RenditionReport(playlistUri, lastMediaSequence, lastPartIndex));
      } else if (line.startsWith(TAG_PRELOAD_HINT)) {
        if (preloadPart != null) {
          continue;
        }
        String type = parseStringAttr(line, REGEX_PRELOAD_HINT_TYPE, variableDefinitions);
        if (!TYPE_PART.equals(type)) {
          continue;
        }
        String url = parseStringAttr(line, REGEX_URI, variableDefinitions);
        long byteRangeStart =
            parseOptionalLongAttr(line, REGEX_BYTERANGE_START, /* defaultValue= */ C.LENGTH_UNSET);
        long byteRangeLength =
            parseOptionalLongAttr(line, REGEX_BYTERANGE_LENGTH, /* defaultValue= */ C.LENGTH_UNSET);
        @Nullable
        String segmentEncryptionIV =
            getSegmentEncryptionIV(
                segmentMediaSequence, fullSegmentEncryptionKeyUri, fullSegmentEncryptionIV);
        if (cachedDrmInitData == null && !currentSchemeDatas.isEmpty()) {
          SchemeData[] schemeDatas = currentSchemeDatas.values().toArray(new SchemeData[0]);
          cachedDrmInitData = new DrmInitData(encryptionScheme, schemeDatas);
          if (playlistProtectionSchemes == null) {
            playlistProtectionSchemes = getPlaylistProtectionSchemes(encryptionScheme, schemeDatas);
          }
        }
        if (byteRangeStart == C.LENGTH_UNSET || byteRangeLength != C.LENGTH_UNSET) {
          // Skip preload part if it is an unbounded range request.
          preloadPart =
              new Part(
                  url,
                  initializationSegment,
                  /* durationUs= */ 0,
                  relativeDiscontinuitySequence,
                  partStartTimeUs,
                  cachedDrmInitData,
                  fullSegmentEncryptionKeyUri,
                  segmentEncryptionIV,
                  byteRangeStart != C.LENGTH_UNSET ? byteRangeStart : 0,
                  byteRangeLength,
                  /* hasGapTag= */ false,
                  /* isIndependent= */ false,
                  /* isPreload= */ true);
        }
      } else if (line.startsWith(TAG_PART)) {
        @Nullable
        String segmentEncryptionIV =
            getSegmentEncryptionIV(
                segmentMediaSequence, fullSegmentEncryptionKeyUri, fullSegmentEncryptionIV);
        String url = parseStringAttr(line, REGEX_URI, variableDefinitions);
        long partDurationUs =
            (long) (parseDoubleAttr(line, REGEX_ATTR_DURATION) * C.MICROS_PER_SECOND);
        boolean isIndependent =
            parseOptionalBooleanAttribute(line, REGEX_INDEPENDENT, /* defaultValue= */ false);
        // The first part of a segment is always independent if the segments are independent.
        isIndependent |= hasIndependentSegmentsTag && trailingParts.isEmpty();
        boolean isGap = parseOptionalBooleanAttribute(line, REGEX_GAP, /* defaultValue= */ false);
        @Nullable
        String byteRange = parseOptionalStringAttr(line, REGEX_ATTR_BYTERANGE, variableDefinitions);
        long partByteRangeLength = C.LENGTH_UNSET;
        if (byteRange != null) {
          String[] splitByteRange = Util.split(byteRange, "@");
          partByteRangeLength = Long.parseLong(splitByteRange[0]);
          if (splitByteRange.length > 1) {
            partByteRangeOffset = Long.parseLong(splitByteRange[1]);
          }
        }
        if (partByteRangeLength == C.LENGTH_UNSET) {
          partByteRangeOffset = 0;
        }
        if (cachedDrmInitData == null && !currentSchemeDatas.isEmpty()) {
          SchemeData[] schemeDatas = currentSchemeDatas.values().toArray(new SchemeData[0]);
          cachedDrmInitData = new DrmInitData(encryptionScheme, schemeDatas);
          if (playlistProtectionSchemes == null) {
            playlistProtectionSchemes = getPlaylistProtectionSchemes(encryptionScheme, schemeDatas);
          }
        }
        trailingParts.add(
            new Part(
                url,
                initializationSegment,
                partDurationUs,
                relativeDiscontinuitySequence,
                partStartTimeUs,
                cachedDrmInitData,
                fullSegmentEncryptionKeyUri,
                segmentEncryptionIV,
                partByteRangeOffset,
                partByteRangeLength,
                isGap,
                isIndependent,
                /* isPreload= */ false));
        partStartTimeUs += partDurationUs;
        if (partByteRangeLength != C.LENGTH_UNSET) {
          partByteRangeOffset += partByteRangeLength;
        }
      } else if (!line.startsWith("#")) {
        @Nullable
        String segmentEncryptionIV =
            getSegmentEncryptionIV(
                segmentMediaSequence, fullSegmentEncryptionKeyUri, fullSegmentEncryptionIV);
        segmentMediaSequence++;
        String segmentUri = replaceVariableReferences(line, variableDefinitions);
        @Nullable Segment inferredInitSegment = urlToInferredInitSegment.get(segmentUri);
        if (segmentByteRangeLength == C.LENGTH_UNSET) {
          // The segment has no byte range defined.
          segmentByteRangeOffset = 0;
        } else if (isIFrameOnly && initializationSegment == null && inferredInitSegment == null) {
          // The segment is a resource byte range without an initialization segment.
          // As per RFC 8216, Section 4.3.3.6, we assume the initialization section exists in the
          // bytes preceding the first segment in this segment's URL.
          // We assume the implicit initialization segment is unencrypted, since there's no way for
          // the playlist to provide an initialization vector for it.
          inferredInitSegment =
              new Segment(
                  segmentUri,
                  /* byteRangeOffset= */ 0,
                  segmentByteRangeOffset,
                  /* fullSegmentEncryptionKeyUri= */ null,
                  /* encryptionIV= */ null);
          urlToInferredInitSegment.put(segmentUri, inferredInitSegment);
        }

        if (cachedDrmInitData == null && !currentSchemeDatas.isEmpty()) {
          SchemeData[] schemeDatas = currentSchemeDatas.values().toArray(new SchemeData[0]);
          cachedDrmInitData = new DrmInitData(encryptionScheme, schemeDatas);
          if (playlistProtectionSchemes == null) {
            playlistProtectionSchemes = getPlaylistProtectionSchemes(encryptionScheme, schemeDatas);
          }
        }

        segments.add(
            new Segment(
                segmentUri,
                initializationSegment != null ? initializationSegment : inferredInitSegment,
                segmentTitle,
                segmentDurationUs,
                relativeDiscontinuitySequence,
                segmentStartTimeUs,
                cachedDrmInitData,
                fullSegmentEncryptionKeyUri,
                segmentEncryptionIV,
                segmentByteRangeOffset,
                segmentByteRangeLength,
                hasGapTag,
                trailingParts));
        segmentStartTimeUs += segmentDurationUs;
        partStartTimeUs = segmentStartTimeUs;
        segmentDurationUs = 0;
        segmentTitle = "";
        trailingParts = new ArrayList<>();
        if (segmentByteRangeLength != C.LENGTH_UNSET) {
          segmentByteRangeOffset += segmentByteRangeLength;
        }
        segmentByteRangeLength = C.LENGTH_UNSET;
        hasGapTag = false;
      }
    }

    Map<Uri, RenditionReport> renditionReportMap = new HashMap<>();
    for (int i = 0; i < renditionReports.size(); i++) {
      RenditionReport renditionReport = renditionReports.get(i);
      long lastMediaSequence = renditionReport.lastMediaSequence;
      if (lastMediaSequence == C.INDEX_UNSET) {
        lastMediaSequence = mediaSequence + segments.size() - (trailingParts.isEmpty() ? 1 : 0);
      }
      int lastPartIndex = renditionReport.lastPartIndex;
      if (lastPartIndex == C.INDEX_UNSET && partTargetDurationUs != C.TIME_UNSET) {
        List<Part> lastParts =
            trailingParts.isEmpty() ? Iterables.getLast(segments).parts : trailingParts;
        lastPartIndex = lastParts.size() - 1;
      }
      renditionReportMap.put(
          renditionReport.playlistUri,
          new RenditionReport(renditionReport.playlistUri, lastMediaSequence, lastPartIndex));
    }

    if (preloadPart != null) {
      trailingParts.add(preloadPart);
    }

    return new HlsMediaPlaylist(
        playlistType,
        baseUri,
        tags,
        startOffsetUs,
        preciseStart,
        playlistStartTimeUs,
        hasDiscontinuitySequence,
        playlistDiscontinuitySequence,
        mediaSequence,
        version,
        targetDurationUs,
        partTargetDurationUs,
        hasIndependentSegmentsTag,
        hasEndTag,
        /* hasProgramDateTime= */ playlistStartTimeUs != 0,
        playlistProtectionSchemes,
        segments,
        trailingParts,
        serverControl,
        renditionReportMap);
  }

  private static DrmInitData getPlaylistProtectionSchemes(
      @Nullable String encryptionScheme, SchemeData[] schemeDatas) {
    SchemeData[] playlistSchemeDatas = new SchemeData[schemeDatas.length];
    for (int i = 0; i < schemeDatas.length; i++) {
      playlistSchemeDatas[i] = schemeDatas[i].copyWithData(null);
    }
    return new DrmInitData(encryptionScheme, playlistSchemeDatas);
  }

  @Nullable
  private static String getSegmentEncryptionIV(
      long segmentMediaSequence,
      @Nullable String fullSegmentEncryptionKeyUri,
      @Nullable String fullSegmentEncryptionIV) {
    if (fullSegmentEncryptionKeyUri == null) {
      return null;
    } else if (fullSegmentEncryptionIV != null) {
      return fullSegmentEncryptionIV;
    }
    return Long.toHexString(segmentMediaSequence);
  }

  private static @C.SelectionFlags int parseSelectionFlags(String line) {
    int flags = 0;
    if (parseOptionalBooleanAttribute(line, REGEX_DEFAULT, false)) {
      flags |= C.SELECTION_FLAG_DEFAULT;
    }
    if (parseOptionalBooleanAttribute(line, REGEX_FORCED, false)) {
      flags |= C.SELECTION_FLAG_FORCED;
    }
    if (parseOptionalBooleanAttribute(line, REGEX_AUTOSELECT, false)) {
      flags |= C.SELECTION_FLAG_AUTOSELECT;
    }
    return flags;
  }

  private static @C.RoleFlags int parseRoleFlags(
      String line, Map<String, String> variableDefinitions) {
    String concatenatedCharacteristics =
        parseOptionalStringAttr(line, REGEX_CHARACTERISTICS, variableDefinitions);
    if (TextUtils.isEmpty(concatenatedCharacteristics)) {
      return 0;
    }
    String[] characteristics = Util.split(concatenatedCharacteristics, ",");
    @C.RoleFlags int roleFlags = 0;
    if (Util.contains(characteristics, "public.accessibility.describes-video")) {
      roleFlags |= C.ROLE_FLAG_DESCRIBES_VIDEO;
    }
    if (Util.contains(characteristics, "public.accessibility.transcribes-spoken-dialog")) {
      roleFlags |= C.ROLE_FLAG_TRANSCRIBES_DIALOG;
    }
    if (Util.contains(characteristics, "public.accessibility.describes-music-and-sound")) {
      roleFlags |= C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND;
    }
    if (Util.contains(characteristics, "public.easy-to-read")) {
      roleFlags |= C.ROLE_FLAG_EASY_TO_READ;
    }
    return roleFlags;
  }

  @Nullable
  private static SchemeData parseDrmSchemeData(
      String line, String keyFormat, Map<String, String> variableDefinitions)
      throws ParserException {
    String keyFormatVersions =
        parseOptionalStringAttr(line, REGEX_KEYFORMATVERSIONS, "1", variableDefinitions);
    if (KEYFORMAT_WIDEVINE_PSSH_BINARY.equals(keyFormat)) {
      String uriString = parseStringAttr(line, REGEX_URI, variableDefinitions);
      return new SchemeData(
          C.WIDEVINE_UUID,
          MimeTypes.VIDEO_MP4,
          Base64.decode(uriString.substring(uriString.indexOf(',')), Base64.DEFAULT));
    } else if (KEYFORMAT_WIDEVINE_PSSH_JSON.equals(keyFormat)) {
      return new SchemeData(C.WIDEVINE_UUID, "hls", Util.getUtf8Bytes(line));
    } else if (KEYFORMAT_PLAYREADY.equals(keyFormat) && "1".equals(keyFormatVersions)) {
      String uriString = parseStringAttr(line, REGEX_URI, variableDefinitions);
      byte[] data = Base64.decode(uriString.substring(uriString.indexOf(',')), Base64.DEFAULT);
      byte[] psshData = PsshAtomUtil.buildPsshAtom(C.PLAYREADY_UUID, data);
      return new SchemeData(C.PLAYREADY_UUID, MimeTypes.VIDEO_MP4, psshData);
    }
    return null;
  }

  private static HlsMediaPlaylist.ServerControl parseServerControl(String line) {
    double skipUntilSeconds =
        parseOptionalDoubleAttr(line, REGEX_CAN_SKIP_UNTIL, /* defaultValue= */ C.TIME_UNSET);
    long skipUntilUs =
        skipUntilSeconds == C.TIME_UNSET
            ? C.TIME_UNSET
            : (long) (skipUntilSeconds * C.MICROS_PER_SECOND);
    boolean canSkipDateRanges =
        parseOptionalBooleanAttribute(line, REGEX_CAN_SKIP_DATE_RANGES, /* defaultValue= */ false);
    double holdBackSeconds =
        parseOptionalDoubleAttr(line, REGEX_HOLD_BACK, /* defaultValue= */ C.TIME_UNSET);
    long holdBackUs =
        holdBackSeconds == C.TIME_UNSET
            ? C.TIME_UNSET
            : (long) (holdBackSeconds * C.MICROS_PER_SECOND);
    double partHoldBackSeconds = parseOptionalDoubleAttr(line, REGEX_PART_HOLD_BACK, C.TIME_UNSET);
    long partHoldBackUs =
        partHoldBackSeconds == C.TIME_UNSET
            ? C.TIME_UNSET
            : (long) (partHoldBackSeconds * C.MICROS_PER_SECOND);
    boolean canBlockReload =
        parseOptionalBooleanAttribute(line, REGEX_CAN_BLOCK_RELOAD, /* defaultValue= */ false);

    return new HlsMediaPlaylist.ServerControl(
        skipUntilUs, canSkipDateRanges, holdBackUs, partHoldBackUs, canBlockReload);
  }

  private static String parseEncryptionScheme(String method) {
    return METHOD_SAMPLE_AES_CENC.equals(method) || METHOD_SAMPLE_AES_CTR.equals(method)
        ? C.CENC_TYPE_cenc
        : C.CENC_TYPE_cbcs;
  }

  private static int parseIntAttr(String line, Pattern pattern) throws ParserException {
    return Integer.parseInt(parseStringAttr(line, pattern, Collections.emptyMap()));
  }

  private static int parseOptionalIntAttr(String line, Pattern pattern, int defaultValue) {
    Matcher matcher = pattern.matcher(line);
    if (matcher.find()) {
      return Integer.parseInt(checkNotNull(matcher.group(1)));
    }
    return defaultValue;
  }

  private static long parseLongAttr(String line, Pattern pattern) throws ParserException {
    return Long.parseLong(parseStringAttr(line, pattern, Collections.emptyMap()));
  }

  private static long parseOptionalLongAttr(String line, Pattern pattern, long defaultValue) {
    Matcher matcher = pattern.matcher(line);
    if (matcher.find()) {
      return Long.parseLong(checkNotNull(matcher.group(1)));
    }
    return defaultValue;
  }

  private static long parseTimeSecondsToUs(String line, Pattern pattern) throws ParserException {
    String timeValueSeconds = parseStringAttr(line, pattern, Collections.emptyMap());
    BigDecimal timeValue = new BigDecimal(timeValueSeconds);
    return timeValue.multiply(new BigDecimal(C.MICROS_PER_SECOND)).longValue();
  }

  private static double parseDoubleAttr(String line, Pattern pattern) throws ParserException {
    return Double.parseDouble(parseStringAttr(line, pattern, Collections.emptyMap()));
  }

  private static String parseStringAttr(
      String line, Pattern pattern, Map<String, String> variableDefinitions)
      throws ParserException {
    String value = parseOptionalStringAttr(line, pattern, variableDefinitions);
    if (value != null) {
      return value;
    } else {
      throw ParserException.createForMalformedManifest(
          "Couldn't match " + pattern.pattern() + " in " + line, /* cause= */ null);
    }
  }

  @Nullable
  private static String parseOptionalStringAttr(
      String line, Pattern pattern, Map<String, String> variableDefinitions) {
    return parseOptionalStringAttr(line, pattern, null, variableDefinitions);
  }

  private static @PolyNull String parseOptionalStringAttr(
      String line,
      Pattern pattern,
      @PolyNull String defaultValue,
      Map<String, String> variableDefinitions) {
    Matcher matcher = pattern.matcher(line);
    @PolyNull String value = matcher.find() ? checkNotNull(matcher.group(1)) : defaultValue;
    return variableDefinitions.isEmpty() || value == null
        ? value
        : replaceVariableReferences(value, variableDefinitions);
  }

  private static double parseOptionalDoubleAttr(String line, Pattern pattern, double defaultValue) {
    Matcher matcher = pattern.matcher(line);
    if (matcher.find()) {
      return Double.parseDouble(checkNotNull(matcher.group(1)));
    }
    return defaultValue;
  }

  private static String replaceVariableReferences(
      String string, Map<String, String> variableDefinitions) {
    Matcher matcher = REGEX_VARIABLE_REFERENCE.matcher(string);
    // TODO: Replace StringBuffer with StringBuilder once Java 9 is available.
    StringBuffer stringWithReplacements = new StringBuffer();
    while (matcher.find()) {
      String groupName = matcher.group(1);
      if (variableDefinitions.containsKey(groupName)) {
        matcher.appendReplacement(
            stringWithReplacements, Matcher.quoteReplacement(variableDefinitions.get(groupName)));
      } else {
        // The variable is not defined. The value is ignored.
      }
    }
    matcher.appendTail(stringWithReplacements);
    return stringWithReplacements.toString();
  }

  private static boolean parseOptionalBooleanAttribute(
      String line, Pattern pattern, boolean defaultValue) {
    Matcher matcher = pattern.matcher(line);
    if (matcher.find()) {
      return BOOLEAN_TRUE.equals(matcher.group(1));
    }
    return defaultValue;
  }

  private static Pattern compileBooleanAttrPattern(String attribute) {
    return Pattern.compile(attribute + "=(" + BOOLEAN_FALSE + "|" + BOOLEAN_TRUE + ")");
  }

  private static class LineIterator {

    private final BufferedReader reader;
    private final Queue<String> extraLines;

    @Nullable private String next;

    public LineIterator(Queue<String> extraLines, BufferedReader reader) {
      this.extraLines = extraLines;
      this.reader = reader;
    }

    @EnsuresNonNullIf(expression = "next", result = true)
    public boolean hasNext() throws IOException {
      if (next != null) {
        return true;
      }
      if (!extraLines.isEmpty()) {
        next = checkNotNull(extraLines.poll());
        return true;
      }
      while ((next = reader.readLine()) != null) {
        next = next.trim();
        if (!next.isEmpty()) {
          return true;
        }
      }
      return false;
    }

    /** Return the next line, or throw {@link NoSuchElementException} if none. */
    public String next() throws IOException {
      if (hasNext()) {
        String result = next;
        next = null;
        return result;
      } else {
        throw new NoSuchElementException();
      }
    }
  }
}