From aaf38adc26e2d2333c67e5989c2d36a7b6627414 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Thu, 3 Nov 2016 03:55:12 -0700 Subject: [PATCH] Add support for HLS live seeking In order to expose the live window, it is necessary (unlike before) to refresh the live playlists being played periodically so as to know where the user can seek to. For this, the HlsPlaylistTracker is added, which is basically a map from HlsUrl's to playlist. One of the playlists involved in the playback will be chosen to define the live window. The playlist tracker it periodically. The rest of the playilst will be loaded lazily. N.B: This means that for VOD, playlists are not refreshed at all. There are three important features missing in this CL(that will be added in later CLs): * Blacklisting HlsUrls that point to resources that return 4xx response codes. As per [Internal: b/18948961]. * Allow loaded chunks to feed timestamps back to the tracker, to fix any drifting in live playlists. * Dinamically choose the HlsUrl that points to the playlist that defines the live window. Other features: -------------- The tracker can also be used for keeping track of discontinuities. In the case of single variant playlists, this is particularly useful. Might also work if there is a that the live playlists are aligned (but this is more like working around the issue, than actually solving it). For this, see [Internal: b/32166568] and [Internal: b/28985320]. Issue:#87 ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=138054302 --- .../playlist/HlsMediaPlaylistParserTest.java | 2 +- .../exoplayer2/source/hls/HlsChunkSource.java | 241 ++--------- .../exoplayer2/source/hls/HlsMediaPeriod.java | 119 ++---- .../exoplayer2/source/hls/HlsMediaSource.java | 42 +- .../source/hls/HlsSampleStreamWrapper.java | 22 +- .../hls/playlist/HlsMasterPlaylist.java | 6 + .../source/hls/playlist/HlsMediaPlaylist.java | 44 +- .../hls/playlist/HlsPlaylistParser.java | 9 +- .../hls/playlist/HlsPlaylistTracker.java | 403 ++++++++++++++++++ 9 files changed, 557 insertions(+), 331 deletions(-) create mode 100644 library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java index cd44a283a24..ac99760d875 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java @@ -74,7 +74,7 @@ public void testParseMediaPlaylist() { assertEquals(2679, mediaPlaylist.mediaSequence); assertEquals(8, mediaPlaylist.targetDurationSecs); assertEquals(3, mediaPlaylist.version); - assertEquals(false, mediaPlaylist.live); + assertEquals(true, mediaPlaylist.hasEndTag); List segments = mediaPlaylist.segments; assertNotNull(segments); assertEquals(5, segments.size()); diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java index 7ef16f361d5..351583a3348 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java @@ -36,7 +36,7 @@ import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; import com.google.android.exoplayer2.trackselection.BaseTrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.upstream.DataSource; @@ -44,7 +44,6 @@ import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.UriUtil; import com.google.android.exoplayer2.util.Util; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.math.BigInteger; import java.util.Arrays; @@ -65,7 +64,7 @@ public HlsChunkHolder() { } /** - * The chunk. + * The chunk to be loaded next. */ public Chunk chunk; @@ -75,9 +74,9 @@ public HlsChunkHolder() { public boolean endOfStream; /** - * Milliseconds to wait before retrying. + * Indicates that the chunk source is waiting for the referred playlist to be refreshed. */ - public long retryInMs; + public HlsMasterPlaylist.HlsUrl playlist; /** * Clears the holder. @@ -85,20 +84,11 @@ public HlsChunkHolder() { public void clear() { chunk = null; endOfStream = false; - retryInMs = C.TIME_UNSET; + playlist = null; } } - /** - * The default time for which a media playlist should be blacklisted. - */ - public static final long DEFAULT_PLAYLIST_BLACKLIST_MS = 60000; - /** - * Subtracted value to lookup position when switching between variants in live streams to avoid - * gaps in playback in case playlist drift apart. - */ - private static final double LIVE_VARIANT_SWITCH_SAFETY_EXTRA_SECS = 2.0; private static final String AAC_FILE_EXTENSION = ".aac"; private static final String AC3_FILE_EXTENSION = ".ac3"; private static final String EC3_FILE_EXTENSION = ".ec3"; @@ -107,18 +97,13 @@ public void clear() { private static final String VTT_FILE_EXTENSION = ".vtt"; private static final String WEBVTT_FILE_EXTENSION = ".webvtt"; - private final String baseUri; private final DataSource dataSource; - private final HlsPlaylistParser playlistParser; private final TimestampAdjusterProvider timestampAdjusterProvider; private final HlsMasterPlaylist.HlsUrl[] variants; - private final HlsMediaPlaylist[] variantPlaylists; + private final HlsPlaylistTracker playlistTracker; private final TrackGroup trackGroup; - private final long[] variantLastPlaylistLoadTimesMs; private byte[] scratchSpace; - private boolean live; - private long durationUs; private IOException fatalError; private HlsInitializationChunk lastLoadedInitializationChunk; @@ -133,22 +118,19 @@ public void clear() { private TrackSelection trackSelection; /** - * @param baseUri The playlist's base uri. + * @param playlistTracker The {@link HlsPlaylistTracker} from which to obtain media playlists. * @param variants The available variants. * @param dataSource A {@link DataSource} suitable for loading the media data. * @param timestampAdjusterProvider A provider of {@link TimestampAdjuster} instances. If * multiple {@link HlsChunkSource}s are used for a single playback, they should all share the * same provider. */ - public HlsChunkSource(String baseUri, HlsMasterPlaylist.HlsUrl[] variants, DataSource dataSource, - TimestampAdjusterProvider timestampAdjusterProvider) { - this.baseUri = baseUri; + public HlsChunkSource(HlsPlaylistTracker playlistTracker, HlsMasterPlaylist.HlsUrl[] variants, + DataSource dataSource, TimestampAdjusterProvider timestampAdjusterProvider) { + this.playlistTracker = playlistTracker; this.variants = variants; this.dataSource = dataSource; this.timestampAdjusterProvider = timestampAdjusterProvider; - playlistParser = new HlsPlaylistParser(); - variantPlaylists = new HlsMediaPlaylist[variants.length]; - variantLastPlaylistLoadTimesMs = new long[variants.length]; Format[] variantFormats = new Format[variants.length]; int[] initialTrackSelection = new int[variants.length]; @@ -172,20 +154,6 @@ public void maybeThrowError() throws IOException { } } - /** - * Returns whether this is a live playback. - */ - public boolean isLive() { - return live; - } - - /** - * Returns the duration of the source, or {@link C#TIME_UNSET} if the duration is unknown. - */ - public long getDurationUs() { - return durationUs; - } - /** * Returns the track group exposed by the source. */ @@ -214,8 +182,8 @@ public void reset() { *

* If a chunk is available then {@link HlsChunkHolder#chunk} is set. If the end of the stream has * been reached then {@link HlsChunkHolder#endOfStream} is set. If a chunk is not available but - * the end of the stream has not been reached, {@link HlsChunkHolder#retryInMs} is set to contain - * the amount of milliseconds to wait before retrying. + * the end of the stream has not been reached, {@link HlsChunkHolder#playlist} is set to + * contain the {@link HlsMasterPlaylist.HlsUrl} that refers to the playlist that needs refreshing. * * @param previous The most recently loaded media chunk. * @param playbackPositionUs The current playback position. If {@code previous} is null then this @@ -226,7 +194,6 @@ public void reset() { public void getNextChunk(HlsMediaChunk previous, long playbackPositionUs, HlsChunkHolder out) { int oldVariantIndex = previous == null ? C.INDEX_UNSET : trackGroup.indexOf(previous.trackFormat); - // Use start time of the previous chunk rather than its end time because switching format will // require downloading overlapping segments. long bufferedDurationUs = previous == null ? 0 @@ -235,67 +202,44 @@ public void getNextChunk(HlsMediaChunk previous, long playbackPositionUs, HlsChu int newVariantIndex = trackSelection.getSelectedIndexInTrackGroup(); boolean switchingVariant = oldVariantIndex != newVariantIndex; - HlsMediaPlaylist mediaPlaylist = variantPlaylists[newVariantIndex]; + HlsMediaPlaylist mediaPlaylist = playlistTracker.getPlaylistSnapshot(variants[newVariantIndex]); if (mediaPlaylist == null) { - // We don't have the media playlist for the next variant. Request it now. - out.chunk = newMediaPlaylistChunk(newVariantIndex, trackSelection.getSelectionReason(), - trackSelection.getSelectionData()); + out.playlist = variants[newVariantIndex]; + // Retry when playlist is refreshed. return; } int chunkMediaSequence; - if (live) { - if (previous == null) { - // When playing a live stream, the starting chunk will be the third counting from the live - // edge. - chunkMediaSequence = Math.max(0, mediaPlaylist.segments.size() - 3) - + mediaPlaylist.mediaSequence; - // TODO: Bring this back for live window seeking. - // chunkMediaSequence = Util.binarySearchFloor(mediaPlaylist.segments, playbackPositionUs, - // true, true) + mediaPlaylist.mediaSequence; + if (previous == null || switchingVariant) { + long targetPositionUs = previous == null ? playbackPositionUs : previous.startTimeUs; + if (targetPositionUs > mediaPlaylist.getEndTimeUs()) { + // If the playlist is too old to contain the chunk, we need to refresh it. + chunkMediaSequence = mediaPlaylist.mediaSequence + mediaPlaylist.segments.size(); } else { - chunkMediaSequence = getLiveNextChunkSequenceNumber(previous.chunkIndex, oldVariantIndex, - newVariantIndex); - if (chunkMediaSequence < mediaPlaylist.mediaSequence) { + chunkMediaSequence = Util.binarySearchFloor(mediaPlaylist.segments, targetPositionUs, true, + !playlistTracker.isLive() || previous == null) + mediaPlaylist.mediaSequence; + if (chunkMediaSequence < mediaPlaylist.mediaSequence && previous != null) { // We try getting the next chunk without adapting in case that's the reason for falling // behind the live window. newVariantIndex = oldVariantIndex; - mediaPlaylist = variantPlaylists[newVariantIndex]; - chunkMediaSequence = getLiveNextChunkSequenceNumber(previous.chunkIndex, oldVariantIndex, - newVariantIndex); - if (chunkMediaSequence < mediaPlaylist.mediaSequence) { - fatalError = new BehindLiveWindowException(); - return; - } + mediaPlaylist = playlistTracker.getPlaylistSnapshot(variants[newVariantIndex]); + chunkMediaSequence = previous.getNextChunkIndex(); } } } else { - // Not live. - if (previous == null) { - chunkMediaSequence = Util.binarySearchFloor(mediaPlaylist.segments, playbackPositionUs, - true, true) + mediaPlaylist.mediaSequence; - } else if (switchingVariant) { - chunkMediaSequence = Util.binarySearchFloor(mediaPlaylist.segments, - previous.startTimeUs, true, true) + mediaPlaylist.mediaSequence; - } else { - chunkMediaSequence = previous.getNextChunkIndex(); - } + chunkMediaSequence = previous.getNextChunkIndex(); + } + if (chunkMediaSequence < mediaPlaylist.mediaSequence) { + fatalError = new BehindLiveWindowException(); + return; } int chunkIndex = chunkMediaSequence - mediaPlaylist.mediaSequence; if (chunkIndex >= mediaPlaylist.segments.size()) { - if (!mediaPlaylist.live) { + if (mediaPlaylist.hasEndTag) { out.endOfStream = true; } else /* Live */ { - long msToRerequestLiveMediaPlaylist = msToRerequestLiveMediaPlaylist(newVariantIndex); - if (msToRerequestLiveMediaPlaylist <= 0) { - out.chunk = newMediaPlaylistChunk(newVariantIndex, - trackSelection.getSelectionReason(), trackSelection.getSelectionData()); - } else { - // 10 milliseconds are added to the wait to make sure the playlist is refreshed when - // getNextChunk() is called. - out.retryInMs = msToRerequestLiveMediaPlaylist + 10; - } + out.playlist = variants[newVariantIndex]; } return; } @@ -319,17 +263,9 @@ public void getNextChunk(HlsMediaChunk previous, long playbackPositionUs, HlsChu } // Compute start and end times, and the sequence number of the next chunk. - long startTimeUs; - if (live) { - if (previous == null) { - startTimeUs = 0; - } else if (switchingVariant) { - startTimeUs = previous.getAdjustedStartTimeUs(); - } else { - startTimeUs = previous.getAdjustedEndTimeUs(); - } - } else /* Not live */ { - startTimeUs = segment.startTimeUs; + long startTimeUs = segment.startTimeUs; + if (previous != null && !switchingVariant) { + startTimeUs = previous.getAdjustedEndTimeUs(); } long endTimeUs = startTimeUs + (long) (segment.durationSecs * C.MICROS_PER_SECOND); Format format = variants[newVariantIndex].format; @@ -424,52 +360,6 @@ public void getNextChunk(HlsMediaChunk previous, long playbackPositionUs, HlsChu encryptionKey, encryptionIv); } - /** - * Returns the media sequence number of a chunk in a new variant for a live stream variant switch. - * - * @param previousChunkIndex The index of the last chunk in the old variant. - * @param oldVariantIndex The index of the old variant. - * @param newVariantIndex The index of the new variant. - * @return Media sequence number of the chunk to switch to in a live stream in the variant that - * corresponds to the given {@code newVariantIndex}. - */ - private int getLiveNextChunkSequenceNumber(int previousChunkIndex, int oldVariantIndex, - int newVariantIndex) { - if (oldVariantIndex == newVariantIndex) { - return previousChunkIndex + 1; - } - HlsMediaPlaylist oldMediaPlaylist = variantPlaylists[oldVariantIndex]; - HlsMediaPlaylist newMediaPlaylist = variantPlaylists[newVariantIndex]; - if (previousChunkIndex < oldMediaPlaylist.mediaSequence) { - // We have fallen behind the live window. - return newMediaPlaylist.mediaSequence - 1; - } - double offsetToLiveInstantSecs = 0; - for (int i = previousChunkIndex - oldMediaPlaylist.mediaSequence; - i < oldMediaPlaylist.segments.size(); i++) { - offsetToLiveInstantSecs += oldMediaPlaylist.segments.get(i).durationSecs; - } - long currentTimeMs = SystemClock.elapsedRealtime(); - offsetToLiveInstantSecs += - (double) (currentTimeMs - variantLastPlaylistLoadTimesMs[oldVariantIndex]) / 1000; - offsetToLiveInstantSecs += LIVE_VARIANT_SWITCH_SAFETY_EXTRA_SECS; - offsetToLiveInstantSecs -= - (double) (currentTimeMs - variantLastPlaylistLoadTimesMs[newVariantIndex]) / 1000; - if (offsetToLiveInstantSecs < 0) { - // The instant we are looking for is not contained in the playlist, we need it to be - // refreshed. - return newMediaPlaylist.mediaSequence + newMediaPlaylist.segments.size() + 1; - } - for (int i = newMediaPlaylist.segments.size() - 1; i >= 0; i--) { - offsetToLiveInstantSecs -= newMediaPlaylist.segments.get(i).durationSecs; - if (offsetToLiveInstantSecs < 0) { - return newMediaPlaylist.mediaSequence + i; - } - } - // We have fallen behind the live window. - return newMediaPlaylist.mediaSequence - 1; - } - /** * Called when the {@link HlsSampleStreamWrapper} has finished loading a chunk obtained from this * source. @@ -479,10 +369,6 @@ private int getLiveNextChunkSequenceNumber(int previousChunkIndex, int oldVarian public void onChunkLoadCompleted(Chunk chunk) { if (chunk instanceof HlsInitializationChunk) { lastLoadedInitializationChunk = (HlsInitializationChunk) chunk; - } else if (chunk instanceof MediaPlaylistChunk) { - MediaPlaylistChunk mediaPlaylistChunk = (MediaPlaylistChunk) chunk; - scratchSpace = mediaPlaylistChunk.getDataHolder(); - setMediaPlaylist(mediaPlaylistChunk.variantIndex, mediaPlaylistChunk.getResult()); } else if (chunk instanceof EncryptionKeyChunk) { EncryptionKeyChunk encryptionKeyChunk = (EncryptionKeyChunk) chunk; scratchSpace = encryptionKeyChunk.getDataHolder(); @@ -519,24 +405,6 @@ private HlsInitializationChunk buildInitializationChunk(HlsMediaPlaylist mediaPl format); } - private long msToRerequestLiveMediaPlaylist(int variantIndex) { - HlsMediaPlaylist mediaPlaylist = variantPlaylists[variantIndex]; - long timeSinceLastMediaPlaylistLoadMs = - SystemClock.elapsedRealtime() - variantLastPlaylistLoadTimesMs[variantIndex]; - // Don't re-request media playlist more often than one-half of the target duration. - return (mediaPlaylist.targetDurationSecs * 1000) / 2 - timeSinceLastMediaPlaylistLoadMs; - } - - private MediaPlaylistChunk newMediaPlaylistChunk(int variantIndex, int trackSelectionReason, - Object trackSelectionData) { - Uri mediaPlaylistUri = UriUtil.resolveToUri(baseUri, variants[variantIndex].url); - DataSpec dataSpec = new DataSpec(mediaPlaylistUri, 0, C.LENGTH_UNSET, null, - DataSpec.FLAG_ALLOW_GZIP); - return new MediaPlaylistChunk(dataSource, dataSpec, variants[variantIndex].format, - trackSelectionReason, trackSelectionData, scratchSpace, playlistParser, variantIndex, - mediaPlaylistUri); - } - private EncryptionKeyChunk newEncryptionKeyChunk(Uri keyUri, String iv, int variantIndex, int trackSelectionReason, Object trackSelectionData) { DataSpec dataSpec = new DataSpec(keyUri, 0, C.LENGTH_UNSET, null, DataSpec.FLAG_ALLOW_GZIP); @@ -571,13 +439,6 @@ private void clearEncryptionData() { encryptionIv = null; } - private void setMediaPlaylist(int variantIndex, HlsMediaPlaylist mediaPlaylist) { - variantLastPlaylistLoadTimesMs[variantIndex] = SystemClock.elapsedRealtime(); - variantPlaylists[variantIndex] = mediaPlaylist; - live |= mediaPlaylist.live; - durationUs = live ? C.TIME_UNSET : mediaPlaylist.durationUs; - } - // Private classes. /** @@ -626,38 +487,6 @@ public Object getSelectionData() { } - private static final class MediaPlaylistChunk extends DataChunk { - - public final int variantIndex; - - private final HlsPlaylistParser playlistParser; - private final Uri playlistUri; - - private HlsMediaPlaylist result; - - public MediaPlaylistChunk(DataSource dataSource, DataSpec dataSpec, Format trackFormat, - int trackSelectionReason, Object trackSelectionData, byte[] scratchSpace, - HlsPlaylistParser playlistParser, int variantIndex, - Uri playlistUri) { - super(dataSource, dataSpec, C.DATA_TYPE_MANIFEST, trackFormat, trackSelectionReason, - trackSelectionData, scratchSpace); - this.variantIndex = variantIndex; - this.playlistParser = playlistParser; - this.playlistUri = playlistUri; - } - - @Override - protected void consume(byte[] data, int limit) throws IOException { - result = (HlsMediaPlaylist) playlistParser.parse(playlistUri, - new ByteArrayInputStream(data, 0, limit)); - } - - public HlsMediaPlaylist getResult() { - return result; - } - - } - private static final class EncryptionKeyChunk extends DataChunk { public final String iv; diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java index 598fa9b281b..0c27b3df7d8 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java @@ -15,30 +15,22 @@ */ package com.google.android.exoplayer2.source.hls; -import android.net.Uri; import android.os.Handler; import android.text.TextUtils; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.ParserException; -import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.AdaptiveMediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.CompositeSequenceableLoader; import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.SampleStream; -import com.google.android.exoplayer2.source.SinglePeriodTimeline; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.Loader; -import com.google.android.exoplayer2.upstream.ParsingLoadable; import com.google.android.exoplayer2.util.Assertions; import java.io.IOException; import java.util.ArrayList; @@ -48,55 +40,41 @@ /** * A {@link MediaPeriod} that loads an HLS stream. */ -/* package */ final class HlsMediaPeriod implements MediaPeriod, - Loader.Callback>, HlsSampleStreamWrapper.Callback { +public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper.Callback, + HlsPlaylistTracker.PlaylistRefreshCallback { - private final Uri manifestUri; + private final HlsPlaylistTracker playlistTracker; private final DataSource.Factory dataSourceFactory; private final int minLoadableRetryCount; private final EventDispatcher eventDispatcher; - private final MediaSource.Listener sourceListener; private final Allocator allocator; private final IdentityHashMap streamWrapperIndices; private final TimestampAdjusterProvider timestampAdjusterProvider; - private final HlsPlaylistParser manifestParser; private final Handler continueLoadingHandler; private final Loader manifestFetcher; private final long preparePositionUs; - private final Runnable continueLoadingRunnable; private Callback callback; private int pendingPrepareCount; - private HlsPlaylist playlist; private boolean seenFirstTrackSelection; - private boolean isLive; private TrackGroupArray trackGroups; private HlsSampleStreamWrapper[] sampleStreamWrappers; private HlsSampleStreamWrapper[] enabledSampleStreamWrappers; private CompositeSequenceableLoader sequenceableLoader; - public HlsMediaPeriod(Uri manifestUri, DataSource.Factory dataSourceFactory, - int minLoadableRetryCount, EventDispatcher eventDispatcher, - MediaSource.Listener sourceListener, Allocator allocator, + public HlsMediaPeriod(HlsPlaylistTracker playlistTracker, DataSource.Factory dataSourceFactory, + int minLoadableRetryCount, EventDispatcher eventDispatcher, Allocator allocator, long positionUs) { - this.manifestUri = manifestUri; + this.playlistTracker = playlistTracker; this.dataSourceFactory = dataSourceFactory; this.minLoadableRetryCount = minLoadableRetryCount; this.eventDispatcher = eventDispatcher; - this.sourceListener = sourceListener; this.allocator = allocator; streamWrapperIndices = new IdentityHashMap<>(); timestampAdjusterProvider = new TimestampAdjusterProvider(); - manifestParser = new HlsPlaylistParser(); continueLoadingHandler = new Handler(); manifestFetcher = new Loader("Loader:ManifestFetcher"); preparePositionUs = positionUs; - continueLoadingRunnable = new Runnable() { - @Override - public void run() { - callback.onContinueLoadingRequested(HlsMediaPeriod.this); - } - }; } public void release() { @@ -112,10 +90,7 @@ public void release() { @Override public void prepare(Callback callback) { this.callback = callback; - ParsingLoadable loadable = new ParsingLoadable<>( - dataSourceFactory.createDataSource(), manifestUri, C.DATA_TYPE_MANIFEST, manifestParser); - long elapsedRealtimeMs = manifestFetcher.startLoading(loadable, this, minLoadableRetryCount); - eventDispatcher.loadStarted(loadable.dataSpec, loadable.type, elapsedRealtimeMs); + buildAndPrepareSampleStreamWrappers(); } @Override @@ -234,8 +209,6 @@ public long getBufferedPositionUs() { @Override public long seekToUs(long positionUs) { - // Treat all seeks into non-seekable media as being to t=0. - positionUs = isLive ? 0 : positionUs; timestampAdjusterProvider.reset(); for (HlsSampleStreamWrapper sampleStreamWrapper : enabledSampleStreamWrappers) { sampleStreamWrapper.seekTo(positionUs); @@ -243,33 +216,6 @@ public long seekToUs(long positionUs) { return positionUs; } - // Loader.Callback implementation. - - @Override - public void onLoadCompleted(ParsingLoadable loadable, long elapsedRealtimeMs, - long loadDurationMs) { - eventDispatcher.loadCompleted(loadable.dataSpec, loadable.type, elapsedRealtimeMs, - loadDurationMs, loadable.bytesLoaded()); - playlist = loadable.getResult(); - buildAndPrepareSampleStreamWrappers(); - } - - @Override - public void onLoadCanceled(ParsingLoadable loadable, long elapsedRealtimeMs, - long loadDurationMs, boolean released) { - eventDispatcher.loadCompleted(loadable.dataSpec, loadable.type, elapsedRealtimeMs, - loadDurationMs, loadable.bytesLoaded()); - } - - @Override - public int onLoadError(ParsingLoadable loadable, long elapsedRealtimeMs, - long loadDurationMs, IOException error) { - boolean isFatal = error instanceof ParserException; - eventDispatcher.loadError(loadable.dataSpec, loadable.type, elapsedRealtimeMs, loadDurationMs, - loadable.bytesLoaded(), error, isFatal); - return isFatal ? Loader.DONT_RETRY_FATAL : Loader.RETRY; - } - // HlsSampleStreamWrapper.Callback implementation. @Override @@ -278,10 +224,6 @@ public void onPrepared() { return; } - // The wrapper at index 0 is the one of type TRACK_TYPE_DEFAULT. - long durationUs = sampleStreamWrappers[0].getDurationUs(); - isLive = sampleStreamWrappers[0].isLive(); - int totalTrackGroupCount = 0; for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) { totalTrackGroupCount += sampleStreamWrapper.getTrackGroups().length; @@ -296,16 +238,11 @@ public void onPrepared() { } trackGroups = new TrackGroupArray(trackGroupArray); callback.onPrepared(this); - - // TODO[playlists]: Calculate the window. - Timeline timeline = new SinglePeriodTimeline(durationUs, durationUs, 0, 0, !isLive, isLive); - sourceListener.onSourceInfoRefreshed(timeline, playlist); } @Override - public void onContinueLoadingRequiredInMs(final HlsSampleStreamWrapper sampleStreamWrapper, - long delayMs) { - continueLoadingHandler.postDelayed(continueLoadingRunnable, delayMs); + public void onPlaylistRefreshRequired(HlsMasterPlaylist.HlsUrl url) { + playlistTracker.refreshPlaylist(url, this); } @Override @@ -317,22 +254,24 @@ public void onContinueLoadingRequested(HlsSampleStreamWrapper sampleStreamWrappe callback.onContinueLoadingRequested(this); } - // Internal methods. + // PlaylistListener implementation. - private void buildAndPrepareSampleStreamWrappers() { - String baseUri = playlist.baseUri; - if (playlist instanceof HlsMediaPlaylist) { - HlsMasterPlaylist.HlsUrl[] variants = new HlsMasterPlaylist.HlsUrl[] { - HlsMasterPlaylist.HlsUrl.createMediaPlaylistHlsUrl(playlist.baseUri)}; - sampleStreamWrappers = new HlsSampleStreamWrapper[] { - buildSampleStreamWrapper(C.TRACK_TYPE_DEFAULT, baseUri, variants, null, null)}; - pendingPrepareCount = 1; - sampleStreamWrappers[0].continuePreparing(); - return; + @Override + public void onPlaylistChanged() { + if (trackGroups != null) { + callback.onContinueLoadingRequested(this); + } else { + // Some of the wrappers were waiting for their media playlist to prepare. + for (HlsSampleStreamWrapper wrapper : sampleStreamWrappers) { + wrapper.continuePreparing(); + } } + } - HlsMasterPlaylist masterPlaylist = (HlsMasterPlaylist) playlist; + // Internal methods. + private void buildAndPrepareSampleStreamWrappers() { + HlsMasterPlaylist masterPlaylist = playlistTracker.getMasterPlaylist(); // Build the default stream wrapper. List selectedVariants = new ArrayList<>(masterPlaylist.variants); ArrayList definiteVideoVariants = new ArrayList<>(); @@ -367,7 +306,7 @@ private void buildAndPrepareSampleStreamWrappers() { HlsMasterPlaylist.HlsUrl[] variants = new HlsMasterPlaylist.HlsUrl[selectedVariants.size()]; selectedVariants.toArray(variants); HlsSampleStreamWrapper sampleStreamWrapper = buildSampleStreamWrapper(C.TRACK_TYPE_DEFAULT, - baseUri, variants, masterPlaylist.muxedAudioFormat, masterPlaylist.muxedCaptionFormat); + variants, masterPlaylist.muxedAudioFormat, masterPlaylist.muxedCaptionFormat); sampleStreamWrappers[currentWrapperIndex++] = sampleStreamWrapper; sampleStreamWrapper.continuePreparing(); } @@ -375,7 +314,7 @@ private void buildAndPrepareSampleStreamWrappers() { // Build audio stream wrappers. for (int i = 0; i < audioVariants.size(); i++) { HlsSampleStreamWrapper sampleStreamWrapper = buildSampleStreamWrapper(C.TRACK_TYPE_AUDIO, - baseUri, new HlsMasterPlaylist.HlsUrl[] {audioVariants.get(i)}, null, null); + new HlsMasterPlaylist.HlsUrl[] {audioVariants.get(i)}, null, null); sampleStreamWrappers[currentWrapperIndex++] = sampleStreamWrapper; sampleStreamWrapper.continuePreparing(); } @@ -384,16 +323,16 @@ private void buildAndPrepareSampleStreamWrappers() { for (int i = 0; i < subtitleVariants.size(); i++) { HlsMasterPlaylist.HlsUrl url = subtitleVariants.get(i); HlsSampleStreamWrapper sampleStreamWrapper = buildSampleStreamWrapper(C.TRACK_TYPE_TEXT, - baseUri, new HlsMasterPlaylist.HlsUrl[] {url}, null, null); + new HlsMasterPlaylist.HlsUrl[] {url}, null, null); sampleStreamWrapper.prepareSingleTrack(url.format); sampleStreamWrappers[currentWrapperIndex++] = sampleStreamWrapper; } } - private HlsSampleStreamWrapper buildSampleStreamWrapper(int trackType, String baseUri, + private HlsSampleStreamWrapper buildSampleStreamWrapper(int trackType, HlsMasterPlaylist.HlsUrl[] variants, Format muxedAudioFormat, Format muxedCaptionFormat) { DataSource dataSource = dataSourceFactory.createDataSource(); - HlsChunkSource defaultChunkSource = new HlsChunkSource(baseUri, variants, dataSource, + HlsChunkSource defaultChunkSource = new HlsChunkSource(playlistTracker, variants, dataSource, timestampAdjusterProvider); return new HlsSampleStreamWrapper(trackType, this, defaultChunkSource, allocator, preparePositionUs, muxedAudioFormat, muxedCaptionFormat, minLoadableRetryCount, diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java index b8b6c033b37..6fd82df3166 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java @@ -23,21 +23,26 @@ import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.SinglePeriodTimeline; +import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.util.List; /** * An HLS {@link MediaSource}. */ -public final class HlsMediaSource implements MediaSource { +public final class HlsMediaSource implements MediaSource, + HlsPlaylistTracker.PrimaryPlaylistListener { /** * The default minimum number of times to retry loading data prior to failing. */ public static final int DEFAULT_MIN_LOADABLE_RETRY_COUNT = 3; - private final Uri manifestUri; + private final HlsPlaylistTracker playlistTracker; private final DataSource.Factory dataSourceFactory; private final int minLoadableRetryCount; private final EventDispatcher eventDispatcher; @@ -53,29 +58,29 @@ public HlsMediaSource(Uri manifestUri, DataSource.Factory dataSourceFactory, Han public HlsMediaSource(Uri manifestUri, DataSource.Factory dataSourceFactory, int minLoadableRetryCount, Handler eventHandler, AdaptiveMediaSourceEventListener eventListener) { - this.manifestUri = manifestUri; this.dataSourceFactory = dataSourceFactory; this.minLoadableRetryCount = minLoadableRetryCount; eventDispatcher = new EventDispatcher(eventHandler, eventListener); + playlistTracker = new HlsPlaylistTracker(manifestUri, dataSourceFactory, eventDispatcher, + minLoadableRetryCount, this); } @Override public void prepareSource(MediaSource.Listener listener) { sourceListener = listener; - // TODO: Defer until the playlist has been loaded. - listener.onSourceInfoRefreshed(new SinglePeriodTimeline(C.TIME_UNSET, false), null); + playlistTracker.start(); } @Override - public void maybeThrowSourceInfoRefreshError() { - // Do nothing. + public void maybeThrowSourceInfoRefreshError() throws IOException { + playlistTracker.maybeThrowPrimaryPlaylistRefreshError(); } @Override public MediaPeriod createPeriod(int index, Allocator allocator, long positionUs) { Assertions.checkArgument(index == 0); - return new HlsMediaPeriod(manifestUri, dataSourceFactory, minLoadableRetryCount, - eventDispatcher, sourceListener, allocator, positionUs); + return new HlsMediaPeriod(playlistTracker, dataSourceFactory, minLoadableRetryCount, + eventDispatcher, allocator, positionUs); } @Override @@ -85,7 +90,26 @@ public void releasePeriod(MediaPeriod mediaPeriod) { @Override public void releaseSource() { + playlistTracker.release(); sourceListener = null; } + @Override + public void onPrimaryPlaylistRefreshed(HlsMediaPlaylist playlist) { + SinglePeriodTimeline timeline; + if (playlistTracker.isLive()) { + // TODO: fix windowPositionInPeriodUs when playlist is empty. + long windowPositionInPeriodUs = playlist.getStartTimeUs(); + List segments = playlist.segments; + long windowDefaultStartPositionUs = segments.isEmpty() ? 0 + : segments.get(Math.max(0, segments.size() - 3)).startTimeUs - windowPositionInPeriodUs; + timeline = new SinglePeriodTimeline(C.TIME_UNSET, playlist.durationUs, + windowPositionInPeriodUs, windowDefaultStartPositionUs, true, !playlist.hasEndTag); + } else /* not live */ { + timeline = new SinglePeriodTimeline(playlist.durationUs, playlist.durationUs, 0, 0, true, + false); + } + sourceListener.onSourceInfoRefreshed(timeline, playlist); + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index fe756da0ef5..c491dc97606 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -32,6 +32,7 @@ import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.chunk.Chunk; +import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.Loader; @@ -58,10 +59,10 @@ public interface Callback extends SequenceableLoader.Callback variants, List aud this.muxedCaptionFormat = muxedCaptionFormat; } + public static HlsMasterPlaylist createSingleVariantMasterPlaylist(String variantUri) { + List variant = Collections.singletonList(HlsUrl.createMediaPlaylistHlsUrl(variantUri)); + List emptyList = Collections.emptyList(); + return new HlsMasterPlaylist(null, variant, emptyList, emptyList, null, null); + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java index 5aa0c8a3d86..177546d3012 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java @@ -16,6 +16,8 @@ package com.google.android.exoplayer2.source.hls.playlist; import com.google.android.exoplayer2.C; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** @@ -60,6 +62,12 @@ public Segment(String uri, double durationSecs, int discontinuitySequenceNumber, public int compareTo(Long startTimeUs) { return this.startTimeUs > startTimeUs ? 1 : (this.startTimeUs < startTimeUs ? -1 : 0); } + + public Segment copyWithStartTimeUs(long startTimeUs) { + return new Segment(url, durationSecs, discontinuitySequenceNumber, startTimeUs, isEncrypted, + encryptionKeyUri, encryptionIV, byterangeOffset, byterangeLength); + } + } public static final String ENCRYPTION_METHOD_NONE = "NONE"; @@ -70,25 +78,51 @@ public int compareTo(Long startTimeUs) { public final int version; public final Segment initializationSegment; public final List segments; - public final boolean live; + public final boolean hasEndTag; public final long durationUs; public HlsMediaPlaylist(String baseUri, int mediaSequence, int targetDurationSecs, int version, - boolean live, Segment initializationSegment, List segments) { + boolean hasEndTag, Segment initializationSegment, List segments) { super(baseUri, HlsPlaylist.TYPE_MEDIA); this.mediaSequence = mediaSequence; this.targetDurationSecs = targetDurationSecs; this.version = version; - this.live = live; + this.hasEndTag = hasEndTag; this.initializationSegment = initializationSegment; - this.segments = segments; + this.segments = Collections.unmodifiableList(segments); if (!segments.isEmpty()) { + Segment first = segments.get(0); Segment last = segments.get(segments.size() - 1); - durationUs = last.startTimeUs + (long) (last.durationSecs * C.MICROS_PER_SECOND); + durationUs = last.startTimeUs + (long) (last.durationSecs * C.MICROS_PER_SECOND) + - first.startTimeUs; } else { durationUs = 0; } } + public long getStartTimeUs() { + return segments.isEmpty() ? 0 : segments.get(0).startTimeUs; + } + + public long getEndTimeUs() { + return getStartTimeUs() + durationUs; + } + + public HlsMediaPlaylist copyWithStartTimeUs(long newStartTimeUs) { + long startTimeOffsetUs = newStartTimeUs - getStartTimeUs(); + int segmentsSize = segments.size(); + List newSegments = new ArrayList<>(segmentsSize); + for (int i = 0; i < segmentsSize; i++) { + Segment segment = segments.get(i); + newSegments.add(segment.copyWithStartTimeUs(segment.startTimeUs + startTimeOffsetUs)); + } + return copyWithSegments(newSegments); + } + + public HlsMediaPlaylist copyWithSegments(List segments) { + return new HlsMediaPlaylist(baseUri, mediaSequence, targetDurationSecs, version, hasEndTag, + initializationSegment, segments); + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java index 21cc75765fd..76606fad173 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java @@ -27,7 +27,6 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.util.ArrayList; -import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Queue; @@ -214,7 +213,7 @@ private static HlsMediaPlaylist parseMediaPlaylist(LineIterator iterator, String int mediaSequence = 0; int targetDurationSecs = 0; int version = 1; // Default version == 1. - boolean live = true; + boolean hasEndTag = false; Segment initializationSegment = null; List segments = new ArrayList<>(); @@ -298,11 +297,11 @@ private static HlsMediaPlaylist parseMediaPlaylist(LineIterator iterator, String } segmentByteRangeLength = C.LENGTH_UNSET; } else if (line.equals(TAG_ENDLIST)) { - live = false; + hasEndTag = true; } } - return new HlsMediaPlaylist(baseUri, mediaSequence, targetDurationSecs, version, live, - initializationSegment, Collections.unmodifiableList(segments)); + return new HlsMediaPlaylist(baseUri, mediaSequence, targetDurationSecs, version, hasEndTag, + initializationSegment, segments); } private static String parseStringAttr(String line, Pattern pattern) throws ParserException { diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java new file mode 100644 index 00000000000..b8c1d96a6bc --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java @@ -0,0 +1,403 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.hls.playlist; + +import android.net.Uri; +import android.os.Handler; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.source.AdaptiveMediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.HlsUrl; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.Loader; +import com.google.android.exoplayer2.upstream.ParsingLoadable; +import com.google.android.exoplayer2.util.UriUtil; +import java.io.IOException; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; + +/** + * Tracks playlists linked to a provided playlist url. The provided url might reference an HLS + * master playlist or a media playlist. + */ +public final class HlsPlaylistTracker implements Loader.Callback> { + + /** + * Listener for primary playlist changes. + */ + public interface PrimaryPlaylistListener { + + /** + * Called when the primary playlist changes. + * + * @param mediaPlaylist The primary playlist new snapshot. + */ + void onPrimaryPlaylistRefreshed(HlsMediaPlaylist mediaPlaylist); + + } + + /** + * Called when the playlist changes. + */ + public interface PlaylistRefreshCallback { + + /** + * Called when the target playlist changes. + */ + void onPlaylistChanged(); + + } + + /** + * Period for refreshing playlists. + */ + private static final long PLAYLIST_REFRESH_PERIOD_MS = 5000; + + private final Uri initialPlaylistUri; + private final DataSource.Factory dataSourceFactory; + private final HlsPlaylistParser playlistParser; + private final int minRetryCount; + private final IdentityHashMap playlistBundles; + private final Handler playlistRefreshHandler; + private final PrimaryPlaylistListener primaryPlaylistListener; + private final Loader initialPlaylistLoader; + private final EventDispatcher eventDispatcher; + + private HlsMasterPlaylist masterPlaylist; + private HlsUrl primaryHlsUrl; + private boolean isLive; + + /** + * @param initialPlaylistUri Uri for the initial playlist of the stream. Can refer a media + * playlist or a master playlist. + * @param dataSourceFactory A factory for {@link DataSource} instances. + * @param eventDispatcher A dispatcher to notify of events. + * @param minRetryCount The minimum number of times the load must be retried before blacklisting a + * playlist. + * @param primaryPlaylistListener A callback for the primary playlist change events. + */ + public HlsPlaylistTracker(Uri initialPlaylistUri, DataSource.Factory dataSourceFactory, + EventDispatcher eventDispatcher, int minRetryCount, + PrimaryPlaylistListener primaryPlaylistListener) { + this.initialPlaylistUri = initialPlaylistUri; + this.dataSourceFactory = dataSourceFactory; + this.eventDispatcher = eventDispatcher; + this.minRetryCount = minRetryCount; + this.primaryPlaylistListener = primaryPlaylistListener; + initialPlaylistLoader = new Loader("HlsPlaylistTracker:MasterPlaylist"); + playlistParser = new HlsPlaylistParser(); + playlistBundles = new IdentityHashMap<>(); + playlistRefreshHandler = new Handler(); + } + + /** + * Starts tracking all the playlists related to the provided Uri. + */ + public void start() { + ParsingLoadable masterPlaylistLoadable = new ParsingLoadable<>( + dataSourceFactory.createDataSource(), initialPlaylistUri, C.DATA_TYPE_MANIFEST, + playlistParser); + initialPlaylistLoader.startLoading(masterPlaylistLoadable, this, minRetryCount); + } + + /** + * Returns the master playlist. + * + * @return The master playlist. Null if the initial playlist has yet to be loaded. + */ + public HlsMasterPlaylist getMasterPlaylist() { + return masterPlaylist; + } + + /** + * Gets the most recent snapshot available of the playlist referred by the provided + * {@link HlsUrl}. + * + * @param url The {@link HlsUrl} corresponding to the requested media playlist. + * @return The most recent snapshot of the playlist referred by the provided {@link HlsUrl}. May + * be null if no snapshot has been loaded yet. + */ + public HlsMediaPlaylist getPlaylistSnapshot(HlsUrl url) { + return playlistBundles.get(url).latestPlaylistSnapshot; + } + + /** + * Releases the playlist tracker. + */ + public void release() { + initialPlaylistLoader.release(); + for (MediaPlaylistBundle bundle : playlistBundles.values()) { + bundle.release(); + } + playlistRefreshHandler.removeCallbacksAndMessages(null); + playlistBundles.clear(); + } + + /** + * If the tracker is having trouble refreshing the primary playlist, this method throws the + * underlying error. Otherwise, does nothing. + * + * @throws IOException The underlying error. + */ + public void maybeThrowPrimaryPlaylistRefreshError() throws IOException { + initialPlaylistLoader.maybeThrowError(); + if (primaryHlsUrl != null) { + playlistBundles.get(primaryHlsUrl).mediaPlaylistLoader.maybeThrowError(); + } + } + + /** + * Triggers a playlist refresh and sets the callback to be called once the playlist referred by + * the provided {@link HlsUrl} changes. + * + * @param key The {@link HlsUrl} of the playlist to be refreshed. + * @param callback The callback. + */ + public void refreshPlaylist(HlsUrl key, PlaylistRefreshCallback callback) { + MediaPlaylistBundle bundle = playlistBundles.get(key); + bundle.setCallback(callback); + bundle.loadPlaylist(); + } + + /** + * Returns whether this is live content. + * + * @return True if the content is live. False otherwise. + */ + public boolean isLive() { + return isLive; + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted(ParsingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs) { + HlsPlaylist result = loadable.getResult(); + HlsMasterPlaylist masterPlaylist; + boolean isMediaPlaylist = result instanceof HlsMediaPlaylist; + if (isMediaPlaylist) { + masterPlaylist = HlsMasterPlaylist.createSingleVariantMasterPlaylist(result.baseUri); + } else /* result instanceof HlsMasterPlaylist */ { + masterPlaylist = (HlsMasterPlaylist) result; + } + this.masterPlaylist = masterPlaylist; + primaryHlsUrl = masterPlaylist.variants.get(0); + ArrayList urls = new ArrayList<>(); + urls.addAll(masterPlaylist.variants); + urls.addAll(masterPlaylist.audios); + urls.addAll(masterPlaylist.subtitles); + createBundles(urls); + MediaPlaylistBundle primaryBundle = playlistBundles.get(primaryHlsUrl); + if (isMediaPlaylist) { + // We don't need to load the playlist again. We can use the same result. + primaryBundle.processLoadedPlaylist((HlsMediaPlaylist) result); + } else { + primaryBundle.loadPlaylist(); + } + eventDispatcher.loadCompleted(loadable.dataSpec, C.DATA_TYPE_MANIFEST, elapsedRealtimeMs, + loadDurationMs, loadable.bytesLoaded()); + } + + @Override + public void onLoadCanceled(ParsingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs, boolean released) { + eventDispatcher.loadCanceled(loadable.dataSpec, C.DATA_TYPE_MANIFEST, elapsedRealtimeMs, + loadDurationMs, loadable.bytesLoaded()); + } + + @Override + public int onLoadError(ParsingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs, IOException error) { + boolean isFatal = error instanceof ParserException; + eventDispatcher.loadError(loadable.dataSpec, C.DATA_TYPE_MANIFEST, elapsedRealtimeMs, + loadDurationMs, loadable.bytesLoaded(), error, isFatal); + return isFatal ? Loader.DONT_RETRY_FATAL : Loader.RETRY; + } + + // Internal methods. + + private void createBundles(List urls) { + int listSize = urls.size(); + for (int i = 0; i < listSize; i++) { + HlsUrl url = urls.get(i); + MediaPlaylistBundle bundle = new MediaPlaylistBundle(url); + playlistBundles.put(urls.get(i), bundle); + } + } + + /** + * Called by the bundles when a snapshot changes. + * + * @param url The url of the playlist. + * @param newSnapshot The new snapshot. + * @param isFirstSnapshot Whether this is the first snapshot for the given playlist. + * @return True if a refresh should be scheduled. + */ + private boolean onPlaylistUpdated(HlsUrl url, HlsMediaPlaylist newSnapshot, + boolean isFirstSnapshot) { + if (url == primaryHlsUrl) { + if (isFirstSnapshot) { + isLive = !newSnapshot.hasEndTag; + } + primaryPlaylistListener.onPrimaryPlaylistRefreshed(newSnapshot); + // If the primary playlist is not the final one, we should schedule a refresh. + return !newSnapshot.hasEndTag; + } + return false; + } + + /** + * TODO: Allow chunks to feed adjusted timestamps back to the playlist tracker. + * TODO: Track discontinuities for media playlists that don't include the discontinuity number. + */ + private HlsMediaPlaylist adjustPlaylistTimestamps(HlsMediaPlaylist oldPlaylist, + HlsMediaPlaylist newPlaylist) { + HlsMediaPlaylist primaryPlaylistSnapshot = + playlistBundles.get(primaryHlsUrl).latestPlaylistSnapshot; + if (oldPlaylist == null) { + if (primaryPlaylistSnapshot == null) { + // Playback has just started so no adjustment is needed. + return newPlaylist; + } else { + return newPlaylist.copyWithStartTimeUs(primaryPlaylistSnapshot.getStartTimeUs()); + } + } + int newSegmentsCount = newPlaylist.mediaSequence - oldPlaylist.mediaSequence; + if (newSegmentsCount == 0 && oldPlaylist.hasEndTag == newPlaylist.hasEndTag) { + return oldPlaylist; + } + List oldSegments = oldPlaylist.segments; + int oldPlaylistSize = oldSegments.size(); + if (newSegmentsCount <= oldPlaylistSize) { + ArrayList newSegments = new ArrayList<>(); + // We can extrapolate the start time of new segments from the segments of the old snapshot. + int newPlaylistSize = newPlaylist.segments.size(); + for (int i = newSegmentsCount; i < oldPlaylistSize; i++) { + newSegments.add(oldSegments.get(i)); + } + HlsMediaPlaylist.Segment lastSegment = oldSegments.get(oldPlaylistSize - 1); + for (int i = newPlaylistSize - newSegmentsCount; i < newPlaylistSize; i++) { + lastSegment = newPlaylist.segments.get(i).copyWithStartTimeUs( + lastSegment.startTimeUs + (long) lastSegment.durationSecs * C.MICROS_PER_SECOND); + newSegments.add(lastSegment); + } + return newPlaylist.copyWithSegments(newSegments); + } else { + // No segments overlap, we assume the new playlist start coincides with the primary playlist. + return newPlaylist.copyWithStartTimeUs(primaryPlaylistSnapshot.getStartTimeUs()); + } + } + + /** + * Holds all information related to a specific Media Playlist. + */ + private final class MediaPlaylistBundle implements Loader.Callback>, + Runnable { + + private final HlsUrl playlistUrl; + private final Loader mediaPlaylistLoader; + private final ParsingLoadable mediaPlaylistLoadable; + + private PlaylistRefreshCallback callback; + private HlsMediaPlaylist latestPlaylistSnapshot; + + public MediaPlaylistBundle(HlsUrl playlistUrl) { + this(playlistUrl, null); + } + + public MediaPlaylistBundle(HlsUrl playlistUrl, HlsMediaPlaylist initialSnapshot) { + this.playlistUrl = playlistUrl; + latestPlaylistSnapshot = initialSnapshot; + mediaPlaylistLoader = new Loader("HlsPlaylistTracker:MediaPlaylist"); + mediaPlaylistLoadable = new ParsingLoadable<>(dataSourceFactory.createDataSource(), + UriUtil.resolveToUri(masterPlaylist.baseUri, playlistUrl.url), C.DATA_TYPE_MANIFEST, + playlistParser); + } + + public void release() { + mediaPlaylistLoader.release(); + } + + public void loadPlaylist() { + if (!mediaPlaylistLoader.isLoading()) { + mediaPlaylistLoader.startLoading(mediaPlaylistLoadable, this, minRetryCount); + } + } + + public void setCallback(PlaylistRefreshCallback callback) { + this.callback = callback; + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted(ParsingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs) { + processLoadedPlaylist((HlsMediaPlaylist) loadable.getResult()); + eventDispatcher.loadCompleted(loadable.dataSpec, C.DATA_TYPE_MANIFEST, elapsedRealtimeMs, + loadDurationMs, loadable.bytesLoaded()); + } + + @Override + public void onLoadCanceled(ParsingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs, boolean released) { + eventDispatcher.loadCanceled(loadable.dataSpec, C.DATA_TYPE_MANIFEST, elapsedRealtimeMs, + loadDurationMs, loadable.bytesLoaded()); + } + + @Override + public int onLoadError(ParsingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs, IOException error) { + // TODO: Add support for playlist blacklisting in response to server error codes. + boolean isFatal = error instanceof ParserException; + eventDispatcher.loadError(loadable.dataSpec, C.DATA_TYPE_MANIFEST, elapsedRealtimeMs, + loadDurationMs, loadable.bytesLoaded(), error, isFatal); + return isFatal ? Loader.DONT_RETRY_FATAL : Loader.RETRY; + } + + // Runnable implementation. + + @Override + public void run() { + loadPlaylist(); + } + + // Internal methods. + + private void processLoadedPlaylist(HlsMediaPlaylist loadedMediaPlaylist) { + HlsMediaPlaylist oldPlaylist = latestPlaylistSnapshot; + latestPlaylistSnapshot = adjustPlaylistTimestamps(oldPlaylist, loadedMediaPlaylist); + boolean shouldScheduleRefresh; + if (oldPlaylist != latestPlaylistSnapshot) { + if (callback != null) { + callback.onPlaylistChanged(); + callback = null; + } + shouldScheduleRefresh = onPlaylistUpdated(playlistUrl, latestPlaylistSnapshot, + oldPlaylist == null); + } else { + shouldScheduleRefresh = !loadedMediaPlaylist.hasEndTag; + } + if (shouldScheduleRefresh) { + playlistRefreshHandler.postDelayed(this, PLAYLIST_REFRESH_PERIOD_MS); + } + } + + } + +}