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 a55e2c2cb0..a2915f53ff 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 @@ -50,6 +50,7 @@ public class DemoUtil { public static final int TYPE_DASH = 0; public static final int TYPE_SS = 1; public static final int TYPE_OTHER = 2; + public static final int TYPE_HLS = 3; 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 99388aa650..94478c48e7 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 @@ -56,6 +56,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 c9ddec33f4..b3eb4af93c 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 @@ -52,6 +52,9 @@ package com.google.android.exoplayer.demo; new Sample("Super speed (SmoothStreaming)", "uid:ss:superspeed", "http://playready.directtaps.net/smoothstreaming/SSWSS720H264/SuperSpeedway_720.ism", DemoUtil.TYPE_SS, false), + new Sample("Apple master playlist (HLS)", "uid:hls:applemaster", + "https://devimages.apple.com.edgekey.net/streaming/examples/bipbop_4x3/" + + "bipbop_4x3_variant.m3u8", DemoUtil.TYPE_HLS, false), new Sample("Dizzy (Misc)", "uid:misc:dizzy", "http://html5demos.com/assets/dizzy.mp4", DemoUtil.TYPE_OTHER, false), }; @@ -124,6 +127,18 @@ package com.google.android.exoplayer.demo; + "22727BB612D24AA4FACE4EF62726F9461A9BF57A&key=ik0", DemoUtil.TYPE_DASH, 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, true), + new Sample("Apple master playlist advanced", "uid:hls:applemasteradvanced", + "https://devimages.apple.com.edgekey.net/streaming/examples/bipbop_16x9/" + + "bipbop_16x9_variant.m3u8", DemoUtil.TYPE_HLS, 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, true), + }; + public static final Sample[] MISC = new Sample[] { new Sample("Dizzy", "uid:misc:dizzy", "http://html5demos.com/assets/dizzy.mp4", DemoUtil.TYPE_OTHER, 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 d32c61fd22..eb2ad94ebf 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 @@ -25,8 +25,10 @@ import com.google.android.exoplayer.demo.full.player.DashRendererBuilder; 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.demo.full.player.UnsupportedDrmException; +import com.google.android.exoplayer.metadata.TxxxMetadata; import com.google.android.exoplayer.text.CaptionStyleCompat; import com.google.android.exoplayer.text.SubtitleView; import com.google.android.exoplayer.util.Util; @@ -40,6 +42,7 @@ import android.graphics.Point; import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; +import android.util.Log; import android.view.Display; import android.view.Menu; import android.view.MenuItem; @@ -57,11 +60,16 @@ import android.widget.PopupMenu.OnMenuItemClickListener; import android.widget.TextView; import android.widget.Toast; +import java.util.Map; + /** * An activity that plays media using {@link DemoPlayer}. */ public class FullPlayerActivity extends Activity implements SurfaceHolder.Callback, OnClickListener, - DemoPlayer.Listener, DemoPlayer.TextListener, AudioCapabilitiesReceiver.Listener { + DemoPlayer.Listener, DemoPlayer.TextListener, DemoPlayer.Id3MetadataListener, + AudioCapabilitiesReceiver.Listener { + + private static final String TAG = "FullPlayerActivity"; private static final float CAPTION_LINE_HEIGHT_RATIO = 0.0533f; private static final int MENU_GROUP_TRACKS = 1; @@ -199,6 +207,8 @@ public class FullPlayerActivity extends Activity implements SurfaceHolder.Callba case DemoUtil.TYPE_DASH: return new DashRendererBuilder(userAgent, contentUri.toString(), contentId, new WidevineTestMediaDrmCallback(contentId), debugTextView, audioCapabilities); + case DemoUtil.TYPE_HLS: + return new HlsRendererBuilder(userAgent, contentUri.toString(), contentId); default: return new DefaultRendererBuilder(this, contentUri, debugTextView); } @@ -209,6 +219,7 @@ public class FullPlayerActivity extends Activity implements SurfaceHolder.Callba player = new DemoPlayer(getRendererBuilder()); player.addListener(this); player.setTextListener(this); + player.setMetadataListener(this); player.seekTo(playerPosition); playerNeedsPrepare = true; mediaController.setMediaPlayer(player.getPlayerControl()); @@ -435,6 +446,19 @@ public class FullPlayerActivity extends Activity implements SurfaceHolder.Callba } } + // DemoPlayer.MetadataListener implementation + + @Override + public void onId3Metadata(Map metadata) { + for (int i = 0; i < metadata.size(); i++) { + if (metadata.containsKey(TxxxMetadata.TYPE)) { + TxxxMetadata txxxMetadata = (TxxxMetadata) metadata.get(TxxxMetadata.TYPE); + Log.i(TAG, String.format("ID3 TimedMetadata: description=%s, value=%s", + txxxMetadata.description, txxxMetadata.value)); + } + } + } + // SurfaceHolder.Callback implementation @Override diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/full/player/DemoPlayer.java b/demo/src/main/java/com/google/android/exoplayer/demo/full/player/DemoPlayer.java index 2a2907fcdf..a7ba111990 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/full/player/DemoPlayer.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/full/player/DemoPlayer.java @@ -27,7 +27,8 @@ import com.google.android.exoplayer.audio.AudioTrack; import com.google.android.exoplayer.chunk.ChunkSampleSource; import com.google.android.exoplayer.chunk.MultiTrackChunkSource; import com.google.android.exoplayer.drm.StreamingDrmSessionManager; -import com.google.android.exoplayer.text.TextTrackRenderer; +import com.google.android.exoplayer.metadata.MetadataTrackRenderer; +import com.google.android.exoplayer.text.TextRenderer; import com.google.android.exoplayer.upstream.DefaultBandwidthMeter; import com.google.android.exoplayer.util.PlayerControl; @@ -37,6 +38,7 @@ import android.os.Looper; import android.view.Surface; import java.io.IOException; +import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; /** @@ -47,7 +49,7 @@ import java.util.concurrent.CopyOnWriteArrayList; public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventListener, DefaultBandwidthMeter.EventListener, MediaCodecVideoTrackRenderer.EventListener, MediaCodecAudioTrackRenderer.EventListener, Ac3PassthroughAudioTrackRenderer.EventListener, - TextTrackRenderer.TextRenderer, StreamingDrmSessionManager.EventListener { + TextRenderer, StreamingDrmSessionManager.EventListener { /** * Builds renderers for the player. @@ -136,6 +138,13 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi void onText(String text); } + /** + * A listener for receiving ID3 metadata parsed from the media stream. + */ + public interface Id3MetadataListener { + void onId3Metadata(Map metadata); + } + // Constants pulled into this class for convenience. public static final int STATE_IDLE = ExoPlayer.STATE_IDLE; public static final int STATE_PREPARING = ExoPlayer.STATE_PREPARING; @@ -146,11 +155,12 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi public static final int DISABLED_TRACK = -1; public static final int PRIMARY_TRACK = 0; - public static final int RENDERER_COUNT = 4; + public static final int RENDERER_COUNT = 5; public static final int TYPE_VIDEO = 0; public static final int TYPE_AUDIO = 1; public static final int TYPE_TEXT = 2; - public static final int TYPE_DEBUG = 3; + public static final int TYPE_TIMED_METADATA = 3; + public static final int TYPE_DEBUG = 4; private static final int RENDERER_BUILDING_STATE_IDLE = 1; private static final int RENDERER_BUILDING_STATE_BUILDING = 2; @@ -175,6 +185,7 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi private int[] selectedTracks; private TextListener textListener; + private Id3MetadataListener id3MetadataListener; private InternalErrorListener internalErrorListener; private InfoListener infoListener; @@ -216,6 +227,10 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi textListener = listener; } + public void setMetadataListener(Id3MetadataListener listener) { + id3MetadataListener = listener; + } + public void setSurface(Surface surface) { this.surface = surface; pushSurfaceAndVideoTrack(false); @@ -465,9 +480,19 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi @Override public void onText(String text) { - if (textListener != null) { - textListener.onText(text); - } + processText(text); + } + + /* package */ MetadataTrackRenderer.MetadataRenderer> + getId3MetadataRenderer() { + return new MetadataTrackRenderer.MetadataRenderer>() { + @Override + public void onMetadata(Map metadata) { + if (id3MetadataListener != null) { + id3MetadataListener.onId3Metadata(metadata); + } + } + }; } @Override @@ -561,6 +586,13 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi } } + /* package */ void processText(String text) { + if (textListener == null || selectedTracks[TYPE_TEXT] == DISABLED_TRACK) { + return; + } + textListener.onText(text); + } + private class InternalRendererBuilderCallback implements RendererBuilderCallback { private boolean canceled; 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..cab7ffcc33 --- /dev/null +++ b/demo/src/main/java/com/google/android/exoplayer/demo/full/player/HlsRendererBuilder.java @@ -0,0 +1,101 @@ +/* + * 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.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.HlsPlaylist; +import com.google.android.exoplayer.hls.HlsPlaylistParser; +import com.google.android.exoplayer.hls.HlsSampleSource; +import com.google.android.exoplayer.metadata.Id3Parser; +import com.google.android.exoplayer.metadata.MetadataTrackRenderer; +import com.google.android.exoplayer.text.eia608.Eia608TrackRenderer; +import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.upstream.DefaultBandwidthMeter; +import com.google.android.exoplayer.upstream.UriDataSource; +import com.google.android.exoplayer.util.ManifestFetcher; +import com.google.android.exoplayer.util.ManifestFetcher.ManifestCallback; + +import android.media.MediaCodec; + +import java.io.IOException; +import java.util.Map; + +/** + * A {@link RendererBuilder} for HLS. + */ +public class HlsRendererBuilder implements RendererBuilder, ManifestCallback { + + private final String userAgent; + private final String url; + private final String contentId; + + private DemoPlayer player; + private RendererBuilderCallback callback; + + public HlsRendererBuilder(String userAgent, String url, String contentId) { + this.userAgent = userAgent; + this.url = url; + this.contentId = contentId; + } + + @Override + public void buildRenderers(DemoPlayer player, RendererBuilderCallback callback) { + this.player = player; + this.callback = callback; + HlsPlaylistParser parser = new HlsPlaylistParser(); + ManifestFetcher playlistFetcher = + new ManifestFetcher(parser, contentId, url, userAgent); + playlistFetcher.singleLoad(player.getMainHandler().getLooper(), this); + } + + @Override + public void onManifestError(String contentId, IOException e) { + callback.onRenderersError(e); + } + + @Override + public void onManifest(String contentId, HlsPlaylist manifest) { + DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); + + DataSource dataSource = new UriDataSource(userAgent, bandwidthMeter); + HlsChunkSource chunkSource = new HlsChunkSource(dataSource, url, manifest, bandwidthMeter, null, + HlsChunkSource.ADAPTIVE_MODE_SPLICE); + HlsSampleSource sampleSource = new HlsSampleSource(chunkSource, true, 3); + MediaCodecVideoTrackRenderer videoRenderer = new MediaCodecVideoTrackRenderer(sampleSource, + MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT, 5000, player.getMainHandler(), player, 50); + MediaCodecAudioTrackRenderer audioRenderer = new MediaCodecAudioTrackRenderer(sampleSource); + + MetadataTrackRenderer> id3Renderer = + new MetadataTrackRenderer>(sampleSource, new Id3Parser(), + player.getId3MetadataRenderer(), player.getMainHandler().getLooper()); + + Eia608TrackRenderer closedCaptionRenderer = new Eia608TrackRenderer(sampleSource, player, + player.getMainHandler().getLooper()); + + TrackRenderer[] renderers = new TrackRenderer[DemoPlayer.RENDERER_COUNT]; + renderers[DemoPlayer.TYPE_VIDEO] = videoRenderer; + renderers[DemoPlayer.TYPE_AUDIO] = audioRenderer; + renderers[DemoPlayer.TYPE_TIMED_METADATA] = id3Renderer; + renderers[DemoPlayer.TYPE_TEXT] = closedCaptionRenderer; + callback.onRenderers(null, null, renderers); + } + +} diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/simple/HlsRendererBuilder.java b/demo/src/main/java/com/google/android/exoplayer/demo/simple/HlsRendererBuilder.java new file mode 100644 index 0000000000..1f08b63b38 --- /dev/null +++ b/demo/src/main/java/com/google/android/exoplayer/demo/simple/HlsRendererBuilder.java @@ -0,0 +1,92 @@ +/* + * 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.simple; + +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; +import com.google.android.exoplayer.demo.simple.SimplePlayerActivity.RendererBuilder; +import com.google.android.exoplayer.demo.simple.SimplePlayerActivity.RendererBuilderCallback; +import com.google.android.exoplayer.hls.HlsChunkSource; +import com.google.android.exoplayer.hls.HlsPlaylist; +import com.google.android.exoplayer.hls.HlsPlaylistParser; +import com.google.android.exoplayer.hls.HlsSampleSource; +import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.upstream.DefaultBandwidthMeter; +import com.google.android.exoplayer.upstream.UriDataSource; +import com.google.android.exoplayer.util.ManifestFetcher; +import com.google.android.exoplayer.util.ManifestFetcher.ManifestCallback; + +import android.media.MediaCodec; + +import java.io.IOException; + +/** + * A {@link RendererBuilder} for HLS. + */ +/* package */ class HlsRendererBuilder implements RendererBuilder, + ManifestCallback { + + private final SimplePlayerActivity playerActivity; + private final String userAgent; + private final String url; + private final String contentId; + + private RendererBuilderCallback callback; + + public HlsRendererBuilder(SimplePlayerActivity playerActivity, String userAgent, String url, + String contentId) { + this.playerActivity = playerActivity; + this.userAgent = userAgent; + this.url = url; + this.contentId = contentId; + } + + @Override + public void buildRenderers(RendererBuilderCallback callback) { + this.callback = callback; + HlsPlaylistParser parser = new HlsPlaylistParser(); + ManifestFetcher playlistFetcher = + new ManifestFetcher(parser, contentId, url, userAgent); + playlistFetcher.singleLoad(playerActivity.getMainLooper(), this); + } + + @Override + public void onManifestError(String contentId, IOException e) { + callback.onRenderersError(e); + } + + @Override + public void onManifest(String contentId, HlsPlaylist manifest) { + DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); + + DataSource dataSource = new UriDataSource(userAgent, bandwidthMeter); + HlsChunkSource chunkSource = new HlsChunkSource(dataSource, url, manifest, bandwidthMeter, null, + HlsChunkSource.ADAPTIVE_MODE_SPLICE); + HlsSampleSource sampleSource = new HlsSampleSource(chunkSource, true, 2); + MediaCodecVideoTrackRenderer videoRenderer = new MediaCodecVideoTrackRenderer(sampleSource, + MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT, 0, playerActivity.getMainHandler(), + playerActivity, 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(videoRenderer, audioRenderer); + } + +} diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/simple/SimplePlayerActivity.java b/demo/src/main/java/com/google/android/exoplayer/demo/simple/SimplePlayerActivity.java index 8c47dea3c1..6577921bbc 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/simple/SimplePlayerActivity.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/simple/SimplePlayerActivity.java @@ -166,6 +166,8 @@ public class SimplePlayerActivity extends Activity implements SurfaceHolder.Call contentId); case DemoUtil.TYPE_DASH: return new DashRendererBuilder(this, userAgent, contentUri.toString(), contentId); + case DemoUtil.TYPE_HLS: + return new HlsRendererBuilder(this, userAgent, contentUri.toString(), contentId); default: return new DefaultRendererBuilder(this, contentUri); } diff --git a/library/src/main/java/com/google/android/exoplayer/MediaFormat.java b/library/src/main/java/com/google/android/exoplayer/MediaFormat.java index d3735ad7af..5d1bf3945b 100644 --- a/library/src/main/java/com/google/android/exoplayer/MediaFormat.java +++ b/library/src/main/java/com/google/android/exoplayer/MediaFormat.java @@ -87,6 +87,14 @@ public class MediaFormat { sampleRate, bitrate, initializationData); } + public static MediaFormat createId3Format() { + return createFormatForMimeType(MimeTypes.APPLICATION_ID3); + } + + public static MediaFormat createEia608Format() { + return createFormatForMimeType(MimeTypes.APPLICATION_EIA608); + } + public static MediaFormat createTtmlFormat() { return createFormatForMimeType(MimeTypes.APPLICATION_TTML); } diff --git a/library/src/main/java/com/google/android/exoplayer/hls/BitArrayChunk.java b/library/src/main/java/com/google/android/exoplayer/hls/BitArrayChunk.java new file mode 100644 index 0000000000..cc56691441 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/BitArrayChunk.java @@ -0,0 +1,100 @@ +/* + * 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.upstream.DataSource; +import com.google.android.exoplayer.upstream.DataSpec; +import com.google.android.exoplayer.util.BitArray; + +import java.io.IOException; + +/** + * An abstract base class for {@link HlsChunk} implementations where the data should be loaded into + * a {@link BitArray} and subsequently consumed. + */ +public abstract class BitArrayChunk extends HlsChunk { + + private static final int READ_GRANULARITY = 16 * 1024; + + private final BitArray bitArray; + private volatile boolean loadFinished; + private volatile boolean loadCanceled; + + /** + * @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 bitArray The {@link BitArray} into which the data should be loaded. + */ + public BitArrayChunk(DataSource dataSource, DataSpec dataSpec, BitArray bitArray) { + super(dataSource, dataSpec); + this.bitArray = bitArray; + } + + @Override + public void consume() throws IOException { + consume(bitArray); + } + + /** + * Invoked by {@link #consume()}. Implementations should override this method to consume the + * loaded data. + * + * @param bitArray The {@link BitArray} containing the loaded data. + * @throws IOException If an error occurs consuming the loaded data. + */ + protected abstract void consume(BitArray bitArray) throws IOException; + + /** + * Whether the whole of the chunk has been loaded. + * + * @return True if the whole of the chunk has been loaded. False otherwise. + */ + @Override + public boolean isLoadFinished() { + return loadFinished; + } + + // Loadable implementation + + @Override + public final void cancelLoad() { + loadCanceled = true; + } + + @Override + public final boolean isLoadCanceled() { + return loadCanceled; + } + + @Override + public final void load() throws IOException, InterruptedException { + try { + bitArray.reset(); + dataSource.open(dataSpec); + int bytesRead = 0; + while (bytesRead != -1 && !loadCanceled) { + bytesRead = bitArray.append(dataSource, READ_GRANULARITY); + } + loadFinished = !loadCanceled; + } finally { + dataSource.close(); + } + } + +} 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..4fe1a18646 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsChunk.java @@ -0,0 +1,51 @@ +/* + * 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.upstream.DataSource; +import com.google.android.exoplayer.upstream.DataSpec; +import com.google.android.exoplayer.upstream.Loader.Loadable; +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 HLS streams. + */ +public abstract class HlsChunk implements Loadable { + + protected final DataSource dataSource; + protected final DataSpec dataSpec; + + /** + * @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}. + */ + public HlsChunk(DataSource dataSource, DataSpec dataSpec) { + Assertions.checkState(dataSpec.length <= Integer.MAX_VALUE); + this.dataSource = Assertions.checkNotNull(dataSource); + this.dataSpec = Assertions.checkNotNull(dataSpec); + } + + public abstract void consume() throws IOException; + + public abstract boolean isLoadFinished(); + +} 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..584372c86a --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsChunkSource.java @@ -0,0 +1,573 @@ +/* + * 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.hls.TsExtractor.SamplePool; +import com.google.android.exoplayer.upstream.Aes128DataSource; +import com.google.android.exoplayer.upstream.BandwidthMeter; +import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.upstream.DataSpec; +import com.google.android.exoplayer.upstream.HttpDataSource.InvalidResponseCodeException; +import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.BitArray; +import com.google.android.exoplayer.util.Util; + +import android.net.Uri; +import android.os.SystemClock; +import android.util.Log; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +/** + * 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 { + + /** + * Adaptive switching is disabled. + *

+ * The initially selected variant will be used throughout playback. + */ + public static final int ADAPTIVE_MODE_NONE = 0; + + /** + * Adaptive switches splice overlapping segments of the old and new variants. + *

+ * When performing a switch from one variant to another, overlapping segments will be requested + * from both the old and new variants. These segments will then be spliced together, allowing + * a seamless switch from one variant to another even if keyframes are misaligned or if keyframes + * are not positioned at the start of each segment. + *

+ * Note that where it can be guaranteed that the source content has keyframes positioned at the + * start of each segment, {@link #ADAPTIVE_MODE_ABRUPT} should always be used in preference to + * this mode. + */ + public static final int ADAPTIVE_MODE_SPLICE = 1; + + /** + * Adaptive switches are performed at segment boundaries. + *

+ * For this mode to perform seamless switches, the source content is required to have keyframes + * positioned at the start of each segment. If this is not the case a visual discontinuity may + * be experienced when switching from one variant to another. + *

+ * Note that where it can be guaranteed that the source content does have keyframes positioned at + * the start of each segment, this mode should always be used in preference to + * {@link #ADAPTIVE_MODE_SPLICE} because it requires fetching less data. + */ + public static final int ADAPTIVE_MODE_ABRUPT = 3; + + /** + * The default target buffer duration in milliseconds. + */ + public static final long DEFAULT_TARGET_BUFFER_DURATION_MS = 40000; + + /** + * The default minimum duration of media that needs to be buffered for a switch to a higher + * quality variant to be considered. + */ + public static final long DEFAULT_MIN_BUFFER_TO_SWITCH_UP_MS = 5000; + + /** + * The default maximum duration of media that needs to be buffered for a switch to a lower + * quality variant to be considered. + */ + public static final long DEFAULT_MAX_BUFFER_TO_SWITCH_DOWN_MS = 20000; + + private static final String TAG = "HlsChunkSource"; + private static final float BANDWIDTH_FRACTION = 0.8f; + + private final SamplePool samplePool = new TsExtractor.SamplePool(); + private final DataSource upstreamDataSource; + private final HlsPlaylistParser playlistParser; + private final Variant[] enabledVariants; + private final BandwidthMeter bandwidthMeter; + private final BitArray bitArray; + private final int adaptiveMode; + private final Uri baseUri; + private final int maxWidth; + private final int maxHeight; + private final long targetBufferDurationUs; + private final long minBufferDurationToSwitchUpUs; + private final long maxBufferDurationToSwitchDownUs; + + /* package */ final HlsMediaPlaylist[] mediaPlaylists; + /* package */ final boolean[] mediaPlaylistBlacklistFlags; + /* package */ final long[] lastMediaPlaylistLoadTimesMs; + /* package */ boolean live; + /* package */ long durationUs; + + private int variantIndex; + private DataSource encryptedDataSource; + private Uri encryptionKeyUri; + private String encryptedDataSourceIv; + private byte[] encryptedDataSourceSecretKey; + + public HlsChunkSource(DataSource dataSource, String playlistUrl, HlsPlaylist playlist, + BandwidthMeter bandwidthMeter, int[] variantIndices, int adaptiveMode) { + this(dataSource, playlistUrl, playlist, bandwidthMeter, variantIndices, adaptiveMode, + DEFAULT_TARGET_BUFFER_DURATION_MS, DEFAULT_MIN_BUFFER_TO_SWITCH_UP_MS, + DEFAULT_MAX_BUFFER_TO_SWITCH_DOWN_MS); + } + + /** + * @param dataSource A {@link DataSource} suitable for loading the media data. + * @param playlistUrl The playlist URL. + * @param playlist The hls playlist. + * @param bandwidthMeter provides an estimate of the currently available bandwidth. + * @param variantIndices A subset of variant indices to consider, or null to consider all of the + * variants in the master playlist. + * @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 targetBufferDurationMs The targeted duration of media to buffer ahead of the current + * playback position. Note that the greater this value, the greater the amount of memory + * that will be consumed. + * @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 + * for a switch to a lower quality variant to be considered. + */ + public HlsChunkSource(DataSource dataSource, String playlistUrl, HlsPlaylist playlist, + BandwidthMeter bandwidthMeter, int[] variantIndices, int adaptiveMode, + long targetBufferDurationMs, long minBufferDurationToSwitchUpMs, + long maxBufferDurationToSwitchDownMs) { + this.upstreamDataSource = dataSource; + this.bandwidthMeter = bandwidthMeter; + this.adaptiveMode = adaptiveMode; + targetBufferDurationUs = targetBufferDurationMs * 1000; + minBufferDurationToSwitchUpUs = minBufferDurationToSwitchUpMs * 1000; + maxBufferDurationToSwitchDownUs = maxBufferDurationToSwitchDownMs * 1000; + baseUri = playlist.baseUri; + bitArray = new BitArray(); + playlistParser = new HlsPlaylistParser(); + + if (playlist.type == HlsPlaylist.TYPE_MEDIA) { + enabledVariants = new Variant[] {new Variant(0, playlistUrl, 0, null, -1, -1)}; + mediaPlaylists = new HlsMediaPlaylist[1]; + mediaPlaylistBlacklistFlags = new boolean[1]; + lastMediaPlaylistLoadTimesMs = new long[1]; + setMediaPlaylist(0, (HlsMediaPlaylist) playlist); + } else { + Assertions.checkState(playlist.type == HlsPlaylist.TYPE_MASTER); + enabledVariants = filterVariants((HlsMasterPlaylist) playlist, variantIndices); + mediaPlaylists = new HlsMediaPlaylist[enabledVariants.length]; + mediaPlaylistBlacklistFlags = new boolean[enabledVariants.length]; + lastMediaPlaylistLoadTimesMs = new long[enabledVariants.length]; + } + + int maxWidth = -1; + int maxHeight = -1; + // Select the first variant from the master playlist that's enabled. + long minOriginalVariantIndex = Integer.MAX_VALUE; + for (int i = 0; i < enabledVariants.length; i++) { + if (enabledVariants[i].index < minOriginalVariantIndex) { + minOriginalVariantIndex = enabledVariants[i].index; + variantIndex = i; + } + maxWidth = Math.max(enabledVariants[i].width, maxWidth); + maxHeight = Math.max(enabledVariants[i].height, maxHeight); + } + // TODO: We should allow the default values to be passed through the constructor. + this.maxWidth = maxWidth > 0 ? maxWidth : 1920; + this.maxHeight = maxHeight > 0 ? maxHeight : 1080; + } + + public long getDurationUs() { + return live ? C.UNKNOWN_TIME_US : 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) { + out.setMaxVideoDimensions(maxWidth, maxHeight); + } + + /** + * Returns the next {@link HlsChunk} that should be loaded. + * + * @param previousTsChunk The previously loaded chunk that the next chunk should follow. + * @param seekPositionUs If there is no previous chunk, this parameter must specify the seek + * position. If there is a previous chunk then this parameter is ignored. + * @param playbackPositionUs The current playback position. + * @return The next chunk to load. + */ + public HlsChunk getChunkOperation(TsChunk previousTsChunk, long seekPositionUs, + long playbackPositionUs) { + if (previousTsChunk != null && (previousTsChunk.isLastChunk + || previousTsChunk.endTimeUs - playbackPositionUs >= targetBufferDurationUs)) { + // We're either finished, or we have the target amount of data buffered. + return null; + } + + int nextVariantIndex = variantIndex; + boolean switchingVariant = false; + boolean switchingVariantSpliced = false; + if (adaptiveMode == ADAPTIVE_MODE_NONE) { + // Do nothing. + } else { + nextVariantIndex = getNextVariantIndex(previousTsChunk, playbackPositionUs); + switchingVariant = nextVariantIndex != variantIndex; + switchingVariantSpliced = switchingVariant && adaptiveMode == ADAPTIVE_MODE_SPLICE; + } + + HlsMediaPlaylist mediaPlaylist = mediaPlaylists[nextVariantIndex]; + if (mediaPlaylist == null) { + // We don't have the media playlist for the next variant. Request it now. + return newMediaPlaylistChunk(nextVariantIndex); + } + + variantIndex = nextVariantIndex; + int chunkMediaSequence = 0; + boolean liveDiscontinuity = false; + if (live) { + if (previousTsChunk == null) { + chunkMediaSequence = getLiveStartChunkMediaSequence(variantIndex); + } else { + chunkMediaSequence = switchingVariantSpliced + ? previousTsChunk.chunkIndex : previousTsChunk.chunkIndex + 1; + if (chunkMediaSequence < mediaPlaylist.mediaSequence) { + // If the chunk is no longer in the playlist. Skip ahead and start again. + chunkMediaSequence = getLiveStartChunkMediaSequence(variantIndex); + liveDiscontinuity = true; + } + } + } else { + // Not live. + if (previousTsChunk == null) { + chunkMediaSequence = Util.binarySearchFloor(mediaPlaylist.segments, seekPositionUs, true, + true) + mediaPlaylist.mediaSequence; + } else { + chunkMediaSequence = switchingVariantSpliced + ? previousTsChunk.chunkIndex : previousTsChunk.chunkIndex + 1; + } + } + + int chunkIndex = chunkMediaSequence - mediaPlaylist.mediaSequence; + if (chunkIndex >= mediaPlaylist.segments.size()) { + if (mediaPlaylist.live && shouldRerequestMediaPlaylist(variantIndex)) { + return newMediaPlaylistChunk(variantIndex); + } else { + return null; + } + } + + HlsMediaPlaylist.Segment segment = mediaPlaylist.segments.get(chunkIndex); + Uri chunkUri = Util.getMergedUri(mediaPlaylist.baseUri, segment.url); + + // Check if encryption is specified. + if (HlsMediaPlaylist.ENCRYPTION_METHOD_AES_128.equals(segment.encryptionMethod)) { + Uri keyUri = Util.getMergedUri(mediaPlaylist.baseUri, segment.encryptionKeyUri); + if (!keyUri.equals(encryptionKeyUri)) { + // Encryption is specified and the key has changed. + HlsChunk toReturn = newEncryptionKeyChunk(keyUri, segment.encryptionIV); + return toReturn; + } + if (!Util.areEqual(segment.encryptionIV, encryptedDataSourceIv)) { + initEncryptedDataSource(keyUri, segment.encryptionIV, encryptedDataSourceSecretKey); + } + } else { + clearEncryptedDataSource(); + } + + // Configure the data source and spec for the chunk. + DataSource dataSource = encryptedDataSource != null ? encryptedDataSource : upstreamDataSource; + DataSpec dataSpec = new DataSpec(chunkUri, segment.byterangeOffset, segment.byterangeLength, + null); + + // 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; + } + } else /* Not live */ { + startTimeUs = segment.startTimeUs; + } + long endTimeUs = startTimeUs + (long) (segment.durationSecs * C.MICROS_PER_SECOND); + boolean isLastChunk = !mediaPlaylist.live && chunkIndex == mediaPlaylist.segments.size() - 1; + + // Configure the extractor that will read the chunk. + TsExtractor extractor; + if (previousTsChunk == null || segment.discontinuity || switchingVariant || liveDiscontinuity) { + extractor = new TsExtractor(startTimeUs, samplePool, switchingVariantSpliced); + } else { + extractor = previousTsChunk.extractor; + } + + return new TsChunk(dataSource, dataSpec, extractor, enabledVariants[variantIndex].index, + startTimeUs, endTimeUs, chunkMediaSequence, isLastChunk); + } + + /** + * Invoked when an error occurs loading a chunk. + * + * @param chunk The chunk whose load failed. + * @param e The failure. + * @return True if the error was handled by the source. False otherwise. + */ + public boolean onLoadError(HlsChunk chunk, IOException e) { + if ((chunk instanceof MediaPlaylistChunk) && (e instanceof InvalidResponseCodeException)) { + InvalidResponseCodeException responseCodeException = (InvalidResponseCodeException) e; + int responseCode = responseCodeException.responseCode; + if (responseCode == 404 || responseCode == 410) { + MediaPlaylistChunk playlistChunk = (MediaPlaylistChunk) chunk; + mediaPlaylistBlacklistFlags[playlistChunk.variantIndex] = true; + if (!allPlaylistsBlacklisted()) { + // We've handled the 404/410 by blacklisting the playlist. + Log.w(TAG, "Blacklisted playlist (" + responseCode + "): " + + playlistChunk.dataSpec.uri); + return true; + } else { + // This was the last non-blacklisted playlist. Don't blacklist it. + Log.w(TAG, "Final playlist not blacklisted (" + responseCode + "): " + + playlistChunk.dataSpec.uri); + mediaPlaylistBlacklistFlags[playlistChunk.variantIndex] = false; + return false; + } + } + } + return false; + } + + private int getNextVariantIndex(TsChunk previousTsChunk, long playbackPositionUs) { + int idealVariantIndex = getVariantIndexForBandwdith( + (int) (bandwidthMeter.getBitrateEstimate() * BANDWIDTH_FRACTION)); + if (idealVariantIndex == variantIndex) { + // We're already using the ideal variant. + return variantIndex; + } + // We're not using the ideal variant for the available bandwidth, but only switch if the + // conditions are appropriate. + long bufferedPositionUs = previousTsChunk == null ? playbackPositionUs + : adaptiveMode == ADAPTIVE_MODE_SPLICE ? previousTsChunk.startTimeUs + : previousTsChunk.endTimeUs; + long bufferedUs = bufferedPositionUs - playbackPositionUs; + if (mediaPlaylistBlacklistFlags[variantIndex] + || (idealVariantIndex > variantIndex && bufferedUs < maxBufferDurationToSwitchDownUs) + || (idealVariantIndex < variantIndex && bufferedUs > minBufferDurationToSwitchUpUs)) { + // Switch variant. + return idealVariantIndex; + } + // Stick with the current variant for now. + return variantIndex; + } + + private int getVariantIndexForBandwdith(int bandwidth) { + int lowestQualityEnabledVariant = 0; + for (int i = 0; i < enabledVariants.length; i++) { + if (!mediaPlaylistBlacklistFlags[i]) { + if (enabledVariants[i].bandwidth <= bandwidth) { + return i; + } + lowestQualityEnabledVariant = i; + } + } + return lowestQualityEnabledVariant; + } + + private boolean shouldRerequestMediaPlaylist(int variantIndex) { + // Don't re-request media playlist more often than one-half of the target duration. + HlsMediaPlaylist mediaPlaylist = mediaPlaylists[variantIndex]; + long timeSinceLastMediaPlaylistLoadMs = + SystemClock.elapsedRealtime() - lastMediaPlaylistLoadTimesMs[variantIndex]; + return timeSinceLastMediaPlaylistLoadMs >= (mediaPlaylist.targetDurationSecs * 1000) / 2; + } + + private int getLiveStartChunkMediaSequence(int variantIndex) { + // For live start playback from the third chunk from the end. + HlsMediaPlaylist mediaPlaylist = mediaPlaylists[variantIndex]; + int chunkIndex = mediaPlaylist.segments.size() > 3 ? mediaPlaylist.segments.size() - 3 : 0; + return chunkIndex + mediaPlaylist.mediaSequence; + } + + private MediaPlaylistChunk newMediaPlaylistChunk(int variantIndex) { + Uri mediaPlaylistUri = Util.getMergedUri(baseUri, enabledVariants[variantIndex].url); + DataSpec dataSpec = new DataSpec(mediaPlaylistUri, 0, C.LENGTH_UNBOUNDED, null); + Uri baseUri = Util.parseBaseUri(mediaPlaylistUri.toString()); + return new MediaPlaylistChunk(variantIndex, upstreamDataSource, dataSpec, baseUri); + } + + private EncryptionKeyChunk newEncryptionKeyChunk(Uri keyUri, String iv) { + DataSpec dataSpec = new DataSpec(keyUri, 0, C.LENGTH_UNBOUNDED, null); + return new EncryptionKeyChunk(upstreamDataSource, dataSpec, iv); + } + + /* package */ void initEncryptedDataSource(Uri keyUri, String iv, byte[] secretKey) { + String trimmedIv; + if (iv.toLowerCase(Locale.getDefault()).startsWith("0x")) { + trimmedIv = iv.substring(2); + } else { + trimmedIv = iv; + } + + byte[] ivData = new BigInteger(trimmedIv, 16).toByteArray(); + byte[] ivDataWithPadding = new byte[16]; + int offset = ivData.length > 16 ? ivData.length - 16 : 0; + System.arraycopy(ivData, offset, ivDataWithPadding, ivDataWithPadding.length - ivData.length + + offset, ivData.length - offset); + + encryptedDataSource = new Aes128DataSource(secretKey, ivDataWithPadding, upstreamDataSource); + encryptionKeyUri = keyUri; + encryptedDataSourceIv = iv; + encryptedDataSourceSecretKey = secretKey; + } + + private void clearEncryptedDataSource() { + encryptionKeyUri = null; + encryptedDataSource = null; + encryptedDataSourceIv = null; + encryptedDataSourceSecretKey = null; + } + + /* package */ void setMediaPlaylist(int variantIndex, HlsMediaPlaylist mediaPlaylist) { + lastMediaPlaylistLoadTimesMs[variantIndex] = SystemClock.elapsedRealtime(); + mediaPlaylists[variantIndex] = mediaPlaylist; + live |= mediaPlaylist.live; + durationUs = mediaPlaylist.durationUs; + } + + private static Variant[] filterVariants(HlsMasterPlaylist masterPlaylist, int[] variantIndices) { + List masterVariants = masterPlaylist.variants; + ArrayList enabledVariants = new ArrayList(); + if (variantIndices != null) { + for (int i = 0; i < variantIndices.length; i++) { + enabledVariants.add(masterVariants.get(variantIndices[i])); + } + } else { + // If variantIndices is null then all variants are initially considered. + enabledVariants.addAll(masterVariants); + } + + ArrayList definiteVideoVariants = new ArrayList(); + ArrayList definiteAudioOnlyVariants = new ArrayList(); + for (int i = 0; i < enabledVariants.size(); i++) { + Variant variant = enabledVariants.get(i); + if (variant.height > 0 || variantHasExplicitCodecWithPrefix(variant, "avc")) { + definiteVideoVariants.add(variant); + } else if (variantHasExplicitCodecWithPrefix(variant, "mp4a")) { + definiteAudioOnlyVariants.add(variant); + } + } + + if (!definiteVideoVariants.isEmpty()) { + // We've identified some variants as definitely containing video. Assume variants within the + // master playlist are marked consistently, and hence that we have the full set. Filter out + // any other variants, which are likely to be audio only. + enabledVariants = definiteVideoVariants; + } else if (definiteAudioOnlyVariants.size() < enabledVariants.size()) { + // We've identified some variants, but not all, as being audio only. Filter them out to leave + // the remaining variants, which are likely to contain video. + enabledVariants.removeAll(definiteAudioOnlyVariants); + } else { + // Leave the enabled variants unchanged. They're likely either all video or all audio. + } + + Collections.sort(enabledVariants, new Variant.DecreasingBandwidthComparator()); + + Variant[] enabledVariantsArray = new Variant[enabledVariants.size()]; + enabledVariants.toArray(enabledVariantsArray); + return enabledVariantsArray; + } + + private static boolean variantHasExplicitCodecWithPrefix(Variant variant, String prefix) { + String[] codecs = variant.codecs; + if (codecs == null) { + return false; + } + for (int i = 0; i < codecs.length; i++) { + if (codecs[i].startsWith(prefix)) { + return true; + } + } + return false; + } + + private boolean allPlaylistsBlacklisted() { + for (int i = 0; i < mediaPlaylistBlacklistFlags.length; i++) { + if (!mediaPlaylistBlacklistFlags[i]) { + return false; + } + } + return true; + } + + private class MediaPlaylistChunk extends BitArrayChunk { + + @SuppressWarnings("hiding") + /* package */ final int variantIndex; + + private final Uri playlistBaseUri; + + public MediaPlaylistChunk(int variantIndex, DataSource dataSource, DataSpec dataSpec, + Uri playlistBaseUri) { + super(dataSource, dataSpec, bitArray); + this.variantIndex = variantIndex; + this.playlistBaseUri = playlistBaseUri; + } + + @Override + protected void consume(BitArray data) throws IOException { + HlsPlaylist playlist = playlistParser.parse( + new ByteArrayInputStream(data.getData(), 0, data.bytesLeft()), null, null, + playlistBaseUri); + Assertions.checkState(playlist.type == HlsPlaylist.TYPE_MEDIA); + HlsMediaPlaylist mediaPlaylist = (HlsMediaPlaylist) playlist; + setMediaPlaylist(variantIndex, mediaPlaylist); + } + + } + + private class EncryptionKeyChunk extends BitArrayChunk { + + private final String iv; + + public EncryptionKeyChunk(DataSource dataSource, DataSpec dataSpec, String iv) { + super(dataSource, dataSpec, bitArray); + this.iv = iv; + } + + @Override + protected void consume(BitArray data) throws IOException { + byte[] secretKey = new byte[data.bytesLeft()]; + data.readBytes(secretKey, 0, secretKey.length); + initEncryptedDataSource(dataSpec.uri, iv, secretKey); + } + + } + +} 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..7ce299df0d --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsMasterPlaylist.java @@ -0,0 +1,34 @@ +/* + * 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 extends HlsPlaylist { + + public final List variants; + + public HlsMasterPlaylist(Uri baseUri, List variants) { + super(baseUri, HlsPlaylist.TYPE_MASTER); + this.variants = 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..3e9f151c08 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsMediaPlaylist.java @@ -0,0 +1,90 @@ +/* + * 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 android.net.Uri; + +import java.util.List; + +/** + * Represents an HLS media playlist. + */ +public final class HlsMediaPlaylist extends HlsPlaylist { + + /** + * 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 final String encryptionMethod; + public final String encryptionKeyUri; + public final String encryptionIV; + public final int byterangeOffset; + public final int byterangeLength; + + public Segment(String uri, double durationSecs, boolean discontinuity, long startTimeUs, + String encryptionMethod, String encryptionKeyUri, String encryptionIV, + int byterangeOffset, int byterangeLength) { + this.url = uri; + this.durationSecs = durationSecs; + this.discontinuity = discontinuity; + this.startTimeUs = startTimeUs; + this.encryptionMethod = encryptionMethod; + this.encryptionKeyUri = encryptionKeyUri; + this.encryptionIV = encryptionIV; + this.byterangeOffset = byterangeOffset; + this.byterangeLength = byterangeLength; + } + + @Override + public int compareTo(Long startTimeUs) { + return this.startTimeUs > startTimeUs ? 1 : (this.startTimeUs < startTimeUs ? -1 : 0); + } + } + + public static final String ENCRYPTION_METHOD_NONE = "NONE"; + public static final String ENCRYPTION_METHOD_AES_128 = "AES-128"; + + 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) { + super(baseUri, HlsPlaylist.TYPE_MEDIA); + this.mediaSequence = mediaSequence; + this.targetDurationSecs = targetDurationSecs; + this.version = version; + this.live = live; + this.segments = segments; + + if (!segments.isEmpty()) { + Segment last = segments.get(segments.size() - 1); + durationUs = last.startTimeUs + (long) (last.durationSecs * C.MICROS_PER_SECOND); + } else { + durationUs = 0; + } + } + +} 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..366bab1178 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsParserUtil.java @@ -0,0 +1,57 @@ +/* + * 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 String parseOptionalStringAttr(String line, Pattern pattern) { + Matcher matcher = pattern.matcher(line); + if (matcher.find() && matcher.groupCount() == 1) { + return matcher.group(1); + } + return null; + } + + 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/HlsPlaylist.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsPlaylist.java new file mode 100644 index 0000000000..3c86328ba6 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsPlaylist.java @@ -0,0 +1,36 @@ +/* + * 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; + +/** + * Represents an HLS playlist. + */ +public abstract class HlsPlaylist { + + public final static int TYPE_MASTER = 0; + public final static int TYPE_MEDIA = 1; + + public final Uri baseUri; + public final int type; + + protected HlsPlaylist(Uri baseUri, int type) { + this.baseUri = baseUri; + this.type = type; + } + +} 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 new file mode 100644 index 0000000000..a2497e3218 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsPlaylistParser.java @@ -0,0 +1,282 @@ +/* + * 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.ParserException; +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.LinkedList; +import java.util.List; +import java.util.Queue; +import java.util.regex.Pattern; + +/** + * HLS playlists parsing logic. + */ +public final class HlsPlaylistParser implements ManifestParser { + + private static final String VERSION_TAG = "#EXT-X-VERSION"; + + private static final String STREAM_INF_TAG = "#EXT-X-STREAM-INF"; + private static final String BANDWIDTH_ATTR = "BANDWIDTH"; + private static final String CODECS_ATTR = "CODECS"; + private static final String RESOLUTION_ATTR = "RESOLUTION"; + + 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 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 METHOD_ATTR = "METHOD"; + private static final String URI_ATTR = "URI"; + private static final String IV_ATTR = "IV"; + + private static final Pattern BANDWIDTH_ATTR_REGEX = + Pattern.compile(BANDWIDTH_ATTR + "=(\\d+)\\b"); + private static final Pattern CODECS_ATTR_REGEX = + Pattern.compile(CODECS_ATTR + "=\"(.+)\""); + private static final Pattern RESOLUTION_ATTR_REGEX = + Pattern.compile(RESOLUTION_ATTR + "=(\\d+x\\d+)"); + + 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"); + private static final Pattern BYTERANGE_REGEX = + Pattern.compile(BYTERANGE_TAG + ":(\\d+(?:@\\d+)?)\\b"); + + private static final Pattern METHOD_ATTR_REGEX = + Pattern.compile(METHOD_ATTR + "=([^,.*]+)"); + private static final Pattern URI_ATTR_REGEX = + Pattern.compile(URI_ATTR + "=\"(.+)\""); + private static final Pattern IV_ATTR_REGEX = + Pattern.compile(IV_ATTR + "=([^,.*]+)"); + + @Override + public HlsPlaylist parse(InputStream inputStream, String inputEncoding, + String contentId, Uri baseUri) throws IOException { + BufferedReader reader = new BufferedReader((inputEncoding == null) + ? new InputStreamReader(inputStream) : new InputStreamReader(inputStream, inputEncoding)); + Queue extraLines = new LinkedList(); + String line; + try { + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) { + // Do nothing. + } else if (line.startsWith(STREAM_INF_TAG)) { + extraLines.add(line); + return parseMasterPlaylist(new LineIterator(extraLines, reader), baseUri); + } else if (line.startsWith(TARGET_DURATION_TAG) + || line.startsWith(MEDIA_SEQUENCE_TAG) + || line.startsWith(MEDIA_DURATION_TAG) + || line.startsWith(KEY_TAG) + || line.startsWith(BYTERANGE_TAG) + || line.equals(DISCONTINUITY_TAG) + || line.equals(ENDLIST_TAG)) { + extraLines.add(line); + return parseMediaPlaylist(new LineIterator(extraLines, reader), baseUri); + } else if (line.startsWith(VERSION_TAG)) { + extraLines.add(line); + } else if (!line.startsWith("#")) { + throw new ParserException("Missing a tag before URL."); + } + } + } finally { + reader.close(); + } + throw new ParserException("Failed to parse the playlist, could not identify any tags."); + } + + private static HlsMasterPlaylist parseMasterPlaylist(LineIterator iterator, Uri baseUri) + throws IOException { + List variants = new ArrayList(); + int bandwidth = 0; + String[] codecs = null; + int width = -1; + int height = -1; + int variantIndex = 0; + + String line; + while (iterator.hasNext()) { + line = iterator.next(); + if (line.startsWith(STREAM_INF_TAG)) { + bandwidth = HlsParserUtil.parseIntAttr(line, BANDWIDTH_ATTR_REGEX, BANDWIDTH_ATTR); + String codecsString = HlsParserUtil.parseOptionalStringAttr(line, CODECS_ATTR_REGEX); + if (codecsString != null) { + codecs = codecsString.split("(\\s*,\\s*)|(\\s*$)"); + } else { + codecs = null; + } + String resolutionString = HlsParserUtil.parseOptionalStringAttr(line, + RESOLUTION_ATTR_REGEX); + if (resolutionString != null) { + String[] widthAndHeight = resolutionString.split("x"); + width = Integer.parseInt(widthAndHeight[0]); + height = Integer.parseInt(widthAndHeight[1]); + } else { + width = -1; + height = -1; + } + } else if (!line.startsWith("#")) { + variants.add(new Variant(variantIndex++, line, bandwidth, codecs, width, height)); + bandwidth = 0; + codecs = null; + width = -1; + height = -1; + } + } + return new HlsMasterPlaylist(baseUri, Collections.unmodifiableList(variants)); + } + + private static HlsMediaPlaylist parseMediaPlaylist(LineIterator iterator, Uri baseUri) + throws IOException { + 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 segmentEncryptionMethod = null; + String segmentEncryptionKeyUri = null; + String segmentEncryptionIV = null; + int segmentByterangeOffset = 0; + int segmentByterangeLength = C.LENGTH_UNBOUNDED; + + int segmentMediaSequence = 0; + + String line; + while (iterator.hasNext()) { + line = iterator.next(); + 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); + segmentMediaSequence = mediaSequence; + } 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.startsWith(KEY_TAG)) { + segmentEncryptionMethod = HlsParserUtil.parseStringAttr(line, METHOD_ATTR_REGEX, + METHOD_ATTR); + if (segmentEncryptionMethod.equals(HlsMediaPlaylist.ENCRYPTION_METHOD_NONE)) { + segmentEncryptionKeyUri = null; + segmentEncryptionIV = null; + } else { + segmentEncryptionKeyUri = HlsParserUtil.parseStringAttr(line, URI_ATTR_REGEX, + URI_ATTR); + segmentEncryptionIV = HlsParserUtil.parseOptionalStringAttr(line, IV_ATTR_REGEX); + if (segmentEncryptionIV == null) { + segmentEncryptionIV = Integer.toHexString(segmentMediaSequence); + } + } + } else if (line.startsWith(BYTERANGE_TAG)) { + String byteRange = HlsParserUtil.parseStringAttr(line, BYTERANGE_REGEX, BYTERANGE_TAG); + String[] splitByteRange = byteRange.split("@"); + segmentByterangeLength = Integer.parseInt(splitByteRange[0]); + if (splitByteRange.length > 1) { + segmentByterangeOffset = Integer.parseInt(splitByteRange[1]); + } + } else if (line.equals(DISCONTINUITY_TAG)) { + segmentDiscontinuity = true; + } else if (!line.startsWith("#")) { + segmentMediaSequence++; + if (segmentByterangeLength == C.LENGTH_UNBOUNDED) { + segmentByterangeOffset = 0; + } + segments.add(new Segment(line, segmentDurationSecs, segmentDiscontinuity, + segmentStartTimeUs, segmentEncryptionMethod, segmentEncryptionKeyUri, + segmentEncryptionIV, segmentByterangeOffset, segmentByterangeLength)); + segmentStartTimeUs += (long) (segmentDurationSecs * C.MICROS_PER_SECOND); + segmentDiscontinuity = false; + segmentDurationSecs = 0.0; + if (segmentByterangeLength != C.LENGTH_UNBOUNDED) { + segmentByterangeOffset += segmentByterangeLength; + } + segmentByterangeLength = C.LENGTH_UNBOUNDED; + } else if (line.equals(ENDLIST_TAG)) { + live = false; + break; + } + } + return new HlsMediaPlaylist(baseUri, mediaSequence, targetDurationSecs, version, live, + Collections.unmodifiableList(segments)); + } + + private static class LineIterator { + + private final BufferedReader reader; + private final Queue extraLines; + + private String next; + + public LineIterator(Queue extraLines, BufferedReader reader) { + this.extraLines = extraLines; + this.reader = reader; + } + + public boolean hasNext() throws IOException { + if (next != null) { + return true; + } + if (!extraLines.isEmpty()) { + next = extraLines.poll(); + return true; + } + while ((next = reader.readLine()) != null) { + next = next.trim(); + if (!next.isEmpty()) { + return true; + } + } + return false; + } + + public String next() throws IOException { + String result = null; + if (hasNext()) { + result = next; + next = null; + } + return result; + } + + } + +} 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..ea6fddd488 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsSampleSource.java @@ -0,0 +1,447 @@ +/* + * 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.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.LinkedList; + +/** + * A {@link SampleSource} for HLS streams. + */ +public class HlsSampleSource implements SampleSource, Loader.Callback { + + /** + * The default minimum number of times to retry loading data prior to failing. + */ + public static final int DEFAULT_MIN_LOADABLE_RETRY_COUNT = 3; + + private static final int NO_RESET_PENDING = -1; + + private final HlsChunkSource chunkSource; + private final LinkedList extractors; + private final boolean frameAccurateSeeking; + private final int minLoadableRetryCount; + + 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 pendingResetPositionUs; + + private TsChunk previousTsLoadable; + private HlsChunk currentLoadable; + private boolean loadingFinished; + + private Loader loader; + private IOException currentLoadableException; + private boolean currentLoadableExceptionFatal; + private int currentLoadableExceptionCount; + private long currentLoadableExceptionTimestamp; + + public HlsSampleSource(HlsChunkSource chunkSource, boolean frameAccurateSeeking, + int downstreamRendererCount) { + this(chunkSource, frameAccurateSeeking, downstreamRendererCount, + DEFAULT_MIN_LOADABLE_RETRY_COUNT); + } + + public HlsSampleSource(HlsChunkSource chunkSource, boolean frameAccurateSeeking, + int downstreamRendererCount, int minLoadableRetryCount) { + this.chunkSource = chunkSource; + this.frameAccurateSeeking = frameAccurateSeeking; + this.remainingReleaseCount = downstreamRendererCount; + this.minLoadableRetryCount = minLoadableRetryCount; + extractors = new LinkedList(); + } + + @Override + public boolean prepare() throws IOException { + if (prepared) { + return true; + } + if (loader == null) { + loader = new Loader("Loader:HLS"); + } + continueBufferingInternal(); + if (!extractors.isEmpty()) { + TsExtractor extractor = extractors.getFirst(); + if (extractor.isPrepared()) { + trackCount = extractor.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 = extractor.getFormat(i); + trackInfos[i] = new TrackInfo(format.mimeType, chunkSource.getDurationUs()); + } + prepared = true; + } + } + if (!prepared) { + maybeThrowLoadableException(); + } + 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 positionUs) { + Assertions.checkState(prepared); + Assertions.checkState(!trackEnabledStates[track]); + enabledTrackCount++; + trackEnabledStates[track] = true; + downstreamMediaFormats[track] = null; + if (enabledTrackCount == 1) { + seekToUs(positionUs); + } + } + + @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 { + clearState(); + } + } + } + + @Override + public boolean continueBuffering(long playbackPositionUs) throws IOException { + Assertions.checkState(prepared); + Assertions.checkState(enabledTrackCount > 0); + downstreamPositionUs = playbackPositionUs; + if (!extractors.isEmpty()) { + discardSamplesForDisabledTracks(extractors.getFirst(), downstreamPositionUs); + } + return continueBufferingInternal(); + } + + private boolean continueBufferingInternal() throws IOException { + maybeStartLoading(); + if (isPendingReset() || extractors.isEmpty()) { + return false; + } + boolean haveSamples = prepared && haveSamplesForEnabledTracks(getCurrentExtractor()); + if (!haveSamples) { + maybeThrowLoadableException(); + } + return haveSamples; + } + + @Override + public int readData(int track, long playbackPositionUs, MediaFormatHolder formatHolder, + SampleHolder sampleHolder, boolean onlyReadDiscontinuity) throws IOException { + Assertions.checkState(prepared); + downstreamPositionUs = playbackPositionUs; + + if (pendingDiscontinuities[track]) { + pendingDiscontinuities[track] = false; + return DISCONTINUITY_READ; + } + + if (onlyReadDiscontinuity || isPendingReset() || extractors.isEmpty()) { + maybeThrowLoadableException(); + return NOTHING_READ; + } + + TsExtractor extractor = getCurrentExtractor(); + if (extractors.size() > 1) { + // If there's more than one extractor, attempt to configure a seamless splice from the + // current one to the next one. + extractor.configureSpliceTo(extractors.get(1)); + } + + int extractorIndex = 0; + while (extractors.size() > extractorIndex + 1 && !extractor.hasSamples(track)) { + // We're finished reading from the extractor for this particular track, so advance to the + // next one for the current read. + extractor = extractors.get(++extractorIndex); + } + + if (!extractor.isPrepared()) { + maybeThrowLoadableException(); + return NOTHING_READ; + } + + MediaFormat mediaFormat = extractor.getFormat(track); + if (mediaFormat != null && !mediaFormat.equals(downstreamMediaFormats[track], true)) { + chunkSource.getMaxVideoDimensions(mediaFormat); + formatHolder.format = mediaFormat; + downstreamMediaFormats[track] = mediaFormat; + return FORMAT_READ; + } + + if (extractor.getSample(track, sampleHolder)) { + sampleHolder.decodeOnly = frameAccurateSeeking && sampleHolder.timeUs < lastSeekPositionUs; + return SAMPLE_READ; + } + + if (loadingFinished) { + return END_OF_STREAM; + } + + maybeThrowLoadableException(); + return NOTHING_READ; + } + + @Override + public void seekToUs(long positionUs) { + Assertions.checkState(prepared); + Assertions.checkState(enabledTrackCount > 0); + lastSeekPositionUs = positionUs; + if (pendingResetPositionUs == positionUs || downstreamPositionUs == positionUs) { + downstreamPositionUs = positionUs; + return; + } + downstreamPositionUs = positionUs; + for (int i = 0; i < pendingDiscontinuities.length; i++) { + pendingDiscontinuities[i] = true; + } + restartFrom(positionUs); + } + + @Override + public long getBufferedPositionUs() { + Assertions.checkState(prepared); + Assertions.checkState(enabledTrackCount > 0); + if (isPendingReset()) { + return pendingResetPositionUs; + } else if (loadingFinished) { + return TrackRenderer.END_OF_TRACK_US; + } else { + long largestSampleTimestamp = extractors.getLast().getLargestSampleTimestamp(); + return largestSampleTimestamp == Long.MIN_VALUE ? downstreamPositionUs + : largestSampleTimestamp; + } + } + + @Override + public void release() { + Assertions.checkState(remainingReleaseCount > 0); + if (--remainingReleaseCount == 0 && loader != null) { + loader.release(); + loader = null; + } + } + + @Override + public void onLoadCompleted(Loadable loadable) { + try { + currentLoadable.consume(); + } catch (IOException e) { + currentLoadableException = e; + currentLoadableExceptionCount++; + currentLoadableExceptionTimestamp = SystemClock.elapsedRealtime(); + currentLoadableExceptionFatal = true; + } finally { + if (isTsChunk(currentLoadable)) { + TsChunk tsChunk = (TsChunk) loadable; + loadingFinished = tsChunk.isLastChunk; + } + if (!currentLoadableExceptionFatal) { + clearCurrentLoadable(); + } + maybeStartLoading(); + } + } + + @Override + public void onLoadCanceled(Loadable loadable) { + if (enabledTrackCount > 0) { + restartFrom(pendingResetPositionUs); + } else { + clearState(); + } + } + + @Override + public void onLoadError(Loadable loadable, IOException e) { + if (chunkSource.onLoadError(currentLoadable, e)) { + // Error handled by source. + clearCurrentLoadable(); + } else { + currentLoadableException = e; + currentLoadableExceptionCount++; + currentLoadableExceptionTimestamp = SystemClock.elapsedRealtime(); + } + maybeStartLoading(); + } + + /** + * Gets the current extractor from which samples should be read. + *

+ * Calling this method discards extractors without any samples from the front of the queue. The + * last extractor is retained even if it doesn't have any samples. + *

+ * This method must not be called unless {@link #extractors} is non-empty. + * + * @return The current extractor from which samples should be read. Guaranteed to be non-null. + */ + private TsExtractor getCurrentExtractor() { + TsExtractor extractor = extractors.getFirst(); + while (extractors.size() > 1 && !haveSamplesForEnabledTracks(extractor)) { + // We're finished reading from the extractor for all tracks, and so can discard it. + extractors.removeFirst().release(); + extractor = extractors.getFirst(); + } + return extractor; + } + + private void discardSamplesForDisabledTracks(TsExtractor extractor, long timeUs) { + if (!extractor.isPrepared()) { + return; + } + for (int i = 0; i < trackEnabledStates.length; i++) { + if (!trackEnabledStates[i]) { + extractor.discardUntil(i, timeUs); + } + } + } + + private boolean haveSamplesForEnabledTracks(TsExtractor extractor) { + if (!extractor.isPrepared()) { + return false; + } + for (int i = 0; i < trackEnabledStates.length; i++) { + if (trackEnabledStates[i] && extractor.hasSamples(i)) { + return true; + } + } + return false; + } + + private void maybeThrowLoadableException() throws IOException { + if (currentLoadableException != null && (currentLoadableExceptionFatal + || currentLoadableExceptionCount > minLoadableRetryCount)) { + throw currentLoadableException; + } + } + + private void restartFrom(long positionUs) { + pendingResetPositionUs = positionUs; + loadingFinished = false; + if (loader.isLoading()) { + loader.cancelLoading(); + } else { + clearState(); + maybeStartLoading(); + } + } + + private void clearState() { + for (int i = 0; i < extractors.size(); i++) { + extractors.get(i).release(); + } + extractors.clear(); + clearCurrentLoadable(); + previousTsLoadable = null; + } + + private void clearCurrentLoadable() { + currentLoadable = null; + currentLoadableException = null; + currentLoadableExceptionCount = 0; + currentLoadableExceptionFatal = false; + } + + private void maybeStartLoading() { + if (currentLoadableExceptionFatal || loadingFinished || loader.isLoading()) { + return; + } + + boolean isBackedOff = currentLoadableException != null; + if (isBackedOff) { + long elapsedMillis = SystemClock.elapsedRealtime() - currentLoadableExceptionTimestamp; + if (elapsedMillis >= getRetryDelayMillis(currentLoadableExceptionCount)) { + currentLoadableException = null; + loader.startLoading(currentLoadable, this); + } + return; + } + + HlsChunk nextLoadable = chunkSource.getChunkOperation(previousTsLoadable, + pendingResetPositionUs, downstreamPositionUs); + if (nextLoadable == null) { + return; + } + + currentLoadable = nextLoadable; + if (isTsChunk(currentLoadable)) { + previousTsLoadable = (TsChunk) currentLoadable; + if (isPendingReset()) { + pendingResetPositionUs = NO_RESET_PENDING; + } + if (extractors.isEmpty() || extractors.getLast() != previousTsLoadable.extractor) { + extractors.addLast(previousTsLoadable.extractor); + } + } + loader.startLoading(currentLoadable, this); + } + + private boolean isTsChunk(HlsChunk chunk) { + return chunk instanceof TsChunk; + } + + private boolean isPendingReset() { + return pendingResetPositionUs != 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..04a3f9200f --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/TsChunk.java @@ -0,0 +1,132 @@ +/* + * 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.upstream.DataSource; +import com.google.android.exoplayer.upstream.DataSpec; + +import java.io.IOException; + +/** + * A MPEG2TS chunk. + */ +public final class TsChunk extends HlsChunk { + + private static final byte[] SCRATCH_SPACE = new byte[4096]; + + /** + * The index of the variant in the master playlist. + */ + public final int variantIndex; + /** + * 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 chunk index. + */ + public final int chunkIndex; + /** + * True if this is the last chunk in the media. False otherwise. + */ + public final boolean isLastChunk; + /** + * The extractor into which this chunk is being consumed. + */ + public final TsExtractor extractor; + + private int loadPosition; + private volatile boolean loadFinished; + private volatile boolean loadCanceled; + + /** + * @param dataSource A {@link DataSource} for loading the data. + * @param dataSpec Defines the data to be loaded. + * @param variantIndex The index of the variant in the master playlist. + * @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 chunkIndex The index of the chunk. + * @param isLastChunk True if this is the last chunk in the media. False otherwise. + */ + public TsChunk(DataSource dataSource, DataSpec dataSpec, TsExtractor tsExtractor, + int variantIndex, long startTimeUs, long endTimeUs, int chunkIndex, boolean isLastChunk) { + super(dataSource, dataSpec); + this.extractor = tsExtractor; + this.variantIndex = variantIndex; + this.startTimeUs = startTimeUs; + this.endTimeUs = endTimeUs; + this.chunkIndex = chunkIndex; + this.isLastChunk = isLastChunk; + } + + @Override + public void consume() throws IOException { + // Do nothing. + } + + @Override + public boolean isLoadFinished() { + return loadFinished; + } + + // Loadable implementation + + @Override + public void cancelLoad() { + loadCanceled = true; + } + + @Override + public boolean isLoadCanceled() { + return loadCanceled; + } + + @Override + public void load() throws IOException, InterruptedException { + try { + dataSource.open(dataSpec); + int bytesRead = 0; + int bytesSkipped = 0; + // If we previously fed part of this chunk to the extractor, skip it this time. + // TODO: Ideally we'd construct a dataSpec that only loads the remainder of the data here, + // rather than loading the whole chunk again and then skipping data we previously loaded. To + // do this is straightforward for non-encrypted content, but more complicated for content + // encrypted with AES, for which we'll need to modify the way that decryption is performed. + while (bytesRead != -1 && !loadCanceled && bytesSkipped < loadPosition) { + int skipLength = Math.min(loadPosition - bytesSkipped, SCRATCH_SPACE.length); + bytesRead = dataSource.read(SCRATCH_SPACE, 0, skipLength); + if (bytesRead != -1) { + bytesSkipped += bytesRead; + } + } + // Feed the remaining data into the extractor. + while (bytesRead != -1 && !loadCanceled) { + bytesRead = extractor.read(dataSource); + if (bytesRead != -1) { + loadPosition += bytesRead; + } + } + loadFinished = !loadCanceled; + } finally { + dataSource.close(); + } + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/hls/TsExtractor.java b/library/src/main/java/com/google/android/exoplayer/hls/TsExtractor.java new file mode 100644 index 0000000000..1c9dcc8804 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/TsExtractor.java @@ -0,0 +1,1223 @@ +/* + * 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.SampleHolder; +import com.google.android.exoplayer.text.eia608.Eia608Parser; +import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.BitArray; +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.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * Facilitates the extraction of data from the MPEG-2 TS container format. + */ +public final class TsExtractor { + + 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 TS_STREAM_TYPE_ID3 = 0x15; + private static final int TS_STREAM_TYPE_EIA608 = 0x100; // 0xFF + 1 + + private static final long MAX_PTS = 0x1FFFFFFFFL; + + private final BitArray tsPacketBuffer; + private final SparseArray sampleQueues; // Indexed by streamType + private final SparseArray tsPayloadReaders; // Indexed by pid + private final SamplePool samplePool; + private final boolean shouldSpliceIn; + private final long firstSampleTimestamp; + + // Accessed only by the consuming thread. + private boolean spliceConfigured; + + // Accessed only by the loading thread. + private long timestampOffsetUs; + private long lastPts; + + // Accessed by both the loading and consuming threads. + private volatile boolean prepared; + /* package */ volatile long largestParsedTimestampUs; + + public TsExtractor(long firstSampleTimestamp, SamplePool samplePool, boolean shouldSpliceIn) { + this.firstSampleTimestamp = firstSampleTimestamp; + this.samplePool = samplePool; + this.shouldSpliceIn = shouldSpliceIn; + tsPacketBuffer = new BitArray(); + sampleQueues = new SparseArray(); + tsPayloadReaders = new SparseArray(); + tsPayloadReaders.put(TS_PAT_PID, new PatReader()); + largestParsedTimestampUs = Long.MIN_VALUE; + lastPts = Long.MIN_VALUE; + } + + /** + * 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 sampleQueues.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 sampleQueues.valueAt(track).getMediaFormat(); + } + + /** + * Whether the extractor is prepared. + * + * @return True if the extractor is prepared. False otherwise. + */ + public boolean isPrepared() { + return prepared; + } + + /** + * Releases the extractor, recycling any pending or incomplete samples to the sample pool. + *

+ * This method should not be called whilst {@link #read(DataSource)} is also being invoked. + */ + public void release() { + for (int i = 0; i < sampleQueues.size(); i++) { + sampleQueues.valueAt(i).release(); + } + } + + /** + * Attempts to configure a splice from this extractor to the next. + *

+ * The splice is performed such that for each track the samples read from the next extractor + * start with a keyframe, and continue from where the samples read from this extractor finish. + * A successful splice may discard samples from either or both extractors. + *

+ * Splice configuration may fail if the next extractor is not yet in a state that allows the + * splice to be performed. Calling this method is a noop if the splice has already been + * configured. Hence this method should be called repeatedly during the window within which a + * splice can be performed. + * + * @param nextExtractor The extractor being spliced to. + */ + public void configureSpliceTo(TsExtractor nextExtractor) { + Assertions.checkState(prepared); + if (spliceConfigured || !nextExtractor.shouldSpliceIn || !nextExtractor.isPrepared()) { + // The splice is already configured, or the next extractor doesn't want to be spliced in, or + // the next extractor isn't ready to be spliced in. + return; + } + boolean spliceConfigured = true; + for (int i = 0; i < sampleQueues.size(); i++) { + spliceConfigured &= sampleQueues.valueAt(i).configureSpliceTo( + nextExtractor.sampleQueues.valueAt(i)); + } + this.spliceConfigured = spliceConfigured; + return; + } + + /** + * Gets the largest timestamp of any sample parsed by the extractor. + * + * @return The largest timestamp, or {@link Long#MIN_VALUE} if no samples have been parsed. + */ + public long getLargestSampleTimestamp() { + return largestParsedTimestampUs; + } + + /** + * Gets the next sample for the specified track. + * + * @param track The track from which to read. + * @param out A {@link SampleHolder} into which the next sample should be read. + * @return True if a sample was read. False otherwise. + */ + public boolean getSample(int track, SampleHolder out) { + Assertions.checkState(prepared); + SampleQueue sampleQueue = sampleQueues.valueAt(track); + Sample sample = sampleQueue.poll(); + if (sample == null) { + return false; + } + convert(sample, out); + sampleQueue.recycle(sample); + return true; + } + + /** + * Discards samples for the specified track up to the specified time. + * + * @param track The track from which samples should be discarded. + * @param timeUs The time up to which samples should be discarded, in microseconds. + */ + public void discardUntil(int track, long timeUs) { + Assertions.checkState(prepared); + sampleQueues.valueAt(track).discardUntil(timeUs); + } + + /** + * Whether samples are available for reading from {@link #getSample(int, SampleHolder)} for the + * specified track. + * + * @return True if samples are available for reading from {@link #getSample(int, SampleHolder)} + * for the specified track. False otherwise. + */ + public boolean hasSamples(int track) { + Assertions.checkState(prepared); + return sampleQueues.valueAt(track).peek() != null; + } + + private boolean checkPrepared() { + int pesPayloadReaderCount = sampleQueues.size(); + if (pesPayloadReaderCount == 0) { + return false; + } + for (int i = 0; i < pesPayloadReaderCount; i++) { + if (!sampleQueues.valueAt(i).hasMediaFormat()) { + return false; + } + } + return true; + } + + /** + * Reads up to a single TS packet. + * + * @param dataSource The {@link DataSource} from which to read. + * @throws IOException If an error occurred reading from the source. + * @return The number of bytes read from the source. + */ + public int read(DataSource dataSource) throws IOException { + int read = tsPacketBuffer.append(dataSource, TS_PACKET_SIZE - tsPacketBuffer.bytesLeft()); + if (read == -1) { + return -1; + } + + if (tsPacketBuffer.bytesLeft() != TS_PACKET_SIZE) { + return read; + } + + // Parse TS header. + // Check sync byte. + int syncByte = tsPacketBuffer.readUnsignedByte(); + if (syncByte != TS_SYNC_BYTE) { + return read; + } + + // Skip transportErrorIndicator. + tsPacketBuffer.skipBits(1); + boolean payloadUnitStartIndicator = tsPacketBuffer.readBit(); + // Skip transportPriority. + tsPacketBuffer.skipBits(1); + int pid = tsPacketBuffer.readBits(13); + // Skip transport_scrambling_control. + tsPacketBuffer.skipBits(2); + boolean adaptationFieldExists = tsPacketBuffer.readBit(); + boolean payloadExists = tsPacketBuffer.readBit(); + // Skip continuityCounter. + tsPacketBuffer.skipBits(4); + + // Read the adaptation field. + if (adaptationFieldExists) { + int adaptationFieldLength = tsPacketBuffer.readBits(8); + tsPacketBuffer.skipBytes(adaptationFieldLength); + } + + // Read Payload. + if (payloadExists) { + TsPayloadReader payloadReader = tsPayloadReaders.get(pid); + if (payloadReader != null) { + payloadReader.read(tsPacketBuffer, payloadUnitStartIndicator); + } + } + + if (!prepared) { + prepared = checkPrepared(); + } + + tsPacketBuffer.reset(); + return read; + } + + @SuppressLint("InlinedApi") + private void convert(Sample in, SampleHolder out) { + if (out.data == null || out.data.capacity() < in.size) { + out.replaceBuffer(in.size); + } + if (out.data != null) { + out.data.put(in.data, 0, in.size); + } + out.size = in.size; + out.flags = in.isKeyframe ? MediaExtractor.SAMPLE_FLAG_SYNC : 0; + out.timeUs = in.timeUs; + } + + /** + * Adjusts a PTS value to the corresponding time in microseconds, accounting for PTS wraparound. + * + * @param pts The raw PTS value. + * @return The corresponding time in microseconds. + */ + /* package */ long ptsToTimeUs(long pts) { + if (lastPts != Long.MIN_VALUE) { + // The wrap count for the current PTS may be closestWrapCount or (closestWrapCount - 1), + // and we need to snap to the one closest to lastPts. + long closestWrapCount = (lastPts + (MAX_PTS / 2)) / MAX_PTS; + long ptsWrapBelow = pts + (MAX_PTS * (closestWrapCount - 1)); + long ptsWrapAbove = pts + (MAX_PTS * closestWrapCount); + pts = Math.abs(ptsWrapBelow - lastPts) < Math.abs(ptsWrapAbove - lastPts) + ? ptsWrapBelow : ptsWrapAbove; + } + // Calculate the corresponding timestamp. + long timeUs = (pts * C.MICROS_PER_SECOND) / 90000; + // If we haven't done the initial timestamp adjustment, do it now. + if (lastPts == Long.MIN_VALUE) { + timestampOffsetUs = firstSampleTimestamp - timeUs; + } + // Record the adjusted PTS to adjust for wraparound next time. + lastPts = pts; + return timeUs + timestampOffsetUs; + } + + /** + * Parses payload data. + */ + private abstract static class TsPayloadReader { + + public abstract void read(BitArray tsBuffer, boolean payloadUnitStartIndicator); + + } + + /** + * Parses Program Association Table data. + */ + private class PatReader extends TsPayloadReader { + + @Override + public void read(BitArray tsBuffer, boolean payloadUnitStartIndicator) { + // Skip pointer. + if (payloadUnitStartIndicator) { + int pointerField = tsBuffer.readBits(8); + tsBuffer.skipBytes(pointerField); + } + + tsBuffer.skipBits(12); // 8+1+1+2 + int sectionLength = tsBuffer.readBits(12); + tsBuffer.skipBits(40); // 16+2+5+1+8+8 + + int programCount = (sectionLength - 9) / 4; + for (int i = 0; i < programCount; i++) { + tsBuffer.skipBits(19); + int pid = tsBuffer.readBits(13); + tsPayloadReaders.put(pid, new PmtReader()); + } + + // Skip CRC_32. + } + + } + + /** + * Parses Program Map Table. + */ + private class PmtReader extends TsPayloadReader { + + @Override + public void read(BitArray tsBuffer, boolean payloadUnitStartIndicator) { + // Skip pointer. + if (payloadUnitStartIndicator) { + 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); + // Skip the descriptors. + tsBuffer.skipBytes(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); + // Skip the descriptors. + tsBuffer.skipBytes(esInfoLength); + entriesSize -= esInfoLength + 5; + + if (sampleQueues.get(streamType) != null) { + continue; + } + + PesPayloadReader pesPayloadReader = null; + switch (streamType) { + case TS_STREAM_TYPE_AAC: + pesPayloadReader = new AdtsReader(samplePool); + break; + case TS_STREAM_TYPE_H264: + SeiReader seiReader = new SeiReader(samplePool); + sampleQueues.put(TS_STREAM_TYPE_EIA608, seiReader); + pesPayloadReader = new H264Reader(samplePool, seiReader); + break; + case TS_STREAM_TYPE_ID3: + pesPayloadReader = new Id3Reader(samplePool); + break; + } + + if (pesPayloadReader != null) { + sampleQueues.put(streamType, pesPayloadReader); + tsPayloadReaders.put(elementaryPid, new PesReader(pesPayloadReader)); + } + } + + // Skip CRC_32. + } + + } + + /** + * Parses PES packet data and extracts samples. + */ + private class PesReader extends TsPayloadReader { + + // Reusable buffer for incomplete PES data. + private final BitArray pesBuffer; + // Parses PES payload and extracts individual samples. + private final PesPayloadReader pesPayloadReader; + + private int packetLength; + + public PesReader(PesPayloadReader pesPayloadReader) { + this.pesPayloadReader = pesPayloadReader; + this.packetLength = -1; + pesBuffer = new BitArray(); + } + + @Override + public void read(BitArray tsBuffer, boolean payloadUnitStartIndicator) { + if (payloadUnitStartIndicator && !pesBuffer.isEmpty()) { + if (packetLength == 0) { + // The length of the previous packet was unspecified. We've now seen the start of the + // next one, so consume the previous packet's body. + readPacketBody(); + } else { + // Either we didn't have enough data to read the length of the previous packet, or we + // did read the length but didn't receive that amount of data. Neither case is expected. + Log.w(TAG, "Unexpected packet fragment of length " + pesBuffer.bytesLeft()); + pesBuffer.reset(); + packetLength = -1; + } + } + + pesBuffer.append(tsBuffer, tsBuffer.bytesLeft()); + + if (packetLength == -1 && pesBuffer.bytesLeft() >= 6) { + // We haven't read the start of the packet, but have enough data to do so. + readPacketStart(); + } + if (packetLength > 0 && pesBuffer.bytesLeft() >= packetLength) { + // The packet length was specified and we now have the whole packet. Read it. + readPacketBody(); + } + } + + private void readPacketStart() { + int startCodePrefix = pesBuffer.readBits(24); + if (startCodePrefix != 0x000001) { + Log.w(TAG, "Unexpected start code prefix: " + startCodePrefix); + pesBuffer.reset(); + packetLength = -1; + } else { + // TODO: Read and use stream_id. + pesBuffer.skipBits(8); // Skip stream_id. + packetLength = pesBuffer.readBits(16); + } + } + + private void readPacketBody() { + // Skip some fields/flags. + // TODO: might need to use data_alignment_indicator. + pesBuffer.skipBits(8); // 2+2+1+1+1+1 + boolean ptsFlag = pesBuffer.readBit(); + // Skip DTS flag. + pesBuffer.skipBits(1); + // Skip some fields/flags. + pesBuffer.skipBits(6); // 1+1+1+1+1+1 + + int headerDataLength = pesBuffer.readBits(8); + if (headerDataLength == 0) { + headerDataLength = pesBuffer.bytesLeft(); + } + + long timeUs = 0; + if (ptsFlag) { + // 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 = ptsToTimeUs(pts); + // Skip the rest of the header. + pesBuffer.skipBytes(headerDataLength - 5); + } else { + // Skip the rest of the header. + pesBuffer.skipBytes(headerDataLength); + } + + int payloadSize; + if (packetLength == 0) { + // If pesPacketLength is not specified read all available data. + payloadSize = pesBuffer.bytesLeft(); + } else { + payloadSize = packetLength - headerDataLength - 3; + } + + pesPayloadReader.read(pesBuffer, payloadSize, timeUs); + pesBuffer.reset(); + packetLength = -1; + } + + } + + /** + * A queue of extracted samples together with their corresponding {@link MediaFormat}. + */ + private abstract class SampleQueue { + + @SuppressWarnings("hiding") + private final SamplePool samplePool; + private final ConcurrentLinkedQueue internalQueue; + + // Accessed only by the consuming thread. + private boolean needKeyframe; + private long lastReadTimeUs; + private long spliceOutTimeUs; + + // Accessed by both the loading and consuming threads. + private volatile MediaFormat mediaFormat; + + protected SampleQueue(SamplePool samplePool) { + this.samplePool = samplePool; + internalQueue = new ConcurrentLinkedQueue(); + needKeyframe = true; + lastReadTimeUs = Long.MIN_VALUE; + spliceOutTimeUs = Long.MIN_VALUE; + } + + public boolean hasMediaFormat() { + return mediaFormat != null; + } + + public MediaFormat getMediaFormat() { + return mediaFormat; + } + + protected void setMediaFormat(MediaFormat mediaFormat) { + this.mediaFormat = mediaFormat; + } + + /** + * Removes and returns the next sample from the queue. + *

+ * The first sample returned is guaranteed to be a keyframe, since any non-keyframe samples + * queued prior to the first keyframe are discarded. + * + * @return The next sample from the queue, or null if a sample isn't available. + */ + public Sample poll() { + Sample head = peek(); + if (head != null) { + internalQueue.remove(); + needKeyframe = false; + lastReadTimeUs = head.timeUs; + } + return head; + } + + /** + * Like {@link #poll()}, except the returned sample is not removed from the queue. + * + * @return The next sample from the queue, or null if a sample isn't available. + */ + public Sample peek() { + Sample head = internalQueue.peek(); + if (needKeyframe) { + // Peeking discard of samples until we find a keyframe or run out of available samples. + while (head != null && !head.isKeyframe) { + recycle(head); + internalQueue.remove(); + head = internalQueue.peek(); + } + } + if (head == null) { + return null; + } + if (spliceOutTimeUs != Long.MIN_VALUE && head.timeUs >= spliceOutTimeUs) { + // The sample is later than the time this queue is spliced out. + recycle(head); + internalQueue.remove(); + return null; + } + return head; + } + + /** + * Discards samples from the queue up to the specified time. + * + * @param timeUs The time up to which samples should be discarded, in microseconds. + */ + public void discardUntil(long timeUs) { + Sample head = peek(); + while (head != null && head.timeUs < timeUs) { + recycle(head); + internalQueue.remove(); + head = internalQueue.peek(); + // We're discarding at least one sample, so any subsequent read will need to start at + // a keyframe. + needKeyframe = true; + } + lastReadTimeUs = Long.MIN_VALUE; + } + + /** + * Clears the queue. + */ + public void release() { + Sample toRecycle = internalQueue.poll(); + while (toRecycle != null) { + recycle(toRecycle); + toRecycle = internalQueue.poll(); + } + } + + /** + * Recycles a sample. + * + * @param sample The sample to recycle. + */ + public void recycle(Sample sample) { + samplePool.recycle(sample); + } + + /** + * Attempts to configure a splice from this queue to the next. + * + * @param nextQueue The queue being spliced to. + * @return Whether the splice was configured successfully. + */ + public boolean configureSpliceTo(SampleQueue nextQueue) { + if (spliceOutTimeUs != Long.MIN_VALUE) { + // We've already configured the splice. + return true; + } + long firstPossibleSpliceTime; + Sample nextSample = internalQueue.peek(); + if (nextSample != null) { + firstPossibleSpliceTime = nextSample.timeUs; + } else { + firstPossibleSpliceTime = lastReadTimeUs + 1; + } + ConcurrentLinkedQueue nextInternalQueue = nextQueue.internalQueue; + Sample nextQueueSample = nextInternalQueue.peek(); + while (nextQueueSample != null + && (nextQueueSample.timeUs < firstPossibleSpliceTime || !nextQueueSample.isKeyframe)) { + // Discard samples from the next queue for as long as they are before the earliest possible + // splice time, or not keyframes. + nextQueue.internalQueue.remove(); + nextQueueSample = nextQueue.internalQueue.peek(); + } + if (nextQueueSample != null) { + // We've found a keyframe in the next queue that can serve as the splice point. Set the + // splice point now. + spliceOutTimeUs = nextQueueSample.timeUs; + return true; + } + return false; + } + + /** + * Obtains a Sample object to use. + * + * @param type The type of the sample. + * @return The sample. + */ + protected Sample getSample(int type) { + return samplePool.get(type); + } + + /** + * Creates a new Sample and adds it to the queue. + * + * @param type The type of the sample. + * @param buffer The buffer to read sample data. + * @param sampleSize The size of the sample data. + * @param sampleTimeUs The sample time stamp. + * @param isKeyframe True if the sample is a keyframe. False otherwise. + */ + protected void addSample(int type, BitArray buffer, int sampleSize, long sampleTimeUs, + boolean isKeyframe) { + Sample sample = getSample(type); + addToSample(sample, buffer, sampleSize); + sample.isKeyframe = isKeyframe; + sample.timeUs = sampleTimeUs; + addSample(sample); + } + + protected void addSample(Sample sample) { + largestParsedTimestampUs = Math.max(largestParsedTimestampUs, sample.timeUs); + internalQueue.add(sample); + } + + protected void addToSample(Sample sample, BitArray 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; + } + + } + + /** + * Extracts individual samples from continuous byte stream. + */ + private abstract class PesPayloadReader extends SampleQueue { + + protected PesPayloadReader(SamplePool samplePool) { + super(samplePool); + } + + public abstract void read(BitArray pesBuffer, int pesPayloadSize, long pesTimeUs); + + } + + /** + * Parses a continuous H264 byte stream and extracts individual frames. + */ + private class H264Reader extends PesPayloadReader { + + private static final int NAL_UNIT_TYPE_IDR = 5; + private static final int NAL_UNIT_TYPE_SPS = 7; + private static final int NAL_UNIT_TYPE_PPS = 8; + private static final int NAL_UNIT_TYPE_AUD = 9; + + public final SeiReader seiReader; + + // Used to store uncompleted sample data. + private Sample currentSample; + + public H264Reader(SamplePool samplePool, SeiReader seiReader) { + super(samplePool); + this.seiReader = seiReader; + } + + @Override + public void release() { + super.release(); + if (currentSample != null) { + recycle(currentSample); + currentSample = null; + } + } + + @Override + public void read(BitArray 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) { + if (!hasMediaFormat() && currentSample.isKeyframe) { + parseMediaFormat(currentSample); + } + seiReader.read(currentSample.data, currentSample.size, currentSample.timeUs); + addSample(currentSample); + } + currentSample = getSample(Sample.TYPE_VIDEO); + 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(BitArray pesBuffer, boolean remainderOnly) { + int offset = remainderOnly ? 0 : 3; + int audStart = pesBuffer.findNextNalUnit(NAL_UNIT_TYPE_AUD, offset); + if (currentSample != null) { + int idrStart = pesBuffer.findNextNalUnit(NAL_UNIT_TYPE_IDR, offset); + if (idrStart < audStart) { + currentSample.isKeyframe = true; + } + addToSample(currentSample, pesBuffer, audStart); + } else { + pesBuffer.skipBytes(audStart); + } + return audStart; + } + + private void parseMediaFormat(Sample sample) { + BitArray bitArray = new BitArray(sample.data, sample.size); + // Locate the SPS and PPS units. + int spsOffset = bitArray.findNextNalUnit(NAL_UNIT_TYPE_SPS, 0); + int ppsOffset = bitArray.findNextNalUnit(NAL_UNIT_TYPE_PPS, 0); + if (spsOffset == bitArray.bytesLeft() || ppsOffset == bitArray.bytesLeft()) { + return; + } + int spsLength = bitArray.findNextNalUnit(-1, spsOffset + 3) - spsOffset; + int ppsLength = bitArray.findNextNalUnit(-1, ppsOffset + 3) - ppsOffset; + byte[] spsData = new byte[spsLength]; + byte[] ppsData = new byte[ppsLength]; + System.arraycopy(bitArray.getData(), spsOffset, spsData, 0, spsLength); + System.arraycopy(bitArray.getData(), ppsOffset, ppsData, 0, ppsLength); + List initializationData = new ArrayList(); + initializationData.add(spsData); + initializationData.add(ppsData); + + // Unescape the SPS unit. + byte[] unescapedSps = unescapeStream(spsData, 0, spsLength); + bitArray.reset(unescapedSps, unescapedSps.length); + + // Parse the SPS unit + // Skip the NAL header. + bitArray.skipBytes(4); + int profileIdc = bitArray.readBits(8); + // Skip 6 constraint bits, 2 reserved bits and level_idc. + bitArray.skipBytes(2); + // Skip seq_parameter_set_id. + bitArray.readUnsignedExpGolombCodedInt(); + + int chromaFormatIdc = 1; // Default is 4:2:0 + if (profileIdc == 100 || profileIdc == 110 || profileIdc == 122 || profileIdc == 244 + || profileIdc == 44 || profileIdc == 83 || profileIdc == 86 || profileIdc == 118 + || profileIdc == 128 || profileIdc == 138) { + chromaFormatIdc = bitArray.readUnsignedExpGolombCodedInt(); + if (chromaFormatIdc == 3) { + // Skip separate_colour_plane_flag + bitArray.skipBits(1); + } + // Skip bit_depth_luma_minus8 + bitArray.readUnsignedExpGolombCodedInt(); + // Skip bit_depth_chroma_minus8 + bitArray.readUnsignedExpGolombCodedInt(); + // Skip qpprime_y_zero_transform_bypass_flag + bitArray.skipBits(1); + boolean seqScalingMatrixPresentFlag = bitArray.readBit(); + if (seqScalingMatrixPresentFlag) { + int limit = (chromaFormatIdc != 3) ? 8 : 12; + for (int i = 0; i < limit; i++) { + boolean seqScalingListPresentFlag = bitArray.readBit(); + if (seqScalingListPresentFlag) { + skipScalingList(bitArray, i < 6 ? 16 : 64); + } + } + } + } + // Skip log2_max_frame_num_minus4 + bitArray.readUnsignedExpGolombCodedInt(); + long picOrderCntType = bitArray.readUnsignedExpGolombCodedInt(); + if (picOrderCntType == 0) { + // Skip log2_max_pic_order_cnt_lsb_minus4 + bitArray.readUnsignedExpGolombCodedInt(); + } else if (picOrderCntType == 1) { + // Skip delta_pic_order_always_zero_flag + bitArray.skipBits(1); + // Skip offset_for_non_ref_pic + bitArray.readSignedExpGolombCodedInt(); + // Skip offset_for_top_to_bottom_field + bitArray.readSignedExpGolombCodedInt(); + long numRefFramesInPicOrderCntCycle = bitArray.readUnsignedExpGolombCodedInt(); + for (int i = 0; i < numRefFramesInPicOrderCntCycle; i++) { + // Skip offset_for_ref_frame[i] + bitArray.readUnsignedExpGolombCodedInt(); + } + } + // Skip max_num_ref_frames + bitArray.readUnsignedExpGolombCodedInt(); + // Skip gaps_in_frame_num_value_allowed_flag + bitArray.skipBits(1); + int picWidthInMbs = bitArray.readUnsignedExpGolombCodedInt() + 1; + int picHeightInMapUnits = bitArray.readUnsignedExpGolombCodedInt() + 1; + boolean frameMbsOnlyFlag = bitArray.readBit(); + int frameHeightInMbs = (2 - (frameMbsOnlyFlag ? 1 : 0)) * picHeightInMapUnits; + if (!frameMbsOnlyFlag) { + // Skip mb_adaptive_frame_field_flag + bitArray.skipBits(1); + } + // Skip direct_8x8_inference_flag + bitArray.skipBits(1); + int frameWidth = picWidthInMbs * 16; + int frameHeight = frameHeightInMbs * 16; + boolean frameCroppingFlag = bitArray.readBit(); + if (frameCroppingFlag) { + int frameCropLeftOffset = bitArray.readUnsignedExpGolombCodedInt(); + int frameCropRightOffset = bitArray.readUnsignedExpGolombCodedInt(); + int frameCropTopOffset = bitArray.readUnsignedExpGolombCodedInt(); + int frameCropBottomOffset = bitArray.readUnsignedExpGolombCodedInt(); + int cropUnitX, cropUnitY; + if (chromaFormatIdc == 0) { + cropUnitX = 1; + cropUnitY = 2 - (frameMbsOnlyFlag ? 1 : 0); + } else { + int subWidthC = (chromaFormatIdc == 3) ? 1 : 2; + int subHeightC = (chromaFormatIdc == 1) ? 2 : 1; + cropUnitX = subWidthC; + cropUnitY = subHeightC * (2 - (frameMbsOnlyFlag ? 1 : 0)); + } + frameWidth -= (frameCropLeftOffset + frameCropRightOffset) * cropUnitX; + frameHeight -= (frameCropTopOffset + frameCropBottomOffset) * cropUnitY; + } + + // Set the format. + setMediaFormat(MediaFormat.createVideoFormat(MimeTypes.VIDEO_H264, MediaFormat.NO_VALUE, + frameWidth, frameHeight, initializationData)); + } + + private void skipScalingList(BitArray bitArray, int size) { + int lastScale = 8; + int nextScale = 8; + for (int i = 0; i < size; i++) { + if (nextScale != 0) { + int deltaScale = bitArray.readSignedExpGolombCodedInt(); + nextScale = (lastScale + deltaScale + 256) % 256; + } + lastScale = (nextScale == 0) ? lastScale : nextScale; + } + } + + /** + * Replaces occurrences of [0, 0, 3] with [0, 0]. + *

+ * See ISO/IEC 14496-10:2005(E) page 36 for more information. + */ + private byte[] unescapeStream(byte[] data, int offset, int limit) { + int position = offset; + List escapePositions = new ArrayList(); + while (position < limit) { + position = findNextUnescapeIndex(data, position, limit); + if (position < limit) { + escapePositions.add(position); + position += 3; + } + } + + int escapeCount = escapePositions.size(); + int escapedPosition = offset; // The position being read from. + int unescapedPosition = 0; // The position being written to. + byte[] unescapedData = new byte[limit - offset - escapeCount]; + for (int i = 0; i < escapeCount; i++) { + int nextEscapePosition = escapePositions.get(i); + int copyLength = nextEscapePosition - escapedPosition; + System.arraycopy(data, escapedPosition, unescapedData, unescapedPosition, copyLength); + escapedPosition += copyLength + 3; + unescapedPosition += copyLength + 2; + } + + int remainingLength = unescapedData.length - unescapedPosition; + System.arraycopy(data, escapedPosition, unescapedData, unescapedPosition, remainingLength); + return unescapedData; + } + + private int findNextUnescapeIndex(byte[] bytes, int offset, int limit) { + for (int i = offset; i < limit - 2; i++) { + if (bytes[i] == 0x00 && bytes[i + 1] == 0x00 && bytes[i + 2] == 0x03) { + return i; + } + } + return limit; + } + + } + + /** + * Parses a SEI data from H.264 frames and extracts samples with closed captions data. + */ + private class SeiReader extends SampleQueue { + + // SEI data, used for Closed Captions. + private static final int NAL_UNIT_TYPE_SEI = 6; + + private final BitArray seiBuffer; + + public SeiReader(SamplePool samplePool) { + super(samplePool); + setMediaFormat(MediaFormat.createEia608Format()); + seiBuffer = new BitArray(); + } + + @SuppressLint("InlinedApi") + public void read(byte[] data, int size, long pesTimeUs) { + seiBuffer.reset(data, size); + while (seiBuffer.bytesLeft() > 0) { + int seiStart = seiBuffer.findNextNalUnit(NAL_UNIT_TYPE_SEI, 0); + if (seiStart == seiBuffer.bytesLeft()) { + return; + } + seiBuffer.skipBytes(seiStart + 4); + int ccDataSize = Eia608Parser.parseHeader(seiBuffer); + if (ccDataSize > 0) { + addSample(Sample.TYPE_MISC, seiBuffer, ccDataSize, pesTimeUs, true); + } + } + } + + } + + /** + * Parses a continuous ADTS byte stream and extracts individual frames. + */ + private class AdtsReader extends PesPayloadReader { + + private final BitArray adtsBuffer; + private long timeUs; + private long frameDurationUs; + + public AdtsReader(SamplePool samplePool) { + super(samplePool); + adtsBuffer = new BitArray(); + } + + @Override + public void read(BitArray 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 { + timeUs = pesTimeUs + (frameDurationUs * 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); + boolean hasCRC = !adtsBuffer.readBit(); + + 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)); + frameDurationUs = (C.MICROS_PER_SECOND * 1024L) / mediaFormat.sampleRate; + 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) { + // Skip CRC. + adtsBuffer.skipBytes(2); + frameSize -= 9; + } else { + frameSize -= 7; + } + + if (frameSize > adtsBuffer.bytesLeft()) { + adtsBuffer.setByteOffset(adtsStartOffset); + adtsBuffer.clearReadData(); + return false; + } + + addSample(Sample.TYPE_AUDIO, adtsBuffer, frameSize, timeUs, true); + return true; + } + + @Override + public void release() { + super.release(); + adtsBuffer.reset(); + } + + } + + /** + * Parses ID3 data and extracts individual text information frames. + */ + private class Id3Reader extends PesPayloadReader { + + public Id3Reader(SamplePool samplePool) { + super(samplePool); + setMediaFormat(MediaFormat.createId3Format()); + } + + @SuppressLint("InlinedApi") + @Override + public void read(BitArray pesBuffer, int pesPayloadSize, long pesTimeUs) { + addSample(Sample.TYPE_MISC, pesBuffer, pesPayloadSize, pesTimeUs, true); + } + + } + + /** + * A pool from which the extractor can obtain sample objects for internal use. + * + * TODO: Over time the average size of a sample in the video pool will become larger, as the + * proportion of samples in the pool that have at some point held a keyframe grows. Currently + * this leads to inefficient memory usage, since samples large enough to hold keyframes end up + * being used to hold non-keyframes. We need to fix this. + */ + public static class SamplePool { + + private static final int[] DEFAULT_SAMPLE_SIZES; + static { + DEFAULT_SAMPLE_SIZES = new int[Sample.TYPE_COUNT]; + DEFAULT_SAMPLE_SIZES[Sample.TYPE_VIDEO] = 10 * 1024; + DEFAULT_SAMPLE_SIZES[Sample.TYPE_AUDIO] = 512; + DEFAULT_SAMPLE_SIZES[Sample.TYPE_MISC] = 512; + } + + private final Sample[] pools; + + public SamplePool() { + pools = new Sample[Sample.TYPE_COUNT]; + } + + /* package */ synchronized Sample get(int type) { + if (pools[type] == null) { + return new Sample(type, DEFAULT_SAMPLE_SIZES[type]); + } + Sample sample = pools[type]; + pools[type] = sample.nextInPool; + sample.nextInPool = null; + return sample; + } + + /* package */ synchronized void recycle(Sample sample) { + sample.reset(); + sample.nextInPool = pools[sample.type]; + pools[sample.type] = sample; + } + + } + + /** + * An internal variant of {@link SampleHolder} for internal pooling and buffering. + */ + private static class Sample { + + public static final int TYPE_VIDEO = 0; + public static final int TYPE_AUDIO = 1; + public static final int TYPE_MISC = 2; + public static final int TYPE_COUNT = 3; + + public final int type; + public Sample nextInPool; + + public byte[] data; + public boolean isKeyframe; + public int size; + public long timeUs; + + public Sample(int type, int length) { + this.type = type; + 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() { + isKeyframe = false; + size = 0; + timeUs = 0; + } + + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/hls/Variant.java b/library/src/main/java/com/google/android/exoplayer/hls/Variant.java new file mode 100644 index 0000000000..47d0a450a9 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/Variant.java @@ -0,0 +1,56 @@ +/* + * 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 java.util.Comparator; + +/** + * Variant stream reference. + */ +public final class Variant { + + /** + * Sorts {@link Variant} objects in order of decreasing bandwidth. + *

+ * When two {@link Variant}s have the same bandwidth, the one with the lowest index comes first. + */ + public static final class DecreasingBandwidthComparator implements Comparator { + + @Override + public int compare(Variant a, Variant b) { + int bandwidthDifference = b.bandwidth - a.bandwidth; + return bandwidthDifference != 0 ? bandwidthDifference : a.index - b.index; + } + + } + + public final int index; + public final int bandwidth; + public final String url; + public final String[] codecs; + public final int width; + public final int height; + + public Variant(int index, String url, int bandwidth, String[] codecs, int width, int height) { + this.index = index; + this.bandwidth = bandwidth; + this.url = url; + this.codecs = codecs; + this.width = width; + this.height = height; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/metadata/Id3Parser.java b/library/src/main/java/com/google/android/exoplayer/metadata/Id3Parser.java new file mode 100644 index 0000000000..efa3b66147 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/metadata/Id3Parser.java @@ -0,0 +1,159 @@ +/* + * 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.metadata; + +import com.google.android.exoplayer.ParserException; +import com.google.android.exoplayer.util.BitArray; +import com.google.android.exoplayer.util.MimeTypes; + +import java.io.UnsupportedEncodingException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Extracts individual TXXX text frames from raw ID3 data. + */ +public class Id3Parser implements MetadataParser> { + + @Override + public boolean canParse(String mimeType) { + return mimeType.equals(MimeTypes.APPLICATION_ID3); + } + + @Override + public Map parse(byte[] data, int size) + throws UnsupportedEncodingException, ParserException { + BitArray id3Buffer = new BitArray(data, size); + int id3Size = parseId3Header(id3Buffer); + + Map metadata = new HashMap(); + + while (id3Size > 0) { + int frameId0 = id3Buffer.readUnsignedByte(); + int frameId1 = id3Buffer.readUnsignedByte(); + int frameId2 = id3Buffer.readUnsignedByte(); + int frameId3 = id3Buffer.readUnsignedByte(); + + int frameSize = id3Buffer.readSynchSafeInt(); + if (frameSize <= 1) { + break; + } + + id3Buffer.skipBytes(2); // Skip frame flags. + + // Check Frame ID == TXXX. + if (frameId0 == 'T' && frameId1 == 'X' && frameId2 == 'X' && frameId3 == 'X') { + int encoding = id3Buffer.readUnsignedByte(); + String charset = getCharsetName(encoding); + byte[] frame = new byte[frameSize - 1]; + id3Buffer.readBytes(frame, 0, frameSize - 1); + + int firstZeroIndex = indexOf(frame, 0, (byte) 0); + String description = new String(frame, 0, firstZeroIndex, charset); + int valueStartIndex = indexOfNot(frame, firstZeroIndex, (byte) 0); + int valueEndIndex = indexOf(frame, valueStartIndex, (byte) 0); + String value = new String(frame, valueStartIndex, valueEndIndex - valueStartIndex, + charset); + metadata.put(TxxxMetadata.TYPE, new TxxxMetadata(description, value)); + } else { + String type = String.format("%c%c%c%c", frameId0, frameId1, frameId2, frameId3); + byte[] frame = new byte[frameSize]; + id3Buffer.readBytes(frame, 0, frameSize); + metadata.put(type, frame); + } + + id3Size -= frameSize + 10 /* header size */; + } + + return Collections.unmodifiableMap(metadata); + } + + private static int indexOf(byte[] data, int fromIndex, byte key) { + for (int i = fromIndex; i < data.length; i++) { + if (data[i] == key) { + return i; + } + } + return data.length; + } + + private static int indexOfNot(byte[] data, int fromIndex, byte key) { + for (int i = fromIndex; i < data.length; i++) { + if (data[i] != key) { + return i; + } + } + return data.length; + } + + /** + * Parses ID3 header. + * @param id3Buffer A {@link BitArray} with raw ID3 data. + * @return The size of data that contains ID3 frames without header and footer. + * @throws ParserException If ID3 file identifier != "ID3". + */ + private static int parseId3Header(BitArray id3Buffer) throws ParserException { + int id1 = id3Buffer.readUnsignedByte(); + int id2 = id3Buffer.readUnsignedByte(); + int id3 = id3Buffer.readUnsignedByte(); + if (id1 != 'I' || id2 != 'D' || id3 != '3') { + throw new ParserException(String.format( + "Unexpected ID3 file identifier, expected \"ID3\", actual \"%c%c%c\".", id1, id2, id3)); + } + id3Buffer.skipBytes(2); // Skip version. + + int flags = id3Buffer.readUnsignedByte(); + int id3Size = id3Buffer.readSynchSafeInt(); + + // Check if extended header presents. + if ((flags & 0x2) != 0) { + int extendedHeaderSize = id3Buffer.readSynchSafeInt(); + if (extendedHeaderSize > 4) { + id3Buffer.skipBytes(extendedHeaderSize - 4); + } + id3Size -= extendedHeaderSize; + } + + // Check if footer presents. + if ((flags & 0x8) != 0) { + id3Size -= 10; + } + + return id3Size; + } + + /** + * Maps encoding byte from ID3v2 frame to a Charset. + * @param encodingByte The value of encoding byte from ID3v2 frame. + * @return Charset name. + */ + private static String getCharsetName(int encodingByte) { + switch (encodingByte) { + case 0: + return "ISO-8859-1"; + case 1: + return "UTF-16"; + case 2: + return "UTF-16BE"; + case 3: + return "UTF-8"; + default: + return "ISO-8859-1"; + } + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/metadata/MetadataParser.java b/library/src/main/java/com/google/android/exoplayer/metadata/MetadataParser.java new file mode 100644 index 0000000000..654f549b18 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/metadata/MetadataParser.java @@ -0,0 +1,45 @@ +/* + * 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.metadata; + +import java.io.IOException; + +/** + * Parses objects of type from binary data. + * + * @param The type of the metadata. + */ +public interface MetadataParser { + + /** + * Checks whether the parser supports a given mime type. + * + * @param mimeType A metadata mime type. + * @return Whether the mime type is supported. + */ + public boolean canParse(String mimeType); + + /** + * Parses metadata objects of type from the provided binary data. + * + * @param data The raw binary data from which to parse the metadata. + * @param size The size of the input data. + * @return @return A parsed metadata object of type . + * @throws IOException If a problem occurred parsing the data. + */ + public T parse(byte[] data, int size) throws IOException; + +} diff --git a/library/src/main/java/com/google/android/exoplayer/metadata/MetadataTrackRenderer.java b/library/src/main/java/com/google/android/exoplayer/metadata/MetadataTrackRenderer.java new file mode 100644 index 0000000000..147a222c4f --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/metadata/MetadataTrackRenderer.java @@ -0,0 +1,214 @@ +/* + * 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.metadata; + +import com.google.android.exoplayer.ExoPlaybackException; +import com.google.android.exoplayer.MediaFormatHolder; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.TrackRenderer; +import com.google.android.exoplayer.util.Assertions; + +import android.os.Handler; +import android.os.Handler.Callback; +import android.os.Looper; +import android.os.Message; + +import java.io.IOException; + +/** + * A {@link TrackRenderer} for metadata embedded in a media stream. + * + * @param The type of the metadata. + */ +public class MetadataTrackRenderer extends TrackRenderer implements Callback { + + /** + * An interface for components that process metadata. + * + * @param The type of the metadata. + */ + public interface MetadataRenderer { + + /** + * Invoked each time there is a metadata associated with current playback time. + * + * @param metadata The metadata to process. + */ + void onMetadata(T metadata); + + } + + private static final int MSG_INVOKE_RENDERER = 0; + + private final SampleSource source; + private final MetadataParser metadataParser; + private final MetadataRenderer metadataRenderer; + private final Handler metadataHandler; + private final MediaFormatHolder formatHolder; + private final SampleHolder sampleHolder; + + private int trackIndex; + private long currentPositionUs; + private boolean inputStreamEnded; + + private long pendingMetadataTimestamp; + private T pendingMetadata; + + /** + * @param source A source from which samples containing metadata can be read. + * @param metadataParser A parser for parsing the metadata. + * @param metadataRenderer The metadata renderer to receive the parsed metadata. + * @param metadataRendererLooper The looper associated with the thread on which metadataRenderer + * should be invoked. If the renderer makes use of standard Android UI components, then this + * should normally be the looper associated with the applications' main thread, which can be + * obtained using {@link android.app.Activity#getMainLooper()}. Null may be passed if the + * renderer should be invoked directly on the player's internal rendering thread. + */ + public MetadataTrackRenderer(SampleSource source, MetadataParser metadataParser, + MetadataRenderer metadataRenderer, Looper metadataRendererLooper) { + this.source = Assertions.checkNotNull(source); + this.metadataParser = Assertions.checkNotNull(metadataParser); + this.metadataRenderer = Assertions.checkNotNull(metadataRenderer); + this.metadataHandler = metadataRendererLooper == null ? null + : new Handler(metadataRendererLooper, this); + formatHolder = new MediaFormatHolder(); + sampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL); + } + + @Override + protected int doPrepare() throws ExoPlaybackException { + try { + boolean sourcePrepared = source.prepare(); + if (!sourcePrepared) { + return TrackRenderer.STATE_UNPREPARED; + } + } catch (IOException e) { + throw new ExoPlaybackException(e); + } + for (int i = 0; i < source.getTrackCount(); i++) { + if (metadataParser.canParse(source.getTrackInfo(i).mimeType)) { + trackIndex = i; + return TrackRenderer.STATE_PREPARED; + } + } + return TrackRenderer.STATE_IGNORE; + } + + @Override + protected void onEnabled(long positionUs, boolean joining) { + source.enable(trackIndex, positionUs); + seekToInternal(positionUs); + } + + @Override + protected void seekTo(long positionUs) throws ExoPlaybackException { + source.seekToUs(positionUs); + seekToInternal(positionUs); + } + + private void seekToInternal(long positionUs) { + currentPositionUs = positionUs; + pendingMetadata = null; + inputStreamEnded = false; + } + + @Override + protected void doSomeWork(long positionUs, long elapsedRealtimeUs) + throws ExoPlaybackException { + currentPositionUs = positionUs; + try { + source.continueBuffering(positionUs); + } catch (IOException e) { + throw new ExoPlaybackException(e); + } + + if (!inputStreamEnded && pendingMetadata == null) { + try { + int result = source.readData(trackIndex, positionUs, formatHolder, sampleHolder, false); + if (result == SampleSource.SAMPLE_READ) { + pendingMetadataTimestamp = sampleHolder.timeUs; + pendingMetadata = metadataParser.parse(sampleHolder.data.array(), sampleHolder.size); + sampleHolder.data.clear(); + } else if (result == SampleSource.END_OF_STREAM) { + inputStreamEnded = true; + } + } catch (IOException e) { + throw new ExoPlaybackException(e); + } + } + + if (pendingMetadata != null && pendingMetadataTimestamp <= currentPositionUs) { + invokeRenderer(pendingMetadata); + pendingMetadata = null; + } + } + + @Override + protected void onDisabled() { + pendingMetadata = null; + source.disable(trackIndex); + } + + @Override + protected long getDurationUs() { + return source.getTrackInfo(trackIndex).durationUs; + } + + @Override + protected long getCurrentPositionUs() { + return currentPositionUs; + } + + @Override + protected long getBufferedPositionUs() { + return TrackRenderer.END_OF_TRACK_US; + } + + @Override + protected boolean isEnded() { + return inputStreamEnded; + } + + @Override + protected boolean isReady() { + return true; + } + + private void invokeRenderer(T metadata) { + if (metadataHandler != null) { + metadataHandler.obtainMessage(MSG_INVOKE_RENDERER, metadata).sendToTarget(); + } else { + invokeRendererInternal(metadata); + } + } + + @SuppressWarnings("unchecked") + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_INVOKE_RENDERER: + invokeRendererInternal((T) msg.obj); + return true; + } + return false; + } + + private void invokeRendererInternal(T metadata) { + metadataRenderer.onMetadata(metadata); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/metadata/TxxxMetadata.java b/library/src/main/java/com/google/android/exoplayer/metadata/TxxxMetadata.java new file mode 100644 index 0000000000..c455bb825d --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/metadata/TxxxMetadata.java @@ -0,0 +1,34 @@ +/* + * 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.metadata; + +/** + * A metadata that contains parsed ID3 TXXX (User defined text information) frame data associated + * with time indices. + */ +public class TxxxMetadata { + + public static final String TYPE = "TXXX"; + + public final String description; + public final String value; + + public TxxxMetadata(String description, String value) { + this.description = description; + this.value = value; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/text/TextRenderer.java b/library/src/main/java/com/google/android/exoplayer/text/TextRenderer.java new file mode 100644 index 0000000000..8b0b1ae6dc --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/text/TextRenderer.java @@ -0,0 +1,30 @@ +/* + * 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.text; + +/** + * An interface for components that render text. + */ +public interface TextRenderer { + + /** + * Invoked each time there is a change in the text to be rendered. + * + * @param text The text to render, or null if no text is to be rendered. + */ + void onText(String text); + +} diff --git a/library/src/main/java/com/google/android/exoplayer/text/TextTrackRenderer.java b/library/src/main/java/com/google/android/exoplayer/text/TextTrackRenderer.java index c85eb469c1..7f3dba9654 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/TextTrackRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer/text/TextTrackRenderer.java @@ -38,20 +38,6 @@ import java.io.IOException; @TargetApi(16) public class TextTrackRenderer extends TrackRenderer implements Callback { - /** - * An interface for components that render text. - */ - public interface TextRenderer { - - /** - * Invoked each time there is a change in the text to be rendered. - * - * @param text The text to render, or null if no text is to be rendered. - */ - void onText(String text); - - } - private static final int MSG_UPDATE_OVERLAY = 0; private final Handler textRendererHandler; diff --git a/library/src/main/java/com/google/android/exoplayer/text/eia608/ClosedCaption.java b/library/src/main/java/com/google/android/exoplayer/text/eia608/ClosedCaption.java new file mode 100644 index 0000000000..dad39fc359 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/text/eia608/ClosedCaption.java @@ -0,0 +1,62 @@ +/* + * 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.text.eia608; + +/** + * A Closed Caption that contains textual data associated with time indices. + */ +public final class ClosedCaption implements Comparable { + + /** + * Identifies closed captions with control characters. + */ + public static final int TYPE_CTRL = 0; + /** + * Identifies closed captions with textual information. + */ + public static final int TYPE_TEXT = 1; + + /** + * The type of the closed caption data. If equals to {@link #TYPE_TEXT} the {@link #text} field + * has the textual data, if equals to {@link #TYPE_CTRL} the {@link #text} field has two control + * characters (C1, C2). + */ + public final int type; + /** + * Contains text or two control characters. + */ + public final String text; + /** + * Timestamp associated with the closed caption. + */ + public final long timeUs; + + public ClosedCaption(int type, String text, long timeUs) { + this.type = type; + this.text = text; + this.timeUs = timeUs; + } + + @Override + public int compareTo(ClosedCaption another) { + long delta = this.timeUs - another.timeUs; + if (delta == 0) { + return 0; + } + return delta > 0 ? 1 : -1; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608Parser.java b/library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608Parser.java new file mode 100644 index 0000000000..97773fb2d8 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608Parser.java @@ -0,0 +1,215 @@ +/* + * 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.text.eia608; + +import com.google.android.exoplayer.util.BitArray; +import com.google.android.exoplayer.util.MimeTypes; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Facilitates the extraction and parsing of EIA-608 (a.k.a. "line 21 captions" and "CEA-608") + * Closed Captions from the SEI data block from H.264. + */ +public class Eia608Parser { + + private static final int PAYLOAD_TYPE_CC = 4; + private static final int COUNTRY_CODE = 0xB5; + private static final int PROVIDER_CODE = 0x31; + private static final int USER_ID = 0x47413934; // "GA94" + private static final int USER_DATA_TYPE_CODE = 0x3; + + // Basic North American 608 CC char set, mostly ASCII. Indexed by (char-0x20). + private static final int[] BASIC_CHARACTER_SET = new int[] { + 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, // ! " # $ % & ' + 0x28, 0x29, // ( ) + 0xE1, // 2A: 225 'á' "Latin small letter A with acute" + 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, // + , - . / + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, // 0 1 2 3 4 5 6 7 + 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, // 8 9 : ; < = > ? + 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, // @ A B C D E F G + 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, // H I J K L M N O + 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, // P Q R S T U V W + 0x58, 0x59, 0x5A, 0x5B, // X Y Z [ + 0xE9, // 5C: 233 'é' "Latin small letter E with acute" + 0x5D, // ] + 0xED, // 5E: 237 'í' "Latin small letter I with acute" + 0xF3, // 5F: 243 'ó' "Latin small letter O with acute" + 0xFA, // 60: 250 'ú' "Latin small letter U with acute" + 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, // a b c d e f g + 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, // h i j k l m n o + 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, // p q r s t u v w + 0x78, 0x79, 0x7A, // x y z + 0xE7, // 7B: 231 'ç' "Latin small letter C with cedilla" + 0xF7, // 7C: 247 '÷' "Division sign" + 0xD1, // 7D: 209 'Ñ' "Latin capital letter N with tilde" + 0xF1, // 7E: 241 'ñ' "Latin small letter N with tilde" + 0x25A0 // 7F: "Black Square" (NB: 2588 = Full Block) + }; + + // Special North American 608 CC char set. + private static final int[] SPECIAL_CHARACTER_SET = new int[] { + 0xAE, // 30: 174 '®' "Registered Sign" - registered trademark symbol + 0xB0, // 31: 176 '°' "Degree Sign" + 0xBD, // 32: 189 '½' "Vulgar Fraction One Half" (1/2 symbol) + 0xBF, // 33: 191 '¿' "Inverted Question Mark" + 0x2122, // 34: "Trade Mark Sign" (tm superscript) + 0xA2, // 35: 162 '¢' "Cent Sign" + 0xA3, // 36: 163 '£' "Pound Sign" - pounds sterling + 0x266A, // 37: "Eighth Note" - music note + 0xE0, // 38: 224 'à' "Latin small letter A with grave" + 0x20, // 39: TRANSPARENT SPACE - for now use ordinary space + 0xE8, // 3A: 232 'è' "Latin small letter E with grave" + 0xE2, // 3B: 226 'â' "Latin small letter A with circumflex" + 0xEA, // 3C: 234 'ê' "Latin small letter E with circumflex" + 0xEE, // 3D: 238 'î' "Latin small letter I with circumflex" + 0xF4, // 3E: 244 'ô' "Latin small letter O with circumflex" + 0xFB // 3F: 251 'û' "Latin small letter U with circumflex" + }; + + public boolean canParse(String mimeType) { + return mimeType.equals(MimeTypes.APPLICATION_EIA608); + } + + public List parse(byte[] data, int size, long timeUs) throws IOException { + if (size <= 0) { + return null; + } + BitArray seiBuffer = new BitArray(data, size); + seiBuffer.skipBits(3); // reserved + process_cc_data_flag + zero_bit + int ccCount = seiBuffer.readBits(5); + seiBuffer.skipBytes(1); + + List captions = new ArrayList(); + + StringBuilder stringBuilder = new StringBuilder(); + for (int i = 0; i < ccCount; i++) { + seiBuffer.skipBits(5); // one_bit + reserved + boolean ccValid = seiBuffer.readBit(); + if (!ccValid) { + seiBuffer.skipBits(18); + continue; + } + int ccType = seiBuffer.readBits(2); + if (ccType != 0) { + seiBuffer.skipBits(16); + continue; + } + seiBuffer.skipBits(1); + byte ccData1 = (byte) seiBuffer.readBits(7); + seiBuffer.skipBits(1); + byte ccData2 = (byte) seiBuffer.readBits(7); + + // Ignore empty captions. + if (ccData1 == 0 && ccData2 == 0) { + continue; + } + + // Special North American character set. + if ((ccData1 == 0x11) && ((ccData2 & 0x70) == 0x30)) { + stringBuilder.append(getSpecialChar(ccData2)); + continue; + } + + // Control character. + if (ccData1 < 0x20) { + if (stringBuilder.length() > 0) { + captions.add(new ClosedCaption(ClosedCaption.TYPE_TEXT, stringBuilder.toString(), + timeUs)); + stringBuilder.setLength(0); + } + captions.add(new ClosedCaption(ClosedCaption.TYPE_CTRL, + new String(new char[] {(char) ccData1, (char) ccData2}), timeUs)); + continue; + } + + // Basic North American character set. + stringBuilder.append(getChar(ccData1)); + if (ccData2 != 0) { + stringBuilder.append(getChar(ccData2)); + } + } + + if (stringBuilder.length() > 0) { + captions.add(new ClosedCaption(ClosedCaption.TYPE_TEXT, stringBuilder.toString(), timeUs)); + } + + return Collections.unmodifiableList(captions); + } + + private static char getChar(byte ccData) { + int index = (ccData & 0x7F) - 0x20; + return (char) BASIC_CHARACTER_SET[index]; + } + + private static char getSpecialChar(byte ccData) { + int index = ccData & 0xF; + return (char) SPECIAL_CHARACTER_SET[index]; + } + + /** + * Parses the beginning of SEI data and returns the size of underlying contains closed captions + * data following the header. Returns 0 if the SEI doesn't contain any closed captions data. + * + * @param seiBuffer The buffer to read from. + * @return The size of closed captions data. + */ + public static int parseHeader(BitArray seiBuffer) { + int b = 0; + int payloadType = 0; + + do { + b = seiBuffer.readUnsignedByte(); + payloadType += b; + } while (b == 0xFF); + + if (payloadType != PAYLOAD_TYPE_CC) { + return 0; + } + + int payloadSize = 0; + do { + b = seiBuffer.readUnsignedByte(); + payloadSize += b; + } while (b == 0xFF); + + if (payloadSize <= 0) { + return 0; + } + + int countryCode = seiBuffer.readUnsignedByte(); + if (countryCode != COUNTRY_CODE) { + return 0; + } + int providerCode = seiBuffer.readBits(16); + if (providerCode != PROVIDER_CODE) { + return 0; + } + int userIdentifier = seiBuffer.readBits(32); + if (userIdentifier != USER_ID) { + return 0; + } + int userDataTypeCode = seiBuffer.readUnsignedByte(); + if (userDataTypeCode != USER_DATA_TYPE_CODE) { + return 0; + } + return payloadSize; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608TrackRenderer.java b/library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608TrackRenderer.java new file mode 100644 index 0000000000..d88e44d506 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608TrackRenderer.java @@ -0,0 +1,260 @@ +/* + * 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.text.eia608; + +import com.google.android.exoplayer.ExoPlaybackException; +import com.google.android.exoplayer.MediaFormatHolder; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.TrackRenderer; +import com.google.android.exoplayer.text.TextRenderer; +import com.google.android.exoplayer.util.Assertions; + +import android.os.Handler; +import android.os.Handler.Callback; +import android.os.Looper; +import android.os.Message; + +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; + +/** + * A {@link TrackRenderer} for EIA-608 closed captions in a media stream. + */ +public class Eia608TrackRenderer extends TrackRenderer implements Callback { + + private static final int MSG_INVOKE_RENDERER = 0; + // The Number of closed captions text line to keep in memory. + private static final int ALLOWED_CAPTIONS_TEXT_LINES_COUNT = 4; + + private final SampleSource source; + private final Eia608Parser eia608Parser; + private final TextRenderer textRenderer; + private final Handler metadataHandler; + private final MediaFormatHolder formatHolder; + private final SampleHolder sampleHolder; + private final StringBuilder closedCaptionStringBuilder; + //Currently displayed captions. + private final List currentCaptions; + private final Queue newLineIndexes; + + private int trackIndex; + private long currentPositionUs; + private boolean inputStreamEnded; + + private long pendingCaptionsTimestamp; + private List pendingCaptions; + + /** + * @param source A source from which samples containing EIA-608 closed captions can be read. + * @param textRenderer The text renderer. + * @param textRendererLooper The looper associated with the thread on which textRenderer should be + * invoked. If the renderer makes use of standard Android UI components, then this should + * normally be the looper associated with the applications' main thread, which can be + * obtained using {@link android.app.Activity#getMainLooper()}. Null may be passed if the + * renderer should be invoked directly on the player's internal rendering thread. + */ + public Eia608TrackRenderer(SampleSource source, TextRenderer textRenderer, + Looper textRendererLooper) { + this.source = Assertions.checkNotNull(source); + this.textRenderer = Assertions.checkNotNull(textRenderer); + this.metadataHandler = textRendererLooper == null ? null + : new Handler(textRendererLooper, this); + eia608Parser = new Eia608Parser(); + formatHolder = new MediaFormatHolder(); + sampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL); + closedCaptionStringBuilder = new StringBuilder(); + currentCaptions = new LinkedList(); + newLineIndexes = new LinkedList(); + } + + @Override + protected int doPrepare() throws ExoPlaybackException { + try { + boolean sourcePrepared = source.prepare(); + if (!sourcePrepared) { + return TrackRenderer.STATE_UNPREPARED; + } + } catch (IOException e) { + throw new ExoPlaybackException(e); + } + for (int i = 0; i < source.getTrackCount(); i++) { + if (eia608Parser.canParse(source.getTrackInfo(i).mimeType)) { + trackIndex = i; + return TrackRenderer.STATE_PREPARED; + } + } + return TrackRenderer.STATE_IGNORE; + } + + @Override + protected void onEnabled(long positionUs, boolean joining) { + source.enable(trackIndex, positionUs); + seekToInternal(positionUs); + } + + @Override + protected void seekTo(long positionUs) throws ExoPlaybackException { + source.seekToUs(positionUs); + seekToInternal(positionUs); + } + + private void seekToInternal(long positionUs) { + currentPositionUs = positionUs; + pendingCaptions = null; + inputStreamEnded = false; + // Clear displayed captions. + currentCaptions.clear(); + } + + @Override + protected void doSomeWork(long positionUs, long elapsedRealtimeUs) + throws ExoPlaybackException { + currentPositionUs = positionUs; + try { + source.continueBuffering(positionUs); + } catch (IOException e) { + throw new ExoPlaybackException(e); + } + + if (!inputStreamEnded && pendingCaptions == null) { + try { + int result = source.readData(trackIndex, positionUs, formatHolder, sampleHolder, false); + if (result == SampleSource.SAMPLE_READ) { + pendingCaptionsTimestamp = sampleHolder.timeUs; + pendingCaptions = eia608Parser.parse(sampleHolder.data.array(), sampleHolder.size, + sampleHolder.timeUs); + sampleHolder.data.clear(); + } else if (result == SampleSource.END_OF_STREAM) { + inputStreamEnded = true; + } + } catch (IOException e) { + throw new ExoPlaybackException(e); + } + } + + if (pendingCaptions != null && pendingCaptionsTimestamp <= currentPositionUs) { + invokeRenderer(pendingCaptions); + pendingCaptions = null; + } + } + + @Override + protected void onDisabled() { + pendingCaptions = null; + source.disable(trackIndex); + } + + @Override + protected long getDurationUs() { + return source.getTrackInfo(trackIndex).durationUs; + } + + @Override + protected long getCurrentPositionUs() { + return currentPositionUs; + } + + @Override + protected long getBufferedPositionUs() { + return TrackRenderer.END_OF_TRACK_US; + } + + @Override + protected boolean isEnded() { + return inputStreamEnded; + } + + @Override + protected boolean isReady() { + return true; + } + + private void invokeRenderer(List metadata) { + if (metadataHandler != null) { + metadataHandler.obtainMessage(MSG_INVOKE_RENDERER, metadata).sendToTarget(); + } else { + invokeRendererInternal(metadata); + } + } + + @SuppressWarnings("unchecked") + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_INVOKE_RENDERER: + invokeRendererInternal((List) msg.obj); + return true; + } + return false; + } + + private void invokeRendererInternal(List metadata) { + currentCaptions.addAll(metadata); + // Sort captions by the timestamp. + Collections.sort(currentCaptions); + closedCaptionStringBuilder.setLength(0); + + // After processing keep only captions after cutIndex. + int cutIndex = 0; + newLineIndexes.clear(); + for (int i = 0; i < currentCaptions.size(); i++) { + ClosedCaption caption = currentCaptions.get(i); + + if (caption.type == ClosedCaption.TYPE_CTRL) { + int cc2 = caption.text.codePointAt(1); + switch (cc2) { + case 0x2C: // Erase Displayed Memory. + closedCaptionStringBuilder.setLength(0); + cutIndex = i; + newLineIndexes.clear(); + break; + case 0x25: // Roll-Up. + case 0x26: + case 0x27: + default: + if (cc2 >= 0x20 && cc2 < 0x40) { + break; + } + if (closedCaptionStringBuilder.length() > 0 + && closedCaptionStringBuilder.charAt(closedCaptionStringBuilder.length() - 1) + != '\n') { + closedCaptionStringBuilder.append('\n'); + newLineIndexes.add(i); + if (newLineIndexes.size() >= ALLOWED_CAPTIONS_TEXT_LINES_COUNT) { + cutIndex = newLineIndexes.poll(); + } + } + break; + } + } else { + closedCaptionStringBuilder.append(caption.text); + } + } + + if (cutIndex > 0 && cutIndex < currentCaptions.size() - 1) { + for (int i = 0; i <= cutIndex; i++) { + currentCaptions.remove(0); + } + } + + textRenderer.onText(closedCaptionStringBuilder.toString()); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/Aes128DataSource.java b/library/src/main/java/com/google/android/exoplayer/upstream/Aes128DataSource.java new file mode 100644 index 0000000000..938dd70ef1 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/upstream/Aes128DataSource.java @@ -0,0 +1,97 @@ +/* + * 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.upstream; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.util.Assertions; + +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.NoSuchAlgorithmException; +import java.security.spec.AlgorithmParameterSpec; + +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * A {@link DataSource} that decrypts the data read from an upstream source, encrypted with AES-128 + * with a 128-bit key and PKCS7 padding. + * + */ +public class Aes128DataSource implements DataSource { + + private final DataSource upstream; + private final byte[] secretKey; + private final byte[] iv; + + private CipherInputStream cipherInputStream; + + public Aes128DataSource(byte[] secretKey, byte[] iv, DataSource upstream) { + this.upstream = upstream; + this.secretKey = secretKey; + this.iv = iv; + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + Cipher cipher; + try { + cipher = Cipher.getInstance("AES/CBC/PKCS7Padding"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } catch (NoSuchPaddingException e) { + throw new RuntimeException(e); + } + + Key cipherKey = new SecretKeySpec(secretKey, "AES"); + AlgorithmParameterSpec cipherIV = new IvParameterSpec(iv); + + try { + cipher.init(Cipher.DECRYPT_MODE, cipherKey, cipherIV); + } catch (InvalidKeyException e) { + throw new RuntimeException(e); + } catch (InvalidAlgorithmParameterException e) { + throw new RuntimeException(e); + } + + cipherInputStream = new CipherInputStream( + new DataSourceInputStream(upstream, dataSpec), cipher); + + return C.LENGTH_UNBOUNDED; + } + + @Override + public void close() throws IOException { + cipherInputStream = null; + upstream.close(); + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws IOException { + Assertions.checkState(cipherInputStream != null); + int bytesRead = cipherInputStream.read(buffer, offset, readLength); + if (bytesRead < 0) { + return -1; + } + return bytesRead; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/util/BitArray.java b/library/src/main/java/com/google/android/exoplayer/util/BitArray.java new file mode 100644 index 0000000000..45d7ec35d0 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/util/BitArray.java @@ -0,0 +1,361 @@ +/* + * 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.util; + +import com.google.android.exoplayer.upstream.DataSource; + +import java.io.IOException; + +/** + * Wraps a byte array, providing methods that allow it to be read as a bitstream. + */ +public final class BitArray { + + 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; + + public BitArray() { + } + + public BitArray(byte[] data, int limit) { + this.data = data; + this.limit = limit; + } + + /** + * Clears all data, setting the offset and limit to zero. + */ + public void reset() { + byteOffset = 0; + bitOffset = 0; + limit = 0; + } + + /** + * Resets to wrap the specified data, setting the offset to zero. + * + * @param data The data to wrap. + * @param limit The limit to set. + */ + public void reset(byte[] data, int limit) { + this.data = data; + this.limit = limit; + byteOffset = 0; + bitOffset = 0; + } + + /** + * Gets the backing byte array. + * + * @return The backing byte array. + */ + public byte[] getData() { + return data; + } + + /** + * 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 DataSource}. + * + * @param dataSource The {@link DataSource} from which to read. + * @param length The maximum number of bytes to read and append. + * @return The number of bytes that were read and appended, or -1 if no more data is available. + * @throws IOException If an error occurs reading from the source. + */ + public int append(DataSource dataSource, int length) throws IOException { + expand(length); + int bytesRead = dataSource.read(data, limit, length); + if (bytesRead == -1) { + return -1; + } + limit += bytesRead; + return bytesRead; + } + + /** + * Appends data from another {@link BitArray}. + * + * @param bitsArray The {@link BitArray} whose data should be appended. + * @param length The number of bytes to read and append. + */ + public void append(BitArray 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() { + int value; + if (bitOffset != 0) { + value = ((data[byteOffset] & 0xFF) << bitOffset) + | ((data[byteOffset + 1] & 0xFF) >>> (8 - bitOffset)); + } else { + value = data[byteOffset]; + } + byteOffset++; + return value & 0xFF; + } + + /** + * Reads a single bit. + * + * @return True if the bit is set. False otherwise. + */ + public boolean readBit() { + return readBits(1) == 1; + } + + /** + * 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; + } + + /** + * Reads an unsigned Exp-Golomb-coded format integer. + * + * @return The value of the parsed Exp-Golomb-coded integer. + */ + public int readUnsignedExpGolombCodedInt() { + return readExpGolombCodeNum(); + } + + /** + * Reads an signed Exp-Golomb-coded format integer. + * + * @return The value of the parsed Exp-Golomb-coded integer. + */ + public int readSignedExpGolombCodedInt() { + int codeNum = readExpGolombCodeNum(); + return ((codeNum % 2) == 0 ? -1 : 1) * ((codeNum + 1) / 2); + } + + private int readExpGolombCodeNum() { + int leadingZeros = 0; + while (!readBit()) { + leadingZeros++; + } + return (1 << leadingZeros) - 1 + (leadingZeros > 0 ? readBits(leadingZeros) : 0); + } + + /** + * Reads a Synchsafe integer. + * Synchsafe integers are integers that keep the highest bit of every byte zeroed. + * A 32 bit synchsafe integer can store 28 bits of information. + * + * @return The value of the parsed Synchsafe integer. + */ + public int readSynchSafeInt() { + int b1 = readUnsignedByte(); + int b2 = readUnsignedByte(); + int b3 = readUnsignedByte(); + int b4 = readUnsignedByte(); + + return (b1 << 21) | (b2 << 14) | (b3 << 7) | b4; + } + + // 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, or -1 for any NAL unit. + * @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 == -1 || (nalUnitType == (data[i + 3] & 0x1F)))) { + return i - byteOffset; + } + } + return limit - byteOffset; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java b/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java index fbf12a894f..dd55492f65 100644 --- a/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java +++ b/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java @@ -40,6 +40,8 @@ public class MimeTypes { public static final String TEXT_VTT = BASE_TYPE_TEXT + "/vtt"; + public static final String APPLICATION_ID3 = BASE_TYPE_APPLICATION + "/id3"; + public static final String APPLICATION_EIA608 = BASE_TYPE_APPLICATION + "/eia-608"; public static final String APPLICATION_TTML = BASE_TYPE_APPLICATION + "/ttml+xml"; private MimeTypes() {}