From 5cce28aacf3cc73c90830458d4a7e42bb6c2f452 Mon Sep 17 00:00:00 2001 From: Srikanth Peddibhotla Date: Wed, 2 Mar 2016 15:21:31 -0800 Subject: [PATCH] HLS Live Streaming with DVR window and seek support added [Problem] Exoplayer does not support HLS Live streaming with DVR window and seek. [Solution] 1. Support for available seek range added. 2. Support for seek within DVR window range added. [Known Issues] 1. Seeking beyond the live window is handled as follows: a. If the seek position is less than lower bound, we start playback from the first available chunk data. b. If the seek position is greater than upper bound, we start giving data from the live edge, however, exoplayer skips frames till it gets the data corresponding to the seeked position 2. We assume either media sequence or program date time is always present in the media playlist. Playlists without media sequency or program date time is not handled. Behaviour is unpredictable. 3. Media playlist refresh time is aggresively fixed at half the target duration. We are yet to honor the spec that says if the last fetch did not change the playlist, try again after half the target duration, otherwise try after the target duration. 4. We assume that playlist always have timezone in program date time or never have timezone. If this assumption fails, live DVR functionality will be broken. [Tests] 1. Manifests with either program date time or media sequence or both tested. 2. Manifests with variable segment duration is tested. 2. Adaptive bitrate Switching tested. Also tested the case where available range might be different across variants. --- .../exoplayer/demo/player/DemoPlayer.java | 7 +- .../demo/player/HlsRendererBuilder.java | 9 +- .../android/exoplayer/hls/HlsChunkSource.java | 213 ++++++++++++++++-- .../exoplayer/hls/HlsMediaPlaylist.java | 6 +- .../exoplayer/hls/HlsPlaylistParser.java | 28 ++- .../exoplayer/hls/HlsSampleSource.java | 14 +- .../android/exoplayer/util/PlayerControl.java | 29 ++- 7 files changed, 264 insertions(+), 42 deletions(-) diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/player/DemoPlayer.java b/demo/src/main/java/com/google/android/exoplayer/demo/player/DemoPlayer.java index 6d3925f7519..8f477612bf8 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/player/DemoPlayer.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/player/DemoPlayer.java @@ -33,6 +33,7 @@ import com.google.android.exoplayer.dash.DashChunkSource; import com.google.android.exoplayer.drm.StreamingDrmSessionManager; import com.google.android.exoplayer.extractor.ExtractorSampleSource; +import com.google.android.exoplayer.hls.HlsChunkSource; import com.google.android.exoplayer.hls.HlsSampleSource; import com.google.android.exoplayer.metadata.MetadataTrackRenderer.MetadataRenderer; import com.google.android.exoplayer.metadata.id3.Id3Frame; @@ -63,7 +64,8 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi SingleSampleSource.EventListener, DefaultBandwidthMeter.EventListener, MediaCodecVideoTrackRenderer.EventListener, MediaCodecAudioTrackRenderer.EventListener, StreamingDrmSessionManager.EventListener, DashChunkSource.EventListener, TextRenderer, - MetadataRenderer>, DebugTextViewHelper.Provider { + MetadataRenderer>, DebugTextViewHelper.Provider, + HlsChunkSource.EventListener { /** * Builds renderers for the player. @@ -533,6 +535,9 @@ public void onAvailableRangeChanged(int sourceId, TimeRange availableRange) { if (infoListener != null) { infoListener.onAvailableRangeChanged(sourceId, availableRange); } + if (playerControl != null) { + playerControl.setAvailableRange(availableRange); + } } @Override diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/player/HlsRendererBuilder.java b/demo/src/main/java/com/google/android/exoplayer/demo/player/HlsRendererBuilder.java index b9c34fe5956..a2e513256aa 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/player/HlsRendererBuilder.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/player/HlsRendererBuilder.java @@ -147,7 +147,8 @@ public void onSingleManifest(HlsPlaylist manifest) { DataSource dataSource = new DefaultUriDataSource(context, bandwidthMeter, userAgent); HlsChunkSource chunkSource = new HlsChunkSource(true /* isMaster */, dataSource, url, manifest, DefaultHlsTrackSelector.newDefaultInstance(context), bandwidthMeter, - timestampAdjusterProvider, HlsChunkSource.ADAPTIVE_MODE_SPLICE); + timestampAdjusterProvider, HlsChunkSource.ADAPTIVE_MODE_SPLICE, + player.getMainHandler(), player, DemoPlayer.TYPE_VIDEO); HlsSampleSource sampleSource = new HlsSampleSource(chunkSource, loadControl, MAIN_BUFFER_SEGMENTS * BUFFER_SEGMENT_SIZE, mainHandler, player, DemoPlayer.TYPE_VIDEO); MediaCodecVideoTrackRenderer videoRenderer = new MediaCodecVideoTrackRenderer(context, @@ -162,7 +163,8 @@ public void onSingleManifest(HlsPlaylist manifest) { DataSource audioDataSource = new DefaultUriDataSource(context, bandwidthMeter, userAgent); HlsChunkSource audioChunkSource = new HlsChunkSource(false /* isMaster */, audioDataSource, url, manifest, DefaultHlsTrackSelector.newAudioInstance(), bandwidthMeter, - timestampAdjusterProvider, HlsChunkSource.ADAPTIVE_MODE_SPLICE); + timestampAdjusterProvider, HlsChunkSource.ADAPTIVE_MODE_SPLICE, + player.getMainHandler(), player,DemoPlayer.TYPE_AUDIO); HlsSampleSource audioSampleSource = new HlsSampleSource(audioChunkSource, loadControl, AUDIO_BUFFER_SEGMENTS * BUFFER_SEGMENT_SIZE, mainHandler, player, DemoPlayer.TYPE_AUDIO); @@ -182,7 +184,8 @@ public void onSingleManifest(HlsPlaylist manifest) { DataSource textDataSource = new DefaultUriDataSource(context, bandwidthMeter, userAgent); HlsChunkSource textChunkSource = new HlsChunkSource(false /* isMaster */, textDataSource, url, manifest, DefaultHlsTrackSelector.newSubtitleInstance(), bandwidthMeter, - timestampAdjusterProvider, HlsChunkSource.ADAPTIVE_MODE_SPLICE); + timestampAdjusterProvider, HlsChunkSource.ADAPTIVE_MODE_SPLICE, null, null, + DemoPlayer.TYPE_VIDEO); HlsSampleSource textSampleSource = new HlsSampleSource(textChunkSource, loadControl, TEXT_BUFFER_SEGMENTS * BUFFER_SEGMENT_SIZE, mainHandler, player, DemoPlayer.TYPE_TEXT); textRenderer = new TextTrackRenderer(textSampleSource, player, mainHandler.getLooper()); diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsChunkSource.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsChunkSource.java index 853f6b3c67f..ac2991d2bd0 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/HlsChunkSource.java +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsChunkSource.java @@ -18,6 +18,7 @@ import com.google.android.exoplayer.BehindLiveWindowException; import com.google.android.exoplayer.C; import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.TimeRange; import com.google.android.exoplayer.chunk.BaseChunkSampleSourceEventListener; import com.google.android.exoplayer.chunk.Chunk; import com.google.android.exoplayer.chunk.ChunkOperationHolder; @@ -38,6 +39,7 @@ import com.google.android.exoplayer.util.Util; import android.net.Uri; +import android.os.Handler; import android.os.SystemClock; import android.text.TextUtils; import android.util.Log; @@ -60,7 +62,14 @@ public class HlsChunkSource implements HlsTrackSelector.Output { /** * Interface definition for a callback to be notified of {@link HlsChunkSource} events. */ - public interface EventListener extends BaseChunkSampleSourceEventListener {} + public interface EventListener extends BaseChunkSampleSourceEventListener { + /** + * Invoked when the available seek range of the stream has changed. + * + * @param availableRange The range which specifies available content that can be seeked to. + */ + public void onAvailableRangeChanged(int sourceId, TimeRange availableRange); + } /** * Adaptive switching is disabled. @@ -159,6 +168,17 @@ public interface EventListener extends BaseChunkSampleSourceEventListener {} private String encryptionIvString; private byte[] encryptionIv; + private TimeRange availableRange[]; + private int currentAvailableRangeIndex = -1; + private final long[] availableRangeBoundsUs; + private long mediaTimeOffsetUs; + private boolean isMediaTimeBaseSet; + private long mediaSequenceOffset; + private final Handler eventHandler; + private final EventListener eventListener; + private final int eventSourceId; + private static final int LIVE_EDGE_CHUNK_INDEX_OFFSET = 3; + /** * @param isMaster True if this is the master source for the playback. False otherwise. Each * playback must have exactly one master source, which should be the source providing video @@ -174,15 +194,19 @@ public interface EventListener extends BaseChunkSampleSourceEventListener {} * @param adaptiveMode The mode for switching from one variant to another. One of * {@link #ADAPTIVE_MODE_NONE}, {@link #ADAPTIVE_MODE_ABRUPT} and * {@link #ADAPTIVE_MODE_SPLICE}. + * @param eventHandler A handler to use when delivering events to {@code EventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param eventSourceId An identifier that gets passed to {@code eventListener} methods. */ public HlsChunkSource(boolean isMaster, DataSource dataSource, String playlistUrl, HlsPlaylist playlist, HlsTrackSelector trackSelector, BandwidthMeter bandwidthMeter, - PtsTimestampAdjusterProvider timestampAdjusterProvider, int adaptiveMode) { + PtsTimestampAdjusterProvider timestampAdjusterProvider, int adaptiveMode, + Handler eventHandler, EventListener eventListener, int eventSourceId) { this(isMaster, dataSource, playlistUrl, playlist, trackSelector, bandwidthMeter, - timestampAdjusterProvider, adaptiveMode, DEFAULT_MIN_BUFFER_TO_SWITCH_UP_MS, - DEFAULT_MAX_BUFFER_TO_SWITCH_DOWN_MS); + timestampAdjusterProvider, adaptiveMode, eventHandler, eventListener, eventSourceId, + DEFAULT_MIN_BUFFER_TO_SWITCH_UP_MS, DEFAULT_MAX_BUFFER_TO_SWITCH_DOWN_MS); } - /** * @param isMaster True if this is the master source for the playback. False otherwise. Each * playback must have exactly one master source, which should be the source providing video @@ -198,6 +222,10 @@ public HlsChunkSource(boolean isMaster, DataSource dataSource, String playlistUr * @param adaptiveMode The mode for switching from one variant to another. One of * {@link #ADAPTIVE_MODE_NONE}, {@link #ADAPTIVE_MODE_ABRUPT} and * {@link #ADAPTIVE_MODE_SPLICE}. + * @param eventHandler A handler to use when delivering events to {@code EventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param eventSourceId An identifier that gets passed to {@code eventListener} methods. * @param minBufferDurationToSwitchUpMs The minimum duration of media that needs to be buffered * for a switch to a higher quality variant to be considered. * @param maxBufferDurationToSwitchDownMs The maximum duration of media that needs to be buffered @@ -206,6 +234,7 @@ public HlsChunkSource(boolean isMaster, DataSource dataSource, String playlistUr public HlsChunkSource(boolean isMaster, DataSource dataSource, String playlistUrl, HlsPlaylist playlist, HlsTrackSelector trackSelector, BandwidthMeter bandwidthMeter, PtsTimestampAdjusterProvider timestampAdjusterProvider, int adaptiveMode, + Handler eventHandler, EventListener eventListener, int eventSourceId, long minBufferDurationToSwitchUpMs, long maxBufferDurationToSwitchDownMs) { this.isMaster = isMaster; this.dataSource = dataSource; @@ -215,6 +244,11 @@ public HlsChunkSource(boolean isMaster, DataSource dataSource, String playlistUr this.adaptiveMode = adaptiveMode; minBufferDurationToSwitchUpUs = minBufferDurationToSwitchUpMs * 1000; maxBufferDurationToSwitchDownUs = maxBufferDurationToSwitchDownMs * 1000; + this.eventHandler = eventHandler; + this.eventListener = eventListener; + this.eventSourceId = eventSourceId; + isMediaTimeBaseSet = false; + availableRangeBoundsUs = new long[2]; baseUri = playlist.baseUri; playlistParser = new HlsPlaylistParser(); tracks = new ArrayList<>(); @@ -350,6 +384,7 @@ public void selectTrack(int index) { selectedVariantIndex = selectedTrack.defaultVariantIndex; variants = selectedTrack.variants; variantPlaylists = new HlsMediaPlaylist[variants.length]; + availableRange = new TimeRange[variants.length]; variantLastPlaylistLoadTimesMs = new long[variants.length]; variantBlacklistTimes = new long[variants.length]; } @@ -382,11 +417,14 @@ public void reset() { * @param playbackPositionUs The current playback position. If previousTsChunk is null then this * parameter is the position from which playback is expected to start (or restart) and hence * should be interpreted as a seek position. + * @param shouldLoadTschunk A boolean to indicate whether the Ts chunk should be loaded or not. + * If false, this function just checks if we need to refresh the live media playlist or not, + * and if needed triggers the re-fetch. * @param out The holder to populate with the result. {@link ChunkOperationHolder#queueSize} is * unused. */ public void getChunkOperation(TsChunk previousTsChunk, long playbackPositionUs, - ChunkOperationHolder out) { + ChunkOperationHolder out, boolean shouldLoadTschunk) { int nextVariantIndex; boolean switchingVariantSpliced; if (adaptiveMode == ADAPTIVE_MODE_NONE) { @@ -406,18 +444,28 @@ public void getChunkOperation(TsChunk previousTsChunk, long playbackPositionUs, return; } + // if the media playlist is live + // we need to check if it needs a reload + if (mediaPlaylist.live && + shouldRerequestLiveMediaPlaylist(nextVariantIndex)) { + out.chunk = newMediaPlaylistChunk(nextVariantIndex); + return; + } + if (!shouldLoadTschunk) { + out.chunk = null; + return; + } + selectedVariantIndex = nextVariantIndex; int chunkMediaSequence = 0; if (live) { if (previousTsChunk == null) { - chunkMediaSequence = getLiveStartChunkMediaSequence(nextVariantIndex); + chunkMediaSequence = getLiveChunkMediaSequence(nextVariantIndex, + playbackPositionUs, true); } else { - chunkMediaSequence = switchingVariantSpliced - ? previousTsChunk.chunkIndex : previousTsChunk.chunkIndex + 1; - if (chunkMediaSequence < mediaPlaylist.mediaSequence) { - fatalError = new BehindLiveWindowException(); - return; - } + chunkMediaSequence = getLiveChunkMediaSequence(nextVariantIndex, + switchingVariantSpliced ? previousTsChunk.startTimeUs : + previousTsChunk.endTimeUs, false); } } else { // Not live. @@ -465,13 +513,14 @@ public void getChunkOperation(TsChunk previousTsChunk, long playbackPositionUs, // Compute start and end times, and the sequence number of the next chunk. long startTimeUs; if (live) { - if (previousTsChunk == null) { - startTimeUs = 0; - } else if (switchingVariantSpliced) { - startTimeUs = previousTsChunk.startTimeUs; - } else { - startTimeUs = previousTsChunk.endTimeUs; - } + // Always calculate the chunk start time because the previous chunk's timestamp + // cannot always be used to calculate this chunk timestamp. + // For example: when the playback is near the lower available range and the user + // paused the playback for some time so that the current variant's DVR window + // would have moved ahead than the previous variant. In this scenario, we cannot blindly + // use previous TS chunk's timestamp to calculate the current chunk's Timestamp. + availableRange[selectedVariantIndex].getCurrentBoundsUs(availableRangeBoundsUs); + startTimeUs = segment.startTimeUs + availableRangeBoundsUs[0]; } else /* Not live */ { startTimeUs = segment.startTimeUs; } @@ -718,13 +767,52 @@ private boolean shouldRerequestLiveMediaPlaylist(int nextVariantIndex) { return timeSinceLastMediaPlaylistLoadMs >= (mediaPlaylist.targetDurationSecs * 1000) / 2; } - private int getLiveStartChunkMediaSequence(int variantIndex) { + + private int getLiveChunkMediaSequence(int variantIndex, long playbackPositionUs, boolean isReset) { // For live start playback from the third chunk from the end. HlsMediaPlaylist mediaPlaylist = variantPlaylists[variantIndex]; - int chunkIndex = mediaPlaylist.segments.size() > 3 ? mediaPlaylist.segments.size() - 3 : 0; + int chunkIndex; + // playbackPositionUs 0 is a special case of seeking/reseting to start playback from + // live edge + if (playbackPositionUs == 0) { + chunkIndex = mediaPlaylist.segments.size() > LIVE_EDGE_CHUNK_INDEX_OFFSET ? + mediaPlaylist.segments.size() - LIVE_EDGE_CHUNK_INDEX_OFFSET : 0; + } else { + availableRange[selectedVariantIndex].getCurrentBoundsUs(availableRangeBoundsUs); + long relativePlaybackPositionUs = Math.abs(playbackPositionUs) - availableRangeBoundsUs[0]; + if (isReset) { + // for seek case we try to find the right chunk index. + // if seek position is less than lower bound we cap it at lower bound. + // if seek position is greater than upper bound, we cap it at the live edge + if (playbackPositionUs > availableRangeBoundsUs[1]) { + chunkIndex = mediaPlaylist.segments.size() - LIVE_EDGE_CHUNK_INDEX_OFFSET; + } else if (playbackPositionUs < availableRangeBoundsUs[0]) { + chunkIndex = 0; + } else { + chunkIndex = Util.binarySearchFloor(mediaPlaylist.segments, relativePlaybackPositionUs, + true, true); + } + } else { + // for normal playback case, we always try to find the chunk index by searching + // in the segments. We cannot use Util.binarySearch,because if the timestamp is more than + // the upper bound, it returns the last chunk index. Hence we cannot use it here because + // we want to indicate to the callee that we have reached beyond the last chunk index. + // negative playbackPositionUs will never happen here because the seek case would have + // handled it already. + chunkIndex = Collections.binarySearch(mediaPlaylist.segments, relativePlaybackPositionUs); + if (chunkIndex == -1) { + chunkIndex = 0;// fallen behind live window - maybe due to long pause + } else if (chunkIndex < (-1 * mediaPlaylist.segments.size())) { + chunkIndex = mediaPlaylist.segments.size(); // reached the end + } else if (chunkIndex < 0) { + chunkIndex = -(chunkIndex + 2); // select the chunk index containing the position + } + } + } return chunkIndex + mediaPlaylist.mediaSequence; } + private MediaPlaylistChunk newMediaPlaylistChunk(int variantIndex) { Uri mediaPlaylistUri = UriUtil.resolveToUri(baseUri, variants[variantIndex].url); DataSpec dataSpec = new DataSpec(mediaPlaylistUri, 0, C.LENGTH_UNBOUNDED, null, @@ -770,6 +858,16 @@ private void setMediaPlaylist(int variantIndex, HlsMediaPlaylist mediaPlaylist) variantPlaylists[variantIndex] = mediaPlaylist; live |= mediaPlaylist.live; durationUs = live ? C.UNKNOWN_TIME_US : mediaPlaylist.durationUs; + + // get this variant's TimeRange + TimeRange variantRange = getAvailableRange(mediaPlaylist); + + // now may be notify app about new time range + maybeUpdateAvailableRange(variantIndex,variantRange); + + // now update this variant's time range + availableRange[variantIndex] = variantRange; + } private boolean allVariantsBlacklisted() { @@ -801,6 +899,77 @@ private int getVariantIndex(Format format) { throw new IllegalStateException("Invalid format: " + format); } + private void maybeUpdateAvailableRange(int variantIndex, TimeRange variantRange) { + // It is very much possible that this variant's playlist's window is behind the currently + // playing variant's window because the server might be still generating the content for it. + // For simplicity, we only update the availableRange to the app if the window has moved + // forward - to avoid moving the window back and forth temporarily. + // Hence we need to maintain a currentAvailableRangeIndex to indicate which variant's + // range was last notified to the app. + if (currentAvailableRangeIndex == -1) { + currentAvailableRangeIndex = variantIndex; + notifyAvailableRangeChanged(variantRange); + } else { + availableRange[currentAvailableRangeIndex].getCurrentBoundsUs(availableRangeBoundsUs); + long currentMinBoundUs = availableRangeBoundsUs[0]; + + variantRange.getCurrentBoundsUs(availableRangeBoundsUs); + long variantMinBoundUs = availableRangeBoundsUs[0]; + // notify app only if the new variant time range is ahead than current time range + if (variantMinBoundUs > currentMinBoundUs) { + currentAvailableRangeIndex = variantIndex; + notifyAvailableRangeChanged(variantRange); + } + } + } + + private TimeRange getAvailableRange(HlsMediaPlaylist mediaPlaylist) { + int lastIndex = mediaPlaylist.segments.size() - + (mediaPlaylist.live ? LIVE_EDGE_CHUNK_INDEX_OFFSET : 1); + HlsMediaPlaylist.Segment lastSegment = mediaPlaylist.segments.get(lastIndex); + if (!isMediaTimeBaseSet) { + if (mediaPlaylist.programDateTime != null) { + mediaTimeOffsetUs = mediaPlaylist.programDateTime.getTime() * 1000; + } else { + // program date time not present!! trying to use media sequence + mediaSequenceOffset = mediaPlaylist.mediaSequence; + } + isMediaTimeBaseSet = true; + } + long minStartPositionUs = 0; + if (mediaPlaylist.programDateTime != null) { + // Use media Time Offset to calculate the start position + minStartPositionUs = (mediaPlaylist.programDateTime.getTime() * 1000) - mediaTimeOffsetUs; + } else { + // Use media sequence offset to calculate the start position + // TODO : We right now assume all segments are of equal duration. + // Need TO handle case where each media segment can be of different duration + minStartPositionUs = (long) ((mediaPlaylist.mediaSequence - mediaSequenceOffset) * + mediaPlaylist.segments.get(0).durationSecs * + C.MICROS_PER_SECOND); + + } + long maxEndPositionUs = lastSegment.startTimeUs + + (long) (lastSegment.durationSecs * C.MICROS_PER_SECOND) + + minStartPositionUs; + // We don't use DynamicTimeRange because we want to allow app to + // seek within the chunk duration as long as the chunk is available in the playlist, + // because unlike MPEG-DASH, there is no timeShiftBufferDepth that is + // enforced by the HLS spec + return new TimeRange.StaticTimeRange(minStartPositionUs, maxEndPositionUs); + } + + private void notifyAvailableRangeChanged(final TimeRange seekRange) { + if (eventHandler != null && eventListener != null) { + eventHandler.post(new Runnable() { + @Override + public void run() { + eventListener.onAvailableRangeChanged(eventSourceId, seekRange); + } + }); + } + } + // Private classes. private static final class ExposedTrack { diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsMediaPlaylist.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsMediaPlaylist.java index 44c760bccf8..755c45d9e17 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/HlsMediaPlaylist.java +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsMediaPlaylist.java @@ -17,6 +17,7 @@ import com.google.android.exoplayer.C; +import java.util.Date; import java.util.List; /** @@ -68,15 +69,16 @@ public int compareTo(Long startTimeUs) { public final List segments; public final boolean live; public final long durationUs; - + public final Date programDateTime; public HlsMediaPlaylist(String baseUri, int mediaSequence, int targetDurationSecs, int version, - boolean live, List segments) { + boolean live, List segments, Date programDateTime) { super(baseUri, HlsPlaylist.TYPE_MEDIA); this.mediaSequence = mediaSequence; this.targetDurationSecs = targetDurationSecs; this.version = version; this.live = live; this.segments = segments; + this.programDateTime = programDateTime; if (!segments.isEmpty()) { Segment last = segments.get(segments.size() - 1); diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsPlaylistParser.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsPlaylistParser.java index eb9812bc97e..c8b1d8d59a1 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/HlsPlaylistParser.java +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsPlaylistParser.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer.hls; +import android.util.Log; + import com.google.android.exoplayer.C; import com.google.android.exoplayer.ParserException; import com.google.android.exoplayer.chunk.Format; @@ -26,6 +28,10 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.text.SimpleDateFormat; + +import java.util.Date; +import java.util.TimeZone; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; @@ -49,6 +55,7 @@ public final class HlsPlaylistParser implements UriLoadable.Parser private static final String ENDLIST_TAG = "#EXT-X-ENDLIST"; private static final String KEY_TAG = "#EXT-X-KEY"; private static final String BYTERANGE_TAG = "#EXT-X-BYTERANGE"; + private static final String PROGRAM_DATE_TIME_TAG = "#EXT-X-PROGRAM-DATE-TIME"; private static final String BANDWIDTH_ATTR = "BANDWIDTH"; private static final String CODECS_ATTR = "CODECS"; @@ -85,6 +92,8 @@ public final class HlsPlaylistParser implements UriLoadable.Parser Pattern.compile(VERSION_TAG + ":(\\d+)\\b"); private static final Pattern BYTERANGE_REGEX = Pattern.compile(BYTERANGE_TAG + ":(\\d+(?:@\\d+)?)\\b"); + private static final Pattern PROGRAM_DATE_TIME_REGEX = + Pattern.compile(PROGRAM_DATE_TIME_TAG + ":(\\S*)"); private static final Pattern METHOD_ATTR_REGEX = Pattern.compile(METHOD_ATTR + "=(" + METHOD_NONE + "|" + METHOD_AES128 + ")"); @@ -127,7 +136,8 @@ public HlsPlaylist parse(String connectionUrl, InputStream inputStream) || line.startsWith(BYTERANGE_TAG) || line.equals(DISCONTINUITY_TAG) || line.equals(DISCONTINUITY_SEQUENCE_TAG) - || line.equals(ENDLIST_TAG)) { + || line.equals(ENDLIST_TAG) + || line.equals(PROGRAM_DATE_TIME_TAG)) { extraLines.add(line); return parseMediaPlaylist(new LineIterator(extraLines, reader), connectionUrl); } else { @@ -242,7 +252,7 @@ private static HlsMediaPlaylist parseMediaPlaylist(LineIterator iterator, String int segmentByterangeOffset = 0; int segmentByterangeLength = C.LENGTH_UNBOUNDED; int segmentMediaSequence = 0; - + Date programDateTime = null; boolean isEncrypted = false; String encryptionKeyUri = null; String encryptionIV = null; @@ -278,6 +288,18 @@ private static HlsMediaPlaylist parseMediaPlaylist(LineIterator iterator, String if (splitByteRange.length > 1) { segmentByterangeOffset = Integer.parseInt(splitByteRange[1]); } + } else if (line.startsWith(PROGRAM_DATE_TIME_TAG) && programDateTime == null) { + String programDateTimeStr = HlsParserUtil.parseStringAttr(line, PROGRAM_DATE_TIME_REGEX, PROGRAM_DATE_TIME_TAG); + // TODO: Need to handle the Timezone & fractional part too + // Currently, either the playlist is assumed to always have timezone, or + // never have timezone. If this assumption fails, live DVR functionality will be broken. + SimpleDateFormat ISO8601Datefmt = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + ISO8601Datefmt.setTimeZone(TimeZone.getTimeZone("UTC")); + try { + programDateTime = ISO8601Datefmt.parse(programDateTimeStr); + } catch (Exception e) { + Log.e("HLS", "Error in parsing program date " + programDateTimeStr, e); + } } else if (line.startsWith(DISCONTINUITY_SEQUENCE_TAG)) { discontinuitySequenceNumber = Integer.parseInt(line.substring(line.indexOf(':') + 1)); } else if (line.equals(DISCONTINUITY_TAG)) { @@ -310,7 +332,7 @@ private static HlsMediaPlaylist parseMediaPlaylist(LineIterator iterator, String } } return new HlsMediaPlaylist(baseUri, mediaSequence, targetDurationSecs, version, live, - Collections.unmodifiableList(segments)); + Collections.unmodifiableList(segments), programDateTime); } private static class LineIterator { diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsSampleSource.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsSampleSource.java index 1ed68da072e..8203e87ecb4 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/HlsSampleSource.java +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsSampleSource.java @@ -206,8 +206,7 @@ public void enable(int track, long positionUs) { loadControl.register(this, bufferSizeContribution); loadControlRegistered = true; } - // Treat enabling of a live stream as occurring at t=0 in both of the blocks below. - positionUs = chunkSource.isLive() ? 0 : positionUs; + int chunkSourceTrack = chunkSourceTrackIndices[track]; if (chunkSourceTrack != -1 && chunkSourceTrack != chunkSource.getSelectedTrackIndex()) { // This is a primary track whose corresponding chunk source track is different to the one @@ -361,8 +360,6 @@ public void maybeThrowError() throws IOException { public void seekToUs(long positionUs) { Assertions.checkState(prepared); Assertions.checkState(enabledTrackCount > 0); - // Treat all seeks into live streams as being to t=0. - positionUs = chunkSource.isLive() ? 0 : positionUs; // Ignore seeks to the current position. long currentPositionUs = isPendingReset() ? pendingResetPositionUs : downstreamPositionUs; @@ -697,14 +694,17 @@ private void maybeStartLoading() { } return; } - - if (loader.isLoading() || !nextLoader || (prepared && enabledTrackCount == 0)) { + // We now call chunkSource.getChunkOperation even if nextLoader is false, so that it is given + // an opportunity to frequently re-fetch the playlist for live DVR use case. However, we + // pass the boolean nextLoader to the getChunkOperation function so that it knows not to load + // the TS Chunk and instead only re-fetch the playlist if needed + if (loader.isLoading() || (prepared && enabledTrackCount == 0)) { return; } chunkSource.getChunkOperation(previousTsLoadable, pendingResetPositionUs != NO_RESET_PENDING ? pendingResetPositionUs : downstreamPositionUs, - chunkOperationHolder); + chunkOperationHolder, nextLoader); boolean endOfStream = chunkOperationHolder.endOfStream; Chunk nextLoadable = chunkOperationHolder.chunk; chunkOperationHolder.clear(); diff --git a/library/src/main/java/com/google/android/exoplayer/util/PlayerControl.java b/library/src/main/java/com/google/android/exoplayer/util/PlayerControl.java index b9c4899de2d..5109c36c4e0 100644 --- a/library/src/main/java/com/google/android/exoplayer/util/PlayerControl.java +++ b/library/src/main/java/com/google/android/exoplayer/util/PlayerControl.java @@ -15,7 +15,9 @@ */ package com.google.android.exoplayer.util; + import com.google.android.exoplayer.ExoPlayer; +import com.google.android.exoplayer.TimeRange; import android.widget.MediaController.MediaPlayerControl; @@ -29,6 +31,9 @@ public class PlayerControl implements MediaPlayerControl { private final ExoPlayer exoPlayer; + private TimeRange availableRange; + private long availableRangeValuesMs[] = new long[2]; + public PlayerControl(ExoPlayer exoPlayer) { this.exoPlayer = exoPlayer; } @@ -70,8 +75,9 @@ public int getBufferPercentage() { @Override public int getCurrentPosition() { - return exoPlayer.getDuration() == ExoPlayer.UNKNOWN_TIME ? 0 - : (int) exoPlayer.getCurrentPosition(); + // we allow query of current position for live content + // to allow seeking within DVR window. See the PlayerActivity.java + return (int) exoPlayer.getCurrentPosition(); } @Override @@ -97,9 +103,24 @@ public void pause() { @Override public void seekTo(int timeMillis) { - long seekPosition = exoPlayer.getDuration() == ExoPlayer.UNKNOWN_TIME ? 0 - : Math.min(Math.max(0, timeMillis), getDuration()); + long seekPosition = (long) timeMillis; + if (exoPlayer.getDuration() == ExoPlayer.UNKNOWN_TIME) { + if (availableRange != null) { + availableRange.getCurrentBoundsMs(availableRangeValuesMs); + seekPosition = Math.min(Math.max(availableRangeValuesMs[0], timeMillis), availableRangeValuesMs[1]); + } + } else { + seekPosition = Math.min(Math.max(0, timeMillis), getDuration()); + } exoPlayer.seekTo(seekPosition); } + public void setAvailableRange(TimeRange availableRange) { + this.availableRange = availableRange; + } + + public TimeRange getAvailableRange() { + return availableRange; + } + }