From d64036c5ed0d34d3dd3c17cb2ef3101882e2aeab Mon Sep 17 00:00:00 2001 From: Andrey Udovenko Date: Wed, 1 Oct 2014 16:54:50 -0400 Subject: [PATCH] Add basic HLS support (VOD and Live) with EXT-X-DISCONTINUITY. --- .../android/exoplayer/demo/DemoUtil.java | 2 + .../exoplayer/demo/SampleChooserActivity.java | 2 + .../android/exoplayer/demo/Samples.java | 9 + .../demo/full/FullPlayerActivity.java | 7 + .../demo/full/player/HlsRendererBuilder.java | 113 +++ .../android/exoplayer/hls/HlsChunk.java | 176 +++++ .../hls/HlsChunkOperationHolder.java | 37 + .../android/exoplayer/hls/HlsChunkSource.java | 206 +++++ .../exoplayer/hls/HlsMasterPlaylist.java | 48 ++ .../hls/HlsMasterPlaylistParser.java | 71 ++ .../exoplayer/hls/HlsMediaPlaylist.java | 74 ++ .../exoplayer/hls/HlsMediaPlaylistParser.java | 107 +++ .../android/exoplayer/hls/HlsParserUtil.java | 49 ++ .../exoplayer/hls/HlsSampleSource.java | 560 ++++++++++++++ .../google/android/exoplayer/hls/TsChunk.java | 125 +++ .../exoplayer/parser/ts/BitsArray.java | 280 +++++++ .../exoplayer/parser/ts/TsExtractor.java | 722 ++++++++++++++++++ 17 files changed, 2588 insertions(+) create mode 100644 demo/src/main/java/com/google/android/exoplayer/demo/full/player/HlsRendererBuilder.java create mode 100644 library/src/main/java/com/google/android/exoplayer/hls/HlsChunk.java create mode 100644 library/src/main/java/com/google/android/exoplayer/hls/HlsChunkOperationHolder.java create mode 100644 library/src/main/java/com/google/android/exoplayer/hls/HlsChunkSource.java create mode 100644 library/src/main/java/com/google/android/exoplayer/hls/HlsMasterPlaylist.java create mode 100644 library/src/main/java/com/google/android/exoplayer/hls/HlsMasterPlaylistParser.java create mode 100644 library/src/main/java/com/google/android/exoplayer/hls/HlsMediaPlaylist.java create mode 100644 library/src/main/java/com/google/android/exoplayer/hls/HlsMediaPlaylistParser.java create mode 100644 library/src/main/java/com/google/android/exoplayer/hls/HlsParserUtil.java create mode 100644 library/src/main/java/com/google/android/exoplayer/hls/HlsSampleSource.java create mode 100644 library/src/main/java/com/google/android/exoplayer/hls/TsChunk.java create mode 100644 library/src/main/java/com/google/android/exoplayer/parser/ts/BitsArray.java create mode 100644 library/src/main/java/com/google/android/exoplayer/parser/ts/TsExtractor.java diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/DemoUtil.java b/demo/src/main/java/com/google/android/exoplayer/demo/DemoUtil.java index 6479b28a7e..7b4a6b41f1 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/DemoUtil.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/DemoUtil.java @@ -47,6 +47,8 @@ public class DemoUtil { public static final int TYPE_DASH_VOD = 0; public static final int TYPE_SS = 1; public static final int TYPE_OTHER = 2; + public static final int TYPE_HLS_MASTER = 3; + public static final int TYPE_HLS_MEDIA = 4; public static final boolean EXPOSE_EXPERIMENTAL_FEATURES = false; diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/SampleChooserActivity.java b/demo/src/main/java/com/google/android/exoplayer/demo/SampleChooserActivity.java index adb28ef0dc..14827f05c2 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/SampleChooserActivity.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/SampleChooserActivity.java @@ -58,6 +58,8 @@ public class SampleChooserActivity extends Activity { sampleAdapter.addAll((Object[]) Samples.SMOOTHSTREAMING); sampleAdapter.add(new Header("Misc")); sampleAdapter.addAll((Object[]) Samples.MISC); + sampleAdapter.add(new Header("HLS")); + sampleAdapter.addAll((Object[]) Samples.HLS); if (DemoUtil.EXPOSE_EXPERIMENTAL_FEATURES) { sampleAdapter.add(new Header("YouTube WebM DASH (Experimental)")); sampleAdapter.addAll((Object[]) Samples.YOUTUBE_DASH_WEBM); diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/Samples.java b/demo/src/main/java/com/google/android/exoplayer/demo/Samples.java index 93d08af4cc..e01eb12d39 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/Samples.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/Samples.java @@ -131,6 +131,15 @@ package com.google.android.exoplayer.demo; + "22727BB612D24AA4FACE4EF62726F9461A9BF57A&key=ik0", DemoUtil.TYPE_DASH_VOD, true, true), }; + public static final Sample[] HLS = new Sample[] { + new Sample("Apple master playlist", "uid:hls:applemaster", + "https://devimages.apple.com.edgekey.net/streaming/examples/bipbop_4x3/" + + "bipbop_4x3_variant.m3u8", DemoUtil.TYPE_HLS_MASTER, false, true), + new Sample("Apple single media playlist", "uid:hls:applesinglemedia", + "https://devimages.apple.com.edgekey.net/streaming/examples/bipbop_4x3/gear1/" + + "prog_index.m3u8", DemoUtil.TYPE_HLS_MEDIA, false, true), + }; + public static final Sample[] MISC = new Sample[] { new Sample("Dizzy", "uid:misc:dizzy", "http://html5demos.com/assets/dizzy.mp4", DemoUtil.TYPE_OTHER, false, true), diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/full/FullPlayerActivity.java b/demo/src/main/java/com/google/android/exoplayer/demo/full/FullPlayerActivity.java index 9966124ced..29a812b11d 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/full/FullPlayerActivity.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/full/FullPlayerActivity.java @@ -23,6 +23,7 @@ import com.google.android.exoplayer.demo.full.player.DashVodRendererBuilder; import com.google.android.exoplayer.demo.full.player.DefaultRendererBuilder; import com.google.android.exoplayer.demo.full.player.DemoPlayer; import com.google.android.exoplayer.demo.full.player.DemoPlayer.RendererBuilder; +import com.google.android.exoplayer.demo.full.player.HlsRendererBuilder; import com.google.android.exoplayer.demo.full.player.SmoothStreamingRendererBuilder; import com.google.android.exoplayer.text.CaptionStyleCompat; import com.google.android.exoplayer.text.SubtitleView; @@ -173,6 +174,12 @@ public class FullPlayerActivity extends Activity implements SurfaceHolder.Callba case DemoUtil.TYPE_DASH_VOD: return new DashVodRendererBuilder(userAgent, contentUri.toString(), contentId, new WidevineTestMediaDrmCallback(contentId), debugTextView); + case DemoUtil.TYPE_HLS_MASTER: + return new HlsRendererBuilder(userAgent, contentUri.toString(), contentId, + HlsRendererBuilder.TYPE_MASTER); + case DemoUtil.TYPE_HLS_MEDIA: + return new HlsRendererBuilder(userAgent, contentUri.toString(), contentId, + HlsRendererBuilder.TYPE_MEDIA); default: return new DefaultRendererBuilder(this, contentUri, debugTextView); } diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/full/player/HlsRendererBuilder.java b/demo/src/main/java/com/google/android/exoplayer/demo/full/player/HlsRendererBuilder.java new file mode 100644 index 0000000000..b7c08e97dc --- /dev/null +++ b/demo/src/main/java/com/google/android/exoplayer/demo/full/player/HlsRendererBuilder.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2014 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.exoplayer.demo.full.player; + +import com.google.android.exoplayer.DefaultLoadControl; +import com.google.android.exoplayer.LoadControl; +import com.google.android.exoplayer.MediaCodecAudioTrackRenderer; +import com.google.android.exoplayer.MediaCodecVideoTrackRenderer; +import com.google.android.exoplayer.TrackRenderer; +import com.google.android.exoplayer.demo.full.player.DemoPlayer.RendererBuilder; +import com.google.android.exoplayer.demo.full.player.DemoPlayer.RendererBuilderCallback; +import com.google.android.exoplayer.hls.HlsChunkSource; +import com.google.android.exoplayer.hls.HlsMasterPlaylist; +import com.google.android.exoplayer.hls.HlsMasterPlaylist.Variant; +import com.google.android.exoplayer.hls.HlsMasterPlaylistParser; +import com.google.android.exoplayer.hls.HlsSampleSource; +import com.google.android.exoplayer.upstream.BufferPool; +import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.upstream.HttpDataSource; +import com.google.android.exoplayer.util.ManifestFetcher; +import com.google.android.exoplayer.util.ManifestFetcher.ManifestCallback; + +import android.media.MediaCodec; +import android.net.Uri; + +import java.io.IOException; +import java.util.Collections; + +/** + * A {@link RendererBuilder} for HLS. + */ +public class HlsRendererBuilder implements RendererBuilder, ManifestCallback { + + public static final int TYPE_MASTER = 0; + public static final int TYPE_MEDIA = 1; + + private static final int BUFFER_SEGMENT_SIZE = 64 * 1024; + private static final int VIDEO_BUFFER_SEGMENTS = 200; + + private final String userAgent; + private final String url; + private final String contentId; + private final int playlistType; + + private DemoPlayer player; + private RendererBuilderCallback callback; + + public HlsRendererBuilder(String userAgent, String url, String contentId, int playlistType) { + this.userAgent = userAgent; + this.url = url; + this.contentId = contentId; + this.playlistType = playlistType; + } + + @Override + public void buildRenderers(DemoPlayer player, RendererBuilderCallback callback) { + this.player = player; + this.callback = callback; + switch (playlistType) { + case TYPE_MASTER: + HlsMasterPlaylistParser parser = new HlsMasterPlaylistParser(); + ManifestFetcher mediaPlaylistFetcher = + new ManifestFetcher(parser, contentId, url); + mediaPlaylistFetcher.singleLoad(player.getMainHandler().getLooper(), this); + break; + case TYPE_MEDIA: + onManifest(contentId, newSimpleMasterPlaylist(url)); + break; + } + } + + @Override + public void onManifestError(String contentId, IOException e) { + callback.onRenderersError(e); + } + + @Override + public void onManifest(String contentId, HlsMasterPlaylist manifest) { + LoadControl loadControl = new DefaultLoadControl(new BufferPool(BUFFER_SEGMENT_SIZE)); + + DataSource dataSource = new HttpDataSource(userAgent, null, null); + HlsChunkSource chunkSource = new HlsChunkSource(dataSource, manifest); + HlsSampleSource sampleSource = new HlsSampleSource(chunkSource, loadControl, + VIDEO_BUFFER_SEGMENTS * BUFFER_SEGMENT_SIZE, true, 2); + MediaCodecVideoTrackRenderer videoRenderer = new MediaCodecVideoTrackRenderer(sampleSource, + MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT, 0, player.getMainHandler(), player, 50); + MediaCodecAudioTrackRenderer audioRenderer = new MediaCodecAudioTrackRenderer(sampleSource); + + TrackRenderer[] renderers = new TrackRenderer[DemoPlayer.RENDERER_COUNT]; + renderers[DemoPlayer.TYPE_VIDEO] = videoRenderer; + renderers[DemoPlayer.TYPE_AUDIO] = audioRenderer; + callback.onRenderers(null, null, renderers); + } + + private HlsMasterPlaylist newSimpleMasterPlaylist(String mediaPlaylistUrl) { + return new HlsMasterPlaylist(Uri.parse(""), + Collections.singletonList(new Variant(mediaPlaylistUrl, 0))); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsChunk.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsChunk.java new file mode 100644 index 0000000000..256921f9ea --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsChunk.java @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2014 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.exoplayer.hls; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.upstream.Allocation; +import com.google.android.exoplayer.upstream.Allocator; +import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.upstream.DataSourceStream; +import com.google.android.exoplayer.upstream.DataSpec; +import com.google.android.exoplayer.upstream.Loader.Loadable; +import com.google.android.exoplayer.upstream.NonBlockingInputStream; +import com.google.android.exoplayer.util.Assertions; + +import java.io.IOException; + +/** + * An abstract base class for {@link Loadable} implementations that load chunks of data required + * for the playback of streams. + *

+ * TODO: Figure out whether this should merge with the chunk package, or whether the hls + * implementation is going to naturally diverge. + */ +public abstract class HlsChunk implements Loadable { + + /** + * The reason for a {@link HlsChunkSource} having generated this chunk. For reporting only. + * Possible values for this variable are defined by the specific {@link HlsChunkSource} + * implementations. + */ + public final int trigger; + + private final DataSource dataSource; + private final DataSpec dataSpec; + + private DataSourceStream dataSourceStream; + + /** + * @param dataSource The source from which the data should be loaded. + * @param dataSpec Defines the data to be loaded. {@code dataSpec.length} must not exceed + * {@link Integer#MAX_VALUE}. If {@code dataSpec.length == C.LENGTH_UNBOUNDED} then + * the length resolved by {@code dataSource.open(dataSpec)} must not exceed + * {@link Integer#MAX_VALUE}. + * @param trigger See {@link #trigger}. + */ + public HlsChunk(DataSource dataSource, DataSpec dataSpec, int trigger) { + Assertions.checkState(dataSpec.length <= Integer.MAX_VALUE); + this.dataSource = Assertions.checkNotNull(dataSource); + this.dataSpec = Assertions.checkNotNull(dataSpec); + this.trigger = trigger; + } + + /** + * Initializes the {@link HlsChunk}. + * + * @param allocator An {@link Allocator} from which the {@link Allocation} needed to contain the + * data can be obtained. + */ + public final void init(Allocator allocator) { + Assertions.checkState(dataSourceStream == null); + dataSourceStream = new DataSourceStream(dataSource, dataSpec, allocator); + } + + /** + * Releases the {@link HlsChunk}, releasing any backing {@link Allocation}s. + */ + public final void release() { + if (dataSourceStream != null) { + dataSourceStream.close(); + dataSourceStream = null; + } + } + + /** + * Gets the length of the chunk in bytes. + * + * @return The length of the chunk in bytes, or {@link C#LENGTH_UNBOUNDED} if the length has yet + * to be determined. + */ + public final long getLength() { + return dataSourceStream.getLength(); + } + + /** + * Whether the whole of the data has been consumed. + * + * @return True if the whole of the data has been consumed. False otherwise. + */ + public final boolean isReadFinished() { + return dataSourceStream.isEndOfStream(); + } + + /** + * Whether the whole of the chunk has been loaded. + * + * @return True if the whole of the chunk has been loaded. False otherwise. + */ + public final boolean isLoadFinished() { + return dataSourceStream.isLoadFinished(); + } + + /** + * Gets the number of bytes that have been loaded. + * + * @return The number of bytes that have been loaded. + */ + public final long bytesLoaded() { + return dataSourceStream.getLoadPosition(); + } + + /** + * Causes loaded data to be consumed. + * + * @throws IOException If an error occurs consuming the loaded data. + */ + public final void consume() throws IOException { + Assertions.checkState(dataSourceStream != null); + consumeStream(dataSourceStream); + } + + /** + * Invoked by {@link #consume()}. Implementations may override this method if they wish to + * consume the loaded data at this point. + *

+ * The default implementation is a no-op. + * + * @param stream The stream of loaded data. + * @throws IOException If an error occurs consuming the loaded data. + */ + protected void consumeStream(NonBlockingInputStream stream) throws IOException { + // Do nothing. + } + + protected final NonBlockingInputStream getNonBlockingInputStream() { + return dataSourceStream; + } + + protected final void resetReadPosition() { + if (dataSourceStream != null) { + dataSourceStream.resetReadPosition(); + } else { + // We haven't been initialized yet, so the read position must already be 0. + } + } + + // Loadable implementation + + @Override + public final void cancelLoad() { + dataSourceStream.cancelLoad(); + } + + @Override + public final boolean isLoadCanceled() { + return dataSourceStream.isLoadCanceled(); + } + + @Override + public final void load() throws IOException, InterruptedException { + dataSourceStream.load(); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsChunkOperationHolder.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsChunkOperationHolder.java new file mode 100644 index 0000000000..27b11c2ebd --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsChunkOperationHolder.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014 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.exoplayer.hls; + +/** + * Holds a hls chunk operation, which consists of a {@link HlsChunk} to load together with the + * number of {@link TsChunk}s that should be retained on the queue. + *

+ * TODO: Figure out whether this should merge with the chunk package, or whether the hls + * implementation is going to naturally diverge. + */ +public final class HlsChunkOperationHolder { + + /** + * The number of {@link TsChunk}s to retain in a queue. + */ + public int queueSize; + + /** + * The chunk. + */ + public HlsChunk chunk; + +} 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 new file mode 100644 index 0000000000..88f2791080 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsChunkSource.java @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2014 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.exoplayer.hls; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.TrackRenderer; +import com.google.android.exoplayer.parser.ts.TsExtractor; +import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.upstream.DataSpec; +import com.google.android.exoplayer.upstream.NonBlockingInputStream; +import com.google.android.exoplayer.util.Util; + +import android.net.Uri; +import android.os.SystemClock; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.List; + +/** + * A temporary test source of HLS chunks. + *

+ * TODO: Figure out whether this should merge with the chunk package, or whether the hls + * implementation is going to naturally diverge. + */ +public class HlsChunkSource { + + private final DataSource dataSource; + private final TsExtractor extractor; + private final HlsMasterPlaylist masterPlaylist; + private final HlsMediaPlaylistParser mediaPlaylistParser; + + /* package */ HlsMediaPlaylist mediaPlaylist; + /* package */ boolean mediaPlaylistWasLive; + /* package */ long lastMediaPlaylistLoadTimeMs; + + // TODO: Once proper m3u8 parsing is in place, actually use the url! + public HlsChunkSource(DataSource dataSource, HlsMasterPlaylist masterPlaylist) { + this.dataSource = dataSource; + this.masterPlaylist = masterPlaylist; + extractor = new TsExtractor(); + mediaPlaylistParser = new HlsMediaPlaylistParser(); + } + + public long getDurationUs() { + return mediaPlaylistWasLive ? TrackRenderer.UNKNOWN_TIME_US : mediaPlaylist.durationUs; + } + + /** + * Adaptive implementations must set the maximum video dimensions on the supplied + * {@link MediaFormat}. Other implementations do nothing. + *

+ * Only called when the source is enabled. + * + * @param out The {@link MediaFormat} on which the maximum video dimensions should be set. + */ + public void getMaxVideoDimensions(MediaFormat out) { + // TODO: Implement this. + } + + /** + * Updates the provided {@link HlsChunkOperationHolder} to contain the next operation that should + * be performed by the calling {@link HlsSampleSource}. + *

+ * The next operation comprises of a possibly shortened queue length (shortened if the + * implementation wishes for the caller to discard {@link TsChunk}s from the queue), together + * with the next {@link HlsChunk} to load. The next chunk may be a {@link TsChunk} to be added to + * the queue, or another {@link HlsChunk} type (e.g. to load initialization data), or null if the + * source is not able to provide a chunk in its current state. + * + * @param queue A representation of the currently buffered {@link TsChunk}s. + * @param seekPositionUs If the queue is empty, this parameter must specify the seek position. If + * the queue is non-empty then this parameter is ignored. + * @param playbackPositionUs The current playback position. + * @param out A holder for the next operation, whose {@link HlsChunkOperationHolder#queueSize} is + * initially equal to the length of the queue, and whose {@linkHls ChunkOperationHolder#chunk} + * is initially equal to null or a {@link TsChunk} previously supplied by the + * {@link HlsChunkSource} that the caller has not yet finished loading. In the latter case the + * chunk can either be replaced or left unchanged. Note that leaving the chunk unchanged is + * both preferred and more efficient than replacing it with a new but identical chunk. + */ + public void getChunkOperation(List queue, long seekPositionUs, long playbackPositionUs, + HlsChunkOperationHolder out) { + if (out.chunk != null) { + // We already have a chunk. Keep it. + return; + } + + if (mediaPlaylist == null) { + out.chunk = newMediaPlaylistChunk(); + return; + } + + int chunkMediaSequence = 0; + if (mediaPlaylistWasLive) { + if (queue.isEmpty()) { + chunkMediaSequence = getLiveStartChunkMediaSequence(); + } else { + // For live nextChunkIndex contains chunk media sequence number. + chunkMediaSequence = queue.get(queue.size() - 1).nextChunkIndex; + // If the updated playlist is far ahead and doesn't even have the last chunk from the + // queue, then try to catch up, skip a few chunks and start as if it was a new playlist. + if (chunkMediaSequence < mediaPlaylist.mediaSequence) { + // TODO: Trigger discontinuity in this case. + chunkMediaSequence = getLiveStartChunkMediaSequence(); + } + } + } else { + if (queue.isEmpty()) { + chunkMediaSequence = Util.binarySearchFloor(mediaPlaylist.segments, seekPositionUs, true, + true) + mediaPlaylist.mediaSequence; + } else { + chunkMediaSequence = queue.get(queue.size() - 1).nextChunkIndex; + } + } + + if (chunkMediaSequence == -1) { + out.chunk = null; + return; + } + + int chunkIndex = chunkMediaSequence - mediaPlaylist.mediaSequence; + // If the end of the playlist is reached. + if (chunkIndex >= mediaPlaylist.segments.size()) { + if (mediaPlaylist.live && shouldRerequestMediaPlaylist()) { + out.chunk = newMediaPlaylistChunk(); + } else { + out.chunk = null; + } + return; + } + + HlsMediaPlaylist.Segment segment = mediaPlaylist.segments.get(chunkIndex); + + Uri chunkUri = Util.getMergedUri(mediaPlaylist.baseUri, segment.url); + DataSpec dataSpec = new DataSpec(chunkUri, 0, C.LENGTH_UNBOUNDED, null); + + long startTimeUs = segment.startTimeUs; + long endTimeUs = startTimeUs + (long) (segment.durationSecs * 1000000); + + int nextChunkMediaSequence = chunkMediaSequence + 1; + if (!mediaPlaylist.live && chunkIndex == mediaPlaylist.segments.size() - 1) { + nextChunkMediaSequence = -1; + } + + out.chunk = new TsChunk(dataSource, dataSpec, 0, extractor, startTimeUs, endTimeUs, + nextChunkMediaSequence, segment.discontinuity); + } + + private boolean shouldRerequestMediaPlaylist() { + // Don't re-request media playlist more often than one-half of the target duration. + long timeSinceLastMediaPlaylistLoadMs = + SystemClock.elapsedRealtime() - lastMediaPlaylistLoadTimeMs; + return timeSinceLastMediaPlaylistLoadMs >= (mediaPlaylist.targetDurationSecs * 1000) / 2; + } + + private int getLiveStartChunkMediaSequence() { + // For live start playback from the third chunk from the end. + int chunkIndex = mediaPlaylist.segments.size() > 3 ? mediaPlaylist.segments.size() - 3 : 0; + return chunkIndex + mediaPlaylist.mediaSequence; + } + + private MediaPlaylistChunk newMediaPlaylistChunk() { + Uri mediaPlaylistUri = Util.getMergedUri(masterPlaylist.baseUri, + masterPlaylist.variants.get(0).url); + DataSpec dataSpec = new DataSpec(mediaPlaylistUri, 0, C.LENGTH_UNBOUNDED, null); + Uri mediaPlaylistBaseUri = Util.parseBaseUri(mediaPlaylistUri.toString()); + return new MediaPlaylistChunk(dataSource, dataSpec, 0, mediaPlaylistBaseUri); + } + + private class MediaPlaylistChunk extends HlsChunk { + + private final Uri baseUri; + + public MediaPlaylistChunk(DataSource dataSource, DataSpec dataSpec, int trigger, Uri baseUri) { + super(dataSource, dataSpec, trigger); + this.baseUri = baseUri; + } + + @Override + protected void consumeStream(NonBlockingInputStream stream) throws IOException { + byte[] data = new byte[(int) stream.getAvailableByteCount()]; + stream.read(data, 0, data.length); + lastMediaPlaylistLoadTimeMs = SystemClock.elapsedRealtime(); + mediaPlaylist = mediaPlaylistParser.parse( + new ByteArrayInputStream(data), null, null, baseUri); + mediaPlaylistWasLive |= mediaPlaylist.live; + } + + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsMasterPlaylist.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsMasterPlaylist.java new file mode 100644 index 0000000000..f118734def --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsMasterPlaylist.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2014 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.exoplayer.hls; + +import android.net.Uri; + +import java.util.List; + +/** + * Represents an HLS master playlist. + */ +public final class HlsMasterPlaylist { + + /** + * Variant stream reference. + */ + public static final class Variant { + public final int bandwidth; + public final String url; + + public Variant(String url, int bandwidth) { + this.bandwidth = bandwidth; + this.url = url; + } + } + + public final Uri baseUri; + public final List variants; + + public HlsMasterPlaylist(Uri baseUri, List variants) { + this.baseUri = baseUri; + this.variants = variants; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsMasterPlaylistParser.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsMasterPlaylistParser.java new file mode 100644 index 0000000000..d00eecc28b --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsMasterPlaylistParser.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2014 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.exoplayer.hls; + +import com.google.android.exoplayer.hls.HlsMasterPlaylist.Variant; +import com.google.android.exoplayer.util.ManifestParser; + +import android.net.Uri; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Pattern; + +/** + * HLS Master playlists parsing logic. + */ +public final class HlsMasterPlaylistParser implements ManifestParser { + + private static final String STREAM_INF_TAG = "#EXT-X-STREAM-INF"; + private static final String BANDWIDTH_ATTR = "BANDWIDTH"; + + private static final Pattern BANDWIDTH_ATTR_REGEX = + Pattern.compile(BANDWIDTH_ATTR + "=(\\d+)\\b"); + + @Override + public HlsMasterPlaylist parse(InputStream inputStream, String inputEncoding, + String contentId, Uri baseUri) throws IOException { + return parseMasterPlaylist(inputStream, inputEncoding, baseUri); + } + + private static HlsMasterPlaylist parseMasterPlaylist(InputStream inputStream, + String inputEncoding, Uri baseUri) throws IOException { + BufferedReader reader = new BufferedReader((inputEncoding == null) + ? new InputStreamReader(inputStream) : new InputStreamReader(inputStream, inputEncoding)); + List variants = new ArrayList(); + int bandwidth = 0; + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) { + continue; + } + if (line.startsWith(STREAM_INF_TAG)) { + bandwidth = HlsParserUtil.parseIntAttr(line, BANDWIDTH_ATTR_REGEX, BANDWIDTH_ATTR); + } else if (!line.startsWith("#")) { + variants.add(new Variant(line, bandwidth)); + bandwidth = 0; + } + } + return new HlsMasterPlaylist(baseUri, Collections.unmodifiableList(variants)); + } + +} 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 new file mode 100644 index 0000000000..fda3e50b03 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsMediaPlaylist.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2014 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.exoplayer.hls; + +import android.net.Uri; + +import java.util.List; + +/** + * Represents an HLS media playlist. + */ +public final class HlsMediaPlaylist { + + /** + * Media segment reference. + */ + public static final class Segment implements Comparable { + public final boolean discontinuity; + public final double durationSecs; + public final String url; + public final long startTimeUs; + + public Segment(String uri, double durationSecs, boolean discontinuity, long startTimeUs) { + this.url = uri; + this.durationSecs = durationSecs; + this.discontinuity = discontinuity; + this.startTimeUs = startTimeUs; + } + + @Override + public int compareTo(Long startTimeUs) { + return (int) (this.startTimeUs - startTimeUs); + } + } + + public final Uri baseUri; + public final int mediaSequence; + public final int targetDurationSecs; + public final int version; + public final List segments; + public final boolean live; + public final long durationUs; + + public HlsMediaPlaylist(Uri baseUri, int mediaSequence, int targetDurationSecs, int version, + boolean live, List segments) { + this.baseUri = baseUri; + this.mediaSequence = mediaSequence; + this.targetDurationSecs = targetDurationSecs; + this.version = version; + this.live = live; + this.segments = segments; + + if (this.segments.size() > 0) { + Segment lastSegment = segments.get(this.segments.size() - 1); + this.durationUs = lastSegment.startTimeUs + (long) (lastSegment.durationSecs * 1000000); + } else { + this.durationUs = 0; + } + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsMediaPlaylistParser.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsMediaPlaylistParser.java new file mode 100644 index 0000000000..d2d514c01e --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsMediaPlaylistParser.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2014 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.exoplayer.hls; + +import com.google.android.exoplayer.hls.HlsMediaPlaylist.Segment; +import com.google.android.exoplayer.util.ManifestParser; + +import android.net.Uri; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Pattern; + +/** + * HLS Media playlists parsing logic. + */ +public final class HlsMediaPlaylistParser implements ManifestParser { + + private static final String DISCONTINUITY_TAG = "#EXT-X-DISCONTINUITY"; + private static final String MEDIA_DURATION_TAG = "#EXTINF"; + private static final String MEDIA_SEQUENCE_TAG = "#EXT-X-MEDIA-SEQUENCE"; + private static final String TARGET_DURATION_TAG = "#EXT-X-TARGETDURATION"; + private static final String VERSION_TAG = "#EXT-X-VERSION"; + private static final String ENDLIST_TAG = "#EXT-X-ENDLIST"; + + private static final Pattern MEDIA_DURATION_REGEX = + Pattern.compile(MEDIA_DURATION_TAG + ":([\\d.]+),"); + private static final Pattern MEDIA_SEQUENCE_REGEX = + Pattern.compile(MEDIA_SEQUENCE_TAG + ":(\\d+)\\b"); + private static final Pattern TARGET_DURATION_REGEX = + Pattern.compile(TARGET_DURATION_TAG + ":(\\d+)\\b"); + private static final Pattern VERSION_REGEX = + Pattern.compile(VERSION_TAG + ":(\\d+)\\b"); + + @Override + public HlsMediaPlaylist parse(InputStream inputStream, String inputEncoding, + String contentId, Uri baseUri) throws IOException { + return parseMediaPlaylist(inputStream, inputEncoding, baseUri); + } + + private static HlsMediaPlaylist parseMediaPlaylist(InputStream inputStream, String inputEncoding, + Uri baseUri) throws IOException { + BufferedReader reader = new BufferedReader((inputEncoding == null) + ? new InputStreamReader(inputStream) : new InputStreamReader(inputStream, inputEncoding)); + + int mediaSequence = 0; + int targetDurationSecs = 0; + int version = 1; // Default version == 1. + boolean live = true; + List segments = new ArrayList(); + + double segmentDurationSecs = 0.0; + boolean segmentDiscontinuity = false; + long segmentStartTimeUs = 0; + + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) { + continue; + } + if (line.startsWith(TARGET_DURATION_TAG)) { + targetDurationSecs = HlsParserUtil.parseIntAttr(line, TARGET_DURATION_REGEX, + TARGET_DURATION_TAG); + } else if (line.startsWith(MEDIA_SEQUENCE_TAG)) { + mediaSequence = HlsParserUtil.parseIntAttr(line, MEDIA_SEQUENCE_REGEX, MEDIA_SEQUENCE_TAG); + } else if (line.startsWith(VERSION_TAG)) { + version = HlsParserUtil.parseIntAttr(line, VERSION_REGEX, VERSION_TAG); + } else if (line.startsWith(MEDIA_DURATION_TAG)) { + segmentDurationSecs = HlsParserUtil.parseDoubleAttr(line, MEDIA_DURATION_REGEX, + MEDIA_DURATION_TAG); + } else if (line.equals(DISCONTINUITY_TAG)) { + segmentDiscontinuity = true; + } else if (!line.startsWith("#")) { + segments.add(new Segment(line, segmentDurationSecs, segmentDiscontinuity, + segmentStartTimeUs)); + segmentStartTimeUs += (long) (segmentDurationSecs * 1000000); + segmentDiscontinuity = false; + segmentDurationSecs = 0.0; + } else if (line.equals(ENDLIST_TAG)) { + live = false; + break; + } + } + return new HlsMediaPlaylist(baseUri, mediaSequence, targetDurationSecs, version, live, + Collections.unmodifiableList(segments)); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsParserUtil.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsParserUtil.java new file mode 100644 index 0000000000..e61483f42c --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsParserUtil.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2014 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.exoplayer.hls; + +import com.google.android.exoplayer.ParserException; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utility methods for HLS manifest parsing. + */ +/* package */ class HlsParserUtil { + + private HlsParserUtil() {} + + public static String parseStringAttr(String line, Pattern pattern, String tag) + throws ParserException { + Matcher matcher = pattern.matcher(line); + if (matcher.find() && matcher.groupCount() == 1) { + return matcher.group(1); + } + throw new ParserException(String.format("Couldn't match %s tag in %s", tag, line)); + } + + public static int parseIntAttr(String line, Pattern pattern, String tag) + throws ParserException { + return Integer.parseInt(parseStringAttr(line, pattern, tag)); + } + + public static double parseDoubleAttr(String line, Pattern pattern, String tag) + throws ParserException { + return Double.parseDouble(parseStringAttr(line, pattern, tag)); + } + +} 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 new file mode 100644 index 0000000000..ad25c12dea --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsSampleSource.java @@ -0,0 +1,560 @@ +/* + * Copyright (C) 2014 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.exoplayer.hls; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.LoadControl; +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.MediaFormatHolder; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.TrackInfo; +import com.google.android.exoplayer.TrackRenderer; +import com.google.android.exoplayer.upstream.Loader; +import com.google.android.exoplayer.upstream.Loader.Loadable; +import com.google.android.exoplayer.util.Assertions; + +import android.os.SystemClock; + +import java.io.IOException; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; + +/** + * A {@link SampleSource} for HLS streams. + *

+ * TODO: Figure out whether this should merge with the chunk package, or whether the hls + * implementation is going to naturally diverge. + */ +public class HlsSampleSource implements SampleSource, Loader.Callback { + + private static final int NO_RESET_PENDING = -1; + + private final LoadControl loadControl; + private final HlsChunkSource chunkSource; + private final HlsChunkOperationHolder currentLoadableHolder; + private final LinkedList mediaChunks; + private final List readOnlyHlsChunks; + private final int bufferSizeContribution; + private final boolean frameAccurateSeeking; + + private int remainingReleaseCount; + private boolean prepared; + private int trackCount; + private int enabledTrackCount; + private boolean[] trackEnabledStates; + private boolean[] pendingDiscontinuities; + private TrackInfo[] trackInfos; + private MediaFormat[] downstreamMediaFormats; + + private long downstreamPositionUs; + private long lastSeekPositionUs; + private long pendingResetTime; + private long lastPerformedBufferOperation; + + private Loader loader; + private IOException currentLoadableException; + private boolean currentLoadableExceptionFatal; + private int currentLoadableExceptionCount; + private long currentLoadableExceptionTimestamp; + + private boolean pendingTimestampOffsetUpdate; + private long timestampOffsetUs; + + public HlsSampleSource(HlsChunkSource chunkSource, LoadControl loadControl, + int bufferSizeContribution, boolean frameAccurateSeeking, int downstreamRendererCount) { + this.chunkSource = chunkSource; + this.loadControl = loadControl; + this.bufferSizeContribution = bufferSizeContribution; + this.frameAccurateSeeking = frameAccurateSeeking; + this.remainingReleaseCount = downstreamRendererCount; + currentLoadableHolder = new HlsChunkOperationHolder(); + mediaChunks = new LinkedList(); + readOnlyHlsChunks = Collections.unmodifiableList(mediaChunks); + } + + @Override + public boolean prepare() { + if (prepared) { + return true; + } + + if (loader == null) { + loader = new Loader("Loader:HLS"); + loadControl.register(this, bufferSizeContribution); + } + updateLoadControl(); + if (mediaChunks.isEmpty()) { + return false; + } + TsChunk mediaChunk = mediaChunks.getFirst(); + if (mediaChunk.prepare()) { + trackCount = mediaChunk.getTrackCount(); + trackEnabledStates = new boolean[trackCount]; + pendingDiscontinuities = new boolean[trackCount]; + downstreamMediaFormats = new MediaFormat[trackCount]; + trackInfos = new TrackInfo[trackCount]; + for (int i = 0; i < trackCount; i++) { + MediaFormat format = mediaChunk.getMediaFormat(i); + trackInfos[i] = new TrackInfo(format.mimeType, chunkSource.getDurationUs()); + } + prepared = true; + } + + return prepared; + } + + @Override + public int getTrackCount() { + Assertions.checkState(prepared); + return trackCount; + } + + @Override + public TrackInfo getTrackInfo(int track) { + Assertions.checkState(prepared); + return trackInfos[track]; + } + + @Override + public void enable(int track, long timeUs) { + Assertions.checkState(prepared); + Assertions.checkState(!trackEnabledStates[track]); + enabledTrackCount++; + trackEnabledStates[track] = true; + downstreamMediaFormats[track] = null; + if (enabledTrackCount == 1) { + downstreamPositionUs = timeUs; + lastSeekPositionUs = timeUs; + restartFrom(timeUs); + } + } + + @Override + public void disable(int track) { + Assertions.checkState(prepared); + Assertions.checkState(trackEnabledStates[track]); + enabledTrackCount--; + trackEnabledStates[track] = false; + pendingDiscontinuities[track] = false; + if (enabledTrackCount == 0) { + if (loader.isLoading()) { + loader.cancelLoading(); + } else { + clearHlsChunks(); + clearCurrentLoadable(); + } + } + } + + @Override + public boolean continueBuffering(long playbackPositionUs) throws IOException { + Assertions.checkState(prepared); + Assertions.checkState(enabledTrackCount > 0); + downstreamPositionUs = playbackPositionUs; + updateLoadControl(); + if (isPendingReset() || mediaChunks.isEmpty()) { + return false; + } else if (mediaChunks.getFirst().sampleAvailable()) { + // There's a sample available to be read from the current chunk. + return true; + } else { + // It may be the case that the current chunk has been fully read but not yet discarded and + // that the next chunk has an available sample. Return true if so, otherwise false. + return mediaChunks.size() > 1 && mediaChunks.get(1).sampleAvailable(); + } + } + + @Override + public int readData(int track, long playbackPositionUs, MediaFormatHolder formatHolder, + SampleHolder sampleHolder, boolean onlyReadDiscontinuity) throws IOException { + Assertions.checkState(prepared); + + if (pendingDiscontinuities[track]) { + pendingDiscontinuities[track] = false; + return DISCONTINUITY_READ; + } + + if (onlyReadDiscontinuity) { + return NOTHING_READ; + } + + downstreamPositionUs = playbackPositionUs; + if (isPendingReset()) { + if (currentLoadableException != null) { + throw currentLoadableException; + } + return NOTHING_READ; + } + + TsChunk mediaChunk = mediaChunks.getFirst(); + + if (mediaChunk.readDiscontinuity()) { + pendingTimestampOffsetUpdate = true; + for (int i = 0; i < pendingDiscontinuities.length; i++) { + pendingDiscontinuities[i] = true; + } + pendingDiscontinuities[track] = false; + return DISCONTINUITY_READ; + } + + if (mediaChunk.isReadFinished()) { + // We've read all of the samples from the current media chunk. + if (mediaChunks.size() > 1) { + discardDownstreamHlsChunk(); + mediaChunk = mediaChunks.getFirst(); + return readData(track, playbackPositionUs, formatHolder, sampleHolder, false); + } else if (mediaChunk.isLastChunk()) { + return END_OF_STREAM; + } + return NOTHING_READ; + } + + if (!mediaChunk.prepare()) { + if (currentLoadableException != null) { + throw currentLoadableException; + } + return NOTHING_READ; + } + + MediaFormat mediaFormat = mediaChunk.getMediaFormat(track); + if (mediaFormat != null && !mediaFormat.equals(downstreamMediaFormats[track], true)) { + chunkSource.getMaxVideoDimensions(mediaFormat); + formatHolder.format = mediaFormat; + downstreamMediaFormats[track] = mediaFormat; + return FORMAT_READ; + } + + if (mediaChunk.read(track, sampleHolder)) { + if (pendingTimestampOffsetUpdate) { + pendingTimestampOffsetUpdate = false; + timestampOffsetUs = sampleHolder.timeUs - mediaChunk.startTimeUs; + } + sampleHolder.timeUs -= timestampOffsetUs; + sampleHolder.decodeOnly = frameAccurateSeeking && sampleHolder.timeUs < lastSeekPositionUs; + return SAMPLE_READ; + } else { + if (currentLoadableException != null) { + throw currentLoadableException; + } + return NOTHING_READ; + } + } + + @Override + public void seekToUs(long timeUs) { + Assertions.checkState(prepared); + Assertions.checkState(enabledTrackCount > 0); + downstreamPositionUs = timeUs; + lastSeekPositionUs = timeUs; + if (pendingResetTime == timeUs) { + return; + } + + for (int i = 0; i < pendingDiscontinuities.length; i++) { + pendingDiscontinuities[i] = true; + } + TsChunk mediaChunk = getHlsChunk(timeUs); + if (mediaChunk == null) { + restartFrom(timeUs); + } else { + pendingTimestampOffsetUpdate = true; + mediaChunk.reset(); + discardDownstreamHlsChunks(mediaChunk); + updateLoadControl(); + } + } + + private TsChunk getHlsChunk(long timeUs) { + Iterator mediaChunkIterator = mediaChunks.iterator(); + while (mediaChunkIterator.hasNext()) { + TsChunk mediaChunk = mediaChunkIterator.next(); + if (timeUs < mediaChunk.startTimeUs) { + return null; + } else if (mediaChunk.isLastChunk() || timeUs < mediaChunk.endTimeUs) { + return mediaChunk; + } + } + return null; + } + + @Override + public long getBufferedPositionUs() { + Assertions.checkState(prepared); + Assertions.checkState(enabledTrackCount > 0); + if (isPendingReset()) { + return pendingResetTime; + } + TsChunk mediaChunk = mediaChunks.getLast(); + HlsChunk currentLoadable = currentLoadableHolder.chunk; + if (currentLoadable != null && mediaChunk == currentLoadable) { + // Linearly interpolate partially-fetched chunk times. + long chunkLength = mediaChunk.getLength(); + if (chunkLength != C.LENGTH_UNBOUNDED) { + return mediaChunk.startTimeUs + ((mediaChunk.endTimeUs - mediaChunk.startTimeUs) + * mediaChunk.bytesLoaded()) / chunkLength; + } else { + return mediaChunk.startTimeUs; + } + } else if (mediaChunk.isLastChunk()) { + return TrackRenderer.END_OF_TRACK_US; + } else { + return mediaChunk.endTimeUs; + } + } + + @Override + public void release() { + Assertions.checkState(remainingReleaseCount > 0); + if (--remainingReleaseCount == 0 && loader != null) { + loadControl.unregister(this); + loader.release(); + loader = null; + } + } + + @Override + public void onLoadCompleted(Loadable loadable) { + HlsChunk currentLoadable = currentLoadableHolder.chunk; + try { + currentLoadable.consume(); + } catch (IOException e) { + currentLoadableException = e; + currentLoadableExceptionCount++; + currentLoadableExceptionTimestamp = SystemClock.elapsedRealtime(); + currentLoadableExceptionFatal = true; + } finally { + if (!isTsChunk(currentLoadable)) { + currentLoadable.release(); + } + if (!currentLoadableExceptionFatal) { + clearCurrentLoadable(); + } + updateLoadControl(); + } + } + + @Override + public void onLoadCanceled(Loadable loadable) { + HlsChunk currentLoadable = currentLoadableHolder.chunk; + if (!isTsChunk(currentLoadable)) { + currentLoadable.release(); + } + clearCurrentLoadable(); + if (enabledTrackCount > 0) { + restartFrom(pendingResetTime); + } else { + clearHlsChunks(); + loadControl.trimAllocator(); + } + } + + @Override + public void onLoadError(Loadable loadable, IOException e) { + currentLoadableException = e; + currentLoadableExceptionCount++; + currentLoadableExceptionTimestamp = SystemClock.elapsedRealtime(); + updateLoadControl(); + } + + private void restartFrom(long timeUs) { + pendingResetTime = timeUs; + if (loader.isLoading()) { + loader.cancelLoading(); + } else { + clearHlsChunks(); + clearCurrentLoadable(); + updateLoadControl(); + } + } + + private void clearHlsChunks() { + discardDownstreamHlsChunks(null); + } + + private void clearCurrentLoadable() { + currentLoadableHolder.chunk = null; + currentLoadableException = null; + currentLoadableExceptionCount = 0; + currentLoadableExceptionFatal = false; + } + + private void updateLoadControl() { + long loadPositionUs; + if (isPendingReset()) { + loadPositionUs = pendingResetTime; + } else { + TsChunk lastHlsChunk = mediaChunks.getLast(); + loadPositionUs = lastHlsChunk.nextChunkIndex == -1 ? -1 : lastHlsChunk.endTimeUs; + } + + boolean isBackedOff = currentLoadableException != null && !currentLoadableExceptionFatal; + boolean nextLoader = loadControl.update(this, downstreamPositionUs, loadPositionUs, + isBackedOff || loader.isLoading(), currentLoadableExceptionFatal); + + if (currentLoadableExceptionFatal) { + return; + } + + long now = SystemClock.elapsedRealtime(); + + if (isBackedOff) { + long elapsedMillis = now - currentLoadableExceptionTimestamp; + if (elapsedMillis >= getRetryDelayMillis(currentLoadableExceptionCount)) { + resumeFromBackOff(); + } + return; + } + + if (!loader.isLoading()) { + if (currentLoadableHolder.chunk == null || now - lastPerformedBufferOperation > 1000) { + lastPerformedBufferOperation = now; + currentLoadableHolder.queueSize = readOnlyHlsChunks.size(); + chunkSource.getChunkOperation(readOnlyHlsChunks, pendingResetTime, downstreamPositionUs, + currentLoadableHolder); + discardUpstreamHlsChunks(currentLoadableHolder.queueSize); + } + if (nextLoader) { + maybeStartLoading(); + } + } + } + + /** + * Resumes loading. + *

+ * If the {@link HlsChunkSource} returns a chunk equivalent to the backed off chunk B, then the + * loading of B will be resumed. In all other cases B will be discarded and the new chunk will + * be loaded. + */ + private void resumeFromBackOff() { + currentLoadableException = null; + + HlsChunk backedOffChunk = currentLoadableHolder.chunk; + if (!isTsChunk(backedOffChunk)) { + currentLoadableHolder.queueSize = readOnlyHlsChunks.size(); + chunkSource.getChunkOperation(readOnlyHlsChunks, pendingResetTime, downstreamPositionUs, + currentLoadableHolder); + discardUpstreamHlsChunks(currentLoadableHolder.queueSize); + if (currentLoadableHolder.chunk == backedOffChunk) { + // HlsChunk was unchanged. Resume loading. + loader.startLoading(backedOffChunk, this); + } else { + backedOffChunk.release(); + maybeStartLoading(); + } + return; + } + + if (backedOffChunk == mediaChunks.getFirst()) { + // We're not able to clear the first media chunk, so we have no choice but to continue + // loading it. + loader.startLoading(backedOffChunk, this); + return; + } + + // The current loadable is the last media chunk. Remove it before we invoke the chunk source, + // and add it back again afterwards. + TsChunk removedChunk = mediaChunks.removeLast(); + Assertions.checkState(backedOffChunk == removedChunk); + currentLoadableHolder.queueSize = readOnlyHlsChunks.size(); + chunkSource.getChunkOperation(readOnlyHlsChunks, pendingResetTime, downstreamPositionUs, + currentLoadableHolder); + mediaChunks.add(removedChunk); + + if (currentLoadableHolder.chunk == backedOffChunk) { + // HlsChunk was unchanged. Resume loading. + loader.startLoading(backedOffChunk, this); + } else { + // This call will remove and release at least one chunk from the end of mediaChunks. Since + // the current loadable is the last media chunk, it is guaranteed to be removed. + discardUpstreamHlsChunks(currentLoadableHolder.queueSize); + clearCurrentLoadable(); + maybeStartLoading(); + } + } + + private void maybeStartLoading() { + HlsChunk currentLoadable = currentLoadableHolder.chunk; + if (currentLoadable == null) { + // Nothing to load. + return; + } + currentLoadable.init(loadControl.getAllocator()); + if (isTsChunk(currentLoadable)) { + TsChunk mediaChunk = (TsChunk) currentLoadable; + if (isPendingReset()) { + pendingTimestampOffsetUpdate = true; + mediaChunk.reset(); + pendingResetTime = NO_RESET_PENDING; + } + mediaChunks.add(mediaChunk); + } + loader.startLoading(currentLoadable, this); + } + + /** + * Discards downstream media chunks until {@code untilChunk} if found. {@code untilChunk} is not + * itself discarded. Null can be passed to discard all media chunks. + * + * @param untilChunk The first media chunk to keep, or null to discard all media chunks. + */ + private void discardDownstreamHlsChunks(TsChunk untilChunk) { + if (mediaChunks.isEmpty() || untilChunk == mediaChunks.getFirst()) { + return; + } + while (!mediaChunks.isEmpty() && untilChunk != mediaChunks.getFirst()) { + mediaChunks.removeFirst().release(); + } + } + + /** + * Discards the first downstream media chunk. + */ + private void discardDownstreamHlsChunk() { + mediaChunks.removeFirst().release(); + } + + /** + * Discard upstream media chunks until the queue length is equal to the length specified. + * + * @param queueLength The desired length of the queue. + */ + private void discardUpstreamHlsChunks(int queueLength) { + while (mediaChunks.size() > queueLength) { + mediaChunks.removeLast().release(); + } + } + + private boolean isTsChunk(HlsChunk chunk) { + return chunk instanceof TsChunk; + } + + private boolean isPendingReset() { + return pendingResetTime != NO_RESET_PENDING; + } + + private long getRetryDelayMillis(long errorCount) { + return Math.min((errorCount - 1) * 1000, 5000); + } + + protected final int usToMs(long timeUs) { + return (int) (timeUs / 1000); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/hls/TsChunk.java b/library/src/main/java/com/google/android/exoplayer/hls/TsChunk.java new file mode 100644 index 0000000000..9da8eb7459 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/TsChunk.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2014 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.exoplayer.hls; + +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.parser.ts.TsExtractor; +import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.upstream.DataSpec; +import com.google.android.exoplayer.upstream.NonBlockingInputStream; + +/** + * A MPEG2TS chunk. + */ +public final class TsChunk extends HlsChunk { + + /** + * The start time of the media contained by the chunk. + */ + public final long startTimeUs; + /** + * The end time of the media contained by the chunk. + */ + public final long endTimeUs; + /** + * The index of the next media chunk, or -1 if this is the last media chunk in the stream. + */ + public final int nextChunkIndex; + /** + * The encoding discontinuity indicator. + */ + private final boolean discontinuity; + + private final TsExtractor extractor; + + private boolean pendingDiscontinuity; + + /** + * @param dataSource A {@link DataSource} for loading the data. + * @param dataSpec Defines the data to be loaded. + * @param extractor The extractor that will be used to extract the samples. + * @param trigger The reason for this chunk being selected. + * @param startTimeUs The start time of the media contained by the chunk, in microseconds. + * @param endTimeUs The end time of the media contained by the chunk, in microseconds. + * @param nextChunkIndex The index of the next chunk, or -1 if this is the last chunk. + * @param discontinuity The encoding discontinuity indicator. + */ + public TsChunk(DataSource dataSource, DataSpec dataSpec, int trigger, TsExtractor extractor, + long startTimeUs, long endTimeUs, int nextChunkIndex, boolean discontinuity) { + super(dataSource, dataSpec, trigger); + this.startTimeUs = startTimeUs; + this.endTimeUs = endTimeUs; + this.nextChunkIndex = nextChunkIndex; + this.extractor = extractor; + this.discontinuity = discontinuity; + this.pendingDiscontinuity = discontinuity; + } + + public boolean readDiscontinuity() { + if (pendingDiscontinuity) { + extractor.reset(); + pendingDiscontinuity = false; + return true; + } + return false; + } + + public boolean prepare() { + return extractor.prepare(getNonBlockingInputStream()); + } + + public int getTrackCount() { + return extractor.getTrackCount(); + } + + public boolean sampleAvailable() { + // TODO: Maybe optimize this to not require looping over the tracks. + if (!prepare()) { + return false; + } + // TODO: Optimize this to not require looping over the tracks. + NonBlockingInputStream inputStream = getNonBlockingInputStream(); + int trackCount = extractor.getTrackCount(); + for (int i = 0; i < trackCount; i++) { + int result = extractor.read(inputStream, i, null); + if ((result & TsExtractor.RESULT_NEED_SAMPLE_HOLDER) != 0) { + return true; + } + } + return false; + } + + public boolean read(int track, SampleHolder holder) { + int result = extractor.read(getNonBlockingInputStream(), track, holder); + return (result & TsExtractor.RESULT_READ_SAMPLE) != 0; + } + + public void reset() { + extractor.reset(); + pendingDiscontinuity = discontinuity; + resetReadPosition(); + } + + public MediaFormat getMediaFormat(int track) { + return extractor.getFormat(track); + } + + public boolean isLastChunk() { + return nextChunkIndex == -1; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/parser/ts/BitsArray.java b/library/src/main/java/com/google/android/exoplayer/parser/ts/BitsArray.java new file mode 100644 index 0000000000..6e51eceac3 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/parser/ts/BitsArray.java @@ -0,0 +1,280 @@ +/* + * Copyright (C) 2014 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.exoplayer.parser.ts; + +import com.google.android.exoplayer.upstream.NonBlockingInputStream; +import com.google.android.exoplayer.util.Assertions; + +/** + * Wraps a byte array, providing methods that allow it to be read as a bitstream. + */ +public final class BitsArray { + + private byte[] data; + + // The length of the valid data. + private int limit; + + // The offset within the data, stored as the current byte offset, and the bit offset within that + // byte (from 0 to 7). + private int byteOffset; + private int bitOffset; + + /** + * Resets the state. + */ + public void reset() { + byteOffset = 0; + bitOffset = 0; + limit = 0; + } + + /** + * Gets the current byte offset. + * + * @return The current byte offset. + */ + public int getByteOffset() { + return byteOffset; + } + + /** + * Sets the current byte offset. + * + * @param byteOffset The byte offset to set. + */ + public void setByteOffset(int byteOffset) { + this.byteOffset = byteOffset; + } + + /** + * Appends data from a {@link NonBlockingInputStream}. + * + * @param inputStream The {@link NonBlockingInputStream} whose data should be appended. + * @param length The maximum number of bytes to read and append. + * @return The number of bytes that were read and appended. May be 0 if no data was available + * from the stream. -1 is returned if the end of the stream has been reached. + */ + public int append(NonBlockingInputStream inputStream, int length) { + expand(length); + int bytesRead = inputStream.read(data, limit, length); + if (bytesRead == -1) { + return -1; + } + limit += bytesRead; + return bytesRead; + } + + /** + * Appends data from another {@link BitsArray}. + * + * @param bitsArray The {@link BitsArray} whose data should be appended. + * @param length The number of bytes to read and append. + */ + public void append(BitsArray bitsArray, int length) { + expand(length); + bitsArray.readBytes(data, limit, length); + limit += length; + } + + private void expand(int length) { + if (data == null) { + data = new byte[length]; + return; + } + if (data.length - limit < length) { + byte[] newBuffer = new byte[limit + length]; + System.arraycopy(data, 0, newBuffer, 0, limit); + data = newBuffer; + } + } + + /** + * Clears data that has already been read, moving the remaining data to the start of the buffer. + */ + public void clearReadData() { + System.arraycopy(data, byteOffset, data, 0, limit - byteOffset); + limit -= byteOffset; + byteOffset = 0; + } + + /** + * Reads a single unsigned byte. + * + * @return The value of the parsed byte. + */ + public int readUnsignedByte() { + byte b; + if (bitOffset != 0) { + b = (byte) ((data[byteOffset] << bitOffset) + | (data[byteOffset + 1] >> (8 - bitOffset))); + } else { + b = data[byteOffset]; + } + byteOffset++; + // Converting a signed byte into unsigned. + return b & 0xFF; + } + + + /** + * Reads up to 32 bits. + * + * @param n The number of bits to read. + * @return An integer whose bottom n bits hold the read data. + */ + public int readBits(int n) { + return (int) readBitsLong(n); + } + + /** + * Reads up to 64 bits. + * + * @param n The number of bits to read. + * @return A long whose bottom n bits hold the read data. + */ + public long readBitsLong(int n) { + if (n == 0) { + return 0; + } + + long retval = 0; + + // While n >= 8, read whole bytes. + while (n >= 8) { + n -= 8; + retval |= (readUnsignedByte() << n); + } + + if (n > 0) { + int nextBit = bitOffset + n; + byte writeMask = (byte) (0xFF >> (8 - n)); + + if (nextBit > 8) { + // Combine bits from current byte and next byte. + retval |= (((getUnsignedByte(byteOffset) << (nextBit - 8) + | (getUnsignedByte(byteOffset + 1) >> (16 - nextBit))) & writeMask)); + byteOffset++; + } else { + // Bits to be read only within current byte. + retval |= ((getUnsignedByte(byteOffset) >> (8 - nextBit)) & writeMask); + if (nextBit == 8) { + byteOffset++; + } + } + + bitOffset = nextBit % 8; + } + + return retval; + } + + private int getUnsignedByte(int offset) { + return data[offset] & 0xFF; + } + + /** + * Skips bits and moves current reading position forward. + * + * @param n The number of bits to skip. + */ + public void skipBits(int n) { + byteOffset += (n / 8); + bitOffset += (n % 8); + if (bitOffset > 7) { + byteOffset++; + bitOffset -= 8; + } + } + + /** + * Skips bytes and moves current reading position forward. + * + * @param n The number of bytes to skip. + */ + public void skipBytes(int n) { + byteOffset += n; + } + + /** + * Reads multiple bytes and copies them into provided byte array. + *

+ * The read position must be at a whole byte boundary for this method to be called. + * + * @param out The byte array to copy read data. + * @param offset The offset in the out byte array. + * @param length The length of the data to read + * @throws IllegalStateException If the method is called with the read position not at a whole + * byte boundary. + */ + public void readBytes(byte[] out, int offset, int length) { + Assertions.checkState(bitOffset == 0); + System.arraycopy(data, byteOffset, out, offset, length); + byteOffset += length; + } + + /** + * @return The number of whole bytes that are available to read. + */ + public int bytesLeft() { + return limit - byteOffset; + } + + /** + * @return Whether or not there is any data available. + */ + public boolean isEmpty() { + return limit == 0; + } + + // TODO: Find a better place for this method. + /** + * Finds the next Adts sync word. + * + * @return The offset from the current position to the start of the next Adts sync word. If an + * Adts sync word is not found, then the offset to the end of the data is returned. + */ + public int findNextAdtsSyncWord() { + for (int i = byteOffset; i < limit - 1; i++) { + int syncBits = (getUnsignedByte(i) << 8) | getUnsignedByte(i + 1); + if ((syncBits & 0xFFF0) == 0xFFF0 && syncBits != 0xFFFF) { + return i - byteOffset; + } + } + return limit - byteOffset; + } + + //TODO: Find a better place for this method. + /** + * Finds the next NAL unit. + * + * @param nalUnitType The type of the NAL unit to search for. + * @param offset The additional offset in the data to start the search from. + * @return The offset from the current position to the start of the NAL unit. If a NAL unit is + * not found, then the offset to the end of the data is returned. + */ + public int findNextNalUnit(int nalUnitType, int offset) { + for (int i = byteOffset + offset; i < limit - 3; i++) { + // Check for NAL unit start code prefix == 0x000001. + if ((data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1) + && (nalUnitType == (data[i + 3] & 0x1F))) { + return i - byteOffset; + } + } + return limit - byteOffset; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/parser/ts/TsExtractor.java b/library/src/main/java/com/google/android/exoplayer/parser/ts/TsExtractor.java new file mode 100644 index 0000000000..cfdc261925 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/parser/ts/TsExtractor.java @@ -0,0 +1,722 @@ +/* + * Copyright (C) 2014 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.exoplayer.parser.ts; + +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.upstream.NonBlockingInputStream; +import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.CodecSpecificDataUtil; +import com.google.android.exoplayer.util.MimeTypes; + +import android.annotation.SuppressLint; +import android.media.MediaExtractor; +import android.util.Log; +import android.util.Pair; +import android.util.SparseArray; + +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.LinkedList; +import java.util.Queue; + +/** + * Facilitates the extraction of data from the MPEG-2 TS container format. + */ +public final class TsExtractor { + + /** + * An attempt to read from the input stream returned insufficient data. + */ + public static final int RESULT_NEED_MORE_DATA = 1; + /** + * A media sample was read. + */ + public static final int RESULT_READ_SAMPLE = 2; + /** + * The next thing to be read is a sample, but a {@link SampleHolder} was not supplied. + */ + public static final int RESULT_NEED_SAMPLE_HOLDER = 4; + + private static final String TAG = "TsExtractor"; + + private static final int TS_PACKET_SIZE = 188; + private static final int TS_SYNC_BYTE = 0x47; // First byte of each TS packet. + private static final int TS_PAT_PID = 0; + + private static final int TS_STREAM_TYPE_AAC = 0x0F; + private static final int TS_STREAM_TYPE_H264 = 0x1B; + + private static final int DEFAULT_BUFFER_SEGMENT_SIZE = 64 * 1024; + + private final BitsArray tsPacketBuffer; + private final SparseArray pesPayloadReaders; // Indexed by streamType + private final SparseArray tsPayloadReaders; // Indexed by pid + private final Queue samplesPool; + + private boolean prepared; + + public TsExtractor() { + tsPacketBuffer = new BitsArray(); + pesPayloadReaders = new SparseArray(); + tsPayloadReaders = new SparseArray(); + tsPayloadReaders.put(TS_PAT_PID, new PatReader()); + samplesPool = new LinkedList(); + } + + /** + * Gets the number of available tracks. + *

+ * This method should only be called after the extractor has been prepared. + * + * @return The number of available tracks. + */ + public int getTrackCount() { + Assertions.checkState(prepared); + return pesPayloadReaders.size(); + } + + /** + * Gets the format of the specified track. + *

+ * This method must only be called after the extractor has been prepared. + * + * @param track The track index. + * @return The corresponding format. + */ + public MediaFormat getFormat(int track) { + Assertions.checkState(prepared); + return pesPayloadReaders.valueAt(track).getMediaFormat(); + } + + /** + * Resets the extractor's internal state. + */ + public void reset() { + prepared = false; + tsPacketBuffer.reset(); + tsPayloadReaders.clear(); + tsPayloadReaders.put(TS_PAT_PID, new PatReader()); + // Clear each reader before discarding it, so as to recycle any queued Sample objects. + for (int i = 0; i < pesPayloadReaders.size(); i++) { + pesPayloadReaders.valueAt(i).clear(); + } + pesPayloadReaders.clear(); + } + + /** + * Attempts to prepare the extractor. The extractor is prepared once it has read sufficient data + * to have established the available tracks and their corresponding media formats. + *

+ * Calling this method is a no-op if the extractor is already prepared. + * + * @param inputStream The input stream from which data can be read. + * @return True if the extractor was prepared. False if more data is required. + */ + public boolean prepare(NonBlockingInputStream inputStream) { + while (!prepared) { + if (readTSPacket(inputStream) == -1) { + return false; + } + prepared = checkPrepared(); + } + return true; + } + + private boolean checkPrepared() { + int pesPayloadReaderCount = pesPayloadReaders.size(); + if (pesPayloadReaderCount == 0) { + return false; + } + for (int i = 0; i < pesPayloadReaderCount; i++) { + if (!pesPayloadReaders.valueAt(i).hasMediaFormat()) { + return false; + } + } + return true; + } + + /** + * Consumes data from a {@link NonBlockingInputStream}. + *

+ * The read terminates if the end of the input stream is reached, if insufficient data is + * available to read a sample, or if a sample is read. The returned flags indicate + * both the reason for termination and data that was parsed during the read. + * + * @param inputStream The input stream from which data should be read. + * @param track The track from which to read. + * @param out A {@link SampleHolder} into which the next sample should be read. If null then + * {@link #RESULT_NEED_SAMPLE_HOLDER} will be returned once a sample has been reached. + * @return One or more of the {@code RESULT_*} flags defined in this class. + */ + public int read(NonBlockingInputStream inputStream, int track, SampleHolder out) { + Assertions.checkState(prepared); + Queue queue = pesPayloadReaders.valueAt(track).samplesQueue; + + // Keep reading if the buffer is empty. + while (queue.isEmpty()) { + if (readTSPacket(inputStream) == -1) { + return RESULT_NEED_MORE_DATA; + } + } + + if (!queue.isEmpty() && out == null) { + return RESULT_NEED_SAMPLE_HOLDER; + } + + Sample sample = queue.remove(); + convert(sample, out); + recycleSample(sample); + return RESULT_READ_SAMPLE; + } + + /** + * Read a single TS packet. + */ + private int readTSPacket(NonBlockingInputStream inputStream) { + // Read entire single TS packet. + if (inputStream.getAvailableByteCount() < TS_PACKET_SIZE) { + return -1; + } + + tsPacketBuffer.reset(); + + int bytesRead = tsPacketBuffer.append(inputStream, TS_PACKET_SIZE); + if (bytesRead != TS_PACKET_SIZE) { + return -1; + } + + // Parse TS header. + // Check sync byte. + int syncByte = tsPacketBuffer.readUnsignedByte(); + if (syncByte != TS_SYNC_BYTE) { + return 0; + } + // Skip transportErrorIndicator. + tsPacketBuffer.skipBits(1); + int payloadUnitStartIndicator = tsPacketBuffer.readBits(1); + // Skip transportPriority. + tsPacketBuffer.skipBits(1); + int pid = tsPacketBuffer.readBits(13); + // Skip transport_scrambling_control. + tsPacketBuffer.skipBits(2); + int adaptationFieldExist = tsPacketBuffer.readBits(1); + int payloadExist = tsPacketBuffer.readBits(1); + // Skip continuityCounter. + tsPacketBuffer.skipBits(4); + + // Read Adaptation Field. + if (adaptationFieldExist == 1) { + int afLength = tsPacketBuffer.readBits(8); + tsPacketBuffer.skipBytes(afLength); + } + + // Read Payload. + if (payloadExist == 1) { + TsPayloadReader payloadReader = tsPayloadReaders.get(pid); + if (payloadReader == null) { + return 0; + } + payloadReader.read(tsPacketBuffer, payloadUnitStartIndicator); + } + return 0; + } + + private void convert(Sample in, SampleHolder out) { + if (out.data == null || out.data.capacity() < in.size) { + if (out.allowDataBufferReplacement) { + out.data = ByteBuffer.allocate(in.size); + } else { + throw new IndexOutOfBoundsException("Buffer too small, and replacement not enabled"); + } + } + out.data.put(in.data, 0, in.size); + out.size = in.size; + out.flags = in.flags; + out.timeUs = in.timeUs; + } + + private Sample getSample() { + if (samplesPool.isEmpty()) { + return new Sample(DEFAULT_BUFFER_SEGMENT_SIZE); + } + return samplesPool.remove(); + } + + private void recycleSample(Sample sample) { + sample.reset(); + samplesPool.add(sample); + } + + /** + * Parses payload data. + */ + private abstract static class TsPayloadReader { + public abstract void read(BitsArray tsBuffer, int payloadUnitStartIndicator); + } + + /** + * Parses Program Association Table data. + */ + private class PatReader extends TsPayloadReader { + + @Override + public void read(BitsArray tsBuffer, int payloadUnitStartIndicator) { + // Skip pointer. + if (payloadUnitStartIndicator == 1) { + int pointerField = tsBuffer.readBits(8); + tsBuffer.skipBytes(pointerField); + } + + // Skip PAT header. + tsBuffer.skipBits(64); // 8+1+1+2+12+16+2+5+1+8+8 + + // Only read the first program and take it. + + // Skip program_number. + tsBuffer.skipBits(16 + 3); + int pid = tsBuffer.readBits(13); + + // Pick the first program. + if (tsPayloadReaders.get(pid) == null) { + tsPayloadReaders.put(pid, new PmtReader()); + } + + // Skip other programs if exist. + // Skip CRC_32. + } + + } + + /** + * Parses Program Map Table. + */ + private class PmtReader extends TsPayloadReader { + + @Override + public void read(BitsArray tsBuffer, int payloadUnitStartIndicator) { + // Skip pointer. + if (payloadUnitStartIndicator == 1) { + int pointerField = tsBuffer.readBits(8); + tsBuffer.skipBytes(pointerField); + } + + // Skip table_id, section_syntax_indicator, etc. + tsBuffer.skipBits(12); // 8+1+1+2 + int sectionLength = tsBuffer.readBits(12); + // Skip the rest of the PMT header. + tsBuffer.skipBits(60); // 16+2+5+1+8+8+3+13+4 + int programInfoLength = tsBuffer.readBits(12); + + // Read descriptors. + readDescriptors(tsBuffer, programInfoLength); + + int entriesSize = sectionLength - 9 /* size of the rest of the fields before descriptors */ + - programInfoLength - 4 /* CRC size */; + while (entriesSize > 0) { + int streamType = tsBuffer.readBits(8); + tsBuffer.skipBits(3); + int elementaryPid = tsBuffer.readBits(13); + tsBuffer.skipBits(4); + int esInfoLength = tsBuffer.readBits(12); + + readDescriptors(tsBuffer, esInfoLength); + entriesSize -= esInfoLength + 5; + + if (pesPayloadReaders.get(streamType) != null) { + continue; + } + + PesPayloadReader pesPayloadReader = null; + switch (streamType) { + case TS_STREAM_TYPE_AAC: + pesPayloadReader = new AdtsReader(); + break; + case TS_STREAM_TYPE_H264: + pesPayloadReader = new H264Reader(); + break; + } + + if (pesPayloadReader != null) { + pesPayloadReaders.put(streamType, pesPayloadReader); + tsPayloadReaders.put(elementaryPid, new PesReader(pesPayloadReader)); + } + } + + // Skip CRC_32. + } + + private void readDescriptors(BitsArray tsBuffer, int descriptorsSize) { + while (descriptorsSize > 0) { + // Skip tag. + tsBuffer.skipBits(8); + int descriptorsLength = tsBuffer.readBits(8); + if (descriptorsLength > 0) { + // Skip entire descriptor data. + tsBuffer.skipBytes(descriptorsLength); + } + descriptorsSize -= descriptorsSize + 2; + } + } + + } + + /** + * Parses PES packet data and extracts samples. + */ + private class PesReader extends TsPayloadReader { + + // Reusable buffer for incomplete PES data. + private final BitsArray pesBuffer; + // Parses PES payload and extracts individual samples. + private final PesPayloadReader pesPayloadReader; + + public PesReader(PesPayloadReader pesPayloadReader) { + this.pesPayloadReader = pesPayloadReader; + pesBuffer = new BitsArray(); + } + + @Override + public void read(BitsArray tsBuffer, int payloadUnitStartIndicator) { + if (payloadUnitStartIndicator == 1 && !pesBuffer.isEmpty()) { + readPES(); + } + pesBuffer.append(tsBuffer, tsBuffer.bytesLeft()); + } + + /** + * Parses completed PES data. + */ + private void readPES() { + int packetStartCodePrefix = pesBuffer.readBits(24); + if (packetStartCodePrefix != 0x000001) { + // Error. + } + // TODO: Read and use stream_id. + // Skip stream_id. + pesBuffer.skipBits(8); + int pesPacketLength = pesBuffer.readBits(16); + + // Skip some fields/flags. + // TODO: might need to use data_alignment_indicator. + pesBuffer.skipBits(8); // 2+2+1+1+1+1 + int ptsFlag = pesBuffer.readBits(1); + // Skip DTS flag. + pesBuffer.skipBits(1); + // Skip some fields/flags. + pesBuffer.skipBits(6); // 1+1+1+1+1+1 + + int pesHeaderDataLength = pesBuffer.readBits(8); + if (pesHeaderDataLength == 0) { + pesHeaderDataLength = pesBuffer.bytesLeft(); + } + + long timeUs = 0; + + if (ptsFlag == 1) { + // Skip prefix. + pesBuffer.skipBits(4); + long pts = pesBuffer.readBitsLong(3) << 30; + pesBuffer.skipBits(1); + pts |= pesBuffer.readBitsLong(15) << 15; + pesBuffer.skipBits(1); + pts |= pesBuffer.readBitsLong(15); + pesBuffer.skipBits(1); + + timeUs = pts * 1000000 / 90000; + + // Skip the rest of the header. + pesBuffer.skipBytes(pesHeaderDataLength - 5); + } else { + // Skip the rest of the header. + pesBuffer.skipBytes(pesHeaderDataLength); + } + + int payloadSize; + if (pesPacketLength == 0) { + // If pesPacketLength is not specified read all available data. + payloadSize = pesBuffer.bytesLeft(); + } else { + payloadSize = pesPacketLength - pesHeaderDataLength - 3; + } + + pesPayloadReader.read(pesBuffer, payloadSize, timeUs); + + pesBuffer.reset(); + } + + } + + /** + * Extracts individual samples from continuous byte stream. + */ + private abstract class PesPayloadReader { + + public final Queue samplesQueue; + + private MediaFormat mediaFormat; + + protected PesPayloadReader() { + this.samplesQueue = new LinkedList(); + } + + public boolean hasMediaFormat() { + return mediaFormat != null; + } + + public MediaFormat getMediaFormat() { + return mediaFormat; + } + + protected void setMediaFormat(MediaFormat mediaFormat) { + this.mediaFormat = mediaFormat; + } + + public abstract void read(BitsArray pesBuffer, int pesPayloadSize, long pesTimeUs); + + public void clear() { + while (!samplesQueue.isEmpty()) { + recycleSample(samplesQueue.remove()); + } + } + + /** + * Creates a new Sample and adds it to the queue. + * + * @param buffer The buffer to read sample data. + * @param sampleSize The size of the sample data. + * @param sampleTimeUs The sample time stamp. + */ + protected void addSample(BitsArray buffer, int sampleSize, long sampleTimeUs, int flags) { + Sample sample = getSample(); + addToSample(sample, buffer, sampleSize); + sample.flags = flags; + sample.timeUs = sampleTimeUs; + samplesQueue.add(sample); + } + + protected void addToSample(Sample sample, BitsArray buffer, int size) { + if (sample.data.length - sample.size < size) { + sample.expand(size - sample.data.length + sample.size); + } + buffer.readBytes(sample.data, sample.size, size); + sample.size += size; + } + + } + + /** + * Parses a continuous H264 byte stream and extracts individual frames. + */ + private class H264Reader extends PesPayloadReader { + + // IDR picture. + private static final int NAL_UNIT_TYPE_IDR = 5; + // Access unit delimiter. + private static final int NAL_UNIT_TYPE_AUD = 9; + + // Used to store uncompleted sample data. + private Sample currentSample; + + public H264Reader() { + // TODO: Parse the format from the stream. + setMediaFormat(MediaFormat.createVideoFormat(MimeTypes.VIDEO_H264, MediaFormat.NO_VALUE, + 1920, 1080, null)); + } + + @Override + public void read(BitsArray pesBuffer, int pesPayloadSize, long pesTimeUs) { + // Read leftover frame data from previous PES packet. + pesPayloadSize -= readOneH264Frame(pesBuffer, true); + + if (pesBuffer.bytesLeft() <= 0 || pesPayloadSize <= 0) { + return; + } + + // Single PES packet should contain only one new H.264 frame. + if (currentSample != null) { + samplesQueue.add(currentSample); + } + currentSample = getSample(); + pesPayloadSize -= readOneH264Frame(pesBuffer, false); + currentSample.timeUs = pesTimeUs; + + if (pesPayloadSize > 0) { + Log.e(TAG, "PES packet contains more frame data than expected"); + } + } + + @SuppressLint("InlinedApi") + private int readOneH264Frame(BitsArray pesBuffer, boolean remainderOnly) { + int offset = remainderOnly ? 0 : 3; + int audStart = pesBuffer.findNextNalUnit(NAL_UNIT_TYPE_AUD, offset); + int idrStart = pesBuffer.findNextNalUnit(NAL_UNIT_TYPE_IDR, offset); + if (audStart > 0) { + if (currentSample != null) { + addToSample(currentSample, pesBuffer, audStart); + if (idrStart < audStart) { + currentSample.flags = MediaExtractor.SAMPLE_FLAG_SYNC; + } + } else { + pesBuffer.skipBytes(audStart); + } + return audStart; + } + return 0; + } + + @Override + public void clear() { + super.clear(); + if (currentSample != null) { + recycleSample(currentSample); + currentSample = null; + } + } + } + + /** + * Parses a continuous ADTS byte stream and extracts individual frames. + */ + private class AdtsReader extends PesPayloadReader { + + private final BitsArray adtsBuffer; + private long timeUs; + + public AdtsReader() { + adtsBuffer = new BitsArray(); + } + + @Override + public void read(BitsArray pesBuffer, int pesPayloadSize, long pesTimeUs) { + boolean needToProcessLeftOvers = !adtsBuffer.isEmpty(); + adtsBuffer.append(pesBuffer, pesPayloadSize); + // If there are leftovers from previous PES packet, process it with last calculated timeUs. + if (needToProcessLeftOvers && !readOneAacFrame(timeUs)) { + return; + } + int frameIndex = 0; + do { + long frameDuration = 0; + // If frameIndex > 0, audioMediaFormat should be already parsed. + // If frameIndex == 0, timeUs = pesTimeUs anyway. + if (hasMediaFormat()) { + frameDuration = 1000000L * 1024L / getMediaFormat().sampleRate; + } + timeUs = pesTimeUs + frameIndex * frameDuration; + frameIndex++; + } while(readOneAacFrame(timeUs)); + } + + @SuppressLint("InlinedApi") + private boolean readOneAacFrame(long timeUs) { + if (adtsBuffer.isEmpty()) { + return false; + } + + int offsetToSyncWord = adtsBuffer.findNextAdtsSyncWord(); + adtsBuffer.skipBytes(offsetToSyncWord); + + int adtsStartOffset = adtsBuffer.getByteOffset(); + + if (adtsBuffer.bytesLeft() < 7) { + adtsBuffer.setByteOffset(adtsStartOffset); + adtsBuffer.clearReadData(); + return false; + } + + adtsBuffer.skipBits(15); + int hasCRC = adtsBuffer.readBits(1); + + if (!hasMediaFormat()) { + int audioObjectType = adtsBuffer.readBits(2) + 1; + int sampleRateIndex = adtsBuffer.readBits(4); + adtsBuffer.skipBits(1); + int channelConfig = adtsBuffer.readBits(3); + + byte[] audioSpecificConfig = CodecSpecificDataUtil.buildAudioSpecificConfig( + audioObjectType, sampleRateIndex, channelConfig); + Pair audioParams = CodecSpecificDataUtil.parseAudioSpecificConfig( + audioSpecificConfig); + + MediaFormat mediaFormat = MediaFormat.createAudioFormat(MimeTypes.AUDIO_AAC, + MediaFormat.NO_VALUE, audioParams.second, audioParams.first, + Collections.singletonList(audioSpecificConfig)); + setMediaFormat(mediaFormat); + } else { + adtsBuffer.skipBits(10); + } + + adtsBuffer.skipBits(4); + int frameSize = adtsBuffer.readBits(13); + adtsBuffer.skipBits(13); + + // Decrement frame size by ADTS header size and CRC. + if (hasCRC == 0) { + // Skip CRC. + adtsBuffer.skipBytes(2); + frameSize -= 9; + } else { + frameSize -= 7; + } + + if (frameSize > adtsBuffer.bytesLeft()) { + adtsBuffer.setByteOffset(adtsStartOffset); + adtsBuffer.clearReadData(); + return false; + } + + addSample(adtsBuffer, frameSize, timeUs, MediaExtractor.SAMPLE_FLAG_SYNC); + return true; + } + + @Override + public void clear() { + super.clear(); + adtsBuffer.reset(); + } + + } + + /** + * Simplified version of SampleHolder for internal buffering. + */ + private static class Sample { + + public byte[] data; + public int flags; + public int size; + public long timeUs; + + public Sample(int length) { + data = new byte[length]; + } + + public void expand(int length) { + byte[] newBuffer = new byte[data.length + length]; + System.arraycopy(data, 0, newBuffer, 0, size); + data = newBuffer; + } + + public void reset() { + flags = 0; + size = 0; + timeUs = 0; + } + + } + +}